All checks were successful
deploy / deploy (push) Successful in 6s
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>
186 lines
7.5 KiB
TypeScript
186 lines
7.5 KiB
TypeScript
/**
|
||
* 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()
|