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,196 @@
1
+ // dexterm autotrade — the LIVE TRADE FIRE signal, `dexterm-trade-fire/1` (task-1645, 0.2.0 card A).
2
+ //
3
+ // A strategy in mode "real" trades exactly like paper (same clamp, exits, monitor and ledger: the paper fill model is
4
+ // the monitor's own view), and every buy or sell it fills also becomes one of these signals for the user's OWN
5
+ // executor (a Telegram trading bot of their choice, or any script). dexterm holds no key, signs no transaction and
6
+ // builds none: a signal is a request the executor may act on, ignore, or reject.
7
+ //
8
+ // type "trade" side buy|sell, mint, sanitized symbol, size in SOL / USD / % of book (a sell also as the fraction
9
+ // of the position), price + mcap + liquidity context, exit reason for a sell, strategy id/version/
10
+ // hash, run id, account id, nonce, idempotency key, issued/expires, `paper: false`
11
+ // type "halted" the run stopped (end, halt, kill, a lost runner): every outstanding signal's nonce, a reason, and
12
+ // `open`: the mints dexterm still counts as held for the account (the next real run of the
13
+ // strategy takes them over and keeps their exits; they still count against the open-position cap)
14
+ // type "test" `dexterm autotrade signal test`: proves the channel; an executor must never trade on it
15
+ //
16
+ // Rules an executor follows (and SignalReceiver below implements, for scripts and the tests' fake executors):
17
+ // 1. verify the HMAC header when a secret is set (`x-dexterm-signature: t=<unix s>,v1=<hex>` over "<t>.<body>")
18
+ // 2. ignore anything past `expiresAt` (the TTL): a late signal is never acted on
19
+ // 3. dedupe by nonce (and the idempotency key `id`): delivery is at-least-once, so a repeat is normal
20
+ // 4. act only on the account id it was set up for
21
+ //
22
+ // Firewall: nothing token-authored rides along except the symbol, reduced to [A-Za-z0-9._$-] (16 chars). No name,
23
+ // no description, no links, no agent text, no paths, no keys. Every other field is an engine number or an id that
24
+ // dexterm validated itself.
25
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
26
+ export const TRADE_FIRE_SCHEMA = 'dexterm-trade-fire/1';
27
+ export const SIGNAL_TTL_DEFAULT_SEC = 60;
28
+ export const SIGNAL_TTL_MIN_SEC = 10;
29
+ export const SIGNAL_TTL_MAX_SEC = 600;
30
+ /** How far a signature's timestamp may sit from the receiver's clock. */
31
+ export const SIGNATURE_TOLERANCE_SEC = 300;
32
+ const SOL_MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
33
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
34
+ export const ACCOUNT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
35
+ const RUN_RE = /^[A-Za-z0-9._:-]{1,96}$/;
36
+ const HEX32 = /^[0-9a-f]{32}$/;
37
+ /** The only token text a signal carries: NFKC, then [A-Za-z0-9._$-] only, 16 chars, '?' when nothing is left. */
38
+ export function sanitizeSymbol(s) {
39
+ const t = String(s ?? '').normalize('NFKC').replace(/[^A-Za-z0-9._$-]/g, '').slice(0, 16);
40
+ return t || '?';
41
+ }
42
+ /** An engine reason ("hard-stop", "time_review:no-answer") reduced to [a-z0-9:_-], 40 chars. */
43
+ export function sanitizeReason(s) {
44
+ return String(s ?? '').toLowerCase().replace(/[^a-z0-9:_-]/g, '-').replace(/-+/g, '-').slice(0, 40) || 'unknown';
45
+ }
46
+ export const signalIdOf = (accountId, runId, nonce) => createHash('sha256').update(`${TRADE_FIRE_SCHEMA}|${accountId}|${runId}|${nonce}`).digest('hex').slice(0, 32);
47
+ export const clampTtl = (sec) => Math.min(SIGNAL_TTL_MAX_SEC, Math.max(SIGNAL_TTL_MIN_SEC, Math.round(Number.isFinite(sec) ? sec : SIGNAL_TTL_DEFAULT_SEC)));
48
+ const r6 = (v) => Math.round(v * 1e6) / 1e6;
49
+ const r2 = (v) => Math.round(v * 100) / 100;
50
+ const fin = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
51
+ function base(type, h) {
52
+ const ttlSec = clampTtl(h.ttlSec);
53
+ return {
54
+ schema: TRADE_FIRE_SCHEMA, type, id: signalIdOf(h.accountId, h.runId, h.nonce), nonce: h.nonce, issuedAt: h.now, expiresAt: h.now + ttlSec * 1000, ttlSec,
55
+ runId: h.runId, accountId: h.accountId, strategy: { id: h.strategy.id, version: h.strategy.version, hash: h.strategy.hash.slice(0, 64) }, paper: false,
56
+ };
57
+ }
58
+ /** A trade signal; throws on anything that is not a plain engine value (fail closed: no signal rather than a bad one). */
59
+ export function buildTradeSignal(t, h) {
60
+ if (!SOL_MINT_RE.test(t.mint))
61
+ throw new Error('signal: mint is not a base58 address');
62
+ if (!(t.solUsd > 0))
63
+ throw new Error('signal: no SOL/USD price');
64
+ // A buy states a positive size; a sell's size is the modelled proceeds and can be ~0 (a rug exit must still go out).
65
+ if (t.side === 'buy' ? !(t.sizeUsd > 0) : !(t.sizeUsd >= 0))
66
+ throw new Error('signal: size must be positive');
67
+ const s = {
68
+ ...base('trade', h), type: 'trade', side: t.side, mint: t.mint, symbol: sanitizeSymbol(t.symbol),
69
+ sizeSol: r6(t.sizeUsd / t.solUsd), sizeUsd: r2(t.sizeUsd), pctOfBook: t.bookUsd > 0 ? Math.round((t.sizeUsd / t.bookUsd) * 1000) / 10 : 0,
70
+ sellFraction: t.side === 'sell' ? Math.min(1, Math.max(0, r6(t.sellFraction ?? 1))) : null,
71
+ price: { usd: fin(t.priceUsd), mcap: fin(t.mcap), liqUsd: fin(t.liqUsd), solUsd: r2(t.solUsd) },
72
+ exitReason: t.side === 'sell' ? sanitizeReason(t.exitReason) : null,
73
+ slippageBps: Math.round(t.slippageBps), maxImpactPct: t.maxImpactPct, positionId: t.positionId && /^[A-Za-z0-9._-]{1,48}$/.test(t.positionId) ? t.positionId : null,
74
+ ...(t.model ? { model: { priceUsd: fin(t.model.priceUsd), tokens: fin(t.model.tokens) } } : {}),
75
+ };
76
+ const v = validateSignal(s);
77
+ if (!v.ok)
78
+ throw new Error(`signal: ${v.error}`);
79
+ return s;
80
+ }
81
+ export function buildHaltedSignal(reason, outstanding, h, open = []) {
82
+ return { ...base('halted', h), type: 'halted', reason: sanitizeReason(reason), outstanding: [...new Set(outstanding)].filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b).slice(0, 500), open: [...new Set(open)].filter((m) => SOL_MINT_RE.test(m)).sort().slice(0, 100) };
83
+ }
84
+ export function buildTestSignal(h) {
85
+ return { ...base('test', h), type: 'test', test: true, note: 'channel test: never trade on this' };
86
+ }
87
+ /** One line of JSON, fixed key order (the webhook body, the hook's stdin, the stream line: byte-identical). */
88
+ export const signalLine = (s) => JSON.stringify(s);
89
+ /** Strict shape check: used on replay of the outbox and by receivers. */
90
+ export function validateSignal(x) {
91
+ const s = x;
92
+ if (!s || typeof s !== 'object')
93
+ return { ok: false, error: 'not an object' };
94
+ if (s.schema !== TRADE_FIRE_SCHEMA)
95
+ return { ok: false, error: 'schema' };
96
+ if (s.type !== 'trade' && s.type !== 'halted' && s.type !== 'test')
97
+ return { ok: false, error: 'type' };
98
+ if (!Number.isSafeInteger(s.nonce) || s.nonce < 1)
99
+ return { ok: false, error: 'nonce' };
100
+ if (typeof s.id !== 'string' || !HEX32.test(s.id))
101
+ return { ok: false, error: 'id' };
102
+ if (typeof s.accountId !== 'string' || !ACCOUNT_ID_RE.test(s.accountId))
103
+ return { ok: false, error: 'accountId' };
104
+ if (typeof s.runId !== 'string' || !RUN_RE.test(s.runId))
105
+ return { ok: false, error: 'runId' };
106
+ if (s.id !== signalIdOf(s.accountId, s.runId, s.nonce))
107
+ return { ok: false, error: 'id does not match account/run/nonce' };
108
+ if (s.paper !== false)
109
+ return { ok: false, error: 'paper must be false' };
110
+ if (!fin(s.issuedAt) || !fin(s.expiresAt) || s.expiresAt <= s.issuedAt)
111
+ return { ok: false, error: 'times' };
112
+ const st = s.strategy;
113
+ if (!st || typeof st.id !== 'string' || !ID_RE.test(st.id) || typeof st.version !== 'string' || typeof st.hash !== 'string')
114
+ return { ok: false, error: 'strategy' };
115
+ if (s.type === 'trade') {
116
+ if (s.side !== 'buy' && s.side !== 'sell')
117
+ return { ok: false, error: 'side' };
118
+ if (typeof s.mint !== 'string' || !SOL_MINT_RE.test(s.mint))
119
+ return { ok: false, error: 'mint' };
120
+ if (typeof s.symbol !== 'string' || s.symbol !== sanitizeSymbol(s.symbol))
121
+ return { ok: false, error: 'symbol' };
122
+ if (s.side === 'buy' ? !(fin(s.sizeSol) > 0) || !(fin(s.sizeUsd) > 0) : !(fin(s.sizeSol) >= 0) || !(fin(s.sizeUsd) >= 0))
123
+ return { ok: false, error: 'size' };
124
+ if (s.side === 'sell' && !(fin(s.sellFraction) > 0 && s.sellFraction <= 1))
125
+ return { ok: false, error: 'sellFraction' };
126
+ }
127
+ if (s.type === 'test' && s.test !== undefined && s.test !== true)
128
+ return { ok: false, error: 'test' };
129
+ if (s.type === 'test' && 'mint' in s)
130
+ return { ok: false, error: 'a test signal names no mint' };
131
+ if (s.type === 'trade' && s.model !== undefined && (typeof s.model !== 'object' || s.model === null))
132
+ return { ok: false, error: 'model' };
133
+ if (s.type === 'halted' && s.open !== undefined && (!Array.isArray(s.open) || s.open.length > 100 || !s.open.every((m) => typeof m === 'string' && SOL_MINT_RE.test(m))))
134
+ return { ok: false, error: 'open' };
135
+ return { ok: true, signal: s };
136
+ }
137
+ // ───────────────────────────── HMAC ─────────────────────────────
138
+ /** `t=<unix s>,v1=<hex sha256 hmac of "<t>.<body>">`. */
139
+ export function signBody(secret, body, nowMs) {
140
+ const t = Math.floor(nowMs / 1000);
141
+ return `t=${t},v1=${createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')}`;
142
+ }
143
+ export function verifyBody(secret, header, body, nowMs, toleranceSec = SIGNATURE_TOLERANCE_SEC) {
144
+ const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(String(header ?? ''));
145
+ if (!m)
146
+ return { ok: false, why: 'signature-missing' };
147
+ const t = Number(m[1]);
148
+ if (Math.abs(nowMs / 1000 - t) > toleranceSec)
149
+ return { ok: false, why: 'signature-stale' };
150
+ const want = createHmac('sha256', secret).update(`${t}.${body}`).digest();
151
+ const got = Buffer.from(m[2], 'hex');
152
+ return got.length === want.length && timingSafeEqual(got, want) ? { ok: true } : { ok: false, why: 'signature-bad' };
153
+ }
154
+ /**
155
+ * What a well-behaved executor does with an incoming signal: signature (when a secret is set), shape, account, TTL,
156
+ * replay (nonce and id). A duplicate is reported as such so a transport can ack it (at-least-once delivery).
157
+ * Exported for users' scripts and for the fake executors of the tests; dexterm itself never executes anything.
158
+ */
159
+ export class SignalReceiver {
160
+ seen = new Set();
161
+ maxNonce = 0;
162
+ o;
163
+ constructor(o) { this.o = o; }
164
+ receive(body, signature) {
165
+ const now = (this.o.now ?? Date.now)();
166
+ if (this.o.secret) {
167
+ const v = verifyBody(this.o.secret, signature, body, now);
168
+ if (!v.ok)
169
+ return { ok: false, why: v.why };
170
+ }
171
+ let raw;
172
+ try {
173
+ raw = JSON.parse(body);
174
+ }
175
+ catch {
176
+ return { ok: false, why: 'not-json' };
177
+ }
178
+ const v = validateSignal(raw);
179
+ if (!v.ok)
180
+ return { ok: false, why: `invalid:${v.error}` };
181
+ const s = v.signal;
182
+ if (s.accountId !== this.o.accountId)
183
+ return { ok: false, why: 'wrong-account', signal: s };
184
+ if (this.seen.has(s.id) || this.seen.has(`n${s.nonce}`))
185
+ return { ok: false, why: 'duplicate', signal: s };
186
+ if (now > s.expiresAt)
187
+ return { ok: false, why: 'expired', signal: s };
188
+ // A nonce at or below one already acted on (that is not a duplicate of it) is a replay of an old signal.
189
+ if (s.nonce <= this.maxNonce && s.type !== 'halted')
190
+ return { ok: false, why: 'nonce-replay', signal: s };
191
+ this.seen.add(s.id);
192
+ this.seen.add(`n${s.nonce}`);
193
+ this.maxNonce = Math.max(this.maxNonce, s.nonce);
194
+ return { ok: true, signal: s };
195
+ }
196
+ }
@@ -0,0 +1,344 @@
1
+ // dexterm autotrade — STRATEGY STATS for the performance dashboard (task-1749). Read-only over the run ledgers
2
+ // ($DEXTERM_HOME/autotrade/runs/*.jsonl): no network, no model, no key, never writes.
3
+ //
4
+ // The definitions are the challenge control's (launch/send-out/challenge-100x/control/report.mjs + mine.mjs, task-1697 /
5
+ // task-1733), so the dashboard and the control report count the same things the same way:
6
+ // entries each position once across restarts (its first fill or adoption), keyed per run FILE + engine id
7
+ // closed `trade_closed` (deduped) + WRITE-OFFS: a position the engine marked `stuck` (no pool to sell into) with no
8
+ // sell for >= 30 min, or dropped stuck at a clean run end, closes at its REALIZABLE value: the sells it made
9
+ // are kept, the unsold remainder counts 0. Derived here; the engine's ledgers are never edited.
10
+ // abandoned still open when a run ended cleanly and not stuck: the engine does not carry it, its outcome never enters
11
+ // the closed sample
12
+ // rugs a closed position at <= -80% on cost (a write-off is a rug only at <= -80%)
13
+ // mean mean % on cost with a 95% seeded percentile bootstrap (4000 resamples, seed 7); a difference of two
14
+ // strategies is a bootstrap of independent groups (seed 11); rates use the Wilson 95% interval
15
+ // sample under 20 closed is EXPLORATORY; the ETA to 20 closed = (20 - closed) / entries per day since the first event
16
+ // One difference from report.mjs (a fix, test/strategy-stats-1749.test.ts): the engine's crash recovery names an adopted
17
+ // position `<runId>/pN` while that run's own fills said `pN`; report.mjs keys `pN` and so counted an adopted position
18
+ // two or three times. Here `<runId>/pN` maps onto the position `pN` of the file `<runId>.jsonl`.
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ export const RUG_PCT = -80;
22
+ export const MIN_CLOSED = 20;
23
+ export const STUCK_WRITE_OFF_MIN = 30;
24
+ const DAY = 86_400_000;
25
+ /** mulberry32: the seeded generator of control/mine.mjs (same seed, same intervals). */
26
+ export function rng(seed = 7) {
27
+ let a = seed >>> 0;
28
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
29
+ }
30
+ const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
31
+ const pctile = (xs, p) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.floor(p * s.length)))]; };
32
+ /** Mean with a 95% percentile-bootstrap interval (control/mine.mjs meanCi). */
33
+ export function meanCi(xs, { B = 4000, seed = 7 } = {}) {
34
+ if (!xs.length)
35
+ return { n: 0, mean: null, lo: null, hi: null };
36
+ const r = rng(seed);
37
+ const ms = [];
38
+ for (let b = 0; b < B; b++) {
39
+ let s = 0;
40
+ for (let i = 0; i < xs.length; i++)
41
+ s += xs[Math.floor(r() * xs.length)];
42
+ ms.push(s / xs.length);
43
+ }
44
+ return { n: xs.length, mean: mean(xs), lo: pctile(ms, 0.025), hi: pctile(ms, 0.975) };
45
+ }
46
+ /** Difference of means (a - b), independent groups, bootstrap 95% (control/mine.mjs diffCi). */
47
+ export function diffCi(a, b, { B = 4000, seed = 11 } = {}) {
48
+ if (!a.length || !b.length)
49
+ return { mean: null, lo: null, hi: null };
50
+ const r = rng(seed);
51
+ const ds = [];
52
+ for (let k = 0; k < B; k++) {
53
+ let sa = 0, sb = 0;
54
+ for (let i = 0; i < a.length; i++)
55
+ sa += a[Math.floor(r() * a.length)];
56
+ for (let i = 0; i < b.length; i++)
57
+ sb += b[Math.floor(r() * b.length)];
58
+ ds.push(sa / a.length - sb / b.length);
59
+ }
60
+ return { mean: mean(a) - mean(b), lo: pctile(ds, 0.025), hi: pctile(ds, 0.975) };
61
+ }
62
+ /** Wilson 95% interval for k of n, as fractions (control/mine.mjs wilson). */
63
+ export function wilsonCi(k, n) {
64
+ if (!n)
65
+ return [null, null];
66
+ const z = 1.96, p = k / n, d = 1 + z * z / n, c = p + z * z / (2 * n), h = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n));
67
+ return [Math.max(0, (c - h) / d), Math.min(1, (c + h) / d)];
68
+ }
69
+ /**
70
+ * Every strategy (fleet member) in one runs directory, its positions tracked across run files (control/report.mjs
71
+ * readMembers, rules in the header). `now` decides which stuck positions are already written off.
72
+ */
73
+ export function readStrategyLedgers(runsDir, { now = Date.now(), writeOffMin = STUCK_WRITE_OFF_MIN } = {}) {
74
+ const M = {};
75
+ if (!existsSync(runsDir))
76
+ return M;
77
+ const symbols = new Map(); // mint -> symbol, from the candidate lines (fills carry no symbol)
78
+ const entryTick = new Map(); // mint -> the candidate tick that preceded the entry (pool age, liquidity, mcap)
79
+ const state = {};
80
+ const fin = (x) => typeof x === 'number' && Number.isFinite(x);
81
+ for (const f of readdirSync(runsDir).filter((x) => x.endsWith('.jsonl') && !/-fleet-/.test(x)).sort()) {
82
+ let member = null;
83
+ let text;
84
+ try {
85
+ text = readFileSync(join(runsDir, f), 'utf8');
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ for (const line of text.split('\n')) {
91
+ if (!line)
92
+ continue;
93
+ let e;
94
+ try {
95
+ e = JSON.parse(line);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (e.kind === 'fleet_member' || e.kind === 'run_start') {
101
+ member ??= e.member ?? e.strategy ?? null;
102
+ if (member && !M[member]) {
103
+ M[member] = { files: 0, entries: 0, closed: [], open: 0, stuckOpen: [], openPositions: [], abandoned: [], writtenOff: [], adopted: 0, lastClampT: null, firstT: null, lastT: null, candidates: 0, entryTimes: [], startUsd: null, mode: null, basedOn: null };
104
+ state[member] = { pos: new Map(), openNow: new Set(), closedKeys: new Set(), local: new Map(), file: null, carry: new Set() };
105
+ }
106
+ if (member) {
107
+ const m = M[member];
108
+ if (fin(e.bankroll?.usd) && m.startUsd === null)
109
+ m.startUsd = e.bankroll.usd;
110
+ m.mode ??= e.mode ?? null;
111
+ if (e.basedOn)
112
+ m.basedOn = e.basedOn;
113
+ if (state[member].file !== f) {
114
+ state[member].file = f;
115
+ state[member].local = new Map();
116
+ state[member].carry = new Set(state[member].openNow);
117
+ m.files++;
118
+ }
119
+ }
120
+ continue;
121
+ }
122
+ if (!member)
123
+ continue;
124
+ const m = M[member];
125
+ const s = state[member];
126
+ if (typeof e.t === 'number') {
127
+ m.firstT = m.firstT === null ? e.t : Math.min(m.firstT, e.t);
128
+ m.lastT = m.lastT === null ? e.t : Math.max(m.lastT, e.t);
129
+ }
130
+ // a restart adopts what it carries at the head of its file; a position of the previous (crashed) file it did not
131
+ // adopt is gone from the engine's book: "abandoned", not open forever
132
+ if (s.carry.size && e.kind !== 'position_adopted') {
133
+ for (const key of s.carry) {
134
+ const p = s.pos.get(key);
135
+ s.openNow.delete(key);
136
+ m.abandoned.push({ positionId: p.positionId, mint: p.mint, openedT: p.firstT, stoppedT: e.t, notCarried: true, _p: p });
137
+ }
138
+ s.carry.clear();
139
+ }
140
+ if (e.kind === 'clamp' && typeof e.t === 'number')
141
+ m.lastClampT = Math.max(m.lastClampT ?? 0, e.t);
142
+ if (e.kind === 'candidate') {
143
+ m.candidates++;
144
+ if (e.mint && e.symbol)
145
+ symbols.set(e.mint, e.symbol);
146
+ if (e.mint && e.tick)
147
+ entryTick.set(e.mint, e.tick);
148
+ continue;
149
+ }
150
+ // the open position of an earlier file this id + mint carries (an adoption), newest first; `<runId>/pN` names the
151
+ // position pN of the file <runId>.jsonl exactly
152
+ const carried = (id, mint) => {
153
+ const slash = id.lastIndexOf('/');
154
+ if (slash > 0) {
155
+ const k = `${id.slice(0, slash)}.jsonl#${id.slice(slash + 1)}`;
156
+ if (s.pos.has(k) && !s.closedKeys.has(k))
157
+ return k;
158
+ }
159
+ return [...s.openNow].reverse().find((k) => { const p = s.pos.get(k); return p.positionId === id && (!mint || !p.mint || p.mint === mint); }) ?? null;
160
+ };
161
+ const open = (id, mint, t, profile = null) => {
162
+ const key = `${f}#${id}`;
163
+ s.pos.set(key, { key, positionId: id, mint: mint ?? null, symbol: null, firstT: t, costUsd: 0, proceedsUsd: 0, stuckSince: null, stuckWhy: null, lastSellT: null, profile });
164
+ s.openNow.add(key);
165
+ s.local.set(id, key);
166
+ m.entries++;
167
+ if (t !== null)
168
+ m.entryTimes.push(t);
169
+ return key;
170
+ };
171
+ const keyOf = (id, mint, t, { create = true, profile = null } = {}) => {
172
+ if (s.local.has(id))
173
+ return s.local.get(id);
174
+ const c = carried(id, mint);
175
+ if (c) {
176
+ s.local.set(id, c);
177
+ return c;
178
+ }
179
+ return create ? open(id, mint, t, profile) : null;
180
+ };
181
+ if (e.kind === 'position_adopted' && e.positionId) {
182
+ const c = carried(e.positionId, e.mint);
183
+ const key = c ?? open(e.positionId, e.mint, e.position?.openedAt ?? e.t); // its fills were in a file that is gone: it counts here
184
+ s.carry.delete(key);
185
+ s.local.set(e.positionId, key);
186
+ m.adopted++;
187
+ const p = s.pos.get(key);
188
+ const snap = e.position ?? {};
189
+ if (!c && fin(snap.costUsd))
190
+ p.costUsd = snap.costUsd;
191
+ if (snap.stuck && p.stuckSince === null)
192
+ p.stuckSince = e.t;
193
+ p.symbol ??= e.symbol ?? null;
194
+ continue;
195
+ }
196
+ if (e.kind === 'fill' && e.positionId) {
197
+ const key = keyOf(e.positionId, e.mint, e.t, { profile: e.profile ?? null });
198
+ if (s.closedKeys.has(key))
199
+ continue;
200
+ const p = s.pos.get(key);
201
+ p.profile ??= e.profile ?? null;
202
+ if (e.side === 'sell') {
203
+ p.proceedsUsd += fin(e.usd) ? e.usd : 0;
204
+ p.lastSellT = e.t;
205
+ p.stuckSince = null;
206
+ p.stuckWhy = null;
207
+ } // a sale ends a stuck spell
208
+ else
209
+ p.costUsd += fin(e.usd) ? e.usd : 0;
210
+ continue;
211
+ }
212
+ if (e.kind === 'stuck' && e.positionId) {
213
+ const key = keyOf(e.positionId, e.mint, e.t, { create: false });
214
+ const p = key && s.pos.get(key);
215
+ if (p && p.stuckSince === null) {
216
+ p.stuckSince = e.t;
217
+ p.stuckWhy = `${e.reason ?? 'stuck'} (${e.why ?? 'cannot sell'})`;
218
+ }
219
+ continue;
220
+ }
221
+ if (e.kind === 'wake' && e.positionId && e.event === 'mechanical_exit') { // the engine's own words for the stuck exit
222
+ const key = s.local.get(e.positionId);
223
+ const p = key && s.pos.get(key);
224
+ if (p && p.stuckSince === e.t && e.detail)
225
+ p.stuckWhy = e.detail;
226
+ continue;
227
+ }
228
+ if (e.kind === 'trade_closed' && e.trade) {
229
+ const key = keyOf(e.trade.id, e.trade.mint, e.trade.openedAt ?? e.t);
230
+ if (s.closedKeys.has(key))
231
+ continue;
232
+ s.closedKeys.add(key);
233
+ s.openNow.delete(key);
234
+ const p = s.pos.get(key);
235
+ const tick = e.trade.mint ? entryTick.get(e.trade.mint) : undefined;
236
+ m.closed.push({ t: e.trade.closedAt ?? e.t, openedAt: e.trade.openedAt ?? null, mint: e.trade.mint ?? null, symbol: e.trade.symbol ?? null, pnlPct: e.trade.pnlPct, pnlUsd: e.trade.pnlUsd ?? null,
237
+ costUsd: fin(e.trade.costUsd) ? e.trade.costUsd : p?.costUsd ?? null, exitReason: e.trade.exitReason ?? null, stuck: !!e.trade.stuck, profile: e.trade.profileId ?? p?.profile ?? null,
238
+ entryAgeMin: fin(tick?.ageMin) ? tick.ageMin : null, entryLiqUsd: fin(tick?.liq) ? tick.liq : null, entryMcap: fin(tick?.mcap) ? tick.mcap : null });
239
+ continue;
240
+ }
241
+ if (e.kind === 'run_end') {
242
+ for (const key of s.openNow) {
243
+ const p = s.pos.get(key);
244
+ m.abandoned.push({ positionId: p.positionId, mint: p.mint, openedT: p.firstT, stoppedT: e.t, _p: p });
245
+ }
246
+ s.openNow.clear();
247
+ }
248
+ }
249
+ }
250
+ const writeOff = (m, p, endT, how) => {
251
+ const pnlUsd = Math.round((p.proceedsUsd - p.costUsd) * 1e4) / 1e4;
252
+ const pnlPct = p.costUsd > 0 ? Math.round((pnlUsd / p.costUsd) * 1e4) / 100 : null;
253
+ const tick = p.mint ? entryTick.get(p.mint) : undefined;
254
+ const w = { t: p.stuckSince, openedAt: p.firstT, mint: p.mint, symbol: p.symbol ?? (p.mint ? symbols.get(p.mint) ?? null : null), pnlPct, pnlUsd, costUsd: Math.round(p.costUsd * 1e4) / 1e4, realizedUsd: Math.round(p.proceedsUsd * 1e4) / 1e4,
255
+ exitReason: 'written-off', stuck: true, writtenOff: true, held: how === 'still held', profile: p.profile,
256
+ reason: `${how}: stuck ${Math.round((endT - p.stuckSince) / 60_000)} min with no sell (${p.stuckWhy ?? 'illiquid'}); unsold remainder valued 0`, stuckSince: p.stuckSince,
257
+ entryAgeMin: typeof tick?.ageMin === 'number' ? tick.ageMin : null, entryLiqUsd: typeof tick?.liq === 'number' ? tick.liq : null, entryMcap: typeof tick?.mcap === 'number' ? tick.mcap : null };
258
+ m.writtenOff.push(w);
259
+ if (pnlPct !== null)
260
+ m.closed.push(w);
261
+ };
262
+ for (const [id, m] of Object.entries(M)) {
263
+ const s = state[id];
264
+ m.abandoned = m.abandoned.filter((a) => { const p = a._p; delete a._p; if (p.stuckSince === null)
265
+ return true; writeOff(m, p, a.stoppedT ?? now, a.notCarried ? 'dropped stuck (not carried over by the restart)' : 'dropped stuck at a clean run end'); return false; });
266
+ for (const key of s.openNow) {
267
+ const p = s.pos.get(key);
268
+ if (p.stuckSince !== null && now - p.stuckSince >= writeOffMin * 60_000)
269
+ writeOff(m, p, now, 'still held');
270
+ else {
271
+ m.open++;
272
+ m.openPositions.push({ positionId: p.positionId, mint: p.mint, symbol: p.symbol ?? (p.mint ? symbols.get(p.mint) ?? null : null), openedAt: p.firstT, costUsd: Math.round(p.costUsd * 1e4) / 1e4, realizedUsd: Math.round(p.proceedsUsd * 1e4) / 1e4, stuckSince: p.stuckSince, profile: p.profile });
273
+ if (p.stuckSince !== null)
274
+ m.stuckOpen.push({ positionId: p.positionId, mint: p.mint, stuckSince: p.stuckSince });
275
+ }
276
+ }
277
+ for (const c of m.closed)
278
+ c.symbol ??= c.mint ? symbols.get(c.mint) ?? null : null;
279
+ m.closed.sort((a, b) => a.t - b.t);
280
+ }
281
+ return M;
282
+ }
283
+ /**
284
+ * REALIZABLE value of an open position (agent-v4/scoreboard.mjs): the engine's mark sold into the pool with the
285
+ * engine's cost model (constant-product impact on half the liquidity, fee + slippage bps, network fee). null = the
286
+ * pool's liquidity is unknown.
287
+ */
288
+ export function realizableUsd(markUsd, liqUsd, ex = { feeBps: 30, slippageBps: 200, networkFeeUsd: 0.02 }) {
289
+ if (!Number.isFinite(markUsd) || typeof liqUsd !== 'number' || !Number.isFinite(liqUsd) || liqUsd <= 0)
290
+ return null;
291
+ const side = liqUsd / 2;
292
+ const impact = markUsd / (side + markUsd);
293
+ return Math.max(0, markUsd * (1 - impact) * (1 - (ex.feeBps + ex.slippageBps) / 10_000) - ex.networkFeeUsd);
294
+ }
295
+ /** The realized equity curve over the closed sample (start + cumulative P/L in close order) and its drawdown from the running peak. */
296
+ export function equityCurve(closed, startUsd) {
297
+ const start = startUsd ?? 100;
298
+ let eq = start;
299
+ let peak = start;
300
+ let maxDd = 0;
301
+ const points = [];
302
+ for (const c of [...closed].sort((a, b) => a.t - b.t)) {
303
+ eq += typeof c.pnlUsd === 'number' && Number.isFinite(c.pnlUsd) ? c.pnlUsd : 0;
304
+ peak = Math.max(peak, eq);
305
+ const dd = peak > 0 ? (1 - eq / peak) * 100 : 0;
306
+ maxDd = Math.max(maxDd, dd);
307
+ points.push({ t: c.t, usd: Math.round(eq * 100) / 100, drawdownPct: Math.round(dd * 10) / 10 });
308
+ }
309
+ return { points, maxDrawdownPct: Math.round(maxDd * 10) / 10, start, end: Math.round(eq * 100) / 100 };
310
+ }
311
+ /** One strategy's row (control/report.mjs summarize: rugs, means, ETA), plus the exits breakdown and the sample badge. */
312
+ export function strategyRow(id, m, { now = Date.now(), minClosed = MIN_CLOSED } = {}) {
313
+ const xs = m.closed.map((c) => c.pnlPct).filter((x) => typeof x === 'number' && Number.isFinite(x));
314
+ const rugs = m.closed.filter((c) => (c.pnlPct ?? 0) <= RUG_PCT || (c.stuck && !c.writtenOff)).length;
315
+ const days = m.firstT === null ? 0 : (now - m.firstT) / DAY;
316
+ const perDay = days > 0 ? m.entries / days : null;
317
+ const ci = meanCi(xs);
318
+ const by = new Map();
319
+ for (const c of m.closed) {
320
+ const r = c.writtenOff ? 'written-off' : c.exitReason ?? 'unknown';
321
+ if (!by.has(r))
322
+ by.set(r, []);
323
+ if (typeof c.pnlPct === 'number')
324
+ by.get(r).push(c.pnlPct);
325
+ }
326
+ const exits = [...by].map(([reason, v]) => ({ reason, n: v.length, meanPct: v.length ? Math.round(mean(v) * 10) / 10 : null, wins: v.filter((x) => x > 0).length })).sort((a, b) => b.n - a.n);
327
+ const sample = xs.length === 0 ? 'none' : xs.length < minClosed ? 'exploratory' : 'minimum';
328
+ return {
329
+ id, entries: m.entries, closed: xs.length, open: m.open, writtenOff: m.writtenOff.length, stuckOpen: m.stuckOpen.length, abandoned: m.abandoned.length, adopted: m.adopted,
330
+ wins: xs.filter((x) => x > 0).length, losses: xs.filter((x) => x <= 0).length, rugs, rugRate: xs.length ? rugs / xs.length : null, rugCi: wilsonCi(rugs, xs.length),
331
+ meanPct: ci.mean, lo: ci.lo, hi: ci.hi, days, entriesPerDay: perDay, etaDays: perDay ? Math.max(0, (minClosed - xs.length) / perDay) : null,
332
+ sample, sampleText: sample === 'none' ? 'no closed trades yet' : sample === 'exploratory' ? `exploratory: ${xs.length} closed, < ${minClosed}` : `${xs.length} closed (>= ${minClosed})`,
333
+ realizedUsd: Math.round(m.closed.reduce((s, c) => s + (typeof c.pnlUsd === 'number' ? c.pnlUsd : 0), 0) * 100) / 100, exits,
334
+ };
335
+ }
336
+ /** The decision rule the control applies to a variant (control/report.mjs DECISION_RULE, baseline named). */
337
+ export const decisionRule = (base, minClosed = MIN_CLOSED) => `A filter or exit change is adopted only if it beats ${base} over >= ${minClosed} closed entries in BOTH strategies with the 95% interval of the difference in mean % per entry clear of zero (above 0). Until then nothing changes.`;
338
+ /** A variant against the baseline (control/report.mjs verdicts). */
339
+ export function compareToBase(id, a, base, b, minClosed = MIN_CLOSED) {
340
+ const d = diffCi(a, b);
341
+ const enough = a.length >= minClosed && b.length >= minClosed;
342
+ const verdict = !enough ? `not yet: ${a.length} / ${b.length} closed (needs ${minClosed} each)` : (d.lo ?? 0) > 0 ? 'passes the rule: beats the baseline' : (d.hi ?? 0) < 0 ? 'worse than the baseline' : 'no clear difference: keep running';
343
+ return { id, base, n: a.length, baseN: b.length, diff: d, enough, verdict };
344
+ }
@@ -59,7 +59,7 @@ export const SCHEMAS = {
59
59
  kind: { type: 'string', enum: ['strategy'] },
60
60
  schema: { type: 'string', enum: [STRATEGY_SCHEMA] },
61
61
  id: str(32, ID), name: str(40), version: str(16, SEMVER), description: str(600),
62
- mode: { type: 'string', enum: ['paper', 'live'] },
62
+ mode: { type: 'string', enum: ['paper', 'real', 'live'] },
63
63
  highRisk: { type: 'boolean' },
64
64
  bankroll: obj({ startUsd: num(1, 1e7), source: { type: 'string', enum: ['fixed', 'desk'] } }, ['startUsd']),
65
65
  entry: obj({
@@ -72,6 +72,13 @@ export const SCHEMAS = {
72
72
  youngVertical: nullable(obj({ maxAgeHours: num(0, 168), maxH1Pct: num(0, 100000) })),
73
73
  unknownHolders: { type: 'string', enum: ['reject', 'allow'] },
74
74
  larp: nullable(obj({ maxScore: num(0, 100), unknown: { type: 'string', enum: ['reject', 'allow'] } })),
75
+ rugproof: nullable(obj({
76
+ minPoolAgeMin: num(0, 1440), requireSafePool: { type: 'boolean' }, maxHolderLpPct: num(0, 100), maxWithdraws: num(0, 100, true), withdrawScanTx: num(1, 100, true),
77
+ liquidityRecheck: nullable(obj({ afterSec: num(5, 600), floorX: num(1, 100), maxDropPct: num(0, 90) })),
78
+ routeX: nullable(num(1, 20)),
79
+ youngPool: nullable(obj({ underMin: num(1, 1440), maxSizePct: num(1, 100) })),
80
+ drainGuard: nullable(obj({ pollSec: num(2, 10), liqDropPct: num(5, 90), exitOnNoRoute: { type: 'boolean' }, minReserveSol: num(0, 1000) })),
81
+ })),
75
82
  }, ['maxTop10Pct', 'maxDevPct', 'requireNoMintAuthority', 'requireNoFreezeAuthority', 'maxMcapSplitX', 'youngVertical', 'unknownHolders']),
76
83
  triggers: { type: 'array', max: 8, items: 'trigger' },
77
84
  triggerMode: { type: 'string', enum: ['any', 'all'] },
@@ -103,7 +110,7 @@ export const SCHEMAS = {
103
110
  onTimeout: { type: 'string', enum: ['skip'] },
104
111
  respawnOnWake: { type: 'boolean' },
105
112
  }, ['model', 'maxSessions', 'budgetUsdPerSession', 'budgetUsdPerDay', 'maxTurns', 'deltaEverySec', 'decideWithinSec', 'sessionMaxMin', 'onTimeout']),
106
- execution: obj({ slippageBps: num(0, 5000), feeBps: num(0, 1000), networkFeeUsd: num(0, 10), latencyMs: num(0, 120000), maxPriceImpactPct: num(0.1, 50), minExitLiquidityUsd: num(0, 1e7) }),
113
+ execution: obj({ slippageBps: num(0, 5000), feeBps: num(0, 1000), networkFeeUsd: num(0, 10), latencyMs: num(0, 120000), maxPriceImpactPct: num(0.1, 50), minExitLiquidityUsd: num(0, 1e7), stuckWriteOffMin: nullable(num(1, 10080)) }, ['slippageBps', 'feeBps', 'networkFeeUsd', 'latencyMs', 'maxPriceImpactPct', 'minExitLiquidityUsd']),
107
114
  notes: { type: 'array', max: 8, items: str(300) },
108
115
  }, ['kind', 'schema', 'id', 'name', 'version', 'description', 'mode', 'bankroll', 'entry', 'exits', 'sizing', 'risk', 'agent', 'execution']),
109
116
  };
