larp-quant-agent/worker/loop.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

186 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string>()
// ── telegram messages ───────────────────────────────────────────────────────
function openMsg(p: Position): string {
const dot = p.dir === 'LONG' ? '🟢' : '🔴'
return [
`${dot} <b>${p.dir} ${p.coin}-PERP</b> @ ${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)}% · <i>${p.reason}</i>`,
].join('\n')
}
function closeMsg(t: Trade): string {
const mark = t.outcome === 'tp' ? '✅ TP' : '❌ SL'
return [
`${mark} <b>${t.coin}-PERP ${t.dir}</b>`,
`${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 [
`📊 <b>$QUANT</b> — paper · ${CONFIG.mode}`,
`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 '🧠 <b>Retrospective</b>\nNo closed trades yet — be patient.'
const pf = s.profitFactor === Infinity ? '∞' : s.profitFactor.toFixed(2)
const lines = [
`🧠 <b>Retrospective</b> — ${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 (
'📂 <b>Open positions</b>\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 = () =>
[
'🤖 <b>$QUANT paper agent</b>',
'/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<void> {
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<void> {
let byCoin = new Map<string, MarketStat>()
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<void> {
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 [
`🟩 <b>$QUANT</b> paper agent online — ${CONFIG.mode}`,
`${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<void> {
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()