larp-quant-agent/worker/paper.ts
zefiroff f3b533b032
All checks were successful
deploy / deploy (push) Successful in 7s
Quant Agent — multi-agent quant terminal on Hyperliquid
Live HL data → in-browser TA → 4-agent committee → LONG/SHORT/SKIP verdict.
Terminal grid command bar + interactive agent-workflow graph. Paper-trading
worker reuses the browser brain (dry-run + Telegram). Forgejo static deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:00:13 +04:00

133 lines
4.5 KiB
TypeScript

/**
* 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<Trade | null>((m, t) => (t.pnlUsd > (m?.pnlUsd ?? -Infinity) ? t : m), null),
worst: c.reduce<Trade | null>((m, t) => (t.pnlUsd < (m?.pnlUsd ?? Infinity) ? t : m), null),
equity: round(st.equity),
}
}