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
@@ -0,0 +1,477 @@
1
+ // dexterm autotrade — the strategy JOURNEY (0.2.0 card C): a strategy built through chat climbs a ladder, and every
2
+ // rung needs evidence measured on THAT strategy's rules:
3
+ //
4
+ // draft -> backtested -> forward-tested -> always-on (paper) -> real
5
+ //
6
+ // backtested a backtest on recorded history WITH the held-out split (the newest 30 % of coins never tuned on)
7
+ // forward-tested a forward test that ran its whole window: the same rules on the LIVE tape, on paper, own bankroll
8
+ // always-on a member of autotrade.json (the always-on service runs it on paper, card B)
9
+ // real mode real AND armed at this exact hash on the user's own terminal (card A's typed confirmation)
10
+ //
11
+ // Evidence binds to the RULES hash (the strategy minus id, name, description, version and mode), so renaming a
12
+ // strategy or arming it for real keeps its evidence, and any change to a rule starts the ladder again.
13
+ //
14
+ // The model can PROPOSE a promotion (a `dexterm-promotion-proposal/1` file with an evidence card); it can never apply
15
+ // one. Applying is the user's: `dexterm autotrade promote apply` (y on a real terminal, or the Q screen) for
16
+ // always-on, and card A's `dexterm autotrade signal arm` (evidence panel + typed phrase with a one-time code) for
17
+ // real. A promotion can never skip a rung, and real-mode caps are only ever set by the user (`signal caps set`).
18
+ //
19
+ // task-1679 (the 0.2.0 product decision, 2026-10-25): the EVIDENCE FLOOR for real. Promoting or arming for real needs a COMPLETED forward
20
+ // test of these exact rules with at least `minForwardTrades` closed paper trades (the user's limit, placeholder 10)
21
+ // AND a backtest on record. Below it the ladder refuses with a plain explanation; only the user can override it, by
22
+ // typing the phrase `signal arm` shows on a real terminal (journaled). No model, MCP tool or proposal can skip it.
23
+ //
24
+ // Verdict rule 6 (spec §3a) is the only path to gain wording: >= 20 trades in BOTH halves of the held-out split and a
25
+ // held-out net above 0. A forward test says plainly when its own sample is too small to claim anything.
26
+ //
27
+ // Files (all under $DEXTERM_HOME/autotrade/journey, 0700/0600):
28
+ // <id>.json dexterm-strategy-journey/1: backtest evidence, forward results, promotions (bounded lists)
29
+ // journal.jsonl append-only: every promotion proposed, applied or dismissed, by whom, at which hash
30
+ // .proposals/<pp>.json promotion proposals
31
+ // Nothing here holds a key, signs, touches a wallet or the network.
32
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
33
+ import { randomBytes } from 'node:crypto';
34
+ import { join } from 'node:path';
35
+ import { SKILL_MIN_N } from '../../packs/src/index.js';
36
+ import { backtest, splitTape } from "./backtest.js";
37
+ import { fleetConfigPath, parseFleetConfig, MAX_MEMBERS } from "./fleet-config.js";
38
+ import { writeJsonAtomic } from "./ledger.js";
39
+ import { DEFAULT_LIMITS, effectiveLimits, loadLimits, raises } from "./limits.js";
40
+ import { STRATEGY_ID } from "./store.js";
41
+ import { round, strategyHash } from "./strategy.js";
42
+ export const JOURNEY_SCHEMA = 'dexterm-strategy-journey/1';
43
+ export const PROMOTION_SCHEMA = 'dexterm-promotion-proposal/1';
44
+ export const JOURNEY_STAGES = ['draft', 'backtested', 'forward-tested', 'always-on', 'real'];
45
+ /** The rungs a promotion proposal can ask for (the first three are reached by doing the work, not by a proposal). */
46
+ export const PROMOTION_TARGETS = ['always-on', 'real'];
47
+ export const EVIDENCE_MIN_N = SKILL_MIN_N;
48
+ export const journeyDir = (home) => join(home, 'autotrade', 'journey');
49
+ export const journeyPath = (home, id) => {
50
+ if (!STRATEGY_ID.test(id))
51
+ throw new Error(`bad strategy id ${JSON.stringify(id)}`);
52
+ return join(journeyDir(home), `${id}.json`);
53
+ };
54
+ export const journeyJournalPath = (home) => join(journeyDir(home), 'journal.jsonl');
55
+ export const promotionsDir = (home) => join(journeyDir(home), '.proposals');
56
+ /** The rules a piece of evidence was measured on: the strategy minus its labels, its version and its mode. */
57
+ export function rulesHash(s) {
58
+ const { id: _i, name: _n, description: _d, version: _v, mode: _m, hash: _h, ...rules } = s;
59
+ return strategyHash(rules);
60
+ }
61
+ /** Every fill of a run: closed trades and positions still open at the end. */
62
+ export function runFills(run) {
63
+ return [...run.trades.flatMap((t) => t.fills), ...[...run.book.open.values()].flatMap((p) => p.fills)];
64
+ }
65
+ export function fillCostBps(fills) {
66
+ const xs = fills.filter((f) => f.price > 0 && f.avgPrice > 0).map((f) => Math.abs(f.avgPrice / f.price - 1) * 10_000);
67
+ return xs.length ? round(xs.reduce((a, b) => a + b, 0) / xs.length, 1) : null;
68
+ }
69
+ export function metricsOf(r, fills) {
70
+ return {
71
+ trades: r.trades, wins: r.wins, losses: r.losses, winRate: r.winRate, netUsd: r.netUsd, netPct: r.netPct, maxDrawdownPct: r.maxDrawdownPct,
72
+ rugs: r.tail.rugs, rugRatePct: r.trades ? round((r.tail.rugs / r.trades) * 100, 1) : null, openAtEnd: r.openAtEnd,
73
+ fillCostBps: fillCostBps(fills), fills: fills.length,
74
+ };
75
+ }
76
+ export const usd = (x) => `${x >= 0 ? '+' : '-'}$${Math.abs(x).toFixed(2)}`;
77
+ export const winText = (m) => (m.winRate ? `${m.winRate.value}% (95% ${m.winRate.lo}–${m.winRate.hi}%)` : '—');
78
+ export function metricsLine(m) {
79
+ return `${m.trades} trades · win ${winText(m)} · net ${usd(m.netUsd)} (${m.netPct}%) · max drawdown ${m.maxDrawdownPct}% · rugs ${m.rugs}${m.rugRatePct === null ? '' : ` (${m.rugRatePct}%)`} · fill cost ${m.fillCostBps ?? '—'} bps`;
80
+ }
81
+ /** Verdict rule 6 on one strategy's own split (no tuning involved). */
82
+ export function evidenceVerdict(train, test) {
83
+ if (train.trades < EVIDENCE_MIN_N || test.trades < EVIDENCE_MIN_N)
84
+ return { claim: false, verdict: `too few trades to claim anything (train ${train.trades}, held-out ${test.trades}; both need >= ${EVIDENCE_MIN_N})` };
85
+ if (test.netUsd <= 0)
86
+ return { claim: false, verdict: `no edge: the held-out net is ${usd(test.netUsd)} on ${test.trades} trades (train ${usd(train.netUsd)}); no gain claim` };
87
+ return { claim: true, verdict: `held-out net ${usd(test.netUsd)} on ${test.trades} trades (train ${usd(train.netUsd)} on ${train.trades}): passes the evidence rule; a backtest is still not a prediction` };
88
+ }
89
+ /** The full backtest, then the strategy alone on each half of the chronological held-out split. */
90
+ export async function backtestEvidence(s, tape, o = { by: 'user' }) {
91
+ const full = o.full ?? await backtest(s, tape, o.sessions ?? null);
92
+ const { train, test, cutoff } = splitTape(tape, o.testFrac ?? 0.3);
93
+ const tr = await backtest(s, train, o.sessions ?? null);
94
+ const te = await backtest(s, test, o.sessions ?? null);
95
+ const trainM = metricsOf(tr.report, runFills(tr.run));
96
+ const testM = metricsOf(te.report, runFills(te.run));
97
+ const v = evidenceVerdict(trainM, testM);
98
+ return {
99
+ report: full.report,
100
+ evidence: {
101
+ kind: 'backtest', t: o.now ?? Date.now(), by: o.by, id: s.id, version: s.version, hash: s.hash, rulesHash: rulesHash(s), days: o.days ?? null,
102
+ tape: full.report.tape, full: metricsOf(full.report, runFills(full.run)),
103
+ heldOut: { cutoff: cutoff ? new Date(cutoff).toISOString() : null, train: trainM, test: testM },
104
+ verdict: v.verdict, claim: v.claim,
105
+ },
106
+ };
107
+ }
108
+ const KEEP = { backtests: 12, forwards: 12, promotions: 60 };
109
+ export function loadJourney(home, id) {
110
+ const empty = { schema: JOURNEY_SCHEMA, id, backtests: [], forwards: [], promotions: [] };
111
+ const p = journeyPath(home, id);
112
+ if (!existsSync(p))
113
+ return empty;
114
+ try {
115
+ const d = JSON.parse(readFileSync(p, 'utf8'));
116
+ if (d.schema !== JOURNEY_SCHEMA || d.id !== id)
117
+ return empty;
118
+ return { ...empty, backtests: Array.isArray(d.backtests) ? d.backtests : [], forwards: Array.isArray(d.forwards) ? d.forwards : [], promotions: Array.isArray(d.promotions) ? d.promotions : [] };
119
+ }
120
+ catch {
121
+ return empty;
122
+ }
123
+ }
124
+ function saveJourney(home, j) {
125
+ mkdirSync(journeyDir(home), { recursive: true, mode: 0o700 });
126
+ writeJsonAtomic(journeyPath(home, j.id), j);
127
+ }
128
+ export function recordBacktestEvidence(home, ev) {
129
+ const j = loadJourney(home, ev.id);
130
+ j.backtests = [ev, ...j.backtests].slice(0, KEEP.backtests);
131
+ saveJourney(home, j);
132
+ }
133
+ export function recordForwardResult(home, r) {
134
+ const j = loadJourney(home, r.id);
135
+ j.forwards = [r, ...j.forwards.filter((x) => x.forwardId !== r.forwardId)].slice(0, KEEP.forwards);
136
+ saveJourney(home, j);
137
+ }
138
+ function recordPromotion(home, id, line) {
139
+ const j = loadJourney(home, id);
140
+ j.promotions = [line, ...j.promotions].slice(0, KEEP.promotions);
141
+ saveJourney(home, j);
142
+ mkdirSync(journeyDir(home), { recursive: true, mode: 0o700 });
143
+ appendFileSync(journeyJournalPath(home), `${JSON.stringify({ id, ...line })}\n`, { mode: 0o600 });
144
+ }
145
+ /** The newest backtest with the held-out split on exactly these rules. */
146
+ export function latestBacktest(j, rules) {
147
+ return j.backtests.find((b) => b.rulesHash === rules && b.heldOut) ?? null;
148
+ }
149
+ /** The newest forward test on these rules that ran its whole window. */
150
+ export function latestForward(j, rules, o = {}) {
151
+ return j.forwards.find((f) => f.rulesHash === rules && (!o.doneOnly || f.state === 'done')) ?? null;
152
+ }
153
+ /**
154
+ * The forward test next to the backtest of the same rules. Plain words when a sample is too small: no rate is
155
+ * compared below EVIDENCE_MIN_N forward trades, and gain wording needs rule 6 on the backtest AND a forward net > 0
156
+ * on >= EVIDENCE_MIN_N trades.
157
+ */
158
+ export function compareForward(bt, fw) {
159
+ const verdict = [];
160
+ const f = fw;
161
+ const b = bt?.full ?? null;
162
+ const h = bt?.heldOut.test ?? null;
163
+ const cell = (m, k) => (m ? k(m) : '—');
164
+ const rows = [
165
+ { metric: 'trades', backtest: cell(b, (m) => String(m.trades)), heldOut: cell(h, (m) => String(m.trades)), forward: cell(f, (m) => String(m.trades)), note: '' },
166
+ { metric: 'win rate (95% interval)', backtest: cell(b, winText), heldOut: cell(h, winText), forward: cell(f, winText), note: '' },
167
+ { metric: 'net', backtest: cell(b, (m) => `${usd(m.netUsd)} (${m.netPct}%)`), heldOut: cell(h, (m) => `${usd(m.netUsd)} (${m.netPct}%)`), forward: cell(f, (m) => `${usd(m.netUsd)} (${m.netPct}%)`), note: '' },
168
+ { metric: 'max drawdown', backtest: cell(b, (m) => `${m.maxDrawdownPct}%`), heldOut: cell(h, (m) => `${m.maxDrawdownPct}%`), forward: cell(f, (m) => `${m.maxDrawdownPct}%`), note: '' },
169
+ { metric: 'rug rate', backtest: cell(b, (m) => (m.rugRatePct === null ? '—' : `${m.rugRatePct}% (${m.rugs})`)), heldOut: cell(h, (m) => (m.rugRatePct === null ? '—' : `${m.rugRatePct}% (${m.rugs})`)), forward: cell(f, (m) => (m.rugRatePct === null ? '—' : `${m.rugRatePct}% (${m.rugs})`)), note: '' },
170
+ { metric: 'fill cost vs mid', backtest: cell(b, (m) => (m.fillCostBps === null ? '—' : `${m.fillCostBps} bps`)), heldOut: cell(h, (m) => (m.fillCostBps === null ? '—' : `${m.fillCostBps} bps`)), forward: cell(f, (m) => (m.fillCostBps === null ? '—' : `${m.fillCostBps} bps`)), note: '' },
171
+ ];
172
+ if (!bt)
173
+ verdict.push('no backtest with the held-out split on these rules: nothing to compare against (backtest it first)');
174
+ else
175
+ verdict.push(`backtest: ${bt.verdict}`);
176
+ const sampleOk = !!f && f.trades >= EVIDENCE_MIN_N;
177
+ if (!f)
178
+ verdict.push('no forward result yet');
179
+ else if (!sampleOk)
180
+ verdict.push(`forward: ${f.trades} closed trade${f.trades === 1 ? '' : 's'} is too small a sample to claim anything (it needs >= ${EVIDENCE_MIN_N}); read the counts, not the rates`);
181
+ if (f && b && sampleOk) {
182
+ // Win rate drift: the forward point estimate outside the backtest's interval (and the other way round).
183
+ if (f.winRate && b.winRate && (f.winRate.value < b.winRate.lo || f.winRate.value > b.winRate.hi))
184
+ rows[1].note = 'outside the backtest interval';
185
+ const dd = f.maxDrawdownPct - b.maxDrawdownPct;
186
+ if (dd > 5)
187
+ rows[3].note = `${round(dd, 1)} pts deeper than the backtest`;
188
+ if (f.rugRatePct !== null && b.rugRatePct !== null && f.rugRatePct - b.rugRatePct > 10)
189
+ rows[4].note = 'more rugs live than in the recorded history (the recorded rug rate is a floor)';
190
+ }
191
+ if (f && b && f.fillCostBps !== null && b.fillCostBps !== null) {
192
+ const d = round(f.fillCostBps - b.fillCostBps, 1);
193
+ rows[5].note = `drift ${d >= 0 ? '+' : ''}${d} bps${Math.abs(d) >= 25 ? ' (large)' : ''}`;
194
+ }
195
+ if (f && sampleOk)
196
+ verdict.push(f.netUsd > 0 ? `forward net ${usd(f.netUsd)} on ${f.trades} trades (paper, live data, modelled fills)` : `forward net ${usd(f.netUsd)} on ${f.trades} trades: it lost money on live data`);
197
+ const claim = !!bt?.claim && sampleOk && !!f && f.netUsd > 0;
198
+ verdict.push(claim ? 'both halves of the evidence agree: gain wording is allowed, still with the trade counts next to it' : 'no performance claim: say the numbers and the trade counts, nothing more');
199
+ return { rows, verdict, claim, sampleOk };
200
+ }
201
+ export function renderComparison(c) {
202
+ const w = [24, 22, 22, 22];
203
+ const pad = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
204
+ return [
205
+ ` ${pad('', w[0])} ${pad('backtest', w[1])} ${pad('held-out', w[2])} ${pad('forward', w[3])}`,
206
+ ...c.rows.map((r) => ` ${pad(r.metric, w[0])} ${pad(r.backtest, w[1])} ${pad(r.heldOut, w[2])} ${pad(r.forward, w[3])}${r.note ? ` ${r.note}` : ''}`),
207
+ ...c.verdict.map((v) => ` · ${v}`),
208
+ ];
209
+ }
210
+ /** autotrade.json members that run this strategy (as written by the user or a promotion). */
211
+ export function fleetMembersFor(home, id) {
212
+ const p = fleetConfigPath(home);
213
+ if (!existsSync(p))
214
+ return [];
215
+ let doc;
216
+ try {
217
+ doc = JSON.parse(readFileSync(p, 'utf8'));
218
+ }
219
+ catch {
220
+ return [];
221
+ }
222
+ const c = parseFleetConfig(doc, p);
223
+ if (!c.ok)
224
+ return [];
225
+ return c.config.strategies.filter((m) => m.preset === id).map((m) => ({ id: m.id, preset: m.preset, enabled: m.enabled, mode: m.mode }));
226
+ }
227
+ export function journeyView(home, s, o = {}) {
228
+ const rules = rulesHash(s);
229
+ const j = loadJourney(home, s.id);
230
+ const bt = latestBacktest(j, rules);
231
+ const fwDone = latestForward(j, rules, { doneOnly: true });
232
+ const fwAny = latestForward(j, rules);
233
+ const members = o.members ?? fleetMembersFor(home, s.id);
234
+ const member = members.find((m) => m.enabled && m.mode === 'paper') ?? null;
235
+ const armed = s.mode === 'real' && !!o.armedHash && o.armedHash === s.hash;
236
+ const notes = [];
237
+ const rungs = [
238
+ { stage: 'draft', ok: true, detail: `v${s.version} validates (mode ${s.mode})` },
239
+ { stage: 'backtested', ok: !!bt, detail: bt ? `${new Date(bt.t).toISOString().slice(0, 16).replace('T', ' ')} UTC · ${metricsLine(bt.full)} · ${bt.verdict}` : 'no backtest with the held-out split on these rules' },
240
+ { stage: 'forward-tested', ok: !!fwDone, detail: fwDone ? `${fwDone.forwardId} · ${fwDone.metrics ? metricsLine(fwDone.metrics) : 'no numbers'} · ${fwDone.endReason}` : fwAny ? `${fwAny.forwardId} ${fwAny.state} early (${fwAny.endReason}); only a forward test that runs its whole window counts` : 'no finished forward test on these rules' },
241
+ { stage: 'always-on', ok: !!member, detail: member ? `autotrade.json member ${member.id} (paper; the always-on service runs it)` : 'not in autotrade.json' },
242
+ { stage: 'real', ok: armed, detail: armed ? `armed at ${s.hash.slice(0, 8)} on the local terminal` : s.mode === 'real' ? 'mode real but NOT armed at this hash (any edit disarms)' : 'paper' },
243
+ ];
244
+ let reached = 0;
245
+ for (let i = 1; i < rungs.length && rungs[i].ok; i++)
246
+ reached = i;
247
+ const stage = JOURNEY_STAGES[reached];
248
+ // A rung above a missing one (e.g. armed without a forward test) is shown, never counted.
249
+ for (let i = reached + 2; i < rungs.length; i++)
250
+ if (rungs[i].ok)
251
+ notes.push(`${rungs[i].stage} is set but ${JOURNEY_STAGES[reached + 1]} is missing: the ladder counts ${stage}`);
252
+ if (s.mode === 'real' && member)
253
+ notes.push(`real strategies do not run in the always-on paper service (one runner per machine): stop the service and run dexterm autotrade run ${s.id} --real`);
254
+ const HOW = {
255
+ draft: '',
256
+ backtested: `dexterm autotrade backtest ${s.id} (records the held-out split), or ask the analyst for strategy_backtest`,
257
+ 'forward-tested': `dexterm autotrade forward start ${s.id} --hours 24 (paper, live data, own bankroll), or ask the analyst to start one`,
258
+ 'always-on': `a promotion: dexterm autotrade promote ${s.id} --to always-on (the analyst can propose it; you apply it)`,
259
+ real: `a promotion to real: set your limits (dexterm autotrade limits set), then dexterm autotrade signal arm ${s.id} on your own terminal (typed phrase)`,
260
+ };
261
+ const nextStage = reached + 1 < JOURNEY_STAGES.length ? JOURNEY_STAGES[reached + 1] : null;
262
+ return { id: s.id, version: s.version, hash: s.hash, rulesHash: rules, mode: s.mode, stage, rungs, next: nextStage ? { stage: nextStage, how: HOW[nextStage] } : null, backtest: bt, forward: fwAny, member, armed, notes };
263
+ }
264
+ /** The held-out lines a backtest prints under its report. */
265
+ export function renderHeldOut(ev) {
266
+ return [
267
+ ` held-out split (newest 30% of coins, never tuned on; cutoff ${ev.heldOut.cutoff ?? '—'}):`,
268
+ ` train ${metricsLine(ev.heldOut.train)}`,
269
+ ` held-out ${metricsLine(ev.heldOut.test)}`,
270
+ ` evidence: ${ev.verdict}`,
271
+ ];
272
+ }
273
+ export function renderJourney(v) {
274
+ return [
275
+ `JOURNEY ${v.id} v${v.version} (${v.hash.slice(0, 8)}, rules ${v.rulesHash.slice(0, 8)}) · stage ${v.stage.toUpperCase()}`,
276
+ ...v.rungs.map((r) => ` ${r.ok ? '[x]' : '[ ]'} ${r.stage.padEnd(15)} ${r.detail}`),
277
+ ...v.notes.map((n) => ` ! ${n}`),
278
+ v.next ? ` next: ${v.next.stage} — ${v.next.how}` : ' top of the ladder (real). Caps, kill switch and receipts: dexterm autotrade signal status',
279
+ ];
280
+ }
281
+ const PROMOTION_ID = /^pp_[a-z0-9]{6,12}_[a-f0-9]{8}$/;
282
+ export const isPromotionId = (x) => typeof x === 'string' && PROMOTION_ID.test(x);
283
+ export const newPromotionId = (now = Date.now()) => `pp_${now.toString(36)}_${randomBytes(4).toString('hex')}`;
284
+ export const MAX_PENDING_PROMOTIONS = 10;
285
+ export class PromotionFiles {
286
+ dir;
287
+ constructor(home) { this.dir = promotionsDir(home); }
288
+ path(id) { if (!isPromotionId(id))
289
+ throw new Error(`bad promotion id ${JSON.stringify(id)}`); return join(this.dir, `${id}.json`); }
290
+ get(id) {
291
+ if (!isPromotionId(id) || !existsSync(this.path(id)))
292
+ return null;
293
+ try {
294
+ const r = JSON.parse(readFileSync(this.path(id), 'utf8'));
295
+ return r.schema === PROMOTION_SCHEMA && r.proposalId === id ? r : null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ list(f = {}) {
302
+ if (!existsSync(this.dir))
303
+ return [];
304
+ return readdirSync(this.dir).filter((x) => x.endsWith('.json')).map((x) => this.get(x.slice(0, -5))).filter((r) => !!r && (!f.status || r.status === f.status) && (!f.id || r.id === f.id))
305
+ .sort((a, b) => b.createdAt - a.createdAt);
306
+ }
307
+ write(r) { mkdirSync(this.dir, { recursive: true, mode: 0o700 }); writeJsonAtomic(this.path(r.proposalId), r); }
308
+ }
309
+ /** The evidence card a promotion shows: the ladder, the backtest, the forward test and the comparison. */
310
+ export function evidenceCard(v, to) {
311
+ const fwM = v.forward?.metrics ?? null;
312
+ const c = compareForward(v.backtest, v.forward?.state === 'done' ? fwM : null);
313
+ return [
314
+ `PROMOTE ${v.id} v${v.version} → ${to.toUpperCase()}${to === 'real' ? ' (LIVE TRADE FIRE signals to YOUR executor, which may spend real money)' : ' (paper, the always-on service)'}`,
315
+ ...renderJourney(v).slice(1),
316
+ ' evidence (paper; modelled fills; not a prediction, not financial advice):',
317
+ ...renderComparison(c),
318
+ ];
319
+ }
320
+ // ───────────────────────────── the evidence floor for real (task-1679) ─────────────────────────────
321
+ export const DEFAULT_MIN_FORWARD_TRADES = DEFAULT_LIMITS.minForwardTrades ?? 10;
322
+ /**
323
+ * The user's floor for this strategy + account (limits.json `minForwardTrades`, the stricter scope wins; `off` = no
324
+ * trade minimum, the completed forward test and the backtest are still required). Fails closed: a limits.json that
325
+ * does not load, or one changed outside dexterm in a way that lowers a floor, counts at least the placeholder.
326
+ */
327
+ export function minForwardTradesFor(home, strategyId, accountId = '-') {
328
+ const lim = loadLimits(home);
329
+ if (!lim.ok)
330
+ return DEFAULT_MIN_FORWARD_TRADES;
331
+ const v = effectiveLimits(lim.doc, { strategyId, accountId, mode: 'real' }).values.minForwardTrades ?? 0;
332
+ return lim.unjournaled && raises(lim.unjournaled).length ? Math.max(v, DEFAULT_MIN_FORWARD_TRADES) : v;
333
+ }
334
+ /** Does `s` have the evidence real needs? Measured on its RULES (any rule change starts again), from the journey file. */
335
+ export function evidenceFloor(home, s, minTrades) {
336
+ const rules = rulesHash(s);
337
+ const j = loadJourney(home, s.id);
338
+ const bt = latestBacktest(j, rules);
339
+ const done = j.forwards.filter((f) => f.rulesHash === rules && f.state === 'done');
340
+ const best = done.reduce((a, f) => (!a || (f.metrics?.trades ?? 0) > (a.metrics?.trades ?? 0) ? f : a), null);
341
+ const problems = [];
342
+ if (!bt)
343
+ problems.push(`no backtest on record for these rules: run dexterm autotrade backtest ${s.id} first`);
344
+ if (!best)
345
+ problems.push(`no COMPLETED forward test of these rules (one that ran its whole window on live data, on paper): dexterm autotrade forward start ${s.id} --hours 24`);
346
+ else if ((best.metrics?.trades ?? 0) < minTrades)
347
+ problems.push(`the completed forward test closed ${best.metrics?.trades ?? 0} paper trade${(best.metrics?.trades ?? 0) === 1 ? '' : 's'}; real needs at least ${minTrades} (your limit minForwardTrades): run a longer forward test`);
348
+ return { ok: problems.length === 0, minTrades, forward: best, backtest: bt, problems };
349
+ }
350
+ export function renderFloor(f) {
351
+ return f.ok
352
+ ? [` evidence floor met: a completed forward test with ${f.forward?.metrics?.trades ?? 0} closed paper trades (at least ${f.minTrades}) and a backtest on record`]
353
+ : [` EVIDENCE FLOOR NOT MET (real needs a completed forward test with at least ${f.minTrades} closed paper trades and a backtest, on these exact rules):`, ...f.problems.map((p) => ` - ${p}`)];
354
+ }
355
+ /** Can `s` be promoted to `to` now? Every rung below `to` must be reached; nothing is skipped. */
356
+ export function checkPromotion(home, s, to, o = {}) {
357
+ if (!PROMOTION_TARGETS.includes(to))
358
+ return { ok: false, reason: 'bad-target', why: `to must be one of ${PROMOTION_TARGETS.join(', ')} (backtested and forward-tested are reached by running them, not by a promotion)` };
359
+ if (s.mode === 'live')
360
+ return { ok: false, reason: 'mode', why: `${s.id} is mode live, which does not run (dexterm holds no key)` };
361
+ const v = journeyView(home, s, o);
362
+ const need = JOURNEY_STAGES.indexOf(to);
363
+ const have = JOURNEY_STAGES.indexOf(v.stage);
364
+ if (have >= need)
365
+ return { ok: false, reason: 'already', why: `${s.id} is already ${v.stage}`, view: v };
366
+ if (have < need - 1)
367
+ return { ok: false, reason: 'skip-refused', why: `${s.id} is ${v.stage}; ${to} needs ${JOURNEY_STAGES[need - 1]} first (no rung can be skipped): ${v.next?.how ?? ''}`.trim(), view: v };
368
+ if (to === 'real') {
369
+ const floor = evidenceFloor(home, s, minForwardTradesFor(home, s.id));
370
+ if (!floor.ok)
371
+ return { ok: false, reason: 'evidence-floor', why: `${s.id} is below the evidence floor for real: ${floor.problems.join('; ')}`, view: v, floor };
372
+ }
373
+ return { ok: true, view: v };
374
+ }
375
+ /** Write a pending promotion proposal (the model's path and the user's own `promote <id> --to`). Never applies. */
376
+ export function proposePromotion(home, s, to, o) {
377
+ const c = checkPromotion(home, s, to, o);
378
+ if (!c.ok)
379
+ return { ok: false, reason: c.reason, why: c.why };
380
+ const files = new PromotionFiles(home);
381
+ const pending = files.list({ status: 'pending' });
382
+ const twin = pending.find((p) => p.id === s.id && p.to === to && p.hash === s.hash);
383
+ if (twin)
384
+ return { ok: true, proposal: twin, duplicate: true };
385
+ if (pending.length >= MAX_PENDING_PROMOTIONS)
386
+ return { ok: false, reason: 'too-many-pending', why: `${pending.length} promotions are already waiting for the user` };
387
+ const t = o.now ?? Date.now();
388
+ const p = {
389
+ schema: PROMOTION_SCHEMA, proposalId: newPromotionId(t), id: s.id, to: to, actor: o.actor, createdAt: t, why: o.why.slice(0, 200) || '(no reason given)',
390
+ version: s.version, hash: s.hash, rulesHash: c.view.rulesHash, card: evidenceCard(c.view, to), status: 'pending',
391
+ };
392
+ files.write(p);
393
+ recordPromotion(home, s.id, { t, to: p.to, action: 'proposed', by: o.actor, proposalId: p.proposalId, version: s.version, rulesHash: p.rulesHash, hash: s.hash });
394
+ return { ok: true, proposal: p, duplicate: false };
395
+ }
396
+ export function dismissPromotion(home, proposalId, by, now = Date.now()) {
397
+ const files = new PromotionFiles(home);
398
+ const p = files.get(proposalId);
399
+ if (!p || p.status !== 'pending')
400
+ return null;
401
+ const next = { ...p, status: 'dismissed', resolvedAt: now, resolvedBy: by };
402
+ files.write(next);
403
+ recordPromotion(home, p.id, { t: now, to: p.to, action: 'dismissed', by, proposalId, version: p.version, rulesHash: p.rulesHash, hash: p.hash });
404
+ return next;
405
+ }
406
+ /**
407
+ * Apply an ALWAYS-ON promotion: the strategy joins autotrade.json as a paper member (the always-on service runs it
408
+ * from its next start). The caller is the user's keypress path only (`promote apply` on a real terminal, the Q
409
+ * screen). The ladder is re-checked against the strategy as it is NOW (an edit since the proposal voids it).
410
+ * A promotion to REAL is never applied here: it is card A's `signal arm` (typed phrase), which `markApplied` records.
411
+ */
412
+ export function applyAlwaysOn(home, proposalId, s, o) {
413
+ const files = new PromotionFiles(home);
414
+ const p = files.get(proposalId);
415
+ if (!p)
416
+ return { ok: false, why: `no promotion ${proposalId}` };
417
+ if (p.status !== 'pending')
418
+ return { ok: false, why: `promotion ${proposalId} is ${p.status}` };
419
+ if (p.to !== 'always-on')
420
+ return { ok: false, why: `promotion ${proposalId} is to ${p.to}: real mode is armed only with dexterm autotrade signal arm ${p.id} on your own terminal` };
421
+ if (s.id !== p.id || s.hash !== p.hash)
422
+ return { ok: false, why: `${p.id} changed since the proposal (v${p.version} → v${s.version}); ask for a new one` };
423
+ const c = checkPromotion(home, s, 'always-on', { members: o.members });
424
+ if (!c.ok)
425
+ return { ok: false, why: c.why };
426
+ const path = fleetConfigPath(home);
427
+ let doc;
428
+ if (existsSync(path)) {
429
+ try {
430
+ doc = JSON.parse(readFileSync(path, 'utf8'));
431
+ }
432
+ catch (e) {
433
+ return { ok: false, why: `${path} is not JSON (${e.message}); fix it first` };
434
+ }
435
+ if (!doc || !Array.isArray(doc.strategies))
436
+ return { ok: false, why: `${path} has no strategies list; fix it first` };
437
+ }
438
+ else
439
+ doc = { schema: 'dexterm-autotrade/1', strategies: [] };
440
+ const existing = doc.strategies.find((m) => m.preset === s.id);
441
+ let memberId = s.id;
442
+ let created = false;
443
+ if (existing) {
444
+ existing.enabled = true;
445
+ memberId = String(existing.id);
446
+ }
447
+ else {
448
+ if (doc.strategies.some((m) => m.id === s.id))
449
+ return { ok: false, why: `autotrade.json already has a member called ${s.id} running something else; rename it first` };
450
+ if (doc.strategies.length >= MAX_MEMBERS)
451
+ return { ok: false, why: `autotrade.json already lists ${doc.strategies.length} strategies (at most ${MAX_MEMBERS} run at once); remove one first` };
452
+ doc.strategies.push({ id: s.id, preset: s.id });
453
+ created = true;
454
+ }
455
+ const check = parseFleetConfig(doc, path);
456
+ if (!check.ok)
457
+ return { ok: false, why: `autotrade.json would not validate: ${check.errors.join('; ')}` };
458
+ mkdirSync(join(home, 'autotrade'), { recursive: true, mode: 0o700 });
459
+ writeJsonAtomic(path, doc);
460
+ const now = o.now ?? Date.now();
461
+ files.write({ ...p, status: 'applied', resolvedAt: now, resolvedBy: o.by });
462
+ recordPromotion(home, s.id, { t: now, to: 'always-on', action: 'applied', by: o.by, proposalId, version: s.version, rulesHash: p.rulesHash, hash: s.hash });
463
+ return { ok: true, memberId, created };
464
+ }
465
+ /** Record that a pending REAL promotion was carried out by card A's arm flow (the typed phrase already happened). */
466
+ export function markRealApplied(home, id, armedHash, version, by, now = Date.now()) {
467
+ const files = new PromotionFiles(home);
468
+ const done = [];
469
+ for (const p of files.list({ status: 'pending', id })) {
470
+ if (p.to !== 'real')
471
+ continue;
472
+ files.write({ ...p, status: 'applied', resolvedAt: now, resolvedBy: by });
473
+ recordPromotion(home, id, { t: now, to: 'real', action: 'applied', by, proposalId: p.proposalId, version, rulesHash: p.rulesHash, hash: armedHash });
474
+ done.push(p.proposalId);
475
+ }
476
+ return done;
477
+ }