larp-quant-agent/worker/loop.ts
zefiroff 76ebbf3954
All checks were successful
deploy / deploy (push) Successful in 6s
worker: align Telegram bot copy with the site (drop paper / dry-run wording)
Boot, status and help messages now say "Quant Agent" instead of
"$QUANT paper agent — dry-run", matching the de-paper'd site voice.

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

186 lines
7.5 KiB
TypeScript
Raw Permalink 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 Agent</b>`,
`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 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 Agent</b> 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<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()