dexterm-cli 0.1.7 → 0.2.1

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.
Files changed (233) hide show
  1. package/dist/packages/agents/src/spectator.js +31 -3
  2. package/dist/packages/agents/src/tiers.js +12 -0
  3. package/dist/packages/agents/src/tools.js +13 -5
  4. package/dist/packages/autotrade/assets/limits.schema.json +569 -0
  5. package/dist/packages/autotrade/examples/executor/README.md +70 -0
  6. package/dist/packages/autotrade/examples/executor/jsonl-tail-executor.mjs +57 -0
  7. package/dist/packages/autotrade/examples/executor/kit.mjs +144 -0
  8. package/dist/packages/autotrade/examples/executor/signal-cmd-executor.mjs +25 -0
  9. package/dist/packages/autotrade/examples/executor/telegram-bot-recipe.md +47 -0
  10. package/dist/packages/autotrade/examples/executor/webhook-executor.mjs +47 -0
  11. package/dist/packages/autotrade/src/backtest.js +7 -2
  12. package/dist/packages/autotrade/src/chat/config.js +4 -2
  13. package/dist/packages/autotrade/src/chat/control.js +1 -0
  14. package/dist/packages/autotrade/src/chat/notify.js +16 -6
  15. package/dist/packages/autotrade/src/chat/secrets.js +25 -18
  16. package/dist/packages/autotrade/src/chat/service.js +3 -0
  17. package/dist/packages/autotrade/src/engine.js +16 -2
  18. package/dist/packages/autotrade/src/executor-health.js +217 -0
  19. package/dist/packages/autotrade/src/fleet.js +5 -2
  20. package/dist/packages/autotrade/src/forward.js +355 -0
  21. package/dist/packages/autotrade/src/index.js +21 -0
  22. package/dist/packages/autotrade/src/journey.js +477 -0
  23. package/dist/packages/autotrade/src/limits.js +682 -0
  24. package/dist/packages/autotrade/src/outbox.js +394 -0
  25. package/dist/packages/autotrade/src/paper-limits.js +87 -0
  26. package/dist/packages/autotrade/src/real-mode.js +688 -0
  27. package/dist/packages/autotrade/src/receipts.js +330 -0
  28. package/dist/packages/autotrade/src/report.js +168 -0
  29. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  30. package/dist/packages/autotrade/src/rugproof.js +231 -0
  31. package/dist/packages/autotrade/src/runner.js +194 -28
  32. package/dist/packages/autotrade/src/service-guard.js +125 -0
  33. package/dist/packages/autotrade/src/service-install.js +215 -0
  34. package/dist/packages/autotrade/src/service-recover.js +232 -0
  35. package/dist/packages/autotrade/src/service.js +289 -0
  36. package/dist/packages/autotrade/src/sessions.js +16 -7
  37. package/dist/packages/autotrade/src/signal-channels.js +312 -0
  38. package/dist/packages/autotrade/src/signal.js +196 -0
  39. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  40. package/dist/packages/autotrade/src/strategy.js +15 -2
  41. package/dist/packages/autotrade/src/trade-ledger.js +2 -2
  42. package/dist/packages/classifier/src/calibrate.js +138 -0
  43. package/dist/packages/classifier/src/classify.js +107 -0
  44. package/dist/packages/classifier/src/config.js +220 -0
  45. package/dist/packages/classifier/src/dossier.js +95 -0
  46. package/dist/packages/classifier/src/hardware.js +63 -0
  47. package/dist/packages/classifier/src/index.js +18 -0
  48. package/dist/packages/classifier/src/models.js +111 -0
  49. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  50. package/dist/packages/classifier/src/providers/http.js +63 -0
  51. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  52. package/dist/packages/classifier/src/providers/llm.js +89 -0
  53. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  54. package/dist/packages/classifier/src/questions.js +97 -0
  55. package/dist/packages/classifier/src/registry.js +42 -0
  56. package/dist/packages/classifier/src/snapshot.js +31 -0
  57. package/dist/packages/classifier/src/systemone.js +88 -0
  58. package/dist/packages/classifier/src/types.js +18 -0
  59. package/dist/packages/cli/assets/dash/dash.css +139 -0
  60. package/dist/packages/cli/assets/dash/dash.js +200 -0
  61. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  62. package/dist/packages/cli/assets/dash/index.html +36 -0
  63. package/dist/packages/cli/src/analyst/backend.js +3 -2
  64. package/dist/packages/cli/src/analyst/codex.js +3 -2
  65. package/dist/packages/cli/src/app-help-gen.js +26 -2
  66. package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
  67. package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
  68. package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
  69. package/dist/packages/cli/src/autotrade-cmd/fleet.js +16 -5
  70. package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
  71. package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
  72. package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
  73. package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
  74. package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
  75. package/dist/packages/cli/src/autotrade-cmd/run.js +268 -14
  76. package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
  77. package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
  78. package/dist/packages/cli/src/autotrade-cmd.js +50 -4
  79. package/dist/packages/cli/src/classifier-cmd.js +353 -0
  80. package/dist/packages/cli/src/classifier-live.js +81 -0
  81. package/dist/packages/cli/src/dash/data.js +217 -0
  82. package/dist/packages/cli/src/dash/server.js +92 -0
  83. package/dist/packages/cli/src/dash-cmd.js +113 -0
  84. package/dist/packages/cli/src/doctor.js +34 -0
  85. package/dist/packages/cli/src/ledger-cmd.js +4 -2
  86. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  87. package/dist/packages/cli/src/live/signals.js +8 -0
  88. package/dist/packages/cli/src/main.js +98 -6
  89. package/dist/packages/cli/src/mcp-follow.js +21 -1
  90. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  91. package/dist/packages/cli/src/narratives.js +114 -0
  92. package/dist/packages/cli/src/overlay/model.js +147 -0
  93. package/dist/packages/cli/src/overlay/page.js +146 -0
  94. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  95. package/dist/packages/cli/src/overlay/server.js +170 -0
  96. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  97. package/dist/packages/cli/src/pnl-card.js +4 -0
  98. package/dist/packages/cli/src/record-cmd.js +82 -11
  99. package/dist/packages/cli/src/rules-cmd.js +2 -1
  100. package/dist/packages/cli/src/rules.js +24 -4
  101. package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
  102. package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
  103. package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
  104. package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
  105. package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
  106. package/dist/packages/cli/src/screens/narrative.js +154 -2
  107. package/dist/packages/cli/src/state.js +1 -0
  108. package/dist/packages/cli/src/telegram-cmd.js +4 -1
  109. package/dist/packages/cli/src/version.js +1 -1
  110. package/dist/packages/custom/src/rules.js +5 -1
  111. package/dist/packages/data/src/trade/ledger/schema.js +1 -1
  112. package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
  113. package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
  114. package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
  115. package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
  116. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  117. package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
  118. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  119. package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
  120. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  121. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  122. package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
  123. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
  124. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
  125. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  126. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
  127. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
  128. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +59 -0
  129. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  130. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
  131. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
  132. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  133. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  134. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  135. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  136. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  137. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  138. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  139. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  140. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
  141. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
  142. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
  143. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
  144. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
  145. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +41 -0
  146. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
  147. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
  148. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +26 -0
  149. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
  150. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  151. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  152. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  153. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  154. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  155. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  156. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  157. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  158. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  159. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  160. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  161. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  162. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  163. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  164. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  165. package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
  166. package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
  167. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
  168. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
  169. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
  170. package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
  171. package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
  172. package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
  173. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  174. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  175. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  176. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
  177. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
  178. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
  179. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
  180. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
  181. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  182. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  183. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  184. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  185. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  186. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  187. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  188. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  189. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  190. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  191. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  192. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  193. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  194. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  195. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  196. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  197. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
  198. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
  199. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
  200. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
  201. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
  202. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +37 -0
  203. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
  204. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
  205. package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
  206. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  207. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  208. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  209. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  210. package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
  211. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  212. package/dist/packages/mcp/src/autotrade-tools.js +168 -5
  213. package/dist/packages/mcp/src/engine/restrictions.js +3 -3
  214. package/dist/packages/mcp/src/index.js +1 -1
  215. package/dist/packages/mcp/src/ledger-tools.js +155 -7
  216. package/dist/packages/mcp/src/server.js +125 -10
  217. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  218. package/dist/packages/narratives/src/cluster.js +193 -0
  219. package/dist/packages/narratives/src/embed.js +184 -0
  220. package/dist/packages/narratives/src/export.js +60 -0
  221. package/dist/packages/narratives/src/index.js +10 -0
  222. package/dist/packages/narratives/src/label.js +48 -0
  223. package/dist/packages/narratives/src/normalize.js +179 -0
  224. package/dist/packages/narratives/src/recorder.js +93 -0
  225. package/dist/packages/narratives/src/tracker.js +328 -0
  226. package/dist/packages/recorder/src/exit-probes.js +215 -0
  227. package/dist/packages/recorder/src/index.js +1 -0
  228. package/dist/packages/recorder/src/recorder.js +11 -1
  229. package/dist/packages/recorder/src/retention.js +4 -0
  230. package/dist/packages/recorder/src/schema.js +20 -1
  231. package/dist/packages/tui/src/keymap.js +12 -0
  232. package/package.json +3 -1
  233. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -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
