dexterm-cli 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/packages/agents/src/spectator.js +3 -1
  2. package/dist/packages/agents/src/tiers.js +9 -0
  3. package/dist/packages/agents/src/tools.js +10 -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 +2 -1
  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 +3 -0
  20. package/dist/packages/autotrade/src/forward.js +355 -0
  21. package/dist/packages/autotrade/src/index.js +19 -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/runner.js +62 -11
  30. package/dist/packages/autotrade/src/service-guard.js +125 -0
  31. package/dist/packages/autotrade/src/service-install.js +213 -0
  32. package/dist/packages/autotrade/src/service-recover.js +232 -0
  33. package/dist/packages/autotrade/src/service.js +289 -0
  34. package/dist/packages/autotrade/src/signal-channels.js +312 -0
  35. package/dist/packages/autotrade/src/signal.js +196 -0
  36. package/dist/packages/autotrade/src/strategy.js +3 -1
  37. package/dist/packages/autotrade/src/trade-ledger.js +2 -2
  38. package/dist/packages/cli/src/analyst/backend.js +3 -2
  39. package/dist/packages/cli/src/analyst/codex.js +3 -2
  40. package/dist/packages/cli/src/app-help-gen.js +14 -2
  41. package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
  42. package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
  43. package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
  44. package/dist/packages/cli/src/autotrade-cmd/fleet.js +8 -4
  45. package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
  46. package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
  47. package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
  48. package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
  49. package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
  50. package/dist/packages/cli/src/autotrade-cmd/run.js +257 -13
  51. package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
  52. package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
  53. package/dist/packages/cli/src/autotrade-cmd.js +50 -4
  54. package/dist/packages/cli/src/ledger-cmd.js +4 -2
  55. package/dist/packages/cli/src/pnl-card.js +4 -0
  56. package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
  57. package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
  58. package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
  59. package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
  60. package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
  61. package/dist/packages/cli/src/telegram-cmd.js +4 -1
  62. package/dist/packages/cli/src/version.js +1 -1
  63. package/dist/packages/data/src/trade/ledger/schema.js +1 -1
  64. package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
  65. package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
  66. package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
  67. package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
  68. package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
  69. package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
  70. package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
  71. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
  72. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
  73. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
  74. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
  75. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +57 -0
  76. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
  77. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
  78. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
  80. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +39 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
  85. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +24 -0
  87. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  88. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
  90. package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
  91. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
  93. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
  94. package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
  95. package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
  96. package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
  98. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
  100. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
  101. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
  102. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
  105. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
  107. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +35 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
  109. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
  110. package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
  111. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  112. package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
  113. package/dist/packages/mcp/src/autotrade-tools.js +168 -5
  114. package/dist/packages/mcp/src/engine/restrictions.js +3 -3
  115. package/dist/packages/mcp/src/index.js +1 -1
  116. package/dist/packages/mcp/src/ledger-tools.js +155 -7
  117. package/dist/packages/mcp/src/server.js +21 -10
  118. package/dist/packages/tui/src/keymap.js +7 -0
  119. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
- // `dexterm autotrade <sub>` (task-1503 AT-1, AUTOTRADE-SPEC). PAPER ONLY: no keys, no wallet, no signer.
1
+ // `dexterm autotrade <sub>` (task-1503 AT-1, AUTOTRADE-SPEC). No keys, no wallet, no signer: paper, or real mode
2
+ // (task-1645: trade-fire signals to the user's own executor, `signal` / `receipt` rows).
2
3
  //
3
4
  // A dispatcher table: each subcommand is one row that lazy-loads its own module under ./autotrade-cmd/, so
4
5
  // later cards add a row (run, service, stop, flatten, audit) without editing anyone else's lines.
@@ -55,8 +56,8 @@ export const SUBCOMMANDS = {
55
56
  load: () => import("./autotrade-cmd/requests.js").then((m) => m.requestsCmd),
56
57
  },
57
58
  run: {
58
- usage: 'dexterm autotrade run <id|file> --paper [--replay TAPE [--ms-per-tick 2000]] [--model M] [--max-minutes N] [--stub] [--raw] [--quiet] [--no-telegram]',
59
- summary: 'the paper runner: the screener hands each candidate to your own Claude (runner.json picks the account), every size clamped; ledger in autotrade/runs/; your paired Telegram bot starts with it (dexterm telegram setup)',
59
+ usage: 'dexterm autotrade run <id|file> --paper|--real [--replay TAPE [--ms-per-tick 2000]] [--model M] [--max-minutes N] [--sol-usd N] [--stub] [--raw] [--quiet] [--no-telegram]',
60
+ summary: 'the runner: the screener hands each candidate to your own Claude (runner.json picks the account), every size clamped; ledger in autotrade/runs/; your paired Telegram bot starts with it (dexterm telegram setup). --real (a strategy you armed with signal arm) runs in the FOREGROUND only, on live data: the always-on service runs paper strategies, and while no real run is running NO signal goes out, sells included',
60
61
  load: () => import("./autotrade-cmd/run.js").then((m) => m.runCmd),
61
62
  },
