PolyTools Trade Cost 0.1.1 / src/lib/calc.js
| 1 | /* |
| 2 | * PolyTools — trade cost math. Pure functions, no DOM. |
| 3 | * Loaded as a classic script (globalThis.PWL.calc) in the extension and via require() in Node tests. |
| 4 | * |
| 5 | * Fee model (docs.polymarket.com/trading/fees, market-data/market-details#trading-fees, checked 2026-10-26): |
| 6 | * fee = C × rate × (p × (1 − p))^exponent, C = shares, p = fill price; rounded to 5 decimals; takers only. |
| 7 | * Docs show exponent = 1 for every category; all live markets sampled on 2026-10-26 had exponent 1. |
| 8 | * API/SDK: a market BUY amount is pre-fee and the fee is charged on top. SELL: fee is deducted from proceeds. |
| 9 | * polymarket.com's ticket, however, treats the typed amount as ALL-IN. It walks the book with the full amount |
| 10 | * (S0 shares at average price a0) and takes the fee out linearized at that average price: |
| 11 | * fee = rate·S0·(a0·(1 − a0))^exponent, shares ("To win") = S0 − fee/a0 (= S0·(1 − rate·(1 − a0)) for exponent 1) |
| 12 | * Verified on 30 live book snapshots to ±0.01% (sweep of 2026-10-27/28; single level: $100 at 44¢, 5% → 220.91). |
| 13 | * analyzeBuy follows the ticket (ctx.amountIncludesFee, default true). |
| 14 | * Order book: clob.polymarket.com/book returns bids ascending / asks descending; YES and NO books mirror each other. |
| 15 | */ |
| 16 | (function (root) { |
| 17 | 'use strict'; |
| 18 | |
| 19 | const EPS = 1e-9; |
| 20 | const round5 = (x) => Math.round(x * 1e5) / 1e5; |
| 21 | |
| 22 | /** Normalise Gamma's feesEnabled/feeSchedule into {rate, exponent, rebateRate, type}. */ |
| 23 | function feeModel(market) { |
| 24 | const m = market || {}; |
| 25 | const fs = m.feeSchedule || null; |
| 26 | const type = m.feeType || null; |
| 27 | if (m.feesEnabled === false || !fs) return { rate: 0, exponent: 1, rebateRate: 0, type }; |
| 28 | const rate = Number(fs.rate); |
| 29 | const exponent = Number(fs.exponent); |
| 30 | return { |
| 31 | rate: Number.isFinite(rate) && rate > 0 ? rate : 0, |
| 32 | exponent: Number.isFinite(exponent) && exponent > 0 ? exponent : 1, |
| 33 | rebateRate: Number(fs.rebateRate) || 0, |
| 34 | type, |
| 35 | }; |
| 36 | } |
| 37 | |
| 38 | /** Taker fee in USD for `shares` filled at `price`. */ |
| 39 | function takerFee(shares, price, fee) { |
| 40 | if (!fee || !fee.rate || !(shares > 0) || !(price > 0) || !(price < 1)) return 0; |
| 41 | return round5(shares * fee.rate * Math.pow(price * (1 - price), fee.exponent || 1)); |
| 42 | } |
| 43 | |
| 44 | /** Fee per share at price p (no rounding) — used for limit/maker comparisons. */ |
| 45 | function feePerShare(price, fee) { |
| 46 | if (!fee || !fee.rate || !(price > 0) || !(price < 1)) return 0; |
| 47 | return fee.rate * Math.pow(price * (1 - price), fee.exponent || 1); |
| 48 | } |
| 49 | |
| 50 | /** Normalise a CLOB /book payload: bids best-first (desc), asks best-first (asc), numbers. */ |
| 51 | function normalizeBook(raw) { |
| 52 | const conv = (arr) => (arr || []) |
| 53 | .map((l) => ({ price: Number(l.price), size: Number(l.size) })) |
| 54 | .filter((l) => l.price > 0 && l.price < 1 && l.size > 0); |
| 55 | const bids = conv(raw && raw.bids).sort((a, b) => b.price - a.price); |
| 56 | const asks = conv(raw && raw.asks).sort((a, b) => a.price - b.price); |
| 57 | const tick = Number(raw && raw.tick_size) || 0.01; |
| 58 | const minSize = Number(raw && raw.min_order_size) || 0; |
| 59 | const bestBid = bids.length ? bids[0].price : null; |
| 60 | const bestAsk = asks.length ? asks[0].price : null; |
| 61 | const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : (bestAsk != null ? bestAsk : bestBid); |
| 62 | return { |
| 63 | bids, asks, tick, minSize, bestBid, bestAsk, mid, |
| 64 | spread: bestBid != null && bestAsk != null ? bestAsk - bestBid : null, |
| 65 | ts: Number(raw && raw.timestamp) || Date.now(), |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | /** Spend `usd` (pre-fee) walking asks. Optional `limit` caps the price. */ |
| 70 | function walkBuyUsd(asks, usd, fee, limit) { |
| 71 | let remaining = usd; |
| 72 | let shares = 0, notional = 0, feeUsd = 0, worst = null, levels = 0; |
| 73 | const eps = Math.max(EPS, 1e-12 * Math.abs(usd)); // relative dollar residue: large amounts must not open a phantom level |
| 74 | for (const lv of asks) { |
| 75 | if (remaining <= eps) break; |
| 76 | if (limit != null && lv.price > limit + EPS) break; |
| 77 | const take = Math.min(lv.size, remaining / lv.price); |
| 78 | if (take <= EPS) continue; |
| 79 | const cost = take * lv.price; |
| 80 | shares += take; notional += cost; remaining -= cost; |
| 81 | feeUsd += takerFee(take, lv.price, fee); |
| 82 | worst = lv.price; levels++; |
| 83 | } |
| 84 | return fillResult(shares, notional, feeUsd, worst, levels, { unfilledUsd: Math.max(remaining, 0) }); |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * The ticket's all-in BUY (see header): walk the full amount, then take the fee out at the average price. |
| 89 | * Same shape as walkBuyUsd's result, with shares/notional/fee replaced by the ticket's numbers. |
| 90 | */ |
| 91 | function ticketBuyFill(asks, usd, fee) { |
| 92 | const pre = walkBuyUsd(asks, usd, fee); |
| 93 | if (!fee || !fee.rate || !(pre.shares > EPS)) return pre; |
| 94 | // Amount the book cannot absorb even with the fee on top: the ticket offers every share in the book and charges |
| 95 | // the fee on that notional (verified on a 7% ETH market: $1700 and $5000 both show Σ ask sizes = 2321.96). |
| 96 | const eps = Math.max(EPS, 1e-12 * Math.abs(usd)); |
| 97 | if (pre.unfilledUsd > eps && usd >= pre.notional + pre.fee - eps) { |
| 98 | return Object.assign({}, pre, { unfilledUsd: Math.max(0, usd - pre.notional - pre.fee) }); |
| 99 | } |
| 100 | const a0 = pre.avgPrice; |
| 101 | const feeEst = round5(fee.rate * pre.shares * Math.pow(a0 * (1 - a0), fee.exponent || 1)); |
| 102 | const shares = Math.max(0, pre.shares - feeEst / a0); |
| 103 | // (between the book's notional and notional + fee the leftover is unspent, not missing liquidity) |
| 104 | return Object.assign({}, pre, { shares, notional: shares * a0, fee: feeEst, unfilledUsd: 0 }); |
| 105 | } |
| 106 | |
| 107 | /** Notional the ticket spends on shares for an all-in `usd` (kept for callers and tests). */ |
| 108 | function ticketBuyNotional(asks, usd, fee) { |
| 109 | return ticketBuyFill(asks, usd, fee).notional; |
| 110 | } |
| 111 | |
| 112 | /** Buy `qty` shares walking asks (optionally capped by `limit`). */ |
| 113 | function walkBuyShares(asks, qty, fee, limit) { |
| 114 | let remaining = qty; |
| 115 | let shares = 0, notional = 0, feeUsd = 0, worst = null, levels = 0; |
| 116 | const eps = Math.max(EPS, 1e-12 * Math.abs(qty)); |
| 117 | for (const lv of asks) { |
| 118 | if (remaining <= eps) break; |
| 119 | if (limit != null && lv.price > limit + EPS) break; |
| 120 | const take = Math.min(lv.size, remaining); |
| 121 | shares += take; notional += take * lv.price; remaining -= take; |
| 122 | feeUsd += takerFee(take, lv.price, fee); |
| 123 | worst = lv.price; levels++; |
| 124 | } |
| 125 | return fillResult(shares, notional, feeUsd, worst, levels, { unfilledShares: Math.max(remaining, 0) }); |
| 126 | } |
| 127 | |
| 128 | /** Sell `qty` shares walking bids (optionally floored by `limit`). */ |
| 129 | function walkSellShares(bids, qty, fee, limit) { |
| 130 | let remaining = qty; |
| 131 | let shares = 0, notional = 0, feeUsd = 0, worst = null, levels = 0; |
| 132 | const eps = Math.max(EPS, 1e-12 * Math.abs(qty)); |
| 133 | for (const lv of bids) { |
| 134 | if (remaining <= eps) break; |
| 135 | if (limit != null && lv.price < limit - EPS) break; |
| 136 | const take = Math.min(lv.size, remaining); |
| 137 | shares += take; notional += take * lv.price; remaining -= take; |
| 138 | feeUsd += takerFee(take, lv.price, fee); |
| 139 | worst = lv.price; levels++; |
| 140 | } |
| 141 | return fillResult(shares, notional, feeUsd, worst, levels, { unfilledShares: Math.max(remaining, 0) }); |
| 142 | } |
| 143 | |
| 144 | function fillResult(shares, notional, feeUsd, worst, levels, extra) { |
| 145 | return Object.assign({ |
| 146 | shares, notional, fee: round5(feeUsd), |
| 147 | avgPrice: shares > EPS ? notional / shares : null, |
| 148 | worstPrice: worst, levels, |
| 149 | }, extra); |
| 150 | } |
| 151 | |
| 152 | /** Days from `now` (ms) until ISO `endDate`; null when unknown. */ |
| 153 | function daysUntil(endDate, now) { |
| 154 | if (!endDate) return null; |
| 155 | const t = Date.parse(endDate); |
| 156 | if (!Number.isFinite(t)) return null; |
| 157 | return (t - now) / 86400000; |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * Annualise a holding-period return. Below `minDays` the figure is not meaningful, so it is |
| 162 | * still returned but flagged. |
| 163 | */ |
| 164 | function annualize(roi, days, minDays) { |
| 165 | if (roi == null || days == null || !(days > 0)) return { simple: null, compound: null, reliable: false }; |
| 166 | const simple = roi * 365 / days; |
| 167 | const compound = days >= 1 ? Math.pow(1 + roi, 365 / days) - 1 : null; |
| 168 | return { simple, compound: Number.isFinite(compound) ? compound : null, reliable: days >= (minDays || 7) }; |
| 169 | } |
| 170 | |
| 171 | /** Maker alternative price for a BUY: one tick above best bid, but strictly below best ask. */ |
| 172 | function makerBuyPrice(book) { |
| 173 | if (book.bestAsk == null) return null; |
| 174 | const tick = book.tick || 0.01; |
| 175 | let p = book.bestBid != null ? book.bestBid + tick : book.bestAsk - tick; |
| 176 | if (p > book.bestAsk - tick + EPS) p = book.bestAsk - tick; |
| 177 | p = Math.round(p / tick) * tick; |
| 178 | return p > 0 && p < 1 ? Number(p.toFixed(6)) : null; |
| 179 | } |
| 180 | |
| 181 | /** Maker alternative price for a SELL: one tick below best ask, strictly above best bid. */ |
| 182 | function makerSellPrice(book) { |
| 183 | if (book.bestBid == null) return null; |
| 184 | const tick = book.tick || 0.01; |
| 185 | let p = book.bestAsk != null ? book.bestAsk - tick : book.bestBid + tick; |
| 186 | if (p < book.bestBid + tick - EPS) p = book.bestBid + tick; |
| 187 | p = Math.round(p / tick) * tick; |
| 188 | return p > 0 && p < 1 ? Number(p.toFixed(6)) : null; |
| 189 | } |
| 190 | |
| 191 | /** Shared context: time to resolution and holding rewards. */ |
| 192 | function horizon(ctx) { |
| 193 | const now = ctx.now || Date.now(); |
| 194 | const days = daysUntil(ctx.endDate, now); |
| 195 | return { days, expired: days != null && days <= 0 }; |
| 196 | } |
| 197 | |
| 198 | function holdingReward(shares, mid, rate, days) { |
| 199 | if (!rate || !(shares > 0) || !(mid > 0) || !(days > 0)) return 0; |
| 200 | return shares * mid * rate * days / 365; |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Market BUY for `usd` as typed into the ticket. |
| 205 | * ctx: {book (normalized), fee (feeModel), endDate, now, holdingRate (e.g. 0.0325 or 0), benchmarkApr (e.g. 0.05), |
| 206 | * amountIncludesFee (default true — polymarket.com ticket semantics; false = API "fee on top")} |
| 207 | */ |
| 208 | function analyzeBuy(ctx, usd) { |
| 209 | const { book, fee } = ctx; |
| 210 | const h = horizon(ctx); |
| 211 | const allIn = ctx.amountIncludesFee !== false; |
| 212 | const out = { kind: 'buy', usd, allIn, days: h.days, expired: h.expired, warnings: [] }; |
| 213 | if (!book || book.bestAsk == null) { out.noLiquidity = true; out.warnings.push('noAsks'); return out; } |
| 214 | out.bestAsk = book.bestAsk; out.bestBid = book.bestBid; out.mid = book.mid; out.spread = book.spread; |
| 215 | const fill = allIn ? ticketBuyFill(book.asks, usd, fee) : walkBuyUsd(book.asks, usd, fee); |
| 216 | Object.assign(out, fill); |
| 217 | out.unfilledUsd = fill.unfilledUsd; |
| 218 | if (!(fill.shares > EPS)) { out.warnings.push('noFill'); return out; } |
| 219 | out.totalCost = fill.notional + fill.fee; |
| 220 | out.payout = fill.shares; // $1 per winning share |
| 221 | out.profit = out.payout - out.totalCost; |
| 222 | out.roi = out.profit / out.totalCost; |
| 223 | out.breakeven = out.totalCost / fill.shares; // probability the outcome must exceed |
| 224 | out.edgeNeeded = book.mid != null ? out.breakeven - book.mid : null; |
| 225 | out.slippage = Math.max(0, (fill.avgPrice - book.bestAsk) / book.bestAsk); // float noise can dip below 0 |
| 226 | out.slippageUsd = Math.max(0, fill.notional - fill.shares * book.bestAsk); |
| 227 | out.feePct = fill.fee / fill.notional; |
| 228 | out.feeShareOfProfit = (out.payout - fill.notional) > EPS ? fill.fee / (out.payout - fill.notional) : null; |
| 229 | out.apr = annualize(out.roi, h.days, 7); |
| 230 | out.holdingRate = ctx.holdingRate || 0; |
| 231 | out.holdingUsd = holdingReward(fill.shares, book.mid, ctx.holdingRate, h.days); |
| 232 | if (out.holdingUsd > 0) { |
| 233 | out.roiWithHolding = (out.profit + out.holdingUsd) / out.totalCost; |
| 234 | out.aprWithHolding = annualize(out.roiWithHolding, h.days, 7); |
| 235 | } |
| 236 | const mp = makerBuyPrice(book); |
| 237 | if (mp != null && mp < fill.avgPrice + feePerShare(fill.avgPrice, fee) - EPS) { |
| 238 | const makerCost = fill.shares * mp; |
| 239 | out.maker = { price: mp, cost: makerCost, saving: out.totalCost - makerCost, sharesForSameUsd: usd / mp }; |
| 240 | } |
| 241 | if (allIn) out.unspent = Math.max(0, usd - out.totalCost - fill.unfilledUsd); |
| 242 | if (fill.unfilledUsd > 0.01) out.warnings.push('unfilled'); |
| 243 | if (out.slippage > 0.01) out.warnings.push('slippage'); |
| 244 | if (out.feeShareOfProfit != null && out.feeShareOfProfit > 0.1) out.warnings.push('feeEatsProfit'); |
| 245 | if (h.expired) out.warnings.push('expired'); |
| 246 | else if (out.apr.reliable && ctx.benchmarkApr && out.apr.simple < ctx.benchmarkApr) out.warnings.push('belowBenchmark'); |
| 247 | return out; |
| 248 | } |
| 249 | |
| 250 | /** Market SELL of `qty` shares. */ |
| 251 | function analyzeSell(ctx, qty) { |
| 252 | const { book, fee } = ctx; |
| 253 | const h = horizon(ctx); |
| 254 | const out = { kind: 'sell', qty, days: h.days, expired: h.expired, warnings: [] }; |
| 255 | if (!book || book.bestBid == null) { out.noLiquidity = true; out.warnings.push('noBids'); return out; } |
| 256 | out.bestAsk = book.bestAsk; out.bestBid = book.bestBid; out.mid = book.mid; out.spread = book.spread; |
| 257 | const fill = walkSellShares(book.bids, qty, fee); |
| 258 | Object.assign(out, fill); |
| 259 | if (!(fill.shares > EPS)) { out.warnings.push('noFill'); return out; } |
| 260 | out.gross = fill.notional; |
| 261 | out.net = fill.notional - fill.fee; |
| 262 | out.netPerShare = out.net / fill.shares; // selling beats holding if P(win) < this |
| 263 | out.slippage = Math.max(0, (book.bestBid - fill.avgPrice) / book.bestBid); |
| 264 | out.slippageUsd = Math.max(0, fill.shares * book.bestBid - fill.notional); |
| 265 | out.feePct = fill.fee / fill.notional; |
| 266 | out.holdPayout = fill.shares; // what the shares pay if the outcome wins |
| 267 | out.holdingUsd = holdingReward(fill.shares, book.mid, ctx.holdingRate, h.days); |
| 268 | const mp = makerSellPrice(book); |
| 269 | if (mp != null && mp > out.netPerShare + EPS) { |
| 270 | const makerNet = fill.shares * mp; |
| 271 | out.maker = { price: mp, net: makerNet, gain: makerNet - out.net }; |
| 272 | } |
| 273 | if (fill.unfilledShares > 0.01) out.warnings.push('unfilled'); |
| 274 | if (out.slippage > 0.01) out.warnings.push('slippage'); |
| 275 | return out; |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Limit order: `price` per share, `qty` shares. The marketable part fills now as taker (pays fee), |
| 280 | * the rest rests on the book as maker (no fee). |
| 281 | */ |
| 282 | function analyzeLimit(ctx, side, price, qty) { |
| 283 | const { book, fee } = ctx; |
| 284 | const h = horizon(ctx); |
| 285 | const out = { kind: 'limit', side, price, qty, days: h.days, expired: h.expired, warnings: [] }; |
| 286 | if (!(price > 0 && price < 1) || !(qty > 0)) { out.warnings.push('badInput'); return out; } |
| 287 | out.bestAsk = book ? book.bestAsk : null; out.bestBid = book ? book.bestBid : null; out.mid = book ? book.mid : null; |
| 288 | const taker = side === 'sell' |
| 289 | ? walkSellShares(book ? book.bids : [], qty, fee, price) |
| 290 | : walkBuyShares(book ? book.asks : [], qty, fee, price); |
| 291 | out.taker = taker; |
| 292 | out.restingShares = Math.max(qty - taker.shares, 0); |
| 293 | out.restingNotional = out.restingShares * price; |
| 294 | out.fee = taker.fee; |
| 295 | if (side === 'sell') { |
| 296 | out.net = taker.notional - taker.fee + out.restingNotional; |
| 297 | out.netPerShare = out.net / qty; |
| 298 | out.holdPayout = qty; |
| 299 | } else { |
| 300 | out.totalCost = taker.notional + taker.fee + out.restingNotional; |
| 301 | out.payout = qty; |
| 302 | out.profit = qty - out.totalCost; |
| 303 | out.roi = out.profit / out.totalCost; |
| 304 | out.breakeven = out.totalCost / qty; |
| 305 | out.apr = annualize(out.roi, h.days, 7); |
| 306 | out.holdingRate = ctx.holdingRate || 0; |
| 307 | out.holdingUsd = holdingReward(qty, book && book.mid, ctx.holdingRate, h.days); |
| 308 | if (h.expired) out.warnings.push('expired'); |
| 309 | else if (out.apr.reliable && ctx.benchmarkApr && out.apr.simple < ctx.benchmarkApr) out.warnings.push('belowBenchmark'); |
| 310 | } |
| 311 | out.marketable = taker.shares > EPS; |
| 312 | out.minSize = book ? book.minSize : 0; |
| 313 | if (out.minSize && qty < out.minSize) out.warnings.push('belowMinSize'); |
| 314 | return out; |
| 315 | } |
| 316 | |
| 317 | /** Quick headline numbers when no amount is entered yet: buy 1 share at the best ask. */ |
| 318 | function quote(ctx, side) { |
| 319 | const { book, fee } = ctx; |
| 320 | const h = horizon(ctx); |
| 321 | if (!book) return null; |
| 322 | if (side === 'sell') { |
| 323 | if (book.bestBid == null) return null; |
| 324 | const f = feePerShare(book.bestBid, fee); |
| 325 | return { side, price: book.bestBid, feePerShare: f, netPerShare: book.bestBid - f, days: h.days, expired: h.expired }; |
| 326 | } |
| 327 | if (book.bestAsk == null) return null; |
| 328 | const f = feePerShare(book.bestAsk, fee); |
| 329 | const cost = book.bestAsk + f; |
| 330 | const roi = (1 - cost) / cost; |
| 331 | return { |
| 332 | side, price: book.bestAsk, feePerShare: f, breakeven: cost, roi, days: h.days, expired: h.expired, |
| 333 | apr: annualize(roi, h.days, 7), feePer100: 100 / book.bestAsk * f, |
| 334 | }; |
| 335 | } |
| 336 | |
| 337 | const calc = { |
| 338 | feeModel, takerFee, feePerShare, normalizeBook, walkBuyUsd, ticketBuyFill, ticketBuyNotional, walkBuyShares, walkSellShares, |
| 339 | daysUntil, annualize, makerBuyPrice, makerSellPrice, analyzeBuy, analyzeSell, analyzeLimit, quote, round5, |
| 340 | }; |
| 341 | root.PWL = root.PWL || {}; |
| 342 | root.PWL.calc = calc; |
| 343 | if (typeof module !== 'undefined' && module.exports) module.exports = calc; |
| 344 | })(typeof globalThis !== 'undefined' ? globalThis : this); |