/** * Polymarket real PnL module * * Data sources: * - /activity?type=TRADE all CLOB buy/sell fills (both maker / taker, covers cases like t8 limit orders being taken) * - /activity?type=REDEEM Claim credited * - /positions current unprocessed positions (not sold / not claimed) * * Note: earlier we used the /trades endpoint, but it only returns fills from the taker's perspective, * so limit orders like t8 that get taken as maker would be missing; therefore we switched to /activity?type=TRADE. * * Design: * - Full load on startup (paginate to the end) * - Incremental sync filtered by lastSyncTs, only pulling new data * - A full refresh every 5 minutes as a fallback * - Pair by (conditionId, outcome) to compute the full PnL of each position */ import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); // ── Constants ─────────────────────────────────────────────── const API_BASE = "https://data-api.polymarket.com"; const API_HEADERS = { "User-Agent": "Mozilla/5.0" }; const PAGE_SIZE = 100; const REQUEST_TIMEOUT_MS = 15000; // Polymarket official fee formula: fee = C × feeRate × p × (1 - p) // For crypto markets (BTC 5m etc.) feeRate = 7.2% (max fee $1.80 / 100 shares @ p=0.5) // Reference: https://docs.polymarket.com/trading/fees const CRYPTO_FEE_RATE = 0.072; // ── Types ─────────────────────────────────────────────────── export interface PmTrade { proxyWallet: string; side: "BUY" | "SELL"; asset: string; conditionId: string; size: number; price: number; timestamp: number; // Unix seconds outcome: string; // "Up" / "Down" outcomeIndex: number; title: string; slug: string; eventSlug: string; transactionHash: string; } export interface PmRedeem { proxyWallet: string; conditionId: string; timestamp: number; // Unix seconds size: number; usdcSize: number; // Claim credited amount transactionHash: string; title: string; slug: string; eventSlug: string; } export interface PmPosition { proxyWallet: string; conditionId: string; asset: string; size: number; avgPrice: number; initialValue: number; currentValue: number; cashPnl: number; realizedPnl: number; redeemable: boolean; outcome: string; outcomeIndex: number; title: string; endDate: string; } export interface PositionSummary { conditionId: string; outcome: string; outcomeIndex: number; title: string; slug: string; windowStart: number; // parsed from slug firstTs: number; // first trade time (seconds) lastTs: number; // last trade time (seconds) buys: PmTrade[]; sells: PmTrade[]; redeems: PmRedeem[]; buyCost: number; // total buy spend (excluding fee) sellRevenue: number; // total sell revenue (excluding fee) redeemRevenue: number; // total Claim payback totalFee: number; // total fee netPnl: number; // real net PnL = sell + Claim - buy - fee status: "claimed" | "sold" | "pending" | "settled_lost"; strategySource?: string; // source from local .strategy-sources.json // Extra info for unsettled positions (from /positions) currentValue?: number; currentRedeemable?: boolean; } /** A flattened single row (one per BUY/SELL/REDEEM/LOST) */ export interface PnlEvent { ts: number; // seconds kind: "BUY" | "SELL" | "REDEEM" | "LOST"; // LOST = settled to zero (virtual event) outcome: string; // Up / Down outcomeIndex: number; conditionId: string; title: string; slug: string; // market slug (e.g. "btc-updown-5m-1777139100"), used by frontend to filter by market size: number; price: number; // = 1 for REDEEM (payout at 1 USDC/share) cost: number; // BUY=spend, SELL=revenue, REDEEM=credited fee: number; // BUY/SELL fee, REDEEM=0 netAmount: number; // net cash change (out=negative, in=positive, fee included) transactionHash: string; strategySource?: string; positionPnl?: number; // position settlement PnL, attached only to the last exit row (SELL/REDEEM/LOST) positionStatus?: "claimed" | "sold" | "pending" | "settled_lost"; pending?: true; // locally pre-inserted, shows "pending calibration" before API data returns } // ── Network utilities ──────────────────────────────────────── async function fetchJson(url: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const res = await fetch(url, { headers: API_HEADERS, signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as T; } finally { clearTimeout(timer); } } async function fetchPaged(path: string, extraQs: string = ""): Promise { const items: T[] = []; let offset = 0; while (true) { const qs = `limit=${PAGE_SIZE}&offset=${offset}${extraQs ? "&" + extraQs : ""}`; const url = `${API_BASE}/${path}${path.includes("?") ? "&" : "?"}${qs}`; const batch = await fetchJson(url); if (!Array.isArray(batch) || batch.length === 0) break; items.push(...batch); if (batch.length < PAGE_SIZE) break; offset += PAGE_SIZE; } return items; } // ── API wrappers ───────────────────────────────────────────── // Use /activity?type=TRADE instead of /trades: the former covers fills from the maker's perspective (e.g. t8 limit orders being taken), // while the latter only returns taker fills and would miss records of being passively filled as the resting order side. export async function fetchAllTrades(proxy: string): Promise { return fetchPaged(`activity?user=${proxy}&type=TRADE`); } export async function fetchAllRedeems(proxy: string): Promise { // Polymarket returns many empty redeem records with size=0 (multi-direction split noise from the same tx), filter them out const all = await fetchPaged(`activity?user=${proxy}&type=REDEEM`); return all.filter(r => (r.usdcSize > 0) || (r.size > 0)); } export async function fetchAllPositions(proxy: string): Promise { return fetchPaged(`positions?user=${proxy}`); } /** Incremental fetch: only data with timestamp > sinceSec */ export async function fetchTradesSince(proxy: string, sinceSec: number): Promise { // The Polymarket API returns in reverse chronological order. Fetch the first page; if the last item is still > sinceSec, continue to the next page const collected: PmTrade[] = []; let offset = 0; while (true) { const url = `${API_BASE}/activity?user=${proxy}&type=TRADE&limit=${PAGE_SIZE}&offset=${offset}`; const batch = await fetchJson(url); if (!Array.isArray(batch) || batch.length === 0) break; const fresh = batch.filter(t => t.timestamp > sinceSec); collected.push(...fresh); if (fresh.length < batch.length) break; // old data appeared, stop paging if (batch.length < PAGE_SIZE) break; offset += PAGE_SIZE; } return collected; } export async function fetchRedeemsSince(proxy: string, sinceSec: number): Promise { const collected: PmRedeem[] = []; let offset = 0; while (true) { const url = `${API_BASE}/activity?user=${proxy}&type=REDEEM&limit=${PAGE_SIZE}&offset=${offset}`; const batch = await fetchJson(url); if (!Array.isArray(batch) || batch.length === 0) break; const fresh = batch.filter(r => r.timestamp > sinceSec); collected.push(...fresh); if (fresh.length < batch.length) break; if (batch.length < PAGE_SIZE) break; offset += PAGE_SIZE; } return collected.filter(r => (r.usdcSize > 0) || (r.size > 0)); } // ── Fee formula ────────────────────────────────────────────── // Official formula: fee = C × feeRate × p × (1 - p), symmetric for buy/sell // Makers are not charged, only the taker pays; our FOK orders are all takers function feeOf(_side: "BUY" | "SELL", size: number, price: number): number { return size * CRYPTO_FEE_RATE * price * (1 - price); } /** Return the Unix seconds of today 0:00 in CST (UTC+8) */ function getCstDayStartSec(): number { const offsetMs = 8 * 3600_000; const cstMs = Date.now() + offsetMs; const cstDay = new Date(cstMs); cstDay.setUTCHours(0, 0, 0, 0); return Math.floor(cstDay.getTime() / 1000) - 8 * 3600; } // ── Position pairing ───────────────────────────────────────── /** * Group trades + redeems by (conditionId, outcome), computing the full PnL for each group * * Note: redeem events do not contain outcome info; they are attributed to this market via conditionId. * If you bought both Up and Down under the same conditionId (rare), the redeem * is attributed to every outcome that appeared (only one side can win, the other has usdcSize=0 and has no effect). */ export function summarizePositions( trades: PmTrade[], redeems: PmRedeem[], positions: PmPosition[], strategySources: Map, ): PositionSummary[] { type Key = string; const mk = (c: string, o: string): Key => `${c}::${o}`; const groups = new Map(); // 1. First group all trades by (conditionId, outcome) for (const t of trades) { const k = mk(t.conditionId, t.outcome); let g = groups.get(k); if (!g) { const ws = parseWindowStartFromSlug(t.slug); g = { conditionId: t.conditionId, outcome: t.outcome, outcomeIndex: t.outcomeIndex, title: t.title, slug: t.slug, windowStart: ws, firstTs: t.timestamp, lastTs: t.timestamp, buys: [], sells: [], redeems: [], buyCost: 0, sellRevenue: 0, redeemRevenue: 0, totalFee: 0, netPnl: 0, status: "pending", }; groups.set(k, g); } if (t.side === "BUY") g.buys.push(t); else g.sells.push(t); g.firstTs = Math.min(g.firstTs, t.timestamp); g.lastTs = Math.max(g.lastTs, t.timestamp); } // 2. Attribute redeems by conditionId (a single conditionId may have multiple outcome groups) const redeemsByCond = new Map(); for (const r of redeems) { const arr = redeemsByCond.get(r.conditionId) ?? []; arr.push(r); redeemsByCond.set(r.conditionId, arr); } // 3. Compute the PnL of each group for (const g of groups.values()) { const rs = redeemsByCond.get(g.conditionId) ?? []; // All redeems of the same conditionId are attached here (the winning side) g.redeems = rs; if (rs.length) g.lastTs = Math.max(g.lastTs, ...rs.map(r => r.timestamp)); g.buyCost = g.buys.reduce((s, b) => s + b.size * b.price, 0); g.sellRevenue = g.sells.reduce((s, x) => s + x.size * x.price, 0); g.redeemRevenue = rs.reduce((s, r) => s + r.usdcSize, 0); g.totalFee = g.buys.reduce((s, b) => s + feeOf("BUY", b.size, b.price), 0) + g.sells.reduce((s, x) => s + feeOf("SELL", x.size, x.price), 0); g.netPnl = g.sellRevenue + g.redeemRevenue - g.buyCost - g.totalFee; // Determine status if (rs.length > 0) g.status = "claimed"; else if (g.sells.length > 0) g.status = "sold"; else g.status = "pending"; // Strategy source: look up by the txHash of the first buy if (g.buys.length) { const src = strategySources.get(g.buys[0].transactionHash.toLowerCase()); if (src) g.strategySource = src; } } // 4. Unsettled position info: supplement from /positions for (const p of positions) { const k = mk(p.conditionId, p.outcome); const g = groups.get(k); if (!g) continue; g.currentValue = p.currentValue; g.currentRedeemable = p.redeemable; // Settled but zeroed out: upgrade from pending to settled_lost if (g.status === "pending" && p.redeemable && p.currentValue === 0) { g.status = "settled_lost"; // In this case cashPnl is -initialValue (position value goes to zero) // Already reflected in g.netPnl (sell=0, redeem=0, buyCost - fee is the loss) } } // 5. Return sorted by most recent time descending return [...groups.values()].sort((a, b) => b.lastTs - a.lastTs); } function parseWindowStartFromSlug(slug: string): number { // slug format "btc-updown-5m-1776762000" const m = slug.match(/(\d{10,})$/); return m ? parseInt(m[1], 10) : 0; } // ── Strategy source mapping (local persistence) ────────────── const STRATEGY_SOURCES_FILE = resolve(__dirname, ".strategy-sources.json"); export function loadStrategySources(): Map { try { if (!existsSync(STRATEGY_SOURCES_FILE)) return new Map(); const data = JSON.parse(readFileSync(STRATEGY_SOURCES_FILE, "utf-8")); if (typeof data !== "object" || data == null) return new Map(); return new Map(Object.entries(data as Record).map(([k, v]) => [k.toLowerCase(), v])); } catch (err) { console.warn(`[PmPnl] Failed to load strategy-sources: ${err instanceof Error ? err.message : String(err)}`); return new Map(); } } export function saveStrategySources(map: Map): void { try { const obj: Record = {}; for (const [k, v] of map) obj[k] = v; writeFileSync(STRATEGY_SOURCES_FILE, JSON.stringify(obj, null, 2) + "\n", "utf-8"); } catch (err) { console.warn(`[PmPnl] Failed to save strategy-sources: ${err instanceof Error ? err.message : String(err)}`); } } // ── Manager: state + sync ──────────────────────────────────── export class PmPnlManager { private trades: PmTrade[] = []; private redeems: PmRedeem[] = []; private positions: PmPosition[] = []; private strategySources: Map = loadStrategySources(); private initialized = false; private refreshing = false; private lastRefreshAt = 0; // timestamp of the most recent successful full fetch (ms) constructor(private proxy: string) {} /** Record the strategy source of a trade (txHash → source) */ recordStrategySource(txHash: string, source: string): void { if (!txHash) return; this.strategySources.set(txHash.toLowerCase(), source); saveStrategySources(this.strategySources); } /** Startup load (fetch today's CST data, equivalent to fetchAll) */ async init(): Promise { if (this.initialized) return; await this.fetchAll(); this.initialized = true; } /** * Fetch today's (from CST 0:00) trades/redeems + current positions, overwriting the local cache * * Note: although the function is named fetchAll, it actually only fetches "today's" fills, not the full history. * Design reason: refreshing every 5 minutes + checking cross-day data on the Polymarket website is enough, no need to cache all history on the backend. */ async fetchAll(): Promise { if (!this.proxy || this.refreshing) return false; this.refreshing = true; try { const sinceSec = getCstDayStartSec(); const [tradesRes, redeemsRes, positionsRes] = await Promise.allSettled([ fetchTradesSince(this.proxy, sinceSec), fetchRedeemsSince(this.proxy, sinceSec), fetchAllPositions(this.proxy), ]); if (tradesRes.status === "fulfilled") this.trades = tradesRes.value; else console.warn(`[PmPnl] today's trades failed: ${tradesRes.reason?.message ?? tradesRes.reason}`); if (redeemsRes.status === "fulfilled") this.redeems = redeemsRes.value; else console.warn(`[PmPnl] today's redeems failed: ${redeemsRes.reason?.message ?? redeemsRes.reason}`); if (positionsRes.status === "fulfilled") this.positions = positionsRes.value; else console.warn(`[PmPnl] positions failed: ${positionsRes.reason?.message ?? positionsRes.reason}`); this.lastRefreshAt = Date.now(); console.log(`[PmPnl] refresh (today CST): trades ${this.trades.length} / redeems ${this.redeems.length} / positions ${this.positions.length}`); return true; } catch (err) { console.warn(`[PmPnl] refresh exception: ${err instanceof Error ? err.message : String(err)}`); return false; } finally { this.refreshing = false; } } getLastRefreshAt(): number { return this.lastRefreshAt; } /** Return a snapshot aggregated by position */ getSummaries(limit?: number): PositionSummary[] { const all = summarizePositions(this.trades, this.redeems, this.positions, this.strategySources); return limit ? all.slice(0, limit) : all; } /** Return flattened per-event rows, in reverse chronological order. Returns only the last 7 days by default. */ getEvents(opts?: { limit?: number; sinceDays?: number }): PnlEvent[] { const sinceDays = opts?.sinceDays ?? 7; const limit = opts?.limit; const nowSec = Math.floor(Date.now() / 1000); const sinceSec = sinceDays > 0 ? nowSec - sinceDays * 86400 : 0; const summaries = summarizePositions(this.trades, this.redeems, this.positions, this.strategySources); // Split each position: BUY + SELL + REDEEM each become a row, with position info attached const events: PnlEvent[] = []; for (const s of summaries) { for (const b of s.buys) { const fee = feeOf("BUY", b.size, b.price); const cost = b.size * b.price; events.push({ ts: b.timestamp, kind: "BUY", outcome: b.outcome, outcomeIndex: b.outcomeIndex, conditionId: b.conditionId, title: b.title, slug: s.slug, size: b.size, price: b.price, cost, fee, netAmount: -(cost + fee), transactionHash: b.transactionHash, strategySource: s.strategySource, }); } // Position settlement PnL is attached only to the last exit row (the one with the largest ts among SELL/REDEEM) // To avoid showing the same netPnl value repeatedly when a position has multiple exits const lastExitTs = Math.max( ...s.sells.map(x => x.timestamp), ...s.redeems.map(r => r.timestamp), -Infinity, ); let pnlAttached = false; // attach only once when multiple rows share the same ts for (const x of s.sells) { const fee = feeOf("SELL", x.size, x.price); const revenue = x.size * x.price; const isLastExit = !pnlAttached && x.timestamp === lastExitTs; if (isLastExit) pnlAttached = true; events.push({ ts: x.timestamp, kind: "SELL", outcome: x.outcome, outcomeIndex: x.outcomeIndex, conditionId: x.conditionId, title: x.title, slug: s.slug, size: x.size, price: x.price, cost: revenue, fee, netAmount: revenue - fee, transactionHash: x.transactionHash, strategySource: s.strategySource, ...(isLastExit ? { positionPnl: s.netPnl, positionStatus: s.status } : {}), }); } for (const r of s.redeems) { const isLastExit = !pnlAttached && r.timestamp === lastExitTs; if (isLastExit) pnlAttached = true; events.push({ ts: r.timestamp, kind: "REDEEM", outcome: s.outcome, outcomeIndex: s.outcomeIndex, conditionId: r.conditionId, title: r.title, slug: s.slug, size: r.size, price: 1, cost: r.usdcSize, fee: 0, netAmount: r.usdcSize, transactionHash: r.transactionHash, strategySource: s.strategySource, ...(isLastExit ? { positionPnl: s.netPnl, positionStatus: s.status } : {}), }); } // Virtual "settled to zero" event: BUY exists + no SELL + no REDEEM + the window's settlement time has passed // windowStart is parsed from slug, settlement time = windowStart + 300 seconds if (s.buys.length > 0 && s.sells.length === 0 && s.redeems.length === 0 && s.windowStart > 0) { const settleTs = s.windowStart + 300; if (nowSec >= settleTs) { // Synthesize a LOST row const totalSize = s.buys.reduce((sum, b) => sum + b.size, 0); events.push({ ts: settleTs, kind: "LOST", outcome: s.outcome, outcomeIndex: s.outcomeIndex, conditionId: s.conditionId, title: s.title, slug: s.slug, size: totalSize, price: 0, cost: 0, fee: 0, netAmount: 0, // zeroing out produces no cash flow (the money was already spent at buy time) transactionHash: s.buys[0].transactionHash, strategySource: s.strategySource, positionPnl: s.netPnl, // real PnL of this position = -buy cost - fee positionStatus: "settled_lost", }); } } } const filtered = sinceSec > 0 ? events.filter(e => e.ts >= sinceSec) : events; filtered.sort((a, b) => b.ts - a.ts); return limit ? filtered.slice(0, limit) : filtered; } /** * Unified stats snapshot (frontend panel / monitor page / TG share the same definition) * * Rules: * - One trade = one trading window (deduped by conditionId) that has had a BUY * - Settled = the window has either a SELL/REDEEM, or satisfies "window settlement time has passed + no sell, no redeem" (fallback, to handle the case where PM /positions does not return small positions) * - Win = the window's net PnL > 0 * * sinceSec=0 means all history; other values are Unix seconds, counting only positions with firstTs >= sinceSec */ computeSnapshot(sinceSec: number = 0): { positions: number; // total count (number of windows, including unsettled) closedPositions: number; // number of settled trades wins: number; // number of settled trades with net PnL > 0 netPnl: number; // net PnL (sum of settled positions + unsettled floating loss i.e. -buyCost-fee also counted, consistent with the frontend recalcTotal behavior) totalFee: number; buyCost: number; sellRevenue: number; redeemRevenue: number; } { const summaries = summarizePositions(this.trades, this.redeems, this.positions, this.strategySources); const nowSec = Math.floor(Date.now() / 1000); // Dedupe by conditionId into "windows", merging stats of multiple outcomes within the same cond interface WinRow { conditionId: string; firstTs: number; hasBuy: boolean; hasSettled: boolean; // sells/redeems/settlement time has passed buyCost: number; sellRevenue: number; redeemRevenue: number; totalFee: number; } const windows = new Map(); for (const s of summaries) { // Skip those never bought (defensive) if (s.buys.length === 0) continue; const cond = s.conditionId; let row = windows.get(cond); if (!row) { row = { conditionId: cond, firstTs: s.firstTs, hasBuy: false, hasSettled: false, buyCost: 0, sellRevenue: 0, redeemRevenue: 0, totalFee: 0, }; windows.set(cond, row); } row.hasBuy = true; row.firstTs = Math.min(row.firstTs, s.firstTs); row.buyCost += s.buyCost; row.sellRevenue += s.sellRevenue; row.redeemRevenue += s.redeemRevenue; row.totalFee += s.totalFee; // Whether this outcome is settled const outcomeSettled = s.sells.length > 0 || s.redeems.length > 0 || s.status === "settled_lost" || // Fallback: windowStart has passed + no sell, no redeem (PM /positions may not return small positions settled to zero) (s.windowStart > 0 && nowSec >= s.windowStart + 300 && s.sells.length === 0 && s.redeems.length === 0); if (outcomeSettled) row.hasSettled = true; } // Apply the sinceSec filter const filtered = sinceSec > 0 ? [...windows.values()].filter(w => w.firstTs >= sinceSec) : [...windows.values()]; let positions = 0, closedPositions = 0, wins = 0; let netPnl = 0, totalFee = 0, buyCost = 0, sellRevenue = 0, redeemRevenue = 0; for (const w of filtered) { if (!w.hasBuy) continue; positions++; buyCost += w.buyCost; sellRevenue += w.sellRevenue; redeemRevenue += w.redeemRevenue; totalFee += w.totalFee; const winNet = w.sellRevenue + w.redeemRevenue - w.buyCost - w.totalFee; netPnl += winNet; if (w.hasSettled) { closedPositions++; if (winNet > 0) wins++; } } return { positions, closedPositions, wins, netPnl, totalFee, buyCost, sellRevenue, redeemRevenue }; } /** Total PnL (last 7 days only by default; pass sinceDays=0 for all) */ getTotalPnl(sinceDays: number = 7): { totalBuy: number; totalSell: number; totalRedeem: number; totalFee: number; netPnl: number; positionCount: number } { const sinceSec = sinceDays > 0 ? Math.floor(Date.now() / 1000) - sinceDays * 86400 : 0; let totalBuy = 0, totalSell = 0, totalRedeem = 0, totalFee = 0; let count = 0; for (const t of this.trades) { if (sinceSec > 0 && t.timestamp < sinceSec) continue; if (t.side === "BUY") totalBuy += t.size * t.price; else totalSell += t.size * t.price; totalFee += feeOf(t.side, t.size, t.price); count++; } for (const r of this.redeems) { if (sinceSec > 0 && r.timestamp < sinceSec) continue; totalRedeem += r.usdcSize; } return { totalBuy, totalSell, totalRedeem, totalFee, netPnl: totalSell + totalRedeem - totalBuy - totalFee, positionCount: count, }; } isInitialized(): boolean { return this.initialized; } }