PolyTools Trade Cost 0.1.1 / src/lib/calc.js

344 lines · 17,040 bytes · SHA-256 64a946f3c58698c68e5a5b76976c7118656099b91eee0c4b6238c9fd93b8913e

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);