dexterm-cli 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/agents/src/spectator.js +3 -1
- package/dist/packages/agents/src/tiers.js +9 -0
- package/dist/packages/agents/src/tools.js +10 -5
- package/dist/packages/autotrade/assets/limits.schema.json +569 -0
- package/dist/packages/autotrade/examples/executor/README.md +70 -0
- package/dist/packages/autotrade/examples/executor/jsonl-tail-executor.mjs +57 -0
- package/dist/packages/autotrade/examples/executor/kit.mjs +144 -0
- package/dist/packages/autotrade/examples/executor/signal-cmd-executor.mjs +25 -0
- package/dist/packages/autotrade/examples/executor/telegram-bot-recipe.md +47 -0
- package/dist/packages/autotrade/examples/executor/webhook-executor.mjs +47 -0
- package/dist/packages/autotrade/src/backtest.js +2 -1
- package/dist/packages/autotrade/src/chat/config.js +4 -2
- package/dist/packages/autotrade/src/chat/control.js +1 -0
- package/dist/packages/autotrade/src/chat/notify.js +16 -6
- package/dist/packages/autotrade/src/chat/secrets.js +25 -18
- package/dist/packages/autotrade/src/chat/service.js +3 -0
- package/dist/packages/autotrade/src/engine.js +16 -2
- package/dist/packages/autotrade/src/executor-health.js +217 -0
- package/dist/packages/autotrade/src/fleet.js +3 -0
- package/dist/packages/autotrade/src/forward.js +355 -0
- package/dist/packages/autotrade/src/index.js +19 -0
- package/dist/packages/autotrade/src/journey.js +477 -0
- package/dist/packages/autotrade/src/limits.js +682 -0
- package/dist/packages/autotrade/src/outbox.js +394 -0
- package/dist/packages/autotrade/src/paper-limits.js +87 -0
- package/dist/packages/autotrade/src/real-mode.js +688 -0
- package/dist/packages/autotrade/src/receipts.js +330 -0
- package/dist/packages/autotrade/src/report.js +168 -0
- package/dist/packages/autotrade/src/runner.js +62 -11
- package/dist/packages/autotrade/src/service-guard.js +125 -0
- package/dist/packages/autotrade/src/service-install.js +213 -0
- package/dist/packages/autotrade/src/service-recover.js +232 -0
- package/dist/packages/autotrade/src/service.js +289 -0
- package/dist/packages/autotrade/src/signal-channels.js +312 -0
- package/dist/packages/autotrade/src/signal.js +196 -0
- package/dist/packages/autotrade/src/strategy.js +3 -1
- package/dist/packages/autotrade/src/trade-ledger.js +2 -2
- package/dist/packages/cli/src/analyst/backend.js +3 -2
- package/dist/packages/cli/src/analyst/codex.js +3 -2
- package/dist/packages/cli/src/app-help-gen.js +14 -2
- package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
- package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
- package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +8 -4
- package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
- package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
- package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
- package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
- package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
- package/dist/packages/cli/src/autotrade-cmd/run.js +257 -13
- package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
- package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
- package/dist/packages/cli/src/autotrade-cmd.js +50 -4
- package/dist/packages/cli/src/ledger-cmd.js +4 -2
- package/dist/packages/cli/src/pnl-card.js +4 -0
- package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
- package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
- package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
- package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
- package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
- package/dist/packages/cli/src/telegram-cmd.js +4 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/data/src/trade/ledger/schema.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
- package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +57 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +24 -0
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
- package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
- package/dist/packages/mcp/src/autotrade-tools.js +168 -5
- package/dist/packages/mcp/src/engine/restrictions.js +3 -3
- package/dist/packages/mcp/src/index.js +1 -1
- package/dist/packages/mcp/src/ledger-tools.js +155 -7
- package/dist/packages/mcp/src/server.js +21 -10
- package/dist/packages/tui/src/keymap.js +7 -0
- package/package.json +1 -1
|
@@ -225,6 +225,19 @@ export class SimBook {
|
|
|
225
225
|
this.triggers.set(id, trigger);
|
|
226
226
|
return p;
|
|
227
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* 0.2.0 card B (service-recover.ts): take over a position a crashed run left open. Its open cost leaves this
|
|
230
|
+
* book's cash (it is exposure here now); the coin counts as entered, so the per-coin rules see it.
|
|
231
|
+
*/
|
|
232
|
+
adopt(p) {
|
|
233
|
+
if (this.open.has(p.mint))
|
|
234
|
+
return false;
|
|
235
|
+
this.state.cashUsd -= Math.max(0, p.costUsd - p.realizedUsd);
|
|
236
|
+
(this.state.perCoin[p.mint] ??= { entries: 0, lastExit: 0 }).entries++;
|
|
237
|
+
this.open.set(p.mint, p);
|
|
238
|
+
this.triggers.set(p.id, ['resumed']);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
228
241
|
addTranche(p, fill) {
|
|
229
242
|
this.state.cashUsd -= fill.usd;
|
|
230
243
|
const tokens = p.tokens + fill.tokens;
|
|
@@ -289,8 +302,9 @@ export function clampEntry(book, k, req, killSwitch = false) {
|
|
|
289
302
|
const st = book.state;
|
|
290
303
|
const clamps = [];
|
|
291
304
|
const no = (refused) => ({ ok: false, refused, clamps });
|
|
292
|
-
// task-1571: paper only (dexterm holds no key and places no live order).
|
|
293
|
-
|
|
305
|
+
// task-1571: paper only (dexterm holds no key and places no live order). task-1645: real is the same paper book; its
|
|
306
|
+
// fills become trade-fire signals for the user's own executor (the runner admits it only through the real-mode hook).
|
|
307
|
+
if (s.mode !== 'paper' && s.mode !== 'real')
|
|
294
308
|
return no('mode-not-paper');
|
|
295
309
|
if (killSwitch)
|
|
296
310
|
return no('kill-switch');
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// dexterm autotrade — executor health: heartbeats, the silence pause, the loopback listener (task-1654, 0.2.0 card E).
|
|
2
|
+
//
|
|
3
|
+
// Your executor (or the reference kit, examples/executor/) says it is alive by appending a
|
|
4
|
+
// `dexterm-executor-heartbeat/1` line to $DEXTERM_HOME/autotrade/real/executor.jsonl, by running
|
|
5
|
+
// `dexterm autotrade executor heartbeat`, or by POSTing it to the optional loopback listener a real run opens
|
|
6
|
+
// (`dexterm autotrade signal channels --executor-port N`: 127.0.0.1 only, the same HMAC as the webhook when a secret
|
|
7
|
+
// is set, /heartbeat and /receipt, 4 KB bodies). A receipt, and an ack on the webhook or command hook, count as
|
|
8
|
+
// signs of life too (the JSONL stream and the MCP feed prove nothing: dexterm writes those itself).
|
|
9
|
+
//
|
|
10
|
+
// When nothing has been heard for longer than the user's `executorSilenceMin` (limits.json; placeholder 15 min) a real
|
|
11
|
+
// run PAUSES new buy signals and says so (run log, Telegram). Exits still go out, and the kill switch and the TTL are
|
|
12
|
+
// unchanged. When the executor is heard again, buys resume and that is said too. Heartbeats are data: they never make
|
|
13
|
+
// dexterm do anything except pause or resume.
|
|
14
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, statSync, writeSync, fsyncSync } from 'node:fs';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { realDir } from "./outbox.js";
|
|
18
|
+
import { appendReceipt, readReceipts, validateReceipt } from "./receipts.js";
|
|
19
|
+
import { ACCOUNT_ID_RE, verifyBody } from "./signal.js";
|
|
20
|
+
export const HEARTBEAT_SCHEMA = 'dexterm-executor-heartbeat/1';
|
|
21
|
+
export const heartbeatPath = (home) => join(realDir(home), 'executor.jsonl');
|
|
22
|
+
const ROTATE_ABOVE = 1 << 20;
|
|
23
|
+
const TAIL_BYTES = 64 << 10;
|
|
24
|
+
const text = (v, max) => { if (typeof v !== 'string')
|
|
25
|
+
return null; const t = v.replace(/[^\x20-\x7e]/g, '').trim().slice(0, max); return t || null; };
|
|
26
|
+
export function validateHeartbeat(x) {
|
|
27
|
+
const h = x;
|
|
28
|
+
if (!h || typeof h !== 'object')
|
|
29
|
+
return { ok: false, error: 'not an object' };
|
|
30
|
+
if (h.schema !== HEARTBEAT_SCHEMA)
|
|
31
|
+
return { ok: false, error: 'schema' };
|
|
32
|
+
if (typeof h.at !== 'number' || !Number.isFinite(h.at) || h.at <= 0)
|
|
33
|
+
return { ok: false, error: 'at' };
|
|
34
|
+
const executor = text(h.executor, 32);
|
|
35
|
+
if (!executor)
|
|
36
|
+
return { ok: false, error: 'executor' };
|
|
37
|
+
if (h.accountId !== undefined && h.accountId !== null && (typeof h.accountId !== 'string' || !ACCOUNT_ID_RE.test(h.accountId)))
|
|
38
|
+
return { ok: false, error: 'accountId' };
|
|
39
|
+
if (h.lastNonce !== undefined && h.lastNonce !== null && (!Number.isSafeInteger(h.lastNonce) || h.lastNonce < 0))
|
|
40
|
+
return { ok: false, error: 'lastNonce' };
|
|
41
|
+
return { ok: true, heartbeat: { schema: HEARTBEAT_SCHEMA, at: h.at, executor, accountId: typeof h.accountId === 'string' ? h.accountId : null, dryRun: typeof h.dryRun === 'boolean' ? h.dryRun : null, lastNonce: typeof h.lastNonce === 'number' ? h.lastNonce : null, note: text(h.note, 120) } };
|
|
42
|
+
}
|
|
43
|
+
/** Append one heartbeat (fsync); the file is rotated to executor.1.jsonl past 1 MB. */
|
|
44
|
+
export function appendHeartbeat(home, hb) {
|
|
45
|
+
const v = validateHeartbeat({ ...hb, schema: HEARTBEAT_SCHEMA });
|
|
46
|
+
if (!v.ok)
|
|
47
|
+
throw new Error(`heartbeat: ${v.error}`);
|
|
48
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
49
|
+
const p = heartbeatPath(home);
|
|
50
|
+
try {
|
|
51
|
+
if (statSync(p).size > ROTATE_ABOVE)
|
|
52
|
+
renameSync(p, join(realDir(home), 'executor.1.jsonl'));
|
|
53
|
+
}
|
|
54
|
+
catch { /* no file yet */ }
|
|
55
|
+
const fd = openSync(p, 'a', 0o600);
|
|
56
|
+
try {
|
|
57
|
+
writeSync(fd, `${JSON.stringify(v.heartbeat)}\n`);
|
|
58
|
+
fsyncSync(fd);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
return v.heartbeat;
|
|
64
|
+
}
|
|
65
|
+
const hbCache = new Map();
|
|
66
|
+
/** The newest valid heartbeat (reads only the last 64 KB, and only when the file changed since the last read). */
|
|
67
|
+
export function lastHeartbeat(home) {
|
|
68
|
+
const p = heartbeatPath(home);
|
|
69
|
+
if (!existsSync(p))
|
|
70
|
+
return null;
|
|
71
|
+
const fd = openSync(p, 'r');
|
|
72
|
+
try {
|
|
73
|
+
const st = fstatSync(fd);
|
|
74
|
+
const size = st.size;
|
|
75
|
+
const c = hbCache.get(p);
|
|
76
|
+
if (c && c.size === size && c.mtimeMs === st.mtimeMs)
|
|
77
|
+
return c.hb;
|
|
78
|
+
const n = Math.min(size, TAIL_BYTES);
|
|
79
|
+
const buf = Buffer.alloc(n);
|
|
80
|
+
readSync(fd, buf, 0, n, size - n);
|
|
81
|
+
const lines = buf.toString('utf8').split('\n');
|
|
82
|
+
let best = null;
|
|
83
|
+
for (const l of lines) {
|
|
84
|
+
if (!l.trim())
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
const v = validateHeartbeat(JSON.parse(l));
|
|
88
|
+
if (v.ok && (!best || v.heartbeat.at > best.at))
|
|
89
|
+
best = v.heartbeat;
|
|
90
|
+
}
|
|
91
|
+
catch { /* torn or partial line */ }
|
|
92
|
+
}
|
|
93
|
+
hbCache.set(p, { size, mtimeMs: st.mtimeMs, hb: best });
|
|
94
|
+
return best;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
closeSync(fd);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* How the executor looks now. `ackAt` = the newest ack on the webhook or command hook (the outbox's channel health,
|
|
102
|
+
* this process); `since` = a floor for "last seen" (the run's start: a run is not paused in its first window).
|
|
103
|
+
*/
|
|
104
|
+
export function executorHealth(home, o) {
|
|
105
|
+
const hb = lastHeartbeat(home);
|
|
106
|
+
const receipts = o.receipts ?? readReceipts(home).receipts;
|
|
107
|
+
const last = receipts.reduce((a, r) => (!a || r.at > a.at ? r : a), null);
|
|
108
|
+
const cands = [];
|
|
109
|
+
if (hb)
|
|
110
|
+
cands.push([hb.at, 'heartbeat']);
|
|
111
|
+
if (last)
|
|
112
|
+
cands.push([last.at, 'receipt']);
|
|
113
|
+
if (o.ackAt)
|
|
114
|
+
cands.push([o.ackAt, 'ack']);
|
|
115
|
+
if (o.since)
|
|
116
|
+
cands.push([o.since, 'run-start']);
|
|
117
|
+
const best = cands.reduce((a, c) => (!a || c[0] > a[0] ? c : a), null);
|
|
118
|
+
const lags = [];
|
|
119
|
+
if (o.signal)
|
|
120
|
+
for (const r of receipts.slice(-20)) {
|
|
121
|
+
const s = o.signal(r.nonce);
|
|
122
|
+
if (s && s.id === r.signalId)
|
|
123
|
+
lags.push(Math.max(0, (r.at - s.issuedAt) / 1000));
|
|
124
|
+
}
|
|
125
|
+
lags.sort((a, b) => a - b);
|
|
126
|
+
const silentSec = best ? Math.max(0, Math.round((o.now - best[0]) / 1000)) : null;
|
|
127
|
+
const windowMin = o.windowMin ?? null;
|
|
128
|
+
return {
|
|
129
|
+
alive: windowMin === null ? null : silentSec !== null && silentSec <= windowMin * 60,
|
|
130
|
+
windowMin, lastSeenAt: best?.[0] ?? null, lastSeenBy: best?.[1] ?? null, silentSec,
|
|
131
|
+
heartbeat: hb ? { at: hb.at, executor: hb.executor, dryRun: hb.dryRun, lastNonce: hb.lastNonce } : null,
|
|
132
|
+
lastReceipt: last ? { at: last.at, nonce: last.nonce, status: last.status } : null,
|
|
133
|
+
receiptLagSec: lags.length ? Math.round(lags[Math.floor(lags.length / 2)] * 10) / 10 : null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const ago = (sec) => (sec < 90 ? `${sec} s` : sec < 5400 ? `${Math.round(sec / 60)} min` : `${Math.round(sec / 3600)} h`);
|
|
137
|
+
export function healthLine(h) {
|
|
138
|
+
const state = h.alive === null ? (h.lastSeenAt ? 'seen' : 'never seen') : h.alive ? 'ALIVE' : 'SILENT';
|
|
139
|
+
const parts = [state];
|
|
140
|
+
if (h.silentSec !== null && h.lastSeenBy && h.lastSeenBy !== 'run-start')
|
|
141
|
+
parts.push(`last ${h.lastSeenBy} ${ago(h.silentSec)} ago`);
|
|
142
|
+
else if (h.lastSeenBy === 'run-start')
|
|
143
|
+
parts.push('nothing heard since the run started');
|
|
144
|
+
if (h.heartbeat)
|
|
145
|
+
parts.push(`${h.heartbeat.executor}${h.heartbeat.dryRun ? ' (dry run)' : ''}`);
|
|
146
|
+
if (h.lastReceipt)
|
|
147
|
+
parts.push(`last receipt #${h.lastReceipt.nonce} ${h.lastReceipt.status}`);
|
|
148
|
+
if (h.receiptLagSec !== null)
|
|
149
|
+
parts.push(`receipt lag ${h.receiptLagSec} s`);
|
|
150
|
+
if (h.windowMin !== null)
|
|
151
|
+
parts.push(`pause after ${h.windowMin} min silent`);
|
|
152
|
+
return parts.join(' · ');
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A loopback-only HTTP listener for executors that prefer to POST: /heartbeat (a heartbeat body) and /receipt (a
|
|
156
|
+
* receipt body). With a secret, the `x-dexterm-signature` header (the webhook's scheme) is required. Writes the same
|
|
157
|
+
* files the CLI and the kit append to. Never acts on anything.
|
|
158
|
+
*/
|
|
159
|
+
export function startExecutorListener(o) {
|
|
160
|
+
const host = o.host ?? '127.0.0.1';
|
|
161
|
+
if (!['127.0.0.1', '::1', 'localhost'].includes(host))
|
|
162
|
+
return Promise.reject(new Error('the executor listener binds to loopback only'));
|
|
163
|
+
const now = o.now ?? Date.now;
|
|
164
|
+
const server = createServer((req, res) => {
|
|
165
|
+
const reply = (code, body) => { res.writeHead(code, { 'content-type': 'text/plain' }); res.end(body); };
|
|
166
|
+
if (req.method !== 'POST' || (req.url !== '/heartbeat' && req.url !== '/receipt'))
|
|
167
|
+
return reply(404, 'POST /heartbeat or /receipt');
|
|
168
|
+
let body = '';
|
|
169
|
+
let tooBig = false;
|
|
170
|
+
req.setEncoding('utf8');
|
|
171
|
+
req.on('data', (c) => { body += c; if (body.length > 4096) {
|
|
172
|
+
tooBig = true;
|
|
173
|
+
req.destroy();
|
|
174
|
+
} });
|
|
175
|
+
req.on('end', () => {
|
|
176
|
+
if (tooBig)
|
|
177
|
+
return;
|
|
178
|
+
const secret = o.secret?.() ?? null;
|
|
179
|
+
if (secret) {
|
|
180
|
+
const v = verifyBody(secret, req.headers['x-dexterm-signature'], body, now());
|
|
181
|
+
if (!v.ok)
|
|
182
|
+
return reply(401, v.why);
|
|
183
|
+
}
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = JSON.parse(body);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return reply(400, 'not json');
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
if (req.url === '/heartbeat') {
|
|
193
|
+
const v = validateHeartbeat({ ...raw, schema: HEARTBEAT_SCHEMA });
|
|
194
|
+
if (!v.ok)
|
|
195
|
+
return reply(400, v.error);
|
|
196
|
+
appendHeartbeat(o.home, v.heartbeat);
|
|
197
|
+
return reply(204, '');
|
|
198
|
+
}
|
|
199
|
+
const v = validateReceipt(raw);
|
|
200
|
+
if (!v.ok)
|
|
201
|
+
return reply(400, v.error);
|
|
202
|
+
appendReceipt(o.home, v.receipt);
|
|
203
|
+
return reply(204, '');
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
return reply(500, e.message.slice(0, 80));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
server.once('error', reject);
|
|
212
|
+
server.listen(o.port, host, () => {
|
|
213
|
+
const a = server.address();
|
|
214
|
+
resolve({ port: typeof a === 'object' && a ? a.port : o.port, close: () => new Promise((r) => server.close(() => r())) });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -538,7 +538,10 @@ export async function runFleet(o) {
|
|
|
538
538
|
fl.add(now, 'member_start', { member: id, runId, why, strategyHash: spec.strategy.hash, bankroll: spec.bankroll, ...(account ? { account: account.id } : {}) });
|
|
539
539
|
say(`[${id}] started (run ${runId}, ${spec.basedOn.id} v${spec.basedOn.version}, ${account ? `paper account ${account.id} ($${account.cashUsd.toFixed(2)} cash of $${account.startUsd})` : `bankroll $${spec.bankroll.usd}${spec.bankroll.sol !== undefined ? ` = ${spec.bankroll.sol} SOL` : ''}`})`);
|
|
540
540
|
const ticks = hub.subscribe(runId);
|
|
541
|
+
const adopt = spec.adopt;
|
|
542
|
+
spec.adopt = undefined; // a later restart of this member (a `start` request) never adopts them twice
|
|
541
543
|
l.done = runAutotrade({
|
|
544
|
+
...(adopt?.length ? { adopt } : {}),
|
|
542
545
|
strategy: spec.strategy, source: { name: o.tape?.source ?? o.source.name, ticks, startT: now }, sessions, gate, ledger, runId,
|
|
543
546
|
dossier: o.dossier?.(spec), vet: o.vet?.(spec), executor: execs.executors.get(id), verbose: o.verbose ?? true, onLine: (s) => say(`[${id}] ${s}`),
|
|
544
547
|
pacing, ...(account ? { account, member: id } : {}),
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// dexterm autotrade — FORWARD TESTS (0.2.0 card C). PAPER ONLY.
|
|
2
|
+
//
|
|
3
|
+
// A forward test runs one strategy, frozen at the version it started with, on the LIVE tape, on paper, for a chosen
|
|
4
|
+
// window (hours, or N closed trades, whichever comes first), with its own bankroll (an isolated simulated book: no
|
|
5
|
+
// paper account, no other strategy's cash) and its own run file. It is started and stopped from the chat
|
|
6
|
+
// (strategy_forward_start / _stop), the Q screen's journey view and `dexterm autotrade forward start|status|stop|report`.
|
|
7
|
+
//
|
|
8
|
+
// It does not take the machine's run.lock: a forward test runs next to the always-on service or a foreground run, in
|
|
9
|
+
// its own process (`dexterm autotrade forward run <fid>`, started detached), with its own status file and stop marker:
|
|
10
|
+
//
|
|
11
|
+
// $DEXTERM_HOME/autotrade/forward/tests/<fid>.json dexterm-forward-test/1: the frozen strategy, the window, state,
|
|
12
|
+
// heartbeat, progress and, at the end, the metrics
|
|
13
|
+
// $DEXTERM_HOME/autotrade/forward/tests/<fid>.stop the stop marker (CLI, chat, Q screen); the host flattens and ends
|
|
14
|
+
// $DEXTERM_HOME/autotrade/forward/tests/<fid>.log the detached host's output
|
|
15
|
+
// $DEXTERM_HOME/autotrade/forward/<id>.json the latest result line for real mode's arm panel (card A)
|
|
16
|
+
// $DEXTERM_HOME/autotrade/runs/<runId>.jsonl the run ledger, hash-chained like every other run
|
|
17
|
+
//
|
|
18
|
+
// At the end of its window the host flattens (every position sold at its next tick, so the numbers are closed
|
|
19
|
+
// numbers), then writes the metrics in the backtest's own shape (backtest.ts report()) and records the result in
|
|
20
|
+
// the strategy's journey. Only a test that ran its whole window on live data is the "forward-tested" rung; one that
|
|
21
|
+
// was stopped early, ran on a replay or died is shown, never counted. The panic file stops it like any run.
|
|
22
|
+
//
|
|
23
|
+
// Model spend: a forward test of an agent strategy opens sessions on the user's own engine inside the strategy's
|
|
24
|
+
// budgets and runner.json's caps, like `run`. A strategy that needs a chat approval for every entry cannot be
|
|
25
|
+
// forward-tested (no tap reaches a forward test). Nothing here holds a key, signs or touches a wallet.
|
|
26
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { report } from "./backtest.js";
|
|
30
|
+
import { compareForward, renderComparison, latestBacktest, loadJourney, metricsLine, metricsOf, recordForwardResult, rulesHash, runFills, usd } from "./journey.js";
|
|
31
|
+
import { fileLedger, writeJsonAtomic } from "./ledger.js";
|
|
32
|
+
import { runAutotrade, simExecutor } from "./runner.js";
|
|
33
|
+
import { STRATEGY_ID } from "./store.js";
|
|
34
|
+
import { validateStrategy } from "./strategy.js";
|
|
35
|
+
export const FORWARD_SCHEMA = 'dexterm-forward-test/1';
|
|
36
|
+
export const FORWARD_LIMITS = { minHours: 0.25, maxHours: 72, maxTrades: 200, maxRunning: 3, minBankrollUsd: 10, maxBankrollUsd: 1_000_000 };
|
|
37
|
+
/** A running host rewrites its status at least this often; older means the host is gone. */
|
|
38
|
+
export const FORWARD_HEARTBEAT_MS = 10_000;
|
|
39
|
+
export const FORWARD_STALE_MS = 90_000;
|
|
40
|
+
/** After the window ends, how long (tape time) the flatten may keep trying before the stream is closed. */
|
|
41
|
+
export const FLATTEN_GRACE_MS = 30 * 60_000;
|
|
42
|
+
/** Live data: an ended test whose stream took no step for this long is closed (the report is written). */
|
|
43
|
+
export const STALL_MS = 2 * 60_000;
|
|
44
|
+
export const forwardRoot = (home) => join(home, 'autotrade', 'forward');
|
|
45
|
+
export const forwardTestsDir = (home) => join(forwardRoot(home), 'tests');
|
|
46
|
+
const FID = /^fw_[a-z0-9]{6,12}_[a-f0-9]{8}$/;
|
|
47
|
+
export const isForwardId = (x) => typeof x === 'string' && FID.test(x);
|
|
48
|
+
const fpath = (home, fid, ext) => {
|
|
49
|
+
if (!isForwardId(fid))
|
|
50
|
+
throw new Error(`bad forward test id ${JSON.stringify(fid)}`);
|
|
51
|
+
return join(forwardTestsDir(home), `${fid}.${ext}`);
|
|
52
|
+
};
|
|
53
|
+
export const forwardTestPath = (home, fid) => fpath(home, fid, 'json');
|
|
54
|
+
export const forwardStopPath = (home, fid) => fpath(home, fid, 'stop');
|
|
55
|
+
export const forwardLogPath = (home, fid) => fpath(home, fid, 'log');
|
|
56
|
+
/** Card A's arm panel reads `autotrade/forward/<id>.json` (summary line). */
|
|
57
|
+
export const forwardSummaryPath = (home, id) => {
|
|
58
|
+
if (!STRATEGY_ID.test(id))
|
|
59
|
+
throw new Error(`bad strategy id ${JSON.stringify(id)}`);
|
|
60
|
+
return join(forwardRoot(home), `${id}.json`);
|
|
61
|
+
};
|
|
62
|
+
export const newForwardId = (now = Date.now()) => `fw_${now.toString(36)}_${randomBytes(4).toString('hex')}`;
|
|
63
|
+
const pidAlive = (pid) => { try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return e.code === 'EPERM';
|
|
69
|
+
} };
|
|
70
|
+
export function liveState(t, now = Date.now(), alive = pidAlive) {
|
|
71
|
+
if (t.state !== 'starting' && t.state !== 'running')
|
|
72
|
+
return t.state;
|
|
73
|
+
if (t.state === 'starting')
|
|
74
|
+
return now - t.createdAt > FORWARD_STALE_MS && (!t.pid || !alive(t.pid)) ? 'lost' : 'starting';
|
|
75
|
+
if (t.pid && !alive(t.pid))
|
|
76
|
+
return 'lost';
|
|
77
|
+
return t.heartbeatAt && now - t.heartbeatAt > FORWARD_STALE_MS ? 'lost' : 'running';
|
|
78
|
+
}
|
|
79
|
+
export function loadForwardTest(home, fid) {
|
|
80
|
+
if (!isForwardId(fid))
|
|
81
|
+
return null;
|
|
82
|
+
const p = forwardTestPath(home, fid);
|
|
83
|
+
if (!existsSync(p))
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
const d = JSON.parse(readFileSync(p, 'utf8'));
|
|
87
|
+
return d.schema === FORWARD_SCHEMA && d.forwardId === fid ? d : null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function saveForwardTest(home, t) {
|
|
94
|
+
mkdirSync(forwardTestsDir(home), { recursive: true, mode: 0o700 });
|
|
95
|
+
writeJsonAtomic(forwardTestPath(home, t.forwardId), t);
|
|
96
|
+
}
|
|
97
|
+
/** Newest first. */
|
|
98
|
+
export function listForwardTests(home, filter = {}) {
|
|
99
|
+
const dir = forwardTestsDir(home);
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
return [];
|
|
102
|
+
return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => loadForwardTest(home, f.slice(0, -5))).filter((t) => !!t && (!filter.strategyId || t.strategyId === filter.strategyId))
|
|
103
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
104
|
+
}
|
|
105
|
+
export const isActive = (t, now = Date.now()) => { const s = liveState(t, now); return s === 'starting' || s === 'running'; };
|
|
106
|
+
/** A forward test id, or a strategy id (its running test, else its newest). */
|
|
107
|
+
export function findForwardTest(home, ref, now = Date.now()) {
|
|
108
|
+
if (isForwardId(ref))
|
|
109
|
+
return loadForwardTest(home, ref);
|
|
110
|
+
if (!STRATEGY_ID.test(ref))
|
|
111
|
+
return null;
|
|
112
|
+
const all = listForwardTests(home, { strategyId: ref });
|
|
113
|
+
return all.find((t) => isActive(t, now)) ?? all[0] ?? null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Freeze a strategy into a new forward test (state `starting`); the caller then starts its host. Refuses a window
|
|
117
|
+
* outside the limits, a second running test of the same strategy, more than FORWARD_LIMITS.maxRunning at once,
|
|
118
|
+
* mode live, and a strategy that needs a chat approval for every entry.
|
|
119
|
+
*/
|
|
120
|
+
export function createForwardTest(home, s, raw, o) {
|
|
121
|
+
const now = o.now ?? Date.now();
|
|
122
|
+
if (s.mode === 'live')
|
|
123
|
+
return { ok: false, why: `${s.id} is mode live, which does not run (dexterm holds no key and places no live order)` };
|
|
124
|
+
if (s.entry.decide === 'agent+approve')
|
|
125
|
+
return { ok: false, why: `${s.id} needs a chat approval for every entry (entry.decide "agent+approve"); no approval reaches a forward test, so it could never enter. Make a copy with decide "agent" or "auto" to forward-test it` };
|
|
126
|
+
const hours = o.hours ?? null;
|
|
127
|
+
const trades = o.trades ?? null;
|
|
128
|
+
if (hours === null && trades === null)
|
|
129
|
+
return { ok: false, why: 'a forward test needs a window: hours, a number of closed trades, or both (whichever comes first ends it)' };
|
|
130
|
+
if (hours !== null && !(Number.isFinite(hours) && hours >= FORWARD_LIMITS.minHours && hours <= FORWARD_LIMITS.maxHours))
|
|
131
|
+
return { ok: false, why: `hours must be from ${FORWARD_LIMITS.minHours} to ${FORWARD_LIMITS.maxHours}` };
|
|
132
|
+
if (trades !== null && !(Number.isInteger(trades) && trades >= 1 && trades <= FORWARD_LIMITS.maxTrades))
|
|
133
|
+
return { ok: false, why: `trades must be a whole number from 1 to ${FORWARD_LIMITS.maxTrades}` };
|
|
134
|
+
const bankrollUsd = o.bankrollUsd ?? s.bankroll.startUsd;
|
|
135
|
+
if (!(Number.isFinite(bankrollUsd) && bankrollUsd >= FORWARD_LIMITS.minBankrollUsd && bankrollUsd <= FORWARD_LIMITS.maxBankrollUsd))
|
|
136
|
+
return { ok: false, why: `the paper bankroll must be from $${FORWARD_LIMITS.minBankrollUsd} to $${FORWARD_LIMITS.maxBankrollUsd}` };
|
|
137
|
+
const running = listForwardTests(home).filter((t) => isActive(t, now));
|
|
138
|
+
const same = running.find((t) => t.strategyId === s.id);
|
|
139
|
+
if (same)
|
|
140
|
+
return { ok: false, why: `${s.id} already has a forward test running (${same.forwardId}); stop it first or read its report` };
|
|
141
|
+
if (running.length >= FORWARD_LIMITS.maxRunning)
|
|
142
|
+
return { ok: false, why: `${running.length} forward tests are already running (at most ${FORWARD_LIMITS.maxRunning} at once)` };
|
|
143
|
+
// The frozen document: always paper (a real strategy forward-tests as its paper twin), the test's own bankroll.
|
|
144
|
+
const doc = { ...structuredClone(raw), mode: 'paper', bankroll: { ...raw.bankroll, startUsd: bankrollUsd } };
|
|
145
|
+
const v = validateStrategy(doc);
|
|
146
|
+
if (!v.ok)
|
|
147
|
+
return { ok: false, why: `${s.id} does not validate as a paper forward test: ${v.errors.map((e) => `${e.path} ${e.msg}`).join('; ')}` };
|
|
148
|
+
const test = {
|
|
149
|
+
schema: FORWARD_SCHEMA, forwardId: newForwardId(now), strategyId: s.id, version: s.version, hash: s.hash, rulesHash: rulesHash(s), doc, bankrollUsd,
|
|
150
|
+
window: { hours, trades }, createdAt: now, by: o.by, source: o.source ?? 'live', stub: !!o.stub, msPerTick: o.msPerTick ?? null,
|
|
151
|
+
state: 'starting', pid: null, runId: null, startedAt: null, endsAt: null, heartbeatAt: null, endedAt: null, endReason: null, progress: null, metrics: null, problems: [],
|
|
152
|
+
};
|
|
153
|
+
saveForwardTest(home, test);
|
|
154
|
+
return { ok: true, test };
|
|
155
|
+
}
|
|
156
|
+
/** Ask a forward test to end (the host flattens and writes its report). Idempotent. */
|
|
157
|
+
export function requestForwardStop(home, fid, by, now = Date.now()) {
|
|
158
|
+
const t = loadForwardTest(home, fid);
|
|
159
|
+
if (!t)
|
|
160
|
+
return { ok: false, why: `no forward test ${fid}` };
|
|
161
|
+
const st = liveState(t, now);
|
|
162
|
+
if (st !== 'starting' && st !== 'running')
|
|
163
|
+
return { ok: false, why: `${fid} is not running (${st})` };
|
|
164
|
+
const p = forwardStopPath(home, fid);
|
|
165
|
+
const already = existsSync(p);
|
|
166
|
+
if (!already)
|
|
167
|
+
writeFileSync(p, `${JSON.stringify({ at: now, by })}\n`, { mode: 0o600 });
|
|
168
|
+
return { ok: true, already, test: t };
|
|
169
|
+
}
|
|
170
|
+
export function forwardStopRequested(home, fid) {
|
|
171
|
+
const p = forwardStopPath(home, fid);
|
|
172
|
+
if (!existsSync(p))
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
const d = JSON.parse(readFileSync(p, 'utf8'));
|
|
176
|
+
return { at: Number(d.at) || 0, by: typeof d.by === 'string' ? d.by.slice(0, 40) : 'unknown' };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { at: 0, by: 'unknown' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Mark a test whose host is gone as failed (a reader that finds it `lost` may call this). */
|
|
183
|
+
export function markLost(home, t, now = Date.now()) {
|
|
184
|
+
const next = { ...t, state: 'failed', endedAt: now, endReason: 'the forward test host stopped without a report (crash, logout or kill)' };
|
|
185
|
+
saveForwardTest(home, next);
|
|
186
|
+
recordForwardResult(home, resultOf(next));
|
|
187
|
+
return next;
|
|
188
|
+
}
|
|
189
|
+
function resultOf(t) {
|
|
190
|
+
const state = t.state === 'done' || t.state === 'stopped' ? t.state : 'failed';
|
|
191
|
+
return {
|
|
192
|
+
kind: 'forward', forwardId: t.forwardId, id: t.strategyId, version: t.version, rulesHash: t.rulesHash, startedAt: t.startedAt ?? t.createdAt, endedAt: t.endedAt ?? Date.now(),
|
|
193
|
+
// Only a test on live data can be the forward-tested rung: a replay is a backtest in disguise.
|
|
194
|
+
state: state === 'done' && !t.source.startsWith('live') ? 'stopped' : state,
|
|
195
|
+
endReason: state === 'done' && !t.source.startsWith('live') ? `${t.endReason ?? 'window reached'} (on a replay, which does not count as a forward test)` : (t.endReason ?? ''),
|
|
196
|
+
window: t.window, source: t.source, metrics: t.metrics,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const dur = (ms) => { const m = Math.max(0, Math.round(ms / 60_000)); return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`; };
|
|
200
|
+
export const windowText = (w) => [w.hours !== null ? `${w.hours} h` : '', w.trades !== null ? `${w.trades} closed trades` : ''].filter(Boolean).join(' or ') + (w.hours !== null && w.trades !== null ? ' (whichever first)' : '');
|
|
201
|
+
/** The forward test next to the backtest (held-out split) of the same rules, from the journey file. */
|
|
202
|
+
export function forwardReport(home, t, now = Date.now()) {
|
|
203
|
+
const state = liveState(t, now);
|
|
204
|
+
const bt = latestBacktest(loadJourney(home, t.strategyId), t.rulesHash);
|
|
205
|
+
const comparison = compareForward(bt, t.metrics);
|
|
206
|
+
const elapsed = t.startedAt ? dur((t.endedAt ?? now) - t.startedAt) : '0 min';
|
|
207
|
+
const lines = [
|
|
208
|
+
`FORWARD TEST ${t.forwardId} · ${t.strategyId} v${t.version} · PAPER · ${t.source} · own bankroll $${t.bankrollUsd}`,
|
|
209
|
+
` state ${state.toUpperCase()}${t.endReason ? ` (${t.endReason})` : ''} · window ${windowText(t.window)} · ran ${elapsed}`,
|
|
210
|
+
t.progress && !t.metrics ? ` so far: ${t.progress.closed} closed · ${t.progress.open} open · equity $${t.progress.equityUsd.toFixed(2)} (${usd(t.progress.netUsd)}) · ${t.progress.candidates} candidates` : '',
|
|
211
|
+
t.metrics ? ` result: ${metricsLine(t.metrics)}` : '',
|
|
212
|
+
...(t.metrics || bt ? [' forward vs backtest of the same rules (paper, modelled fills; not a prediction, not financial advice):', ...renderComparison(comparison)] : []),
|
|
213
|
+
t.source.startsWith('live') ? '' : ' NOTE: this ran on a replay, so it is not a forward test of live data and does not count toward the journey',
|
|
214
|
+
].filter(Boolean);
|
|
215
|
+
return { forwardId: t.forwardId, strategyId: t.strategyId, version: t.version, state, source: t.source, window: windowText(t.window), elapsed, endReason: t.endReason, progress: t.progress, metrics: t.metrics, comparison, lines };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Run a `starting` forward test to its end in this process: own ledger, own book (the frozen paper strategy and its
|
|
219
|
+
* bankroll), the window, the stop marker and the panic file; then the metrics, the journey record and the summary.
|
|
220
|
+
*/
|
|
221
|
+
export async function hostForward(o) {
|
|
222
|
+
const { home, forwardId } = o;
|
|
223
|
+
const clock = o.now ?? Date.now;
|
|
224
|
+
const say = o.onLine ?? (() => { });
|
|
225
|
+
let t = loadForwardTest(home, forwardId);
|
|
226
|
+
if (!t)
|
|
227
|
+
return { ok: false, why: `no forward test ${forwardId}` };
|
|
228
|
+
if (!o.source === !o.tape)
|
|
229
|
+
return { ok: false, why: 'a forward test runs on one live source or one replay tape' };
|
|
230
|
+
const srcName = o.source?.name ?? `replay ${o.tape.source}`;
|
|
231
|
+
if (t.state !== 'starting')
|
|
232
|
+
return { ok: false, why: `${forwardId} is ${t.state}; a forward test runs once` };
|
|
233
|
+
const v = validateStrategy(t.doc);
|
|
234
|
+
if (!v.ok || v.strategy.mode !== 'paper')
|
|
235
|
+
return { ok: false, why: `${forwardId}: the frozen strategy does not validate as paper` };
|
|
236
|
+
const strategy = v.strategy;
|
|
237
|
+
const runId = `${new Date(clock()).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}-fwd-${strategy.id}-${forwardId.slice(-4)}`;
|
|
238
|
+
let closed = 0;
|
|
239
|
+
const { ledger, files } = fileLedger(home, runId, (e) => { if (e.kind === 'trade_closed')
|
|
240
|
+
closed++; });
|
|
241
|
+
const startedAt = clock();
|
|
242
|
+
t = { ...t, state: 'running', pid: o.pid ?? process.pid, runId, startedAt, heartbeatAt: startedAt };
|
|
243
|
+
saveForwardTest(home, t);
|
|
244
|
+
ledger.add(startedAt, 'forward_start', { forwardId, strategy: strategy.id, version: t.version, rulesHash: t.rulesHash, window: t.window, bankrollUsd: t.bankrollUsd, source: srcName, by: t.by });
|
|
245
|
+
say(`forward test ${forwardId} · ${strategy.id} v${t.version} · PAPER · ${srcName} · own bankroll $${t.bankrollUsd} · window ${windowText(t.window)} · run ${runId}`);
|
|
246
|
+
// The window clock: tape time on a tape or a lockstep feed; WALL time on live data, where a cached row keeps the
|
|
247
|
+
// last tick's time and the tape clock can stall (an hours window measured on it might never close).
|
|
248
|
+
const wall = !o.tape && (o.pacing?.mode ?? 'live') === 'live';
|
|
249
|
+
let tapeT = null;
|
|
250
|
+
let endsAt = wall && t.window.hours !== null ? startedAt + t.window.hours * 3_600_000 : null;
|
|
251
|
+
let ended = null;
|
|
252
|
+
let endedT = null;
|
|
253
|
+
let lastStepAt = startedAt;
|
|
254
|
+
let last = null;
|
|
255
|
+
let lastBeat = 0;
|
|
256
|
+
const windowNow = () => (wall ? clock() : tapeT);
|
|
257
|
+
/** Panic, the stop marker and the window; checked on every step and on every heartbeat (a stream can go quiet). */
|
|
258
|
+
const checkEnd = (panic) => {
|
|
259
|
+
if (panic && !ended)
|
|
260
|
+
ended = { state: 'stopped', reason: `the panic switch is engaged (${panic.reason})` };
|
|
261
|
+
const stop = forwardStopRequested(home, forwardId);
|
|
262
|
+
if (stop && !ended)
|
|
263
|
+
ended = { state: 'stopped', reason: `stopped early by ${stop.by}` };
|
|
264
|
+
const nowT = windowNow();
|
|
265
|
+
if (!ended && nowT !== null && endsAt !== null && nowT >= endsAt)
|
|
266
|
+
ended = { state: 'done', reason: `window reached (${t.window.hours} h)` };
|
|
267
|
+
if (!ended && t.window.trades !== null && closed >= t.window.trades)
|
|
268
|
+
ended = { state: 'done', reason: `window reached (${closed} closed trades)` };
|
|
269
|
+
if (ended && endedT === null)
|
|
270
|
+
endedT = nowT ?? clock();
|
|
271
|
+
};
|
|
272
|
+
const beat = (force = false) => {
|
|
273
|
+
const n = clock();
|
|
274
|
+
if (!force && n - lastBeat < FORWARD_HEARTBEAT_MS / 2)
|
|
275
|
+
return;
|
|
276
|
+
lastBeat = n;
|
|
277
|
+
const cur = loadForwardTest(home, forwardId) ?? t;
|
|
278
|
+
t = { ...cur, heartbeatAt: n, endsAt, progress: last ? { closed, open: last.positions.length, equityUsd: last.book.equityUsd, netUsd: last.book.equityUsd - last.book.startUsd, candidates: last.candidates.length } : cur.progress };
|
|
279
|
+
try {
|
|
280
|
+
saveForwardTest(home, t);
|
|
281
|
+
}
|
|
282
|
+
catch { /* the next beat retries */ }
|
|
283
|
+
};
|
|
284
|
+
const timer = setInterval(() => {
|
|
285
|
+
beat(true);
|
|
286
|
+
if (!wall)
|
|
287
|
+
return;
|
|
288
|
+
checkEnd(o.killSwitch?.engaged() ?? null);
|
|
289
|
+
// Ended, but no step for a while (a quiet stream): close the stream so the run ends and the report is written.
|
|
290
|
+
if (ended && clock() - lastStepAt > STALL_MS)
|
|
291
|
+
o.source?.stop?.();
|
|
292
|
+
}, FORWARD_HEARTBEAT_MS);
|
|
293
|
+
timer.unref?.();
|
|
294
|
+
const safety = () => {
|
|
295
|
+
const panic = o.killSwitch?.engaged() ?? null;
|
|
296
|
+
checkEnd(panic);
|
|
297
|
+
// The window end flattens at the next tick; a bag that cannot sell (no liquidity) ends the stream after a grace.
|
|
298
|
+
const nowT = windowNow();
|
|
299
|
+
if (ended && endedT !== null && nowT !== null && nowT - endedT > FLATTEN_GRACE_MS)
|
|
300
|
+
o.source?.stop?.();
|
|
301
|
+
if (ended) {
|
|
302
|
+
return { halt: ended.state === 'done' ? 'forward-window' : panic ? 'kill-switch' : 'forward-stopped', detail: ended.reason, flatten: true, pauseEntries: null, endWhenFlat: true };
|
|
303
|
+
}
|
|
304
|
+
return { halt: null, flatten: false, pauseEntries: null, endWhenFlat: false };
|
|
305
|
+
};
|
|
306
|
+
let res = null;
|
|
307
|
+
let error = null;
|
|
308
|
+
try {
|
|
309
|
+
res = await runAutotrade({
|
|
310
|
+
strategy, ...(o.source ? { source: o.source } : { tape: o.tape }), sessions: o.sessions, gate: o.gate, dossier: o.dossier, vet: o.vet, executor: simExecutor(strategy.execution),
|
|
311
|
+
pacing: o.pacing ?? { mode: 'live' }, ledger, runId, verbose: true, onLine: say, safety,
|
|
312
|
+
onState: (st) => {
|
|
313
|
+
if (!wall && tapeT === null && t.window.hours !== null)
|
|
314
|
+
endsAt = st.t + t.window.hours * 3_600_000;
|
|
315
|
+
tapeT = st.t;
|
|
316
|
+
lastStepAt = clock();
|
|
317
|
+
last = st;
|
|
318
|
+
beat();
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
error = e.message.slice(0, 300);
|
|
324
|
+
}
|
|
325
|
+
clearInterval(timer);
|
|
326
|
+
const endedAt = clock();
|
|
327
|
+
const cur = loadForwardTest(home, forwardId) ?? t;
|
|
328
|
+
let metrics = null;
|
|
329
|
+
if (res) {
|
|
330
|
+
const r = report(strategy, { source: srcName, ticks: [], mints: [] }, res);
|
|
331
|
+
metrics = metricsOf(r, runFills(res));
|
|
332
|
+
}
|
|
333
|
+
// A live stream that ends by itself (the data layer stopped) before the window is an early end, never "done".
|
|
334
|
+
const fin = error ? { state: 'failed', reason: `the run failed: ${error}` }
|
|
335
|
+
: ended ? ended : { state: 'stopped', reason: 'the tick stream ended before the window' };
|
|
336
|
+
t = { ...cur, state: fin.state, endReason: fin.reason, endedAt, endsAt, heartbeatAt: endedAt, metrics, progress: last ? { closed: metrics?.trades ?? closed, open: res?.openAtEnd.length ?? 0, equityUsd: last.book.equityUsd, netUsd: metrics?.netUsd ?? 0, candidates: res?.candidates ?? 0 } : cur.progress };
|
|
337
|
+
ledger.add(endedAt, 'forward_end', { forwardId, state: fin.state, reason: fin.reason, ...(metrics ? { trades: metrics.trades, netUsd: metrics.netUsd } : {}) });
|
|
338
|
+
saveForwardTest(home, t);
|
|
339
|
+
rmSync(forwardStopPath(home, forwardId), { force: true });
|
|
340
|
+
recordForwardResult(home, resultOf(t));
|
|
341
|
+
writeForwardSummary(home, t);
|
|
342
|
+
say(`forward test ${forwardId} ${fin.state}: ${fin.reason}${metrics ? ` · ${metricsLine(metrics)}` : ''} · ledger ${files.ledger}`);
|
|
343
|
+
return { ok: true, test: t };
|
|
344
|
+
}
|
|
345
|
+
/** The one-line result real mode's arm panel shows (card A reads `summary`). */
|
|
346
|
+
export function writeForwardSummary(home, t) {
|
|
347
|
+
if (!t.metrics)
|
|
348
|
+
return;
|
|
349
|
+
const days = t.startedAt && t.endedAt ? Math.round(((t.endedAt - t.startedAt) / 86_400_000) * 100) / 100 : null;
|
|
350
|
+
const counted = t.state === 'done' && t.source.startsWith('live');
|
|
351
|
+
// ASCII only: real mode's arm panel prints this line through a printable-ASCII filter.
|
|
352
|
+
const summary = `${t.forwardId} ${t.state}${counted ? '' : ' (does not count)'}; v${t.version}; ${t.metrics.trades} trades; net ${usd(t.metrics.netUsd)} (${t.metrics.netPct}%); ${t.source.startsWith('live') ? 'paper, live data' : 'paper, replay'}${t.metrics.trades < 20 ? '; too few trades to claim anything' : ''}`;
|
|
353
|
+
mkdirSync(forwardRoot(home), { recursive: true, mode: 0o700 });
|
|
354
|
+
writeJsonAtomic(forwardSummaryPath(home, t.strategyId), { schema: 'dexterm-forward-summary/1', forwardId: t.forwardId, version: t.version, rulesHash: t.rulesHash, state: t.state, counted, summary, trades: t.metrics.trades, netPct: t.metrics.netPct, days });
|
|
355
|
+
}
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
// execution-port where an approved entry goes: the paper port (local single-use proofs) and the live stub (task-1527)
|
|
23
23
|
// chat Telegram notify, control and approve-to-trade through the user's own bot; chat/ (task-1527)
|
|
24
24
|
// yolo task-1532: the full-port replay (rugs, loss spread, how often it zeroed the wallet); paper only
|
|
25
|
+
// signal / outbox / signal-channels / receipts / real-mode task-1645: mode real = the paper engine + a LIVE TRADE FIRE
|
|
26
|
+
// signal per fill to the user's own executor (webhook, command hook, JSONL, MCP feed, Telegram copy); no key
|
|
27
|
+
// limits / executor-health / report task-1654: every real-mode limit user-set per scope (stricter wins), executor
|
|
28
|
+
// heartbeats + the silence pause, the paper-vs-real divergence report and the digest
|
|
25
29
|
//
|
|
26
30
|
// The screener filter grammar is imported by path from the CLI's screener (one parser for the screen and
|
|
27
31
|
// for strategies); nothing in this package touches a key or a wallet, and nothing places a live order (task-1571). The desk executor
|
|
@@ -51,3 +55,18 @@ export * from "./trade-ledger.js";
|
|
|
51
55
|
export * from "./execution-port.js";
|
|
52
56
|
export * from "./chat/index.js";
|
|
53
57
|
export * from "./yolo.js";
|
|
58
|
+
export * from "./signal.js";
|
|
59
|
+
export * from "./outbox.js";
|
|
60
|
+
export * from "./signal-channels.js";
|
|
61
|
+
export * from "./receipts.js";
|
|
62
|
+
export * from "./real-mode.js";
|
|
63
|
+
export * from "./journey.js";
|
|
64
|
+
export * from "./forward.js";
|
|
65
|
+
export * from "./service.js";
|
|
66
|
+
export * from "./service-guard.js";
|
|
67
|
+
export * from "./service-recover.js";
|
|
68
|
+
export * from "./service-install.js";
|
|
69
|
+
export * from "./limits.js";
|
|
70
|
+
export * from "./paper-limits.js";
|
|
71
|
+
export * from "./executor-health.js";
|
|
72
|
+
export * from "./report.js";
|