PolyTools Journal 0.1.1 / src/lib/journal-stats.js

358 lines · 19,219 bytes · SHA-256 b5d688fe9a4f6a4658acb133e32e97cbedf1371e161a7297cae54db146ece560

1/*
2 * PolyTools — trader journal analytics. Pure functions over Data API v2 rows.
3 * Loaded as a classic script (globalThis.PWL.journal) and via require() in Node tests.
4 *
5 * Data semantics (data-api.polymarket.com/v2/openapi.json + reconciliation against /v2/activity, 2026-10-26):
6 * /v2/positions rows: total_size (lifetime bought shares), current_size, first_entry_at / last_event_at (epoch s).
7 * PnL is NET of trading fees on both arms, but the fee sits in different places:
8 * CLOSED arm — avg_price already embeds BUY fees (Σ activity usdc_size / shares), realized_pnl = payouts − that cost;
9 * OPEN arm — avg_price is fee-exclusive (≈ entry_cost_usdc / current_size) and the BUY fee is booked as a
10 * negative realized_pnl, unrealized_pnl = current_value − entry_cost_usdc.
11 * So the fee-inclusive cost basis is total_size × avg_price on CLOSED and total_size × avg_price + entry_fees_usdc on OPEN.
12 * status=OPEN is a superset that includes settled-but-unredeemed (REDEEMABLE) rows, losers included.
13 * /v2/tokens: final_price = payout per share once resolved (0, 1 or a fraction), null while unresolved.
14 */
15(function (root) {
16 'use strict';
17
18 // Ordered: the first category whose slug set intersects the event's tag slugs wins.
19 const CATEGORY_RULES = [
20 ['esports', ['esports', 'counter-strike-2', 'cs2', 'league-of-legends', 'dota-2', 'valorant', 'lol-worlds-2026']],
21 ['sports', ['sports', 'soccer', 'basketball', 'nba', 'nfl', 'nhl', 'mlb', 'tennis', 'hockey', 'baseball', 'football',
22 'cfb', 'ufc', 'mma', 'boxing', 'f1', 'formula1', 'golf', 'cricket', 'epl', 'premier-league', 'la-liga', 'ucl',
23 'fifa', 'fifa-world-cup', 'world-cup', 'games', 'nfl-gameday', 'ballon-dor']],
24 ['crypto', ['crypto', 'crypto-prices', 'bitcoin', 'ethereum', 'solana', 'xrp', 'dogecoin', 'airdrops', 'stablecoins']],
25 ['weather', ['weather', 'daily-temperature', 'highest-temperature', 'lowest-temperature', 'climate', 'hurricanes']],
26 ['mentions', ['mentions', 'mention-markets']],
27 ['tech', ['tech', 'ai', 'big-tech', 'openai', 'anthropic', 'claude', 'ai-releases', 'ai-rankings', 'apple', 'spacex']],
28 ['culture', ['pop-culture', 'culture', 'entertainment', 'movies', 'music', 'awards', 'oscars', 'celebrity-news',
29 'tweets-markets', 'tv', 'youtube']],
30 ['economy', ['economy', 'fed', 'fed-rates', 'fomc', 'cpi-release', 'inflation', 'economic-policy', 'macro-indicators',
31 'global-rates', 'jobs-report', 'gdp', 'recession']],
32 ['finance', ['finance', 'stocks', 'equities', 'finance-updown', 'pyth-finance', 'ipos', 'ipo', 'commodities',
33 'business', 'earnings', 'oil', 'gold']],
34 ['geopolitics', ['geopolitics', 'world', 'middle-east', 'ukraine', 'russia', 'israel-x-iran', 'gaza', 'iran',
35 'military-strikes', 'military-action', 'peace-deal', 'diplomacy-ceasefire', 'china']],
36 ['politics', ['politics', 'elections', 'us-presidential-election', 'midterms', 'global-elections', 'world-elections',
37 'trump', 'congress', 'senate-elections', 'house-elections', 'us-elections', 'trump-presidency']],
38 ];
39 const CATEGORIES = CATEGORY_RULES.map((r) => r[0]).concat(['other']);
40
41 function categorize(tagSlugs) {
42 const set = new Set((tagSlugs || []).map((s) => String(s).toLowerCase()));
43 for (const [cat, slugs] of CATEGORY_RULES) {
44 for (const s of slugs) if (set.has(s)) return cat;
45 }
46 return 'other';
47 }
48
49 /** "Earn 3.25%" event tag → 0.0325 (Polymarket shows the holding-rewards rate as an event tag). */
50 function holdingRateFromTags(tags) {
51 for (const t of tags || []) {
52 const m = /\bearn\s+(\d+(?:\.\d+)?)\s*%/i.exec(t && (t.label || t));
53 if (m) return Number(m[1]) / 100;
54 }
55 return null;
56 }
57
58 const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? 0 : Number(v));
59 const PNL_EPS = 0.005;
60
61 /**
62 * Merge CLOSED and OPEN rows into one record per token.
63 * tokenInfo: Map/obj token_id → {resolved, final_price}; eventCats: Map/obj event_id → category.
64 */
65 function buildPositions(closedRows, openRows, tokenInfo, eventCats) {
66 const get = (m, k) => (m == null ? undefined : (m instanceof Map ? m.get(k) : m[k]));
67 const byToken = new Map();
68 for (const r of closedRows || []) byToken.set(r.token_id, { row: r, arm: 'closed' });
69 for (const r of openRows || []) byToken.set(r.token_id, { row: r, arm: 'open' }); // open row carries realized + unrealized
70 const out = [];
71 for (const [token, { row: r, arm }] of byToken) {
72 const ti = get(tokenInfo, token) || null;
73 const finalPrice = ti && ti.final_price != null && ti.resolved !== false ? Number(ti.final_price) : null;
74 const resolved = finalPrice != null;
75 const totalSize = num(r.total_size);
76 const entryFees = num(r.entry_fees_usdc);
77 const costBasis = totalSize * num(r.avg_price) + (arm === 'closed' ? 0 : entryFees); // fee-inclusive, see header
78 // Recomputing costBasis / totalSize drifts by an ulp (0.7 → 0.6999…), which moves round entries into the wrong
79 // calibration bucket; only derive the price when a fee actually has to be folded in.
80 const avgPrice = arm !== 'closed' && entryFees > 0 && totalSize > 0 ? costBasis / totalSize : num(r.avg_price);
81 const realized = num(r.realized_pnl);
82 const curSize = num(r.current_size);
83 const remainingCost = num(r.entry_cost_usdc);
84 const closed = arm === 'closed' || curSize < 1e-6;
85 let pnl, unrealized, settledValue = null;
86 if (closed) {
87 pnl = realized; unrealized = 0;
88 } else if (resolved) {
89 settledValue = curSize * finalPrice;
90 unrealized = settledValue - remainingCost;
91 pnl = realized + unrealized;
92 } else {
93 unrealized = num(r.unrealized_pnl);
94 pnl = realized + unrealized;
95 }
96 const finished = closed || resolved;
97 const first = num(r.first_entry_at);
98 const last = num(r.last_event_at);
99 const endTs = /^\d{4}-\d{2}-\d{2}$/.test(r.end_date || '') && r.end_date !== '1970-01-01'
100 ? Date.parse(r.end_date + 'T23:59:59Z') / 1000 : 0;
101 // When did the position finish? last_event_at can be a much later batch event (e.g. a protocol migration
102 // touched 500+ old positions on 2026-03-01) or, for unredeemed rows, the last buy. For resolved markets the
103 // market end date bounds it from the right side.
104 let closedAt = finished ? last : 0;
105 if (finished && resolved && endTs > 0) {
106 if (arm === 'open') closedAt = Math.max(last, endTs); // never redeemed: finished at resolution
107 else if (endTs >= first) closedAt = last > 0 ? Math.min(last, endTs) : endTs;
108 }
109 // Tokens received by transfer/split can carry a ~0 cost basis; keep them in PnL but out of cost-based ratios.
110 const costKnown = avgPrice >= 0.001 && costBasis >= 0.01;
111 let heldToResolution = null, earlyExitDelta = null, holdPnl = null;
112 if (resolved && costKnown) {
113 holdPnl = totalSize * (finalPrice - avgPrice);
114 const tol = Math.max(0.005 * costBasis, 0.5);
115 heldToResolution = Math.abs(pnl - holdPnl) <= tol;
116 earlyExitDelta = heldToResolution ? 0 : pnl - holdPnl;
117 }
118 out.push({
119 token, conditionId: r.condition_id, title: r.title || '', slug: r.slug || '', eventSlug: r.event_slug || '',
120 eventId: r.event_id || '', outcome: r.outcome || '', icon: r.icon || '',
121 category: get(eventCats, r.event_id) || 'other',
122 arm, closed, resolved, finished, finalPrice,
123 avgPrice, totalSize, costBasis, costKnown, entryFees,
124 realized, unrealized, pnl, curSize, currentValue: settledValue != null ? settledValue : num(r.current_value),
125 currentPrice: num(r.current_price), endDate: r.end_date || '',
126 firstEntryAt: first, lastEventAt: last, closedAt,
127 holdDays: finished && first > 0 && closedAt >= first ? (closedAt - first) / 86400 : null,
128 heldToResolution, earlyExitDelta, holdPnl,
129 outcomeLabel: finished ? (pnl > PNL_EPS ? 'win' : pnl < -PNL_EPS ? 'loss' : 'flat') : 'open',
130 });
131 }
132 return out;
133 }
134
135 function median(arr) {
136 const a = arr.filter((x) => Number.isFinite(x)).sort((x, y) => x - y);
137 if (!a.length) return null;
138 const m = a.length >> 1;
139 return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
140 }
141
142 function sum(arr, f) { let s = 0; for (const x of arr) s += f(x); return s; }
143
144 /** Headline stats over finished positions + open book. */
145 function summarize(positions) {
146 const fin = positions.filter((p) => p.finished);
147 const wins = fin.filter((p) => p.outcomeLabel === 'win');
148 const losses = fin.filter((p) => p.outcomeLabel === 'loss');
149 const open = positions.filter((p) => !p.finished);
150 const grossWin = sum(wins, (p) => p.pnl);
151 const grossLoss = -sum(losses, (p) => p.pnl);
152 const decided = wins.length + losses.length;
153 const sorted = fin.slice().sort((a, b) => b.pnl - a.pnl);
154 return {
155 positions: positions.length,
156 finished: fin.length,
157 wins: wins.length,
158 losses: losses.length,
159 flats: fin.length - decided,
160 winRate: decided ? wins.length / decided : null,
161 grossWin, grossLoss,
162 profitFactor: grossLoss > 0 ? grossWin / grossLoss : (grossWin > 0 ? Infinity : null),
163 avgWin: wins.length ? grossWin / wins.length : null,
164 avgLoss: losses.length ? -grossLoss / losses.length : null,
165 expectancy: fin.length ? sum(fin, (p) => p.pnl) / fin.length : null,
166 finishedPnl: sum(fin, (p) => p.pnl),
167 finishedCost: sum(fin.filter((p) => p.costKnown), (p) => p.costBasis),
168 realizedOnOpen: sum(open, (p) => p.realized),
169 openCount: open.length,
170 openValue: sum(open, (p) => p.currentValue),
171 openUnrealized: sum(open, (p) => p.unrealized),
172 entryFees: sum(positions, (p) => p.entryFees),
173 best: sorted.slice(0, 5),
174 worst: sorted.slice(-5).reverse().filter((p) => p.pnl < 0),
175 };
176 }
177
178 /** Per-category breakdown of finished positions (open ones counted separately). */
179 function byCategory(positions) {
180 const map = new Map();
181 for (const p of positions) {
182 if (!map.has(p.category)) map.set(p.category, []);
183 map.get(p.category).push(p);
184 }
185 const rows = [];
186 for (const [category, list] of map) {
187 const fin = list.filter((p) => p.finished);
188 const w = fin.filter((p) => p.outcomeLabel === 'win').length;
189 const l = fin.filter((p) => p.outcomeLabel === 'loss').length;
190 const pnl = sum(fin, (p) => p.pnl);
191 const known = fin.filter((p) => p.costKnown);
192 const cost = sum(known, (p) => p.costBasis);
193 rows.push({
194 category, finished: fin.length, open: list.length - fin.length, wins: w, losses: l,
195 winRate: w + l ? w / (w + l) : null, pnl, cost, roi: cost >= 1 ? sum(known, (p) => p.pnl) / cost : null,
196 fees: sum(fin, (p) => p.entryFees),
197 });
198 }
199 return rows.sort((a, b) => b.pnl - a.pnl);
200 }
201
202 /** Wilson score interval for a proportion (z = 1.96). */
203 function wilson(p, n, z) {
204 if (!n) return [null, null];
205 z = z || 1.96;
206 const d = 1 + z * z / n;
207 const c = (p + z * z / (2 * n)) / d;
208 const h = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d;
209 return [Math.max(0, c - h), Math.min(1, c + h)];
210 }
211
212 /**
213 * Calibration of entries: for resolved positions, bucket by entry price (avg_price) and compare the
214 * average price paid with how often the outcome actually paid. Above the diagonal = edge.
215 */
216 function calibration(positions, buckets) {
217 const B = buckets || 10;
218 const res = positions.filter((p) => p.resolved && p.costKnown && p.avgPrice < 1);
219 const rows = [];
220 for (let i = 0; i < B; i++) rows.push({ lo: i / B, hi: (i + 1) / B, n: 0, sumP: 0, sumY: 0, w: 0, wP: 0, wY: 0, pnl: 0, cost: 0 });
221 for (const p of res) {
222 const i = Math.min(B - 1, Math.floor(p.avgPrice * B));
223 const r = rows[i];
224 r.n++; r.sumP += p.avgPrice; r.sumY += p.finalPrice;
225 r.w += p.costBasis; r.wP += p.costBasis * p.avgPrice; r.wY += p.costBasis * p.finalPrice;
226 r.pnl += p.pnl; r.cost += p.costBasis;
227 }
228 const out = rows.map((r) => {
229 const avgPrice = r.n ? r.sumP / r.n : null;
230 const winRate = r.n ? r.sumY / r.n : null;
231 const [ciLo, ciHi] = wilson(winRate || 0, r.n);
232 return {
233 lo: r.lo, hi: r.hi, n: r.n, avgPrice, winRate,
234 edge: r.n ? winRate - avgPrice : null,
235 moneyWinRate: r.w ? r.wY / r.w : null, moneyAvgPrice: r.w ? r.wP / r.w : null,
236 pnl: r.pnl, cost: r.cost, roi: r.cost >= 1 ? r.pnl / r.cost : null, ciLo, ciHi,
237 };
238 });
239 const n = res.length;
240 const edge = n ? sum(res, (p) => p.finalPrice - p.avgPrice) / n : null;
241 const w = sum(res, (p) => p.costBasis);
242 const moneyEdge = w ? sum(res, (p) => p.costBasis * (p.finalPrice - p.avgPrice)) / w : null;
243 return { buckets: out, n, edge, moneyEdge };
244 }
245
246 /** Behaviour: holding to resolution vs exiting early, holding time, longshots vs favourites. */
247 function behaviour(positions) {
248 const res = positions.filter((p) => p.resolved && p.heldToResolution != null);
249 const held = res.filter((p) => p.heldToResolution);
250 const exited = res.filter((p) => !p.heldToResolution);
251 const fin = positions.filter((p) => p.finished);
252 const seg = (list) => {
253 const f = list.filter((p) => p.finished);
254 const w = f.filter((p) => p.outcomeLabel === 'win').length;
255 const l = f.filter((p) => p.outcomeLabel === 'loss').length;
256 const known = f.filter((p) => p.costKnown);
257 const cost = sum(known, (p) => p.costBasis);
258 const pnl = sum(f, (p) => p.pnl);
259 return { n: f.length, winRate: w + l ? w / (w + l) : null, pnl, roi: cost >= 1 ? sum(known, (p) => p.pnl) / cost : null };
260 };
261 return {
262 resolved: res.length,
263 held: { n: held.length, pnl: sum(held, (p) => p.pnl) },
264 exited: {
265 n: exited.length, pnl: sum(exited, (p) => p.pnl),
266 holdPnl: sum(exited, (p) => p.holdPnl),
267 delta: sum(exited, (p) => p.earlyExitDelta), // >0: exiting early saved money; <0: left money on the table
268 saved: sum(exited.filter((p) => p.earlyExitDelta > 0), (p) => p.earlyExitDelta),
269 missed: -sum(exited.filter((p) => p.earlyExitDelta < 0), (p) => p.earlyExitDelta),
270 },
271 medianHoldWinDays: median(fin.filter((p) => p.outcomeLabel === 'win').map((p) => p.holdDays)),
272 medianHoldLossDays: median(fin.filter((p) => p.outcomeLabel === 'loss').map((p) => p.holdDays)),
273 longshots: seg(positions.filter((p) => p.costKnown && p.avgPrice < 0.15)),
274 middle: seg(positions.filter((p) => p.costKnown && p.avgPrice >= 0.15 && p.avgPrice <= 0.85)),
275 favorites: seg(positions.filter((p) => p.costKnown && p.avgPrice > 0.85)),
276 };
277 }
278
279 /** Realized PnL of finished positions by calendar month (UTC) of the last economic event. */
280 function monthly(positions) {
281 const map = new Map();
282 for (const p of positions) {
283 if (!p.finished || !(p.closedAt > 0)) continue;
284 const d = new Date(p.closedAt * 1000);
285 const key = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0');
286 const r = map.get(key) || { month: key, n: 0, wins: 0, losses: 0, pnl: 0, cost: 0 };
287 r.n++; r.pnl += p.pnl; r.cost += p.costBasis;
288 if (p.outcomeLabel === 'win') r.wins++; else if (p.outcomeLabel === 'loss') r.losses++;
289 map.set(key, r);
290 }
291 return [...map.values()].sort((a, b) => (a.month < b.month ? -1 : 1));
292 }
293
294 // ---------- CSV ----------
295 /** Long numeric ids (77-digit token ids) as text so spreadsheets don't turn them into 6.77E+75. */
296 const idText = (id) => (id ? { csvRaw: '"\t' + String(id).replace(/"/g, '') + '"' } : '');
297
298 function csvCell(v) {
299 if (v == null) return '';
300 if (typeof v === 'object' && v.csvRaw != null) return v.csvRaw;
301 if (typeof v === 'number') return Number.isFinite(v) ? String(Math.round(v * 1e6) / 1e6) : '';
302 let s = String(v);
303 if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; // spreadsheet formula-injection guard for text cells
304 return /[",\n\r;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
305 }
306 function toCsv(header, rows) {
307 return '\uFEFF' + [header.map(csvCell).join(','), ...rows.map((r) => r.map(csvCell).join(','))].join('\r\n') + '\r\n';
308 }
309 const isoDate = (s) => (s > 0 ? new Date(s * 1000).toISOString().replace('.000Z', 'Z') : '');
310 const marketUrl = (p) => (p.eventSlug ? 'https://polymarket.com/event/' + p.eventSlug : '');
311
312 function positionsCsv(positions) {
313 const header = ['market', 'outcome', 'category', 'status', 'result', 'first_entry_utc', 'closed_utc', 'last_event_utc',
314 'shares_bought', 'avg_entry_price_incl_fees', 'cost_basis_incl_fees_usdc', 'entry_fees_reported_usdc',
315 'realized_pnl_usdc', 'unrealized_pnl_usdc', 'total_pnl_after_fees_usdc', 'settlement_price', 'held_to_resolution',
316 'url', 'condition_id', 'token_id'];
317 const rows = positions.slice().sort((a, b) => (a.closedAt || a.lastEventAt) - (b.closedAt || b.lastEventAt)).map((p) => [
318 p.title, p.outcome, p.category, p.finished ? 'finished' : 'open', p.outcomeLabel,
319 isoDate(p.firstEntryAt), isoDate(p.closedAt), isoDate(p.lastEventAt), p.totalSize, p.avgPrice, p.costBasis, p.entryFees,
320 p.realized, p.unrealized, p.pnl, p.finalPrice, p.heldToResolution == null ? '' : (p.heldToResolution ? 'yes' : 'no'),
321 marketUrl(p), p.conditionId, idText(p.token),
322 ]);
323 return toCsv(header, rows);
324 }
325
326 const ACTIVITY_HEADER = ['time_utc', 'type', 'side', 'market', 'outcome', 'shares', 'price', 'usdc', 'tx_hash', 'url', 'condition_id', 'token_id'];
327
328 /** Streaming form: header once, then one chunk per API page (the journal builds a Blob from the parts). */
329 function activityCsvHeader() { return '\uFEFF' + ACTIVITY_HEADER.map(csvCell).join(',') + '\r\n'; }
330 function activityCsvRows(rows) {
331 return rows.map((r) => activityCells(r).map(csvCell).join(',') + '\r\n').join('');
332 }
333
334 function activityCsv(rows) {
335 return activityCsvHeader() + activityCsvRows(rows);
336 }
337
338 function activityCells(r) {
339 return [
340 isoDate(num(r.timestamp)), r.type, r.side || '', r.title || '', r.outcome || '', num(r.size), num(r.price),
341 num(r.usdc_size), r.transaction_hash || '', r.event_slug ? 'https://polymarket.com/event/' + r.event_slug : '',
342 r.condition_id || '', idText(r.token_id),
343 ];
344 }
345
346 function monthlyCsv(months) {
347 return toCsv(['month_utc', 'finished_positions', 'wins', 'losses', 'pnl_usdc', 'cost_basis_usdc'],
348 months.map((m) => [m.month, m.n, m.wins, m.losses, m.pnl, m.cost]));
349 }
350
351 const journal = {
352 CATEGORIES, categorize, holdingRateFromTags, buildPositions, summarize, byCategory, calibration, behaviour,
353 monthly, wilson, median, positionsCsv, activityCsv, activityCsvHeader, activityCsvRows, monthlyCsv, csvCell,
354 };
355 root.PWL = root.PWL || {};
356 root.PWL.journal = journal;
357 if (typeof module !== 'undefined' && module.exports) module.exports = journal;
358})(typeof globalThis !== 'undefined' ? globalThis : this);