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

72
worker/README.md Normal file
View file

@ -0,0 +1,72 @@
# Paper-trading worker
The always-on runtime that makes the swarm **real** — without real-money risk.
It reuses the exact browser brain (`src/lib/hyperliquid.ts` · `ta.ts` · `signals.ts`,
pure functions, no DOM) on a server loop, paper-trades the verdict against **live
Hyperliquid prices**, and broadcasts every fill to **Telegram**. This turns the site's
biggest fiction — the performance track record — into an honest, verifiable record.
```
real candles → real TA → 4 agents vote → verdict → paper open/close → Telegram + JSON
(L1) (L2) (L3) (L4) (L5 paper) (L6)
```
## Run
```bash
pnpm worker # run forever, polling every QUANT_POLL_MS (default 60s)
pnpm worker:once # single tick then exit (smoke test)
```
No setup required — with no Telegram configured it runs head-less and prints every
signal/fill to stdout. Add a bot to broadcast (see below).
## Telegram (optional)
1. Create a bot with [@BotFather](https://t.me/BotFather) → copy the token.
2. Message the bot once, then get your chat id from [@userinfobot](https://t.me/userinfobot).
3. Put both in `.env.local`:
```
TELEGRAM_BOT_TOKEN=123456:ABC-...
TELEGRAM_CHAT_ID=123456789
```
4. `pnpm worker`. The bot posts on open/close and answers commands:
`/status` · `/positions` · `/pnl` (a.k.a. `/learn`) · `/help`.
## What's real vs. paper
- **Real:** market data, candles, every indicator, the agent votes, the verdict, the
entry/exit *prices*, and therefore the PnL.
- **Paper:** no order is ever signed or sent. `mode` is fixed to `dry-run`. Positions are
marked to market against the live price; TP/SL are assumed filled at the level. This is
the deliberate 80/20 line — all the credibility, none of the key-management / loss risk.
## Config (env, all optional)
| var | default | meaning |
|---|---|---|
| `QUANT_COINS` | `BTC,ETH,SOL` | universe |
| `QUANT_INTERVAL` | `1h` | candle timeframe |
| `QUANT_POLL_MS` | `60000` | tick cadence |
| `QUANT_EQUITY_START` | `10000` | starting paper bankroll (USD) |
| `QUANT_LEVERAGE` | `5` | applied to `sizePct` → notional |
| `QUANT_CONF_FLOOR` | `0.55` | min verdict confidence to open |
| `QUANT_MAX_OPEN_PER_COIN` | `1` | concurrent positions per coin |
| `QUANT_DATA_FILE` | `worker/data/state.json` | persisted state |
## State
A single JSON file (`worker/data/`, gitignored) written atomically each tick:
`equity`, open `positions`, `closed` trades, `signals` log, and an `equityCurve`. It is
exactly the shape a future `/api/equity` · `/api/signals` endpoint would serve so the
site's Performance section and "recent fills" can read the real record instead of the
seeded one.
## Next 20% (deliberately skipped)
- **Live execution:** sign + send real Hyperliquid orders. Needs an account, key
management, and accepts real loss — keep behind a flag, `dry-run` stays default.
- **`/api/*` + frontend wiring:** serve this state and point `Performance` / fills at it.
- **Claude in the loop:** LLM-written reasoning per verdict and a real LLM `/learn`
retrospective (off the risk path).

48
worker/config.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* Worker config. Loaded from `.env.local` (gitignored) with safe defaults so the
* loop runs out-of-the-box in dry-run *paper* mode even with zero configuration.
*
* Everything here is intentionally paper-only: this process never signs or sends a
* real order. Live execution is the genuinely-risky 20% we deliberately skip the
* `mode` is fixed to 'dry-run' so the credibility comes from an honest, verifiable
* paper track record, not from putting an LLM/agent in the money-losing path.
*/
// Node 21+ — load .env.local into process.env without any dependency.
try {
process.loadEnvFile('.env.local')
} catch {
/* no .env.local — fall back to real env / defaults */
}
const num = (v: string | undefined, d: number) =>
v != null && v !== '' && isFinite(+v) ? +v : d
const list = (v: string | undefined, d: string[]) =>
v ? v.split(',').map((s) => s.trim()).filter(Boolean) : d
export const CONFIG = {
// ── universe / cadence
coins: list(process.env.QUANT_COINS, ['BTC', 'ETH', 'SOL']),
interval: process.env.QUANT_INTERVAL ?? '1h',
lookback: num(process.env.QUANT_LOOKBACK, 120),
pollMs: num(process.env.QUANT_POLL_MS, 60_000),
// ── paper trading
equityStart: num(process.env.QUANT_EQUITY_START, 10_000),
leverage: num(process.env.QUANT_LEVERAGE, 5),
confFloor: num(process.env.QUANT_CONF_FLOOR, 0.55),
maxOpenPerCoin: num(process.env.QUANT_MAX_OPEN_PER_COIN, 1),
// ── telegram (optional — worker logs to stdout if unset)
tgToken: process.env.TELEGRAM_BOT_TOKEN ?? '',
tgChat: process.env.TELEGRAM_CHAT_ID ?? '',
// ── execution mode — paper only. 'live' is intentionally not implemented.
mode: 'dry-run' as const,
// ── persistence
dataFile: process.env.QUANT_DATA_FILE ?? 'worker/data/state.json',
}
export type Config = typeof CONFIG

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()

133
worker/paper.ts Normal file
View file

@ -0,0 +1,133 @@
/**
* Paper-trading engine. Turns the (real) verdict into (paper) positions and marks
* them to market against (real) Hyperliquid prices so the PnL track record is
* honest and verifiable, not seeded. One position per coin; TP/SL assumed filled
* at the level. This is the layer that makes the LARP's "performance" real.
*/
import { getState, type Position, type Trade, type SignalLog } from './store.ts'
import { CONFIG } from './config.ts'
import type { Verdict } from '../src/lib/signals.ts'
const round = (n: number, d = 2) => Math.round(n * 10 ** d) / 10 ** d
const sum = (a: number[]) => a.reduce((s, x) => s + x, 0)
let seq = 0
const newId = (coin: string) => `${coin}-${Date.now().toString(36)}-${(seq++).toString(36)}`
/** Close any open position whose TP/SL the current price has crossed. */
export function markToMarket(coin: string, price: number): Trade[] {
const st = getState()
const closed: Trade[] = []
const keep: Position[] = []
for (const p of st.positions) {
if (p.coin !== coin) {
keep.push(p)
continue
}
const hitTp = p.dir === 'LONG' ? price >= p.tp : price <= p.tp
const hitSl = p.dir === 'LONG' ? price <= p.sl : price >= p.sl
if (hitTp || hitSl) {
closed.push(close(p, hitTp ? p.tp : p.sl, hitTp ? 'tp' : 'sl'))
} else {
keep.push(p)
}
}
st.positions = keep
return closed
}
function close(p: Position, exit: number, outcome: Trade['outcome']): Trade {
const st = getState()
const move = (exit - p.entry) / p.entry
const pnlPct = (p.dir === 'LONG' ? move : -move) * 100
const pnlUsd = p.notional * (pnlPct / 100)
st.equity = round(st.equity + pnlUsd)
const trade: Trade = {
...p,
exit,
closedAt: Date.now(),
pnlPct: round(pnlPct, 3),
pnlUsd: round(pnlUsd),
outcome,
holdMin: Math.round((Date.now() - p.openedAt) / 60_000),
}
st.closed.push(trade)
if (st.closed.length > 500) st.closed.splice(0, st.closed.length - 500)
st.equityCurve.push({ t: trade.closedAt, equity: st.equity })
if (st.equityCurve.length > 1000) st.equityCurve.splice(0, st.equityCurve.length - 1000)
return trade
}
/** Open a paper position if the verdict is actionable and above the conf floor. */
export function maybeOpen(coin: string, price: number, v: Verdict): Position | null {
const st = getState()
if (v.decision === 'SKIP') return null
if (v.confidence < CONFIG.confFloor) return null
if (st.positions.filter((p) => p.coin === coin).length >= CONFIG.maxOpenPerCoin) return null
const dir = v.decision as Position['dir']
const tp = dir === 'LONG' ? price * (1 + v.tpPct / 100) : price * (1 - v.tpPct / 100)
const sl = dir === 'LONG' ? price * (1 - v.slPct / 100) : price * (1 + v.slPct / 100)
const notional = round(st.equity * (v.sizePct / 100) * CONFIG.leverage)
const pos: Position = {
id: newId(coin),
coin,
dir,
entry: price,
notional,
tp: round(tp, 4),
sl: round(sl, 4),
tpPct: v.tpPct,
slPct: v.slPct,
confidence: v.confidence,
reason: v.reasoning,
openedAt: Date.now(),
}
st.positions.push(pos)
return pos
}
/** Append to the rolling signal log (call only when a coin's decision changes). */
export function recordSignal(s: SignalLog): void {
const st = getState()
st.signals.push(s)
if (st.signals.length > 200) st.signals.splice(0, st.signals.length - 200)
}
export type Stats = {
trades: number
open: number
winRate: number
pnlUsd: number
roiPct: number
profitFactor: number
avgWin: number
avgLoss: number
best: Trade | null
worst: Trade | null
equity: number
}
/** Retrospective over closed trades — the honest, no-LLM version of /learn. */
export function stats(): Stats {
const st = getState()
const c = st.closed
const wins = c.filter((t) => t.pnlUsd > 0)
const losses = c.filter((t) => t.pnlUsd <= 0)
const grossWin = sum(wins.map((t) => t.pnlUsd))
const grossLoss = -sum(losses.map((t) => t.pnlUsd))
return {
trades: c.length,
open: st.positions.length,
winRate: c.length ? wins.length / c.length : 0,
pnlUsd: round(st.equity - CONFIG.equityStart),
roiPct: round((st.equity / CONFIG.equityStart - 1) * 100, 2),
profitFactor: grossLoss ? round(grossWin / grossLoss) : grossWin > 0 ? Infinity : 0,
avgWin: wins.length ? round(grossWin / wins.length) : 0,
avgLoss: losses.length ? round(grossLoss / losses.length) : 0,
best: c.reduce<Trade | null>((m, t) => (t.pnlUsd > (m?.pnlUsd ?? -Infinity) ? t : m), null),
worst: c.reduce<Trade | null>((m, t) => (t.pnlUsd < (m?.pnlUsd ?? Infinity) ? t : m), null),
equity: round(st.equity),
}
}

103
worker/store.ts Normal file
View file

@ -0,0 +1,103 @@
/**
* Persistence a single JSON file written atomically (tmp + rename). No native
* deps, survives restarts, and the exact shape a future `/api/*` endpoint would
* serve to make the site's Performance + fills honest. Swap for SQLite later
* without touching callers (the engine only talks to getState()/save()).
*/
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs'
import { dirname } from 'node:path'
import { CONFIG } from './config.ts'
import type { Direction } from '../src/lib/signals.ts'
export type Side = Exclude<Direction, 'SKIP'>
export type Position = {
id: string
coin: string
dir: Side
entry: number
notional: number // USD exposure (= equity * size% * leverage)
tp: number
sl: number
tpPct: number
slPct: number
confidence: number
reason: string
openedAt: number
}
export type Trade = Position & {
exit: number
closedAt: number
pnlPct: number // direction-adjusted price move, %
pnlUsd: number // notional * pnlPct
outcome: 'tp' | 'sl'
holdMin: number
}
export type SignalLog = {
t: number
coin: string
decision: Direction
confidence: number
price: number
net: number
reason: string
}
export type State = {
startedAt: number
mode: string
equity: number
positions: Position[]
closed: Trade[]
signals: SignalLog[]
equityCurve: { t: number; equity: number }[]
}
let state: State | null = null
function fresh(): State {
return {
startedAt: Date.now(),
mode: CONFIG.mode,
equity: CONFIG.equityStart,
positions: [],
closed: [],
signals: [],
equityCurve: [{ t: Date.now(), equity: CONFIG.equityStart }],
}
}
/** Load persisted state, or start fresh. Idempotent. */
export function load(): State {
if (state) return state
try {
if (existsSync(CONFIG.dataFile)) {
const raw = JSON.parse(readFileSync(CONFIG.dataFile, 'utf8')) as State
state = { ...fresh(), ...raw }
return state
}
} catch (e) {
console.error('[store] load failed, starting fresh:', (e as Error).message)
}
state = fresh()
return state
}
export function getState(): State {
return state ?? load()
}
/** Atomic write: serialise to `<file>.tmp`, then rename over the real file. */
export function save(): void {
const st = getState()
try {
mkdirSync(dirname(CONFIG.dataFile), { recursive: true })
const tmp = `${CONFIG.dataFile}.tmp`
writeFileSync(tmp, JSON.stringify(st, null, 2))
renameSync(tmp, CONFIG.dataFile)
} catch (e) {
console.error('[store] save failed:', (e as Error).message)
}
}

66
worker/telegram.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* Telegram the real L6 "Comms" layer, in ~60 lines via the raw Bot API (no
* dependency). Outbound: tgSend() broadcasts signals/closes. Inbound: tgPoll()
* long-polls getUpdates for slash commands (/status, /pnl, /positions, /help).
*
* If no token/chat is configured the worker still runs messages print to stdout
* so you can demo the whole loop with zero Telegram setup.
*/
import { CONFIG } from './config.ts'
const api = (method: string) => `https://api.telegram.org/bot${CONFIG.tgToken}/${method}`
export const TG_ENABLED = Boolean(CONFIG.tgToken && CONFIG.tgChat)
const stripHtml = (s: string) => s.replace(/<[^>]+>/g, '')
/** Send a message (HTML). Defaults to the configured chat; pass chatId to reply. */
export async function tgSend(text: string, chatId: string | number = CONFIG.tgChat): Promise<void> {
if (!CONFIG.tgToken || !chatId) {
console.log('\n' + stripHtml(text) + '\n')
return
}
try {
const res = await fetch(api('sendMessage'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
disable_web_page_preview: true,
}),
})
if (!res.ok) console.error('[tg] send failed', res.status, await res.text())
} catch (e) {
console.error('[tg] send error', (e as Error).message)
}
}
let offset = 0
/** Poll once for new commands and dispatch them. Cheap to call on an interval. */
export async function tgPoll(
handler: (cmd: string, chatId: number) => Promise<void>,
): Promise<void> {
if (!CONFIG.tgToken) return
try {
const res = await fetch(api('getUpdates') + `?timeout=0&offset=${offset}`)
if (!res.ok) return
const json = (await res.json()) as {
result?: { update_id: number; message?: { text?: string; chat?: { id: number } } }[]
}
for (const u of json.result ?? []) {
offset = u.update_id + 1
const text = u.message?.text
const chatId = u.message?.chat?.id
if (text && chatId != null && text.startsWith('/')) {
// strip @BotName suffix and arguments → bare command
const cmd = text.trim().split(/\s+/)[0].split('@')[0].toLowerCase()
await handler(cmd, chatId)
}
}
} catch {
/* transient — ignore, next poll retries */
}
}