Files

655 lines
26 KiB
TypeScript
Raw Permalink Normal View History

2026-05-31 13:49:36 +08:00
/**
* 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;
}
}