Quant Agent — multi-agent quant terminal on Hyperliquid
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>
This commit is contained in:
zefiroff 2026-06-09 19:00:13 +04:00
commit f3b533b032
110 changed files with 15045 additions and 0 deletions

186
worker/loop.ts Normal file
View file

@ -0,0 +1,186 @@
/**
* 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()