All checks were successful
deploy / deploy (push) Successful in 7s
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>
133 lines
4.5 KiB
TypeScript
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),
|
|
}
|
|
}
|