@@ -200,6 +207,8 @@ export function validateStrategy(doc, library = builtinProfiles()) {
200
207
  errors.push({ path: '/risk/drawdown', msg: 'sizeDownAtPct must not exceed haltAtPct' });
201
208
  if (r.drawdown.recoverBelowPct >= r.drawdown.sizeDownAtPct)
202
209
  errors.push({ path: '/risk/drawdown/recoverBelowPct', msg: 'recoverBelowPct must be below sizeDownAtPct' });
210
+ if (s.execution.stuckWriteOffMin != null && s.mode !== 'paper')
211
+ errors.push({ path: '/execution/stuckWriteOffMin', msg: `the stuck write-off is paper only: in mode ${s.mode} the bag is still in your wallet and keeps its exit signals (remove it or set it to null)` });
203
212
  if (s.agent.budgetUsdPerSession > s.agent.budgetUsdPerDay)
204
213
  errors.push({ path: '/agent/budgetUsdPerSession', msg: 'a session budget above the daily budget' });
205
214
  if (s.entry.ageHours.max !== null && s.entry.ageHours.max < s.entry.ageHours.min)
@@ -211,8 +220,12 @@ export function validateStrategy(doc, library = builtinProfiles()) {
211
220
  warnings.push({ path: `/entry/triggers/${i}`, msg: `${t.kind} triggers need the live ${t.kind} feed; replays and backtests treat them as not firing` });
212
221
  if (s.bankroll.source === 'desk')
213
222
  warnings.push({ path: '/bankroll/source', msg: 'desk reads the paper desk book during a run; backtests use startUsd' });
223
+ if (s.entry.safety.rugproof)
224
+ warnings.push({ path: '/entry/safety/rugproof', msg: 'the rug-proof chain and route gates (pool structure, LP, withdraw history, sell route) and the drain poller need live data; a replay evaluates only the tick gates and the pump.fun-mint proxy' });
214
225
  if (s.entry.safety.larp)
215
226
  warnings.push({ path: '/entry/safety/larp', msg: 'the LARP gate needs the live web (live data runs only); replays and backtests do not evaluate it' });
227
+ if (s.mode === 'real')
228
+ warnings.push({ path: '/mode', msg: 'mode real sends a LIVE TRADE FIRE signal for every trade to your own executor; it runs only after `dexterm autotrade signal arm` on your own terminal, at this exact version' });
216
229
  if (s.mode === 'live')
217
230
  warnings.push({ path: '/mode', msg: 'mode live does not run: dexterm runs strategies on paper only (it holds no key and places no live order); live trading happens in the Telegram trading bot of your choice' });
218
231
  if (errors.length)
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Where each ledger field comes from:
11
11
  // strategy run_start: strategy id, version and strategyHash
12
- // wallet mode run_start.mode (paper; a live run's executor name says devnet or mainnet)
12
+ // wallet mode run_start.mode (paper; real = a real-mode run, task-1655; a live run's executor name says devnet or mainnet)
13
13
  // profile the entry fill's profile; 'structured' when the agent's DECISION named it, else 'default'
14
14
  // (the strategy's default profile), 'rule' for a mechanical (auto) entry
15
15
  // entry reason the agent's DECISION {"action":"enter","reason":…} of the session that entered (structured)
@@ -70,7 +70,7 @@ export function importRunLedger(path, runId) {
70
70
  const start = events.find((e) => e.kind === 'run_start') ?? null;
71
71
  const strategy = start && str(start.strategy) ? { id: String(start.strategy), version: str(start.version), hash: str(start.strategyHash) } : null;
72
72
  const executor = String(start?.executor ?? '');
73
- const walletMode = start?.mode === 'live' ? (/mainnet/.test(executor) ? 'live' : 'devnet') : 'paper';
73
+ const walletMode = start?.mode === 'real' ? 'real' : start?.mode === 'live' ? (/mainnet/.test(executor) ? 'live' : 'devnet') : 'paper';
74
74
  const auto = start?.decide === 'auto';
75
75
  // task-1538: a parallel-run strategy on a shared paper account names it in its first line (and run_start).
76
76
  const memberLine = events.find((e) => e.kind === 'fleet_member') ?? null;