655 lines
26 KiB
TypeScript
655 lines
26 KiB
TypeScript
/**
|
||||
|
|
* 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<T>(url: string): Promise<T> {
|
|||
|
|
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<T>(path: string, extraQs: string = ""): Promise<T[]> {
|
|||
|
|
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<T[]>(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<PmTrade[]> {
|
|||
|
|
return fetchPaged<PmTrade>(`activity?user=${proxy}&type=TRADE`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function fetchAllRedeems(proxy: string): Promise<PmRedeem[]> {
|
|||
|
|
// Polymarket returns many empty redeem records with size=0 (multi-direction split noise from the same tx), filter them out
|
|||
|
|
const all = await fetchPaged<PmRedeem>(`activity?user=${proxy}&type=REDEEM`);
|
|||
|
|
return all.filter(r => (r.usdcSize > 0) || (r.size > 0));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function fetchAllPositions(proxy: string): Promise<PmPosition[]> {
|
|||
|
|
return fetchPaged<PmPosition>(`positions?user=${proxy}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Incremental fetch: only data with timestamp > sinceSec */
|
|||
|
|
export async function fetchTradesSince(proxy: string, sinceSec: number): Promise<PmTrade[]> {
|
|||
|
|
// 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<PmTrade[]>(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<PmRedeem[]> {
|
|||
|
|
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<PmRedeem[]>(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<string, string>,
|
|||
|
|
): PositionSummary[] {
|
|||
|
|
type Key = string;
|
|||
|
|
const mk = (c: string, o: string): Key => `${c}::${o}`;
|
|||
|
|
const groups = new Map<Key, PositionSummary>();
|
|||
|
|
|
|||
|
|
// 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<string, PmRedeem[]>();
|
|||
|
|
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<string, string> {
|
|||
|
|
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<string, string>).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<string, string>): void {
|
|||
|
|
try {
|
|||
|
|
const obj: Record<string, string> = {};
|
|||
|
|
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<string, string> = 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<void> {
|
|||
|
|
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<boolean> {
|
|||
|
|
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<string, WinRow>();
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|