/** * Paper-trading engine. Turns the (real) verdict into (paper) positions and marks * them to market against (real) Hyperliquid prices — so the PnL track record is * honest and verifiable, not seeded. One position per coin; TP/SL assumed filled * at the level. This is the layer that makes the LARP's "performance" real. */ import { getState, type Position, type Trade, type SignalLog } from './store.ts' import { CONFIG } from './config.ts' import type { Verdict } from '../src/lib/signals.ts' const round = (n: number, d = 2) => Math.round(n * 10 ** d) / 10 ** d const sum = (a: number[]) => a.reduce((s, x) => s + x, 0) let seq = 0 const newId = (coin: string) => `${coin}-${Date.now().toString(36)}-${(seq++).toString(36)}` /** Close any open position whose TP/SL the current price has crossed. */ export function markToMarket(coin: string, price: number): Trade[] { const st = getState() const closed: Trade[] = [] const keep: Position[] = [] for (const p of st.positions) { if (p.coin !== coin) { keep.push(p) continue } const hitTp = p.dir === 'LONG' ? price >= p.tp : price <= p.tp const hitSl = p.dir === 'LONG' ? price <= p.sl : price >= p.sl if (hitTp || hitSl) { closed.push(close(p, hitTp ? p.tp : p.sl, hitTp ? 'tp' : 'sl')) } else { keep.push(p) } } st.positions = keep return closed } function close(p: Position, exit: number, outcome: Trade['outcome']): Trade { const st = getState() const move = (exit - p.entry) / p.entry const pnlPct = (p.dir === 'LONG' ? move : -move) * 100 const pnlUsd = p.notional * (pnlPct / 100) st.equity = round(st.equity + pnlUsd) const trade: Trade = { ...p, exit, closedAt: Date.now(), pnlPct: round(pnlPct, 3), pnlUsd: round(pnlUsd), outcome, holdMin: Math.round((Date.now() - p.openedAt) / 60_000), } st.closed.push(trade) if (st.closed.length > 500) st.closed.splice(0, st.closed.length - 500) st.equityCurve.push({ t: trade.closedAt, equity: st.equity }) if (st.equityCurve.length > 1000) st.equityCurve.splice(0, st.equityCurve.length - 1000) return trade } /** Open a paper position if the verdict is actionable and above the conf floor. */ export function maybeOpen(coin: string, price: number, v: Verdict): Position | null { const st = getState() if (v.decision === 'SKIP') return null if (v.confidence < CONFIG.confFloor) return null if (st.positions.filter((p) => p.coin === coin).length >= CONFIG.maxOpenPerCoin) return null const dir = v.decision as Position['dir'] const tp = dir === 'LONG' ? price * (1 + v.tpPct / 100) : price * (1 - v.tpPct / 100) const sl = dir === 'LONG' ? price * (1 - v.slPct / 100) : price * (1 + v.slPct / 100) const notional = round(st.equity * (v.sizePct / 100) * CONFIG.leverage) const pos: Position = { id: newId(coin), coin, dir, entry: price, notional, tp: round(tp, 4), sl: round(sl, 4), tpPct: v.tpPct, slPct: v.slPct, confidence: v.confidence, reason: v.reasoning, openedAt: Date.now(), } st.positions.push(pos) return pos } /** Append to the rolling signal log (call only when a coin's decision changes). */ export function recordSignal(s: SignalLog): void { const st = getState() st.signals.push(s) if (st.signals.length > 200) st.signals.splice(0, st.signals.length - 200) } export type Stats = { trades: number open: number winRate: number pnlUsd: number roiPct: number profitFactor: number avgWin: number avgLoss: number best: Trade | null worst: Trade | null equity: number } /** Retrospective over closed trades — the honest, no-LLM version of /learn. */ export function stats(): Stats { const st = getState() const c = st.closed const wins = c.filter((t) => t.pnlUsd > 0) const losses = c.filter((t) => t.pnlUsd <= 0) const grossWin = sum(wins.map((t) => t.pnlUsd)) const grossLoss = -sum(losses.map((t) => t.pnlUsd)) return { trades: c.length, open: st.positions.length, winRate: c.length ? wins.length / c.length : 0, pnlUsd: round(st.equity - CONFIG.equityStart), roiPct: round((st.equity / CONFIG.equityStart - 1) * 100, 2), profitFactor: grossLoss ? round(grossWin / grossLoss) : grossWin > 0 ? Infinity : 0, avgWin: wins.length ? round(grossWin / wins.length) : 0, avgLoss: losses.length ? round(grossLoss / losses.length) : 0, best: c.reduce((m, t) => (t.pnlUsd > (m?.pnlUsd ?? -Infinity) ? t : m), null), worst: c.reduce((m, t) => (t.pnlUsd < (m?.pnlUsd ?? Infinity) ? t : m), null), equity: round(st.equity), } }