Quant Agent — multi-agent quant terminal on Hyperliquid
All checks were successful
deploy / deploy (push) Successful in 7s
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:
commit
f3b533b032
110 changed files with 15045 additions and 0 deletions
72
worker/README.md
Normal file
72
worker/README.md
Normal 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
48
worker/config.ts
Normal 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
186
worker/loop.ts
Normal 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
133
worker/paper.ts
Normal 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
103
worker/store.ts
Normal 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
66
worker/telegram.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue