/** * The runtime — the always-on cycle that makes the swarm real. * * Per tick, for each coin: real candles → real TA → 4 agents vote → verdict * (all reused verbatim from src/lib/*), then the paper engine opens/closes * positions and the Telegram layer broadcasts every fill. State is persisted * each tick so it survives restarts. * * pnpm worker # run forever (default) * pnpm worker:once # single tick, then exit (smoke test) */ import { getCandles, getMarketStats, type MarketStat } from '../src/lib/hyperliquid.ts' import { summarizeTA, type TASnapshot } from '../src/lib/ta.ts' import { screen } from '../src/lib/signals.ts' import { CONFIG } from './config.ts' import { load, save, getState } from './store.ts' import { tgSend, tgPoll, TG_ENABLED } from './telegram.ts' import { markToMarket, maybeOpen, recordSignal, stats, type Stats } from './paper.ts' import type { Position, Trade } from './store.ts' // ── formatting ──────────────────────────────────────────────────────────────── const usd = (n: number) => '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 100 ? 0 : 2 }) const px = (n: number) => '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 100 ? 0 : n >= 1 ? 2 : 4 }) const pct = (n: number) => (n >= 0 ? '+' : '') + n.toFixed(2) + '%' const signed = (n: number) => (n >= 0 ? '+' : '−') + usd(Math.abs(n)).slice(1) const lastDecision = new Map() // ── telegram messages ─────────────────────────────────────────────────────── function openMsg(p: Position): string { const dot = p.dir === 'LONG' ? '🟢' : '🔴' return [ `${dot} ${p.dir} ${p.coin}-PERP @ ${px(p.entry)}`, `size ${(p.notional / getState().equity / CONFIG.leverage * 100).toFixed(1)}% · ×${CONFIG.leverage} → ${usd(p.notional)} notional`, `TP ${pct(p.tpPct)} (${px(p.tp)}) · SL ${pct(-p.slPct)} (${px(p.sl)})`, `confidence ${Math.round(p.confidence * 100)}% · ${p.reason}`, ].join('\n') } function closeMsg(t: Trade): string { const mark = t.outcome === 'tp' ? '✅ TP' : '❌ SL' return [ `${mark} ${t.coin}-PERP ${t.dir}`, `${px(t.entry)} → ${px(t.exit)} (${pct(t.pnlPct)}, ${signed(t.pnlUsd)})`, `held ${t.holdMin >= 60 ? (t.holdMin / 60).toFixed(1) + 'h' : t.holdMin + 'm'} · equity ${usd(getState().equity)}`, ].join('\n') } function statusMsg(): string { const st = getState() const s = stats() const open = st.positions.length ? st.positions.map((p) => `${p.coin} ${p.dir}`).join(' · ') : '—' return [ `📊 Quant Agent`, `equity ${usd(s.equity)} (${pct(s.roiPct)})`, `open ${open}`, `closed ${s.trades} · win ${Math.round(s.winRate * 100)}%`, ].join('\n') } function learnMsg(): string { const s = stats() if (!s.trades) return '🧠 Retrospective\nNo closed trades yet — be patient.' const pf = s.profitFactor === Infinity ? '∞' : s.profitFactor.toFixed(2) const lines = [ `🧠 Retrospective — ${s.trades} trades`, `win rate ${Math.round(s.winRate * 100)}% · profit factor ${pf}`, `ROI ${pct(s.roiPct)} (${signed(s.pnlUsd)})`, `avg win ${signed(s.avgWin)} · avg loss ${signed(-s.avgLoss)}`, ] if (s.best) lines.push(`best ${signed(s.best.pnlUsd)} (${s.best.coin} ${s.best.dir})`) if (s.worst) lines.push(`worst ${signed(s.worst.pnlUsd)} (${s.worst.coin} ${s.worst.dir})`) return lines.join('\n') } function positionsMsg(): string { const st = getState() if (!st.positions.length) return '📭 No open positions.' return ( '📂 Open positions\n' + st.positions .map( (p) => `${p.dir === 'LONG' ? '🟢' : '🔴'} ${p.coin} ${p.dir} @ ${px(p.entry)} · TP ${px(p.tp)} / SL ${px(p.sl)}`, ) .join('\n') ) } const helpMsg = () => [ '🤖 Quant Agent', '/status — equity, open positions, win rate', '/positions — open positions detail', '/pnl or /learn — retrospective over closed trades', '/help — this message', ].join('\n') // ── core tick ───────────────────────────────────────────────────────────────── async function tickCoin(coin: string, stat: MarketStat | null): Promise { const ctrl = new AbortController() const t = setTimeout(() => ctrl.abort(), 12_000) try { const candles = await getCandles(coin, CONFIG.interval, CONFIG.lookback, ctrl.signal) const ta: TASnapshot = summarizeTA(candles, stat?.funding ?? 0) const { verdict } = screen(ta, stat ?? undefined) const price = stat?.mark ?? ta.price // 1) manage open positions against the real price for (const tr of markToMarket(coin, price)) await tgSend(closeMsg(tr)) // 2) open a fresh position when the swarm has an edge const opened = maybeOpen(coin, price, verdict) if (opened) await tgSend(openMsg(opened)) // 3) log the call only when the decision flips (keeps the feed meaningful) if (verdict.decision !== lastDecision.get(coin)) { lastDecision.set(coin, verdict.decision) recordSignal({ t: Date.now(), coin, decision: verdict.decision, confidence: verdict.confidence, price, net: verdict.net, reason: verdict.reasoning, }) } console.log( `[${coin}] ${px(price)} ${verdict.decision.padEnd(5)} conf ${verdict.confidence.toFixed(2)} net ${verdict.net.toFixed(2)}`, ) } catch (e) { console.error(`[${coin}] tick error:`, (e as Error).message) } finally { clearTimeout(t) } } async function tick(): Promise { let byCoin = new Map() try { const all = await getMarketStats(CONFIG.coins) byCoin = new Map(all.map((s) => [s.coin, s])) } catch (e) { console.error('[stats] fetch failed:', (e as Error).message) } for (const coin of CONFIG.coins) await tickCoin(coin, byCoin.get(coin) ?? null) save() } async function commandLoop(): Promise { await tgPoll(async (cmd, chatId) => { if (cmd === '/status' || cmd === '/start') await tgSend(statusMsg(), chatId) else if (cmd === '/pnl' || cmd === '/learn') await tgSend(learnMsg(), chatId) else if (cmd === '/positions') await tgSend(positionsMsg(), chatId) else if (cmd === '/help') await tgSend(helpMsg(), chatId) }) } // ── boot ────────────────────────────────────────────────────────────────────── function bootMsg(s: Stats): string { return [ `🟩 Quant Agent online`, `${CONFIG.coins.join(' · ')} · ${CONFIG.interval} · poll ${CONFIG.pollMs / 1000}s`, `equity ${usd(s.equity)} · ${s.trades} trades so far`, ].join('\n') } async function main(): Promise { const once = process.argv.includes('--once') load() console.log( `QUANT paper worker · mode ${CONFIG.mode} · coins ${CONFIG.coins.join(',')} · interval ${CONFIG.interval} · TG ${TG_ENABLED ? 'on' : 'off'}${once ? ' · ONCE' : ''}`, ) await tgSend(bootMsg(stats())) await tick() if (once) return setInterval(tick, CONFIG.pollMs) if (CONFIG.tgToken) setInterval(commandLoop, 3_000) } main()