+ }
@@ -268,7 +268,7 @@ export class MemberGate extends SessionGate {
268
268
  overDay() { const o = super.overDay(); if (o)
269
269
  return o; const g = this.fleet.gate.overDay(); return g ? `fleet-${g}` : null; }
270
270
  rateLimit(r) { super.rateLimit(r); this.fleet.rateLimit(r, this); }
271
- block(reason) { super.block(reason); this.fleet.block(reason, this); }
271
+ block(reason, sessionId) { super.block(reason, sessionId); this.fleet.block(reason, this); }
272
272
  paused() { return super.paused() || this.fleet.paused(); }
273
273
  /** A reading another strategy's session made (the same Claude account). */
274
274
  shareRateLimit(r) { super.rateLimit(r); }
@@ -538,9 +538,12 @@ 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
- dossier: o.dossier?.(spec), vet: o.vet?.(spec), executor: execs.executors.get(id), verbose: o.verbose ?? true, onLine: (s) => say(`[${id}] ${s}`),
546
+ dossier: o.dossier?.(spec), vet: o.vet?.(spec), drain: o.drain?.(spec), executor: execs.executors.get(id), verbose: o.verbose ?? true, onLine: (s) => say(`[${id}] ${s}`),
544
547
  pacing, ...(account ? { account, member: id } : {}),
545
548
  safety: () => {
546
549
  const g = o.globalSafety();
@@ -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,20 @@ 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 "./rugproof.js";
59
+ export * from "./rugproof-probe.js";
60
+ export * from "./signal.js";
61
+ export * from "./outbox.js";
62
+ export * from "./signal-channels.js";
63
+ export * from "./receipts.js";
64
+ export * from "./real-mode.js";
65
+ export * from "./journey.js";
66
+ export * from "./forward.js";
67
+ export * from "./service.js";
68
+ export * from "./service-guard.js";
69
+ export * from "./service-recover.js";
70
+ export * from "./service-install.js";
71
+ export * from "./limits.js";
72
+ export * from "./paper-limits.js";
73
+ export * from "./executor-health.js";
74
+ export * from "./report.js";