62
63
  'run --all': {
@@ -99,6 +100,51 @@ export const SUBCOMMANDS = {
99
100
  summary: 'list the run ledgers; --verify recomputes each hash chain and names the first edited, missing or reordered event',
100
101
  load: () => import("./autotrade-cmd/audit.js").then((m) => m.auditCmd),
101
102
  },
103
+ signal: {
104
+ usage: 'dexterm autotrade signal status|channels|secret|arm|disarm|caps|kill|unkill|held|release|test|flush|tail|reconcile …',
105
+ summary: 'real mode: a LIVE TRADE FIRE signal per trade to YOUR executor (webhook, --signal-cmd, JSONL, MCP, Telegram copy); arm on your own terminal; hard caps, kill switch; dexterm holds no key',
106
+ load: () => import("./autotrade-cmd/signal.js").then((m) => m.signalCmd),
107
+ },
108
+ receipt: {
109
+ usage: 'dexterm autotrade receipt <nonce|id> executed|rejected|failed [--price USD] [--size-sol N] [--tokens N] [--tx SIG] [--reason TEXT] | receipt list [--json]',
110
+ summary: 'what your executor did with a trade-fire signal, as data only (positions stay unconfirmed until a receipt); the drift between signals and fills',
111
+ load: () => import("./autotrade-cmd/receipt.js").then((m) => m.receiptCmd),
112
+ },
113
+ service: {
114
+ usage: 'dexterm autotrade service install|uninstall|ensure|run|start|stop|status|logs [--home PATH]',
115
+ summary: 'the always-on service: your PAPER strategies (autotrade.json) start at login, restart after a crash, stop on any kill switch and tell you when they cannot run; real mode does not run under it (run --real, in the foreground)',
116
+ load: () => import("./autotrade-cmd/service.js").then((m) => m.serviceCmd),
117
+ },
118
+ forward: {
119
+ usage: 'dexterm autotrade forward start <id> [--hours H] [--trades N] [--bankroll-usd N] | status [<id>] | stop <id> | report <id> [--json]',
120
+ summary: 'forward test: the strategy on the LIVE tape, on paper, own bankroll and run file, for hours or N trades; the report sets it next to its backtest',
121
+ load: () => import("./autotrade-cmd/forward.js").then((m) => m.forwardCmd),
122
+ },
123
+ journey: {
124
+ usage: 'dexterm autotrade journey [<id>] [--json]',
125
+ summary: 'where a strategy stands on the ladder draft → backtested → forward-tested → always-on → real, and what the next rung needs',
126
+ load: () => import("./autotrade-cmd/journey.js").then((m) => m.journeyCmd),
127
+ },
128
+ promote: {
129
+ usage: 'dexterm autotrade promote <id> --to always-on|real | list | show <pp-id> | apply <pp-id> | dismiss <pp-id>',
130
+ summary: 'a promotion with its evidence card: always-on needs your y on a real terminal; real needs the evidence floor (a completed forward test with minForwardTrades closed paper trades + a backtest) and hands over to signal arm (your caps, a typed phrase); no rung is skipped',
131
+ load: () => import("./autotrade-cmd/journey.js").then((m) => m.promoteCmd),
132
+ },
133
+ limits: {
134
+ usage: 'dexterm autotrade limits show|set|diff|reset|accept|breaker reset|explain|schema …',
135
+ summary: 'real mode: every limit is yours (per trade SOL or % of book, per day, open positions, exposure, loss limits, breaker, rate, slippage, TTL, executor silence), global / per mode / per strategy / per account, the stricter wins; raising is typed on your own terminal',
136
+ load: () => import("./autotrade-cmd/limits.js").then((m) => m.limitsCmd),
137
+ },
138
+ executor: {
139
+ usage: 'dexterm autotrade executor status [--json] | heartbeat [--name N] [--account ID] [--dry-run] [--nonce N] | kit --out DIR',
140
+ summary: 'real mode: is YOUR executor alive (heartbeat, last receipt, lag)? A silent executor pauses new buy signals; `kit --out DIR` copies the dry-run reference executors (webhook, signal-cmd, JSONL tail)',
141
+ load: () => import("./autotrade-cmd/executor.js").then((m) => m.executorCmd),
142
+ },
143
+ report: {
144
+ usage: 'dexterm autotrade report [--period day|week] [--strategy ID] [--json] [--telegram]',
145
+ summary: 'the digest per strategy and mode: trades, win rate with its interval, net, drawdown, refusals, limits hit, executor health, paper vs real fills, an honest small-sample note',
146
+ load: () => import("./autotrade-cmd/report.js").then((m) => m.reportCmd),
147
+ },
102
148
  extract: {
103
149
  usage: 'dexterm autotrade extract --out FILE [--db PATH] [--max-mints N] [--min-peak-liq USD] [--span-hours H]',
104
150
  summary: 'save a replay tape from your recorder history (read-only) to a file you can backtest offline',
@@ -107,7 +153,7 @@ export const SUBCOMMANDS = {
107
153
  };
108
154
  export function autotradeHelp() {
109
155
  return [
110
- 'dexterm autotrade: strategies, backtests and the paper runner. PAPER ONLY, not financial advice.',
156
+ 'dexterm autotrade: strategies, backtests and the runner. PAPER by default; mode real sends a LIVE TRADE FIRE signal per trade to YOUR executor (`signal arm`, on your own terminal; dexterm holds no key). Not financial advice.',
111
157
  '',
112
158
  ...Object.values(SUBCOMMANDS).map((s) => ` ${s.usage}\n ${s.summary}`),
113
159
  '',
@@ -20,7 +20,7 @@ export const LEDGER_HELP = `dexterm ledger: the unified trade ledger (paper, age
20
20
  dexterm ledger sync [--json] append what the sources hold to the ledger file
21
21
  dexterm ledger verify [--json] recompute the ledger file's sha256 chain
22
22
 
23
- filters: --source paper|desk|autotrade|live --mode paper|devnet|live --strategy ID[@VERSION|@HASH] --profile P
23
+ filters: --source paper|desk|autotrade|live --mode paper|devnet|live|real --strategy ID[@VERSION|@HASH] --profile P
24
24
  --exit REASON --mint MINT --run RUN_ID --account ID --days N --status open|closed --wallet-db PATH
25
25
 
26
26
  The file is $DEXTERM_HOME/ledger/trades.jsonl (default ~/.dexterm). PAPER rows are simulated; filter by --mode
@@ -95,9 +95,11 @@ function banner(v, rows) {
95
95
  out.push(` ! ${s.problem}`);
96
96
  const modes = [...new Set(rows.map((t) => t.walletMode))];
97
97
  if (modes.length > 1)
98
- out.push(' PAPER, DEVNET and LIVE trades are mixed here: use --mode before comparing results.');
98
+ out.push(' PAPER, REAL, DEVNET and LIVE trades are mixed here: use --mode before comparing results.');
99
99
  else if (modes[0] === 'paper')
100
100
  out.push(' PAPER (simulated) trades.');
101
+ else if (modes[0] === 'real')
102
+ out.push(' REAL-mode trades: dexterm modelled the trades it signalled to your executor; your executor\'s receipts are the real fills.');
101
103
  return out;
102
104
  }
103
105
  function tradesTable(rows) {
@@ -138,6 +138,10 @@ export function autotradeCardInput(events, o) {
138
138
  * needs `solUsd`; a fill with no token amount refuses the card rather than guess.
139
139
  */
140
140
  export function ledgerCardInput(row, o = {}) {
141
+ // task-1655: a real-mode row is dexterm's model of a trade it signalled; the real fill is the executor's receipt.
142
+ // A card would stamp it PAPER or LIVE, and both are wrong, so there is none.
143
+ if (row.walletMode === 'real')
144
+ throw new CardError('a real-mode trade: dexterm only modelled this fill (the signal it sent your executor); your executor\'s receipt is the record, so dexterm makes no card for it');
141
145
  const decimals = row.decimals ?? 6;
142
146
  const usdDesk = row.fills.some((f) => f.sol === null);
143
147
  if (usdDesk && !(o.solUsd && o.solUsd.usd > 0))
@@ -0,0 +1,134 @@
1
+ // dexterm — the AUTOTRADE screen's JOURNEY view (`Q`, then `J` on a strategy; 0.2.0 card C). One strategy's ladder:
2
+ // draft → backtested → forward-tested → always-on (paper) → real, with the last backtest (held-out split), the forward
3
+ // test (running or its result next to the backtest), the always-on service membership and the mode.
4
+ //
5
+ // Keys on the view: `b` backtests (recorded as evidence), `F` starts a 24 h forward test (paper, live data, the
6
+ // strategy's own bankroll, in the background), `X` stops it, `A` opens the pending ALWAYS-ON promotion's card (y
7
+ // applies it: autotrade.json gets the paper member). A promotion to REAL is never applied here: it needs the user's own
8
+ // caps and the typed phrase on a terminal (`dexterm autotrade promote apply <pp-id>`). Nothing here reaches a wallet,
9
+ // a key, a mandate or the signer.
10
+ import { dirname } from 'node:path';
11
+ import { PromotionFiles, applyAlwaysOn, createForwardTest, findForwardTest, forwardReport, journeyView, liveState, loadArmings, requestForwardStop, saveForwardTest, windowText, } from '../../../autotrade/src/index.js';
12
+ import { spawnForwardHost } from "../autotrade-cmd/forward.js";
13
+ export const defaultForwardSpawn = (home, t) => spawnForwardHost(home, t, process.env);
14
+ const homeOf = (ui) => dirname(ui.deps.store.dir);
15
+ export function openJourney(ui, id) {
16
+ if (!id)
17
+ return 'no strategy selected';
18
+ ui.journey = { id, view: null, error: null, forward: null, report: null, pending: [], readAt: 0 };
19
+ ui.view = 'journey';
20
+ refreshJourney(ui, true);
21
+ return ui.journey.error;
22
+ }
23
+ /** Re-read the journey (at most every 2 s unless forced): evidence, forward test, pending promotions. */
24
+ export function refreshJourney(ui, force = false) {
25
+ const j = ui.journey;
26
+ if (!j)
27
+ return;
28
+ const now = ui.deps.now();
29
+ if (!force && now - j.readAt < 2000)
30
+ return;
31
+ j.readAt = now;
32
+ const home = homeOf(ui);
33
+ const got = ui.deps.store.get(j.id);
34
+ if (!got || !got.ok) {
35
+ j.view = null;
36
+ j.error = got ? `${j.id} does not validate` : `no strategy ${j.id}`;
37
+ return;
38
+ }
39
+ try {
40
+ j.view = journeyView(home, got.strategy, { armedHash: loadArmings(home)[j.id]?.strategyHash ?? null });
41
+ j.forward = findForwardTest(home, j.id, now);
42
+ j.report = j.forward ? forwardReport(home, j.forward, now) : null;
43
+ j.pending = new PromotionFiles(home).list({ status: 'pending', id: j.id });
44
+ j.error = null;
45
+ }
46
+ catch (e) {
47
+ j.error = `journey unreadable: ${e.message}`;
48
+ }
49
+ }
50
+ /** `F`: a 24 h forward test of this version, on paper, live data, its own bankroll, started in the background. */
51
+ export function journeyForward(ui) {
52
+ const j = ui.journey;
53
+ if (!j)
54
+ return 'F starts a forward test from the journey view (J)';
55
+ const got = ui.deps.store.get(j.id);
56
+ if (!got || !got.ok)
57
+ return `no valid strategy ${j.id}`;
58
+ if (got.source !== 'user')
59
+ return `${j.id} is a preset: copy it first (d)`;
60
+ const home = homeOf(ui);
61
+ const c = createForwardTest(home, got.strategy, ui.deps.store.raw(j.id), { hours: 24, by: 'user:tui', now: ui.deps.now() });
62
+ if (!c.ok)
63
+ return c.why;
64
+ const pid = (ui.deps.forwardSpawn ?? defaultForwardSpawn)(home, c.test);
65
+ if (!pid) {
66
+ saveForwardTest(home, { ...c.test, state: 'failed', endedAt: ui.deps.now(), endReason: 'could not start the host' });
67
+ return `could not start it; in a terminal: dexterm autotrade forward start ${j.id}`;
68
+ }
69
+ refreshJourney(ui, true);
70
+ return `forward test ${c.test.forwardId} started · ${windowText(c.test.window)} · paper, live data, own bankroll $${c.test.bankrollUsd} · X stops it`;
71
+ }
72
+ /** `X`: stop the running forward test (it flattens and writes its report; an early stop does not count). */
73
+ export function journeyStopForward(ui) {
74
+ const j = ui.journey;
75
+ if (!j)
76
+ return 'X stops a forward test from the journey view (J)';
77
+ const t = findForwardTest(homeOf(ui), j.id, ui.deps.now());
78
+ if (!t)
79
+ return 'no forward test';
80
+ const r = requestForwardStop(homeOf(ui), t.forwardId, 'tui', ui.deps.now());
81
+ refreshJourney(ui, true);
82
+ return r.ok ? `${r.already ? 'already stopping' : 'stop requested'}: ${t.forwardId} flattens and writes its report (an early stop does not count)` : r.why;
83
+ }
84
+ /** `A`: open the pending always-on promotion's confirm card (y applies). Real is a terminal-only step. */
85
+ export function journeyPromote(ui) {
86
+ const j = ui.journey;
87
+ if (!j)
88
+ return 'A applies a promotion from the journey view (J)';
89
+ refreshJourney(ui, true);
90
+ const p = j.pending.find((x) => x.to === 'always-on');
91
+ if (p) {
92
+ ui.confirm = { kind: 'promote', proposalId: p.proposalId };
93
+ return `promotion ${p.proposalId} → always-on (proposed by ${p.actor}): y applies, any key cancels`;
94
+ }
95
+ const real = j.pending.find((x) => x.to === 'real');
96
+ if (real)
97
+ return `real mode is armed only on your own terminal with your caps and a typed phrase: dexterm autotrade promote apply ${real.proposalId}`;
98
+ return j.view?.next?.stage === 'always-on' ? `nothing pending: ask the analyst to propose it, or in a terminal: dexterm autotrade promote ${j.id} --to always-on` : 'no promotion pending';
99
+ }
100
+ /** The y / any-key answer on the promotion card. */
101
+ export function confirmPromote(ui, proposalId, yes) {
102
+ if (!yes)
103
+ return 'promotion not applied (it stays pending)';
104
+ const j = ui.journey;
105
+ const got = j ? ui.deps.store.get(j.id) : null;
106
+ if (!got || !got.ok)
107
+ return 'the strategy is not valid any more';
108
+ const a = applyAlwaysOn(homeOf(ui), proposalId, got.strategy, { by: 'user:tui', now: ui.deps.now() });
109
+ refreshJourney(ui, true);
110
+ return a.ok ? `applied: ${got.strategy.id} is an always-on paper member (${a.memberId}); the service runs it from its next start` : `not applied: ${a.why}`;
111
+ }
112
+ export function journeyLines(j, now) {
113
+ if (j.error || !j.view)
114
+ return [{ text: j.error ?? 'loading…', tone: 'warn' }];
115
+ const v = j.view;
116
+ const out = [
117
+ { text: `${v.id} v${v.version} · rules ${v.rulesHash.slice(0, 8)} · mode ${v.mode} · stage ${v.stage.toUpperCase()}`, tone: 'text' },
118
+ { text: '', tone: 'dim' },
119
+ ...v.rungs.map((r) => ({ text: `${r.ok ? '[x]' : '[ ]'} ${r.stage.padEnd(15)} ${r.detail}`, tone: (r.ok ? 'ok' : 'dim') })),
120
+ ...v.notes.map((n) => ({ text: `! ${n}`, tone: 'warn' })),
121
+ { text: v.next ? `next: ${v.next.stage} — ${v.next.how}` : 'top of the ladder: real (caps, kill switch, receipts: dexterm autotrade signal status)', tone: 'text' },
122
+ { text: '', tone: 'dim' },
123
+ ];
124
+ if (j.forward && j.report) {
125
+ const st = liveState(j.forward, now);
126
+ out.push({ text: `forward test ${j.forward.forwardId} · ${st.toUpperCase()} · ${windowText(j.forward.window)}`, tone: st === 'running' || st === 'starting' ? 'warn' : 'text' });
127
+ for (const l of j.report.lines.slice(1))
128
+ out.push({ text: l.replace(/^ /, ''), tone: 'text' });
129
+ out.push({ text: '', tone: 'dim' });
130
+ }
131
+ for (const p of j.pending)
132
+ out.push({ text: `pending promotion ${p.proposalId} → ${p.to} (proposed by ${p.actor})${p.to === 'always-on' ? ' · A to review' : ' · terminal only: dexterm autotrade promote apply'}`, tone: 'warn' });
133
+ return out;
134
+ }
@@ -4,6 +4,7 @@
4
4
  // strategy files; nothing here reaches a wallet, a key, a mandate or the signer.
5
5
  import { INPUT_MAX, backtestSubject, beginFieldEdit, beginImport, beginNew, commitInput, commitTyping, confirmKey, exportSelected, nextView, openEditor, reloadList, refreshRunner, revalidateTyping, runnerVerb, saveEditor, selectedEntry, startBacktest, startCompareBacktest, startSweep, toggleMark, undoEditor, } from "./autotrade-ui.js";
6
6
  import { openReview, reviewKey } from "./autotrade-proposals.js";
7
+ import { journeyForward, journeyPromote, journeyStopForward, openJourney, refreshJourney } from "./autotrade-journey.js";
7
8
  const PRINTABLE = (k) => (k.name === 'space' ? ' ' : k.name === 'char' && k.ch && !k.ctrl && !k.alt && !/[\u0000-\u001f\u007f]/.test(k.ch) ? k.ch : null);
8
9
  /** A captured key (input line, field value or confirm). Returns true when the key was consumed. */
9
10
  export function autotradeKey(ui, key) {
@@ -135,6 +136,8 @@ export function autotradeAction(ui, action) {
135
136
  case 'autotrade.save': return say(ui.view === 'editor' ? saveEditor(ui) : 's saves in the editor');
136
137
  case 'autotrade.undo': return say(ui.view === 'editor' ? undoEditor(ui) : 'u undoes in the editor');
137
138
  case 'autotrade.backtest': {
139
+ if (ui.view === 'journey' && ui.journey)
140
+ return say(startBacktest(ui, ui.journey.id));
138
141
  if (ui.view === 'compare')
139
142
  return say(startCompareBacktest(ui));
140
143
  if (ui.view === 'editor' && ui.editor)
@@ -150,6 +153,16 @@ export function autotradeAction(ui, action) {
150
153
  return say(id ? startSweep(ui, id) : 'no strategy selected');
151
154
  }
152
155
  case 'autotrade.proposal': return say(ui.view === 'list' ? openReview(ui) || null : 'o reviews chat proposals on the strategies list (esc)');
156
+ // task-1647: the journey view
157
+ case 'autotrade.journey':
158
+ if (ui.view === 'journey') {
159
+ refreshJourney(ui, true);
160
+ return say(null);
161
+ }
162
+ return say(openJourney(ui, ui.view === 'editor' ? ui.editor?.id : ui.view === 'backtest' ? backtestSubject(ui)?.id : e?.id));
163
+ case 'autotrade.forward': return say(ui.view === 'journey' ? journeyForward(ui) : 'F starts a forward test on the journey view (J)');
164
+ case 'autotrade.forwardstop': return say(ui.view === 'journey' ? journeyStopForward(ui) : 'X stops a forward test on the journey view (J)');
165
+ case 'autotrade.promote': return say(ui.view === 'journey' ? journeyPromote(ui) : 'A reviews a promotion on the journey view (J)');
153
166
  case 'autotrade.run':
154
167
  ui.view = 'runner';
155
168
  refreshRunner(ui);
@@ -5,6 +5,7 @@ import { Canvas, header, footer, breakpoint, fmtAgeSec, sparkline, wrap } from '
5
5
  import { compareRows as fleetCompareRows, compareTable as fleetCompareTable, fleetTotals } from '../../../autotrade/src/index.js';
6
6
  import { tokenize } from "../screener/filter.js";
7
7
  import { headerModel } from "../state.js";
8
+ import { journeyLines, refreshJourney } from "./autotrade-journey.js";
8
9
  import { AUTOTRADE_VIEWS, VIEW_TITLE, READ_ONLY_PATHS, backtestSubject, compareRows, getAt, heartbeatStale, issuesByField, screenHighlight, } from "./autotrade-ui.js";
9
10
  /** Per-screen memo of file reads + validation, keyed by what the list says about a strategy (id, hash, version,
10
11
  * last backtest): a frame never re-reads a strategy file; a save, import or backtest reloads the list and so the key. */
@@ -46,10 +47,14 @@ export function renderAutotrade(c, state) {
46
47
  renderCompare(c, ui, area);
47
48
  else if (ui.view === 'backtest')
48
49
  renderBacktest(c, ui, area, now);
50
+ else if (ui.view === 'journey')
51
+ renderJourneyView(c, ui, area, now);
49
52
  else
50
53
  renderRunner(c, ui, area, now);
51
- if (ui.confirm)
54
+ if (ui.confirm?.kind === 'flatten')
52
55
  confirmCard(c);
56
+ else if (ui.confirm?.kind === 'promote')
57
+ promoteCard(c, ui, ui.confirm.proposalId);
53
58
  if (ui.review)
54
59
  reviewCard(c, ui);
55
60
  footer(c, hintsFor(ui), t.name);
@@ -68,6 +73,8 @@ function tabs(c, ui) {
68
73
  const off = (v === 'compare' && !ui.compare) || (v === 'editor' && !ui.editor);
69
74
  x = c.text(x, y, ` ${VIEW_TITLE[v]} `, { fg: on ? t.text : off ? t.dim : t.muted, bg: on ? t.tabActiveBg : t.bg, b: on }) + 1;
70
75
  }
76
+ if (ui.view === 'journey')
77
+ x = c.text(x, y, ` ${VIEW_TITLE.journey} `, { fg: t.text, bg: t.tabActiveBg, b: true }) + 1;
71
78
  const line = ui.input
72
79
  ? `${ui.input.kind === 'import' ? 'import file: ' : ui.input.kind === 'dup' ? `duplicate ${ui.input.from} as: ` : `new from ${ui.input.from} as: `}${ui.input.text}█ ⏎ ok · esc cancel`
73
80
  : ui.notice;
@@ -77,7 +84,7 @@ function tabs(c, ui) {
77
84
  function hintsFor(ui) {
78
85
  const common = [['esc', 'back'], ['q', 'quit']];
79
86
  if (ui.confirm)
80
- return [['y', 'flatten'], ['any key', 'cancel']];
87
+ return [['y', ui.confirm.kind === 'promote' ? 'apply promotion' : 'flatten'], ['any key', 'cancel']];
81
88
  if (ui.review)
82
89
  return ui.review.stage === 'less-safe' ? [['y', 'apply (less safe)'], ['any key', 'cancel']] : [['y', ui.review.check.ok && ui.review.check.riskDelta === 'looser' ? 'apply… (less safe)' : 'apply'], ['n', 'dismiss'], ['any key', 'close']];
83
90
  if (ui.input)
@@ -89,6 +96,7 @@ function hintsFor(ui) {
89
96
  : [['tab', 'view'], ['↑↓', 'field'], ['⏎', 'change'], ['s', 'save'], ['u', 'undo'], ['b', 'backtest'], ['w', 'sweep'], ...common];
90
97
  case 'compare': return [['tab', 'view'], ['↑↓', 'scroll'], ['b', 'backtest both, one tape'], ...common];
91
98
  case 'backtest': return [['tab', 'view'], ['b', 'run again'], ['w', 'sweep (held-out)'], ...common];
99
+ case 'journey': return [['b', 'backtest'], ['F', 'forward test'], ['X', 'stop it'], ['A', 'promotion'], ['J', 'refresh'], ...common];
92
100
  default: return [['tab', 'view'], ['!', 'kill'], ['p', 'pause entries'], ['f', 'flatten (confirm)'], ...common];
93
101
  }
94
102
  }
@@ -206,7 +214,7 @@ function renderList(c, ui, a, now) {
206
214
  }
207
215
  const running = (ui) => ui.runner.kind === 'ok';
208
216
  /** task-1528: the running runner's mode from its rendezvous ('live' = mainnet through the live signer). */
209
- const runMode = (ui) => { const m = ui.runner.kind === 'ok' ? ui.runner.snap.mode : 'paper'; return m === 'live' || m === 'devnet' ? m : 'paper'; };
217
+ const runMode = (ui) => { const m = ui.runner.kind === 'ok' ? ui.runner.snap.mode : 'paper'; return m === 'live' || m === 'devnet' || m === 'real' ? m : 'paper'; };
210
218
  /** One chat proposal card (§2a): id, hashes, the diff lines, validation, LOOSER in the warn colour. */
211
219
  export function proposalCard(c, p, x, y, w, maxY) {
212
220
  const t = c.theme;
@@ -639,9 +647,9 @@ function sweepRow(x, pw) {
639
647
  function renderRunner(c, ui, a, now) {
640
648
  const t = c.theme;
641
649
  const rr = ui.runner;
642
- const sh = 5;
643
650
  const mode = runMode(ui);
644
- const sbox = c.box(a.x, a.y, a.w, sh, { title: `RUNNER · ${mode === 'live' ? 'LIVE · MAINNET' : mode === 'devnet' ? 'DEVNET (live signer rehearsal)' : 'PAPER'}`, caption: `status file: ${rr.path || '~/.dexterm/autotrade/runner.json'}`, focus: true });
651
+ const sh = mode === 'real' ? (ui.runner.kind === 'ok' && ui.runner.snap.real?.limits ? 8 : 6) : 5;
652
+ const sbox = c.box(a.x, a.y, a.w, sh, { title: `RUNNER · ${mode === 'live' ? 'LIVE · MAINNET' : mode === 'devnet' ? 'DEVNET (live signer rehearsal)' : mode === 'real' ? 'REAL · LIVE TRADE FIRE signals to your executor (no key here)' : 'PAPER'}`, caption: `status file: ${rr.path || '~/.dexterm/autotrade/runner.json'}`, focus: true });
645
653
  let y = sbox.y;
646
654
  const sl = (s, fg = t.text, b = false) => { if (y < sbox.y + sbox.h) {
647
655
  c.text(sbox.x + 1, y, fit(s, sbox.w - 2), { fg, bg: sbox.bg, b });
@@ -665,6 +673,23 @@ function renderRunner(c, ui, a, now) {
665
673
  sl(`${stale ? 'STALE' : s.state.toUpperCase()} · ${s.strategy.id} v${s.strategy.version} (${s.strategy.hash}) · pid ${s.pid} · heartbeat ${age} ago${stale ? ' (no heartbeat for over 30 s: treat it as down)' : ''}`, stateFg, true);
666
674
  sl(`book $${s.book.equityUsd.toFixed(2)} of $${s.book.startUsd.toFixed(2)} · cash $${s.book.cashUsd.toFixed(2)} · realized today ${usd(s.book.realizedTodayUsd)} · model spend $${s.spend.todayUsd.toFixed(2)} of $${s.spend.dayCapUsd.toFixed(2)} today`);
667
675
  sl(s.haltReason ? `halted: ${s.haltReason}` : 'no halt', s.haltReason ? t.down : t.dim, !!s.haltReason);
676
+ // task-1645: real mode: the account, the kill switch, the outbox, each channel's health, unconfirmed positions.
677
+ const rm = s.real;
678
+ if (rm) {
679
+ const chans = rm.channels.map((h) => `${h.name} ${h.failing ? `FAILING ×${h.failing}` : 'ok'}${h.pending ? ` (${h.pending} pending)` : ''}`).join(', ') || 'none';
680
+ const unconf = rm.positions.filter((p) => p.state === 'unconfirmed').length;
681
+ sl(`real · account ${rm.accountId} · ${rm.killed ? `KILL ON (${rm.killed})` : 'kill off'} · outbox ${rm.outboxDepth} pending · ${chans} · ${unconf} unconfirmed · today ${rm.day.buySol} of ${rm.caps.dailySol ?? 'off'} SOL`, rm.killed || rm.channels.some((h) => h.failing) || unconf ? t.warn : t.text, true);
682
+ // task-1654: the limits panel (status only: limits change on your own terminal, `dexterm autotrade limits`).
683
+ if (rm.limits) {
684
+ const L = rm.limits;
685
+ const v = (k, unit) => (typeof L[k] === 'number' ? `${L[k]}${unit}` : 'off');
686
+ const pause = rm.risk?.buyPause;
687
+ sl(`limits · ${v('perTradeSol', ' SOL')}/trade${typeof L.perTradePct === 'number' ? ` (${L.perTradePct}% of book)` : ''} · ${v('dailySol', ' SOL')}/day · ${v('buysPerDay', '')} buys · ${v('maxOpenPositions', '')} open · loss stop ${v('dailyLossSol', ' SOL')} · streak ${rm.risk?.lossStreak ?? 0}/${v('lossStreak', '')}${rm.risk?.breaker ? ' · BREAKER TRIPPED' : ''}${pause ? ` · BUYS PAUSED: ${pause}` : ''} · dexterm autotrade limits show`, pause || rm.risk?.breaker ? t.warn : t.muted, !!pause);
688
+ const ex = rm.executor;
689
+ if (ex)
690
+ sl(`executor · ${ex.alive === false ? 'SILENT' : ex.alive ? 'alive' : 'not judged'}${ex.silentSec !== null && ex.lastSeenBy !== 'run-start' ? ` · last ${ex.lastSeenBy} ${fmtAgeSec(ex.silentSec)} ago` : ''}${ex.heartbeat ? ` · ${ex.heartbeat.executor}${ex.heartbeat.dryRun ? ' (dry run)' : ''}` : ' · no heartbeat yet'}${ex.lastReceipt ? ` · receipt #${ex.lastReceipt.nonce} ${ex.lastReceipt.status}` : ''}${ex.receiptLagSec !== null ? ` · lag ${ex.receiptLagSec} s` : ''}${ex.windowMin !== null ? ` · pauses buys after ${ex.windowMin} min silent` : ''}`, ex.alive === false ? t.down : t.muted, ex.alive === false);
691
+ }
692
+ }
668
693
  // task-1529: a `run --all` shows its strategies side by side above the shared panels (rows tagged [strategy]).
669
694
  const fleet = ui.fleet;
670
695
  let fh = 0;
@@ -703,7 +728,7 @@ function renderRunner(c, ui, a, now) {
703
728
  [` last: ${k.lastDecision ?? '—'}`, t.muted],
704
729
  ]));
705
730
  panel(a.x + 2 * third, a.w - 2 * third, `POSITIONS · ${s.positions.length}`, s.positions.flatMap((p) => [
706
- [`${tag(p)}${p.symbol.padEnd(8)}${pct(p.pnlPct).padStart(8)}${usd(p.pnlUsd).padStart(9)}${p.freeBag ? ' free bag' : ''}`, p.pnlUsd >= 0 ? t.up : t.down],
731
+ [`${tag(p)}${p.symbol.padEnd(8)}${pct(p.pnlPct).padStart(8)}${usd(p.pnlUsd).padStart(9)}${p.freeBag ? ' free bag' : ''}${p.confirm ? ` · ${p.confirm}` : ''}`, p.pnlUsd >= 0 ? t.up : t.down],
707
732
  [` ${p.profile} · next: ${p.next ?? '—'}`, t.muted],
708
733
  ]));
709
734
  const lb = c.box(a.x, a.y + sh + fh + upper, a.w, ledgerH, { title: 'RUN LEDGER · newest last', caption: 'hash-chained; every step with its exact inputs' });
@@ -723,3 +748,29 @@ function confirmCard(c) {
723
748
  c.text(b.x + 1, b.y + 1, fit('New entries stop first; mechanical exits keep running.', b.w - 2), { fg: t.muted, bg: b.bg });
724
749
  c.text(b.x + 1, b.y + 3, 'y flatten · any other key cancels', { fg: t.warn, bg: b.bg, b: true });
725
750
  }
751
+ // ───────────────────────────── journey (task-1647) ─────────────────────────────
752
+ function renderJourneyView(c, ui, a, now) {
753
+ const t = c.theme;
754
+ refreshJourney(ui);
755
+ const j = ui.journey;
756
+ const box = c.box(a.x, a.y, a.w, a.h, { title: `JOURNEY · ${j?.id ?? '—'}`, caption: 'draft → backtested → forward-tested → always-on (paper) → real · one rung at a time, on these exact rules', focus: true });
757
+ const bg = box.bg;
758
+ if (!j) {
759
+ c.text(box.x + 1, box.y, 'J on a strategy opens its journey', { fg: t.dim, bg });
760
+ return;
761
+ }
762
+ const tone = { text: t.text, ok: t.up, warn: t.warn, dim: t.dim };
763
+ // tables keep their columns: only a line wider than the box wraps
764
+ const lines = journeyLines(j, now).flatMap((l) => (l.text.length <= box.w - 2 ? [l] : wrap(l.text, box.w - 2).map((x) => ({ text: x, tone: l.tone }))));
765
+ lines.slice(0, box.h).forEach((l, i) => c.text(box.x + 1, box.y + i, fit(l.text, box.w - 2), { fg: tone[l.tone], bg }));
766
+ }
767
+ function promoteCard(c, ui, proposalId) {
768
+ const t = c.theme;
769
+ const p = ui.journey?.pending.find((x) => x.proposalId === proposalId);
770
+ const body = p ? p.card : [`promotion ${proposalId}`];
771
+ const w = Math.min(c.w - 4, 104), h = Math.min(c.h - 4, body.length + 5);
772
+ const x = Math.floor((c.w - w) / 2), y = Math.floor((c.h - h) / 2);
773
+ const box = c.box(x, y, w, h, { title: 'APPLY PROMOTION? (paper: the always-on service)', focus: true });
774
+ body.slice(0, h - 4).forEach((l, i) => c.text(box.x + 1, box.y + i, fit(l, box.w - 2), { fg: t.text, bg: box.bg }));
775
+ c.text(box.x + 1, box.y + h - 3, fit(`proposed by ${p?.actor ?? '?'} · y applies (autotrade.json gets the paper member) · any other key cancels`, box.w - 2), { fg: t.warn, bg: box.bg, b: true });
776
+ }
@@ -15,13 +15,14 @@
15
15
  // cards; applying them is the chat card's keypress flow). Kill / pause / flatten go through RunnerControl: the
16
16
  // runner's control file (task-1509 fileControl), the same request `dexterm autotrade stop|flatten` writes.
17
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
- import { join } from 'node:path';
18
+ import { dirname, join } from 'node:path';
19
19
  import { applyOps, dextermHome } from '../../../custom/src/index.js';
20
- import { StrategyStore, MAX_OPS, STRATEGY_ID, activeRunner, bumpPatch, fmtIssue, readControl, readFleetStatus, requestControl, runnerRendezvousPath, validateStrategy, } from '../../../autotrade/src/index.js';
20
+ import { StrategyStore, MAX_OPS, recordBacktestEvidence, STRATEGY_ID, activeRunner, bumpPatch, fmtIssue, readControl, readFleetStatus, requestControl, runnerRendezvousPath, validateStrategy, } from '../../../autotrade/src/index.js';
21
21
  import { parseFilter } from "../screener/filter.js";
22
22
  import { proposalFilesFor, proposalSource } from "./autotrade-proposals.js";
23
+ import { confirmPromote } from "./autotrade-journey.js";
23
24
  export const AUTOTRADE_VIEWS = ['list', 'editor', 'compare', 'backtest', 'runner'];
24
- export const VIEW_TITLE = { list: 'STRATEGIES', editor: 'EDITOR', compare: 'COMPARE', backtest: 'BACKTEST', runner: 'RUNNER' };
25
+ export const VIEW_TITLE = { list: 'STRATEGIES', editor: 'EDITOR', compare: 'COMPARE', backtest: 'BACKTEST', runner: 'RUNNER', journey: 'JOURNEY' };
25
26
  export const SECTIONS = ['entry', 'exits', 'risk', 'sizing', 'agent', 'execution'];
26
27
  /** Who the journal names for an edit made on this screen (the same actor the CLI uses for the user's own edits). */
27
28
  export const TUI_ACTOR = 'user';
@@ -94,9 +95,9 @@ function runnerShapeError(d) {
94
95
  const o = d;
95
96
  if (o.schema !== RUNNER_SCHEMA)
96
97
  return `unknown rendezvous shape ${JSON.stringify(o.schema)} (expected ${RUNNER_SCHEMA})`;
97
- // Paper only: a rendezvous that claims anything else is shown as an error, never as a running live runner.
98
- if (o.mode !== 'paper')
99
- return `mode ${JSON.stringify(o.mode)}: this screen shows paper runners only`;
98
+ // Paper, or real (task-1645: signals to the user's executor). Anything else is an error, never a running live runner.
99
+ if (o.mode !== 'paper' && o.mode !== 'real')
100
+ return `mode ${JSON.stringify(o.mode)}: this screen shows paper runners only (and real-mode signal runners)`;
100
101
  for (const k of ['pid', 'startedAt', 'heartbeatAt'])
101
102
  if (typeof o[k] !== 'number')
102
103
  return `${k} is not a number`;
@@ -133,7 +134,7 @@ export function defaultDeps(env = process.env, jobs = null) {
133
134
  export function newAutotradeUi(deps) {
134
135
  const ui = {
135
136
  view: 'list', deps, entries: [], selected: 0, marked: [], notice: null, input: null, editor: null, compare: null,
136
- focusId: null, backtests: new Map(), compareRun: null, sweep: null, runner: { kind: 'none', path: '' }, fleet: null, runnerReadAt: 0, confirm: null, review: null,
137
+ focusId: null, backtests: new Map(), compareRun: null, sweep: null, runner: { kind: 'none', path: '' }, fleet: null, runnerReadAt: 0, confirm: null, review: null, journey: null,
137
138
  };
138
139
  reloadList(ui);
139
140
  refreshRunner(ui);
@@ -525,6 +526,13 @@ export function startBacktest(ui, id) {
525
526
  job.result = out;
526
527
  job.doneAt = ui.deps.now();
527
528
  recordLine(ui, s, out.report);
529
+ // task-1647: the held-out split of a stored strategy is its journey evidence
530
+ if (out.evidence && ui.deps.store.get(s.id)?.ok) {
531
+ try {
532
+ recordBacktestEvidence(dirname(ui.deps.store.dir), out.evidence);
533
+ }
534
+ catch { /* evidence is best effort here */ }
535
+ }
528
536
  }, (e) => { job.status = 'error'; job.error = errText(e); job.doneAt = ui.deps.now(); });
529
537
  return `backtesting ${s.id} v${s.version} on the last ${BACKTEST_DAYS} days of recorded history…`;
530
538
  }
@@ -631,6 +639,8 @@ export function confirmKey(ui, yes) {
631
639
  ui.confirm = null;
632
640
  if (!c)
633
641
  return '';
642
+ if (c.kind === 'promote')
643
+ return confirmPromote(ui, c.proposalId, yes);
634
644
  return yes ? ui.deps.control.flatten() : 'flatten cancelled';
635
645
  }
636
646
  /** The views tab cycles through (compare only once two strategies were marked). */
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs';
7
7
  import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
8
8
  import { defaultDbPath } from '../../../data/src/index.js';
9
9
  import { openHistoryReadOnly } from '../../../recorder/src/index.js';
10
- import { backtest, sweep, tapeFromDb, tapeFromDoc } from '../../../autotrade/src/index.js';
10
+ import { backtest, backtestEvidence, sweep, tapeFromDb, tapeFromDoc } from '../../../autotrade/src/index.js';
11
11
  const DAY_MS = 86_400_000;
12
12
  /** The equity curve is resampled to this many points before it crosses the thread boundary (it feeds a sparkline). */
13
13
  const CURVE_POINTS = 240;
@@ -42,7 +42,9 @@ export async function backtestOn(tape, ss) {
42
42
  const out = [];
43
43
  for (const s of ss) {
44
44
  const { report, run } = await backtest(s, tape);
45
- out.push({ report, curve: resample(run.equityCurve.map((p) => p.equity), CURVE_POINTS) });
45
+ // task-1647: one strategy alone (not a compare) also runs on each half of the held-out split: journey evidence
46
+ const evidence = ss.length === 1 && s.mode === 'paper' ? (await backtestEvidence(s, tape, { by: 'user:tui', full: { report, run } })).evidence : undefined;
47
+ out.push({ report, curve: resample(run.equityCurve.map((p) => p.equity), CURVE_POINTS), ...(evidence ? { evidence } : {}) });
46
48
  }
47
49
  return out;
48
50
  }
@@ -15,7 +15,7 @@
15
15
  // The runner starts the bot by itself when it is set up and paired (`dexterm autotrade run … --paper`; --no-telegram
16
16
  // keeps it off). Nothing here is hosted: the bot long-polls from this machine.
17
17
  import { dextermHome } from '../../custom/src/index.js';
18
- import { ChatAudit, PairingWindow, TelegramConnector, TIERS_ALL, chooseSecretStore, connectorsPath, defaultTelegram, loadConnectors, looksLikeBotToken, redact, saveConnectors, secretStore, verifyChatAudit, ChatHub, } from '../../autotrade/src/index.js';
18
+ import { ChatAudit, PairingWindow, TelegramConnector, TIERS_ALL, chooseSecretStore, connectorsPath, defaultTelegram, loadConnectors, looksLikeBotToken, redact, saveConnectors, secretStore, verifyChatAudit, ChatHub, enabledChannels, parseSignalsConfig, } from '../../autotrade/src/index.js';
19
19
  export const TELEGRAM_USAGE = `dexterm telegram: your own Telegram bot for the autotrade runner (paper first). Nothing is hosted.
20
20
 
21
21
  dexterm telegram setup [--tiers notify,control,approve] [--no-pair]
@@ -321,6 +321,8 @@ export async function telegramCommand(argv, io, deps = {}) {
321
321
  paired: t.allow.map((a) => ({ senderId: a.senderId, chatId: a.chatId, tiers: a.tiers, pairedAt: a.pairedAt ? new Date(a.pairedAt).toISOString() : null })),
322
322
  approvals: t.approvals, approvalExpirySec: t.approvalExpirySec, repriceTolPct: t.repriceTolPct, notify: t.notify, dailySummaryAt: t.dailySummaryAt, quietHours: t.quietHours,
323
323
  audit: { days: chain.length, broken: chain.filter((c) => !c.check.ok).map((c) => c.day) },
324
+ // task-1645: the real-mode trade-fire channels live in the same file (dexterm autotrade signal channels).
325
+ signals: (() => { const sc = parseSignalsConfig(cfg.signals); return { channels: enabledChannels(sc.config), telegramCopy: sc.config.telegram.enabled, ttlSec: sc.config.ttlSec, problems: sc.problems }; })(),
324
326
  };
325
327
  if (argv.includes('--json')) {
326
328
  io.stdout.write(`${JSON.stringify(view, null, 2)}\n`);
@@ -332,6 +334,7 @@ export async function telegramCommand(argv, io, deps = {}) {
332
334
  io.stdout.write(` approvals ${t.approvals} · expiry ${t.approvalExpirySec} s · re-quote tolerance ${t.repriceTolPct}%\n`);
333
335
  io.stdout.write(` notify candidates ${t.notify.candidates} · entries ${t.notify.entries ? 'on' : 'off'} · closes ${t.notify.closes ? 'on' : 'off'} · halts ${t.notify.halts ? 'on' : 'off'} · spend ${t.notify.spend ? 'on' : 'off'} · daily ${t.notify.daily ? `at ${t.dailySummaryAt ?? 'off'}` : 'off'}${t.quietHours ? ` · quiet ${t.quietHours}` : ''}\n`);
334
336
  io.stdout.write(` audit ${chain.length} day file${chain.length === 1 ? '' : 's'}${view.audit.broken.length ? ` · BROKEN: ${view.audit.broken.join(', ')}` : ''}\n`);
337
+ io.stdout.write(` trade-fire signals (real mode) · channels ${view.signals.channels.filter((c) => c !== 'telegram').join(', ') || 'none'} · Telegram copy ${view.signals.telegramCopy ? 'on' : 'off'} · ttl ${view.signals.ttlSec} s · set with dexterm autotrade signal channels${view.signals.problems.length ? ` · OFF: ${view.signals.problems.join('; ')}` : ''}\n`);
335
338
  return 0;
336
339
  }
337
340
  case 'approvals': {
@@ -1,3 +1,3 @@
1
1
  // The one place the app's version lives (task-1397). --version, the help banner and `dexterm update` read it;
2
2
  // cli.test.ts keeps it equal to packages/cli/package.json (release.yml refuses a tag that differs).
3
- export const VERSION = '0.1.7';
3
+ export const VERSION = '0.2.0';
@@ -23,7 +23,7 @@ export const LEDGER_SCHEMA = 'dexterm-ledger/1';
23
23
  export const LEDGER_VERSION = 1;
24
24
  export const GENESIS = 'genesis';
25
25
  export const LEDGER_SOURCES = ['paper', 'desk', 'autotrade', 'live'];
26
- export const WALLET_MODES = ['paper', 'devnet', 'live'];
26
+ export const WALLET_MODES = ['paper', 'devnet', 'live', 'real'];
27
27
  // ───────────────────────────── hygiene ─────────────────────────────
28
28
  /** A base58 secret key (64 bytes ≈ 87-88 chars) or a 64-number byte array: never stored, whatever field it is in. */
29
29
  const KEY_SHAPED = /(?:[1-9A-HJ-NP-Za-km-z]{85,90})|(?:\[(?:\s*\d{1,3}\s*,){63}\s*\d{1,3}\s*\])/g;
@@ -0,0 +1,44 @@
1
+ ---
2
+ id: autotrade-executor-kit
3
+ title: Wire up a test executor, check it is alive, and get the digest (dexterm autotrade executor, signal test, report)
4
+ kind: app-help
5
+ screen: all
6
+ feature: autotrade-executor-kit
7
+ tags:
8
+ - app-help
9
+ - executor
10
+ - kit
11
+ - example
12
+ - heartbeat
13
+ - alive
14
+ - silent
15
+ - health
16
+ - test
17
+ - webhook
18
+ - receiver
19
+ - dry
20
+ - run
21
+ - digest
22
+ - report
23
+ - daily
24
+ - weekly
25
+ - divergence
26
+ - slippage
27
+ - latency
28
+ commands:
29
+ - autotrade
30
+ confidence: high
31
+ owner: maintainers
32
+ origin: app-help-generated
33
+ reviewed_by: generated@2026-10-16
34
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
35
+ sources:
36
+ - packages/tui/src/keymap.ts
37
+ - packages/cli/src/main.ts
38
+ - packages/mcp/src/server.ts
39
+ date: 2026-10-16
40
+ ---
41
+
42
+ `dexterm autotrade executor kit --out ~/my-executor` copies the reference executors: a webhook receiver, a `--signal-cmd` hook and a JSONL tail. Each verifies the HMAC, dedupes by nonce, honours the TTL, writes a receipt and a heartbeat, and is DRY RUN: it only logs what it would do, with no wallet code in it. Start one, point a channel at it (`dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal`), then `dexterm autotrade signal test` sends a test signal (`test: true`, no coin, nothing to trade) and shows, per channel, whether it was delivered, how long it took and the executor's receipt. `dexterm autotrade executor status` shows the last heartbeat, the last receipt and the lag; a real run pauses new buy signals (exits still go out) when nothing is heard for longer than your `executorSilenceMin` limit, and tells you. With receipts that carry fills, `signal status`, `autotrade audit` and `dexterm autotrade report` show paper vs real: slippage against dexterm's own paper fill, latency, partial fills and rejections. `report [--period day|week] [--telegram]` is the digest per strategy and mode with the win rate's interval and a small-sample note; `report --schedule daily|weekly` makes a run with your bot on send it.
43
+
44
+ CLI: `dexterm autotrade`.