PolyTools Trade Cost 0.1.1 / src/lib/journal-stats.js
| 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); |