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
@@ -10,6 +10,11 @@
10
10
  // Nothing here arms a mandate, signs, holds a key or touches a wallet. task-1571: there is no `--live`: dexterm places
11
11
  // no live order. Live trading happens in the Telegram trading bot of the user's choice.
12
12
  //
13
+ // task-1645: `--real` runs a strategy armed for real mode (`dexterm autotrade signal arm`, on the user's terminal) on
14
+ // LIVE data only: the same engine as paper, and every fill first goes out as a LIVE TRADE FIRE signal (outbox +
15
+ // the channels of connectors.json `signals`, the Telegram copy through the user's bot). Never on a replay (no stale
16
+ // price may ever reach an executor), never with chat approvals (a tap never authorises a real signal).
17
+ //
13
18
  // Telegram (task-1527): when `dexterm telegram setup` stored a token and a chat is paired, the user's own bot starts
14
19
  // with the run (telegram-host.ts): notify, control through the same control file, and approve-to-trade on paper (a
15
20
  // strategy with `entry.decide: "agent+approve"`, or approvals on). --no-telegram keeps it off; an agent+approve
@@ -26,10 +31,14 @@ import { pickBasis } from '../../../data/src/basis.js';
26
31
  import { KillSwitch, createDataLayer, defaultDbPath, resolveRpcUrl } from '../../../data/src/index.js';
27
32
  import { HEARTBEAT_MS, LiveSource, badgeOf, RUNNER_RENDEZVOUS_SCHEMA, RunnerSafety, SessionGate, acquireRunLock, clearControl, clearStaleControl, controlPath, claudeSessionFactory, effectiveCaps, fileLedger, loadRunnerConfig, loadTape, newRunId, prepareAccount, releaseRunLock, renderDossierRecord, runAutotrade, runnerRendezvousPath, runnerSystemPrompt, simExecutor, stubSession, writeJsonAtomic, writeRendezvous, RUN_SUMMARY_SCHEMA, builtinTapePath, } from '../../../autotrade/src/index.js';
28
33
  import { assertAutotradeAllowed, ollamaSessionFactory } from '../../../autotrade/src/ollama-sessions.js';
34
+ import { Outbox, RealSignalExecutor, channelsFromConfig, enabledChannels, loadSignalsConfig, openRealRun, realModeCheck } from '../../../autotrade/src/index.js';
35
+ import { PaperLimitsExecutor, effectiveLimits, loadLimits } from '../../../autotrade/src/index.js';
36
+ import { digestDue, digestLines, hmacStore, limitsSummary, loadDigestState, saveDigestState, startExecutorListener } from '../../../autotrade/src/index.js';
37
+ import { digestFor } from "./report.js";
29
38
  import { parseArgs, resolveStrategyArg } from "./strategies.js";
30
39
  import { startTelegramHost } from "./telegram-host.js";
31
- const BOOL = ['paper', 'live', 'stub', 'verbose', 'quiet', 'raw', 'no-telegram'];
32
- export const RUN_USAGE = 'dexterm autotrade run <id|file> --paper [--replay TAPE [--ms-per-tick 2000]] [--engine claude|ollama] [--model M] [--max-minutes N] [--stub] [--raw] [--quiet] [--no-telegram]';
40
+ const BOOL = ['paper', 'real', 'live', 'stub', 'verbose', 'quiet', 'raw', 'no-telegram'];
41
+ export const RUN_USAGE = 'dexterm autotrade run <id|file> --paper|--real [--replay TAPE [--ms-per-tick 2000]] [--engine claude|ollama] [--model M] [--max-minutes N] [--sol-usd N] [--stub] [--raw] [--quiet] [--no-telegram]';
33
42
  /** task-1571: what `--live` says. dexterm holds no key and places no live order. */
34
43
  export const NO_LIVE = 'dexterm autotrade run: there is no --live: dexterm runs strategies on paper only and never holds a key or signs; live trading happens in the Telegram trading bot of your choice (your own bot gets the alerts: dexterm telegram setup)';
35
44
  function num(v, dflt) {
@@ -45,8 +54,12 @@ export async function runCmd(argv, io, over = {}) {
45
54
  io.stderr.write(`${NO_LIVE}\n`);
46
55
  return 2;
47
56
  }
48
- if (!opts.paper) {
49
- io.stderr.write(`dexterm autotrade run: pass --paper (autotrade runs on paper only)\nusage: ${RUN_USAGE}\n`);
57
+ if (!opts.paper && !opts.real) {
58
+ io.stderr.write(`dexterm autotrade run: pass --paper (or --real for a strategy armed for real mode)\nusage: ${RUN_USAGE}\n`);
59
+ return 2;
60
+ }
61
+ if (opts.paper && opts.real) {
62
+ io.stderr.write('dexterm autotrade run: --paper or --real, not both\n');
50
63
  return 2;
51
64
  }
52
65
  const arg = typeof opts.strategy === 'string' ? opts.strategy : pos[0];
@@ -62,11 +75,17 @@ export async function runCmd(argv, io, over = {}) {
62
75
  io.stderr.write(`error ${e.path} ${e.msg}\n`);
63
76
  return 1;
64
77
  }
65
- const strategy = r.strategy;
66
- if (strategy.mode !== 'paper') {
67
- io.stderr.write(`dexterm autotrade run: ${strategy.id} is mode "${strategy.mode}": the runner runs paper only (dexterm holds no key and places no live order)\n`);
78
+ // task-1645: --paper on a real strategy runs its paper twin (a forward test: no signal goes out).
79
+ const strategy = opts.paper && r.strategy.mode === 'real' ? { ...r.strategy, mode: 'paper' } : r.strategy;
80
+ if (opts.real && strategy.mode !== 'real') {
81
+ io.stderr.write(`dexterm autotrade run: ${strategy.id} is mode "${strategy.mode}": --real needs a strategy armed for real mode (dexterm autotrade signal arm ${strategy.id}, on your own terminal)\n`);
68
82
  return 1;
69
83
  }
84
+ if (strategy.mode !== 'paper' && strategy.mode !== 'real') {
85
+ io.stderr.write(`dexterm autotrade run: ${strategy.id} is mode "${strategy.mode}": the runner runs paper only (dexterm holds no key and places no live order); real mode, which sends signals to your own executor, is armed with dexterm autotrade signal arm\n`);
86
+ return 1;
87
+ }
88
+ const realMode = strategy.mode === 'real';
70
89
  const home = dextermHome(io.env);
71
90
  // task-1537: the run's lines and the agents' messages speak the user's basis (--price/--mcap/env, config, market cap)
72
91
  const basis = pickBasis(loadConfig(home).config.display?.priceMode, io.env);
@@ -83,6 +102,19 @@ export async function runCmd(argv, io, over = {}) {
83
102
  }
84
103
  const config = cfg.config;
85
104
  const replay = typeof opts.replay === 'string' ? opts.replay : null;
105
+ // task-1645: real mode's preconditions, before anything starts (armed at this hash, caps, kill off, a channel).
106
+ let real = null;
107
+ if (realMode) {
108
+ if (replay) {
109
+ io.stderr.write('dexterm autotrade run: --real runs on live data only (a replay would send old prices to your executor); replay its paper twin with --paper\n');
110
+ return 2;
111
+ }
112
+ real = await prepareReal(home, strategy, opts, io);
113
+ if ('error' in real) {
114
+ io.stderr.write(`dexterm autotrade run: ${real.error}\n`);
115
+ return 1;
116
+ }
117
+ }
86
118
  // task-1543: the engine for the sessions: --engine, else ai.json (surfaces.autotrade / provider); auto = claude.
87
119
  let local = null;
88
120
  if (!opts.stub) {
@@ -100,6 +132,8 @@ export async function runCmd(argv, io, over = {}) {
100
132
  const lock = acquireRunLock(home, runId);
101
133
  if (!lock.ok) {
102
134
  io.stderr.write(`dexterm autotrade run: another runner is active (pid ${lock.holder.pid}, run ${lock.holder.runId}); stop it first\n`);
135
+ if (real && !('error' in real))
136
+ real.stop();
103
137
  return 1;
104
138
  }
105
139
  if (lock.reclaimed)
@@ -109,7 +143,7 @@ export async function runCmd(argv, io, over = {}) {
109
143
  releaseRunLock(home, runId);
110
144
  return 1;
111
145
  }
112
- const badge = 'PAPER';
146
+ const badge = realMode ? 'REAL (LIVE TRADE FIRE signals to your executor)' : 'PAPER';
113
147
  // Telegram: the user's own bot, when it is set up and paired (or the host a /use switch carried over).
114
148
  let host = over.telegramHost ?? null;
115
149
  const ownHost = !over.telegramHost;
@@ -127,14 +161,103 @@ export async function runCmd(argv, io, over = {}) {
127
161
  else if (!t.quiet)
128
162
  say(`telegram: off (${t.why})`);
129
163
  }
164
+ if (realMode && host && (strategy.entry.decide === 'agent+approve' || host.service.broker.required(strategy))) {
165
+ io.stderr.write(`dexterm autotrade run: approve-to-trade over chat is paper only, so ${strategy.id} cannot take an entry in real mode (entry.decide "agent+approve" or \`dexterm telegram approvals on\`); your executor is the last gate in real mode\n`);
166
+ if (ownHost)
167
+ await host.stop();
168
+ releaseRunLock(home, runId);
169
+ return 1;
170
+ }
130
171
  if (strategy.entry.decide === 'agent+approve' && !host) {
131
172
  io.stderr.write(`dexterm autotrade run: ${strategy.id} needs a one-tap approval for every entry (entry.decide "agent+approve"), and no paired Telegram chat is connected; set it up with \`dexterm telegram setup\`\n`);
132
173
  releaseRunLock(home, runId);
133
174
  return 1;
134
175
  }
176
+ // task-1654: a paper run obeys the limits only when limits.json has a `paper` scope (a forward test that shows what
177
+ // real mode would have refused or clamped). Its state is the run's own; real mode's files are never touched.
178
+ let paperLimits = null;
179
+ if (!realMode) {
180
+ paperLimits = await preparePaperLimits(home, strategy, opts, io, !!replay);
181
+ if (paperLimits && 'error' in paperLimits) {
182
+ io.stderr.write(`dexterm autotrade run: ${paperLimits.error}\n`);
183
+ if (host && ownHost)
184
+ await host.stop();
185
+ releaseRunLock(home, runId);
186
+ return 1;
187
+ }
188
+ }
135
189
  const baseExec = simExecutor(strategy.execution);
136
190
  if (host)
137
191
  host.attach(runId, strategy, baseExec, solUsdFor);
192
+ // task-1645: the signal executor wraps the paper path (the host's port when the bot is on), so every fill is first a signal.
193
+ let realExec = null;
194
+ let outbox = null;
195
+ let pumpTimer = null;
196
+ let digestTimer = null;
197
+ let listener = null;
198
+ let bookUsd = strategy.bankroll.startUsd;
199
+ let paperExec = null;
200
+ if (paperLimits && !('error' in paperLimits)) {
201
+ paperExec = new PaperLimitsExecutor({ inner: host ? host.port : baseExec, limits: paperLimits.limits, solUsd: paperLimits.solUsd, bookUsd: () => bookUsd, onTrip: say });
202
+ }
203
+ if (real && !('error' in real)) {
204
+ const sc = real.signals;
205
+ const tg = host ? async (lines) => {
206
+ const before = host.service.audit.events.length;
207
+ await host.service.hub.broadcast({ kind: 'text', badge: 'LIVE', lines }, 'trade_fire');
208
+ if (host.service.audit.events.slice(before).some((e) => e.kind === 'out_failed'))
209
+ throw new Error('telegram send failed');
210
+ } : undefined;
211
+ const { channels, defer } = channelsFromConfig(home, sc, { telegram: tg, env: io.env });
212
+ try {
213
+ outbox = Outbox.open(home, { channels, defer });
214
+ }
215
+ catch (e) {
216
+ io.stderr.write(`dexterm autotrade run: ${e.message}\n`);
217
+ if (host && ownHost)
218
+ await host.stop();
219
+ releaseRunLock(home, runId);
220
+ return 1;
221
+ }
222
+ const swept = openRealRun(outbox, runId, strategy, real.check.arming.accountId).swept;
223
+ if (swept.length)
224
+ say(`real mode: sent "halted" (runner-lost) for ${swept.length} earlier run(s) that ended without one`);
225
+ // task-1654: every limit (the effective set for this strategy + account), and a line to the run log and the
226
+ // paired chat when the executor goes silent / comes back or a risk limit trips.
227
+ const notify = (line) => { say(line); if (host)
228
+ void host.service.hub.broadcast({ kind: 'text', badge: 'LIVE', lines: [line] }, 'real_limits').catch(() => { }); };
229
+ realExec = new RealSignalExecutor({ home, inner: host ? host.port : baseExec, outbox, strategy, arming: real.check.arming, caps: real.check.caps, limits: real.check.limits.values, notify, runId, ttlSec: sc.ttlSec, solUsd: real.solUsd, bookUsd: () => bookUsd });
230
+ pumpTimer = setInterval(() => { void outbox.pump().catch(() => { }); }, 1000);
231
+ if (sc.executor.listenPort) {
232
+ const store = hmacStore(home, sc, io.env);
233
+ try {
234
+ listener = await startExecutorListener({ home, port: sc.executor.listenPort, secret: () => { try {
235
+ return store.get();
236
+ }
237
+ catch {
238
+ return null;
239
+ } } });
240
+ say(`real mode: executor listener on 127.0.0.1:${listener.port} (POST /heartbeat, /receipt)`);
241
+ }
242
+ catch (e) {
243
+ say(`real mode: executor listener NOT started (${e.message.slice(0, 80)}); executors can still append to autotrade/real/executor.jsonl`);
244
+ }
245
+ }
246
+ }
247
+ // task-1654: the scheduled digest (opt-in: dexterm autotrade report --schedule daily|weekly), sent by a run with the bot on.
248
+ if (host) {
249
+ const tick = () => {
250
+ const st = loadDigestState(home);
251
+ const due = digestDue(st, Date.now());
252
+ if (!due)
253
+ return;
254
+ saveDigestState(home, { ...st, lastSentDay: localDayNow(), lastSentAt: Date.now() });
255
+ const d = digestFor(home, { period: due, now: Date.now() });
256
+ void host.service.hub.broadcast({ kind: 'text', badge: realMode ? 'LIVE' : 'PAPER', lines: digestLines(d) }, 'report').catch(() => { });
257
+ };
258
+ digestTimer = setInterval(tick, 10 * 60_000);
259
+ digestTimer.unref?.();
260
+ }
138
261
  const { ledger, files } = fileLedger(home, runId, host?.onLedger);
139
262
  const startedAt = Date.now();
140
263
  const gate = new SessionGate(effectiveCaps(strategy, config), config.allowanceGuard);
@@ -226,7 +349,7 @@ export async function runCmd(argv, io, over = {}) {
226
349
  lastWrite = now;
227
350
  const { t: _t, ...body } = last;
228
351
  try {
229
- writeRendezvous(home, { schema: RUNNER_RENDEZVOUS_SCHEMA, pid: process.pid, mode: 'paper', runId, startedAt, heartbeatAt: now, ledgerPath: files.ledger, ...body });
352
+ writeRendezvous(home, { schema: RUNNER_RENDEZVOUS_SCHEMA, pid: process.pid, mode: realMode ? 'real' : 'paper', runId, startedAt, heartbeatAt: now, ledgerPath: files.ledger, ...body });
230
353
  writeJsonAtomic(files.summary, { schema: RUN_SUMMARY_SCHEMA, runId, strategy: body.strategy, startedAt, updatedAt: now, state: body.state, book: body.book, spend: body.spend, events: body.events, ledgerHead: body.ledgerHead, source: replay ? `replay ${replay}` : 'live' });
231
354
  lastBeatOk = now;
232
355
  if (beatFailing) {
@@ -256,14 +379,23 @@ export async function runCmd(argv, io, over = {}) {
256
379
  say(`⚠ HIGH RISK strategy (${strategy.id}): one position at a time, early entries, most of them lose. Paper only; not financial advice.`);
257
380
  say(`dexterm autotrade run · ${badge} · strategy ${strategy.id} v${strategy.version} (${strategy.hash}) · ${replay ? `replay ${replay}` : 'live keyless data'} · sessions ${opts.stub ? 'stub' : local ? `ollama (local ${local.model}, profile ${local.level}, $0)` : `claude (${config.account.kind}${typeof opts.model === 'string' ? `, ${opts.model}` : ''})`}`);
258
381
  say(`run ${runId} · ledger ${files.ledger} · rendezvous ${runnerRendezvousPath(home)}`);
382
+ if (realExec && real && !('error' in real)) {
383
+ say(`REAL MODE · account ${real.check.arming.accountId} · limits ${limitsSummary(real.check.limits.values)} · channels ${enabledChannels(real.signals).join(', ')} · ttl ${Math.min(real.signals.ttlSec, real.check.limits.values.signalTtlSec ?? Infinity)} s · kill: dexterm autotrade signal kill · limits: dexterm autotrade limits show`);
384
+ const carried = realExec.inherited().length;
385
+ const elsewhere = realExec.heldElsewhere();
386
+ if (carried || elsewhere.length)
387
+ say(`REAL HOLDINGS · ${carried} carried from an earlier run of ${strategy.id} (its exits keep running)${elsewhere.length ? ` · ${elsewhere.length} held by other strategies (${elsewhere.map((h) => `${h.symbol} ${h.strategyId}`).join(', ')}): counted against the open cap, not traded here` : ''}${realExec.holdingsSource === 'rebuilt' ? ' · rebuilt from the outbox' : ''} · dexterm autotrade signal held`);
388
+ }
389
+ if (paperExec && paperLimits && !('error' in paperLimits))
390
+ say(`PAPER LIMITS · the paper scope of limits.json applies · ${limitsSummary(paperLimits.limits)} · dexterm autotrade limits show --mode paper`);
259
391
  if (host)
260
- say(`telegram: on (your own bot, ${badgeOf(host.port.network)}) · approvals ${strategy.entry.decide === 'agent+approve' ? 'required by the strategy' : 'when turned on'} · /status /pause /kill from your paired chat`);
392
+ say(`telegram: on (your own bot, ${realMode ? 'LIVE copy of each signal' : badgeOf(host.port.network)}) · approvals ${strategy.entry.decide === 'agent+approve' ? 'required by the strategy' : 'when turned on'} · /status /pause /kill from your paired chat`);
261
393
  let switchTo = null;
262
394
  let code = 0;
263
395
  try {
264
396
  const res = await runAutotrade({
265
397
  strategy, basis, tape, source, sessions, gate, dossier, vet, verbose: true, onLine: say, ledger, runId,
266
- ...(host ? { executor: host.port, approvals: host.service.broker } : { executor: baseExec }),
398
+ ...(realExec ? { executor: realExec, real: realExec, ...(host ? { approvals: host.service.broker } : {}) } : paperExec ? { executor: paperExec, maxHoldMinutes: () => paperExec.maxHoldMinutes(), ...(host ? { approvals: host.service.broker } : {}) } : host ? { executor: host.port, approvals: host.service.broker } : { executor: baseExec }),
267
399
  pacing: replay ? { mode: 'paced', msPerTick: num(opts['ms-per-tick'], 2000) } : { mode: 'live' },
268
400
  // The panic file (`dexterm wallet panic`), a stop / flatten request and a local stop (signal, --max-minutes) all
269
401
  // halt: no new entries, sessions close. A signal or --max-minutes also ends the tick stream at once.
@@ -273,7 +405,7 @@ export async function runCmd(argv, io, over = {}) {
273
405
  const v = safety.step();
274
406
  return stopping && !v.halt ? { ...v, halt: 'runner-stopped', detail: 'a signal or --max-minutes' } : v;
275
407
  },
276
- onState: (st) => { runNow = st.t; last = st; writeState(st.state === 'stopped'); host?.onState(st); },
408
+ onState: (st) => { runNow = st.t; last = st; bookUsd = st.book.equityUsd; writeState(st.state === 'stopped'); host?.onState(st); },
277
409
  onFocus: (marks) => { live?.setFocus(marks); writeFocus(home, marks); },
278
410
  });
279
411
  const next = host?.takeSwitch() ?? null;
@@ -287,6 +419,35 @@ export async function runCmd(argv, io, over = {}) {
287
419
  }
288
420
  finally {
289
421
  clearInterval(beat);
422
+ if (pumpTimer)
423
+ clearInterval(pumpTimer);
424
+ if (digestTimer)
425
+ clearInterval(digestTimer);
426
+ if (listener)
427
+ await listener.close().catch(() => { });
428
+ // The runner always says goodbye: idempotent after a normal end, and the only `halted` when the run threw.
429
+ if (realExec) {
430
+ realExec.sendHalted(code ? 'runner-error' : 'runner-stopped');
431
+ // task-1679 (0.2.0 decision): what is still open when a real run ends (stop, kill, time limit, error) is impossible to miss.
432
+ const open = realExec.openOnAccount();
433
+ if (open.length) {
434
+ const line = `REAL POSITIONS STILL OPEN (${open.length}): ${open.map((h) => `${h.symbol} ${h.mint}${h.strategyId === strategy.id ? '' : ` (${h.strategyId})`}`).join(', ')}`;
435
+ say(line);
436
+ say(' these positions are still open: manage them in your executor. While no real run of the strategy that holds one is running, dexterm sends NO signal for it, sells included. The next real run of that strategy on this account carries it and its exits fire again (the "halted" signal lists them; dexterm autotrade signal held).');
437
+ if (host)
438
+ void host.service.hub.broadcast({ kind: 'text', badge: 'LIVE', lines: [line, 'these positions are still open: manage them in your executor'] }, 'real_limits').catch(() => { });
439
+ }
440
+ }
441
+ if (outbox) {
442
+ try {
443
+ await outbox.pump();
444
+ }
445
+ catch { /* pending signals stay in the outbox; they expire by TTL */ }
446
+ outbox.close();
447
+ }
448
+ real?.stop?.();
449
+ if (paperLimits && !('error' in paperLimits))
450
+ paperLimits.stop();
290
451
  process.removeListener('SIGINT', onSig);
291
452
  process.removeListener('SIGTERM', onSig);
292
453
  anchorChat('end');
@@ -335,7 +496,7 @@ function writeFocus(home, marks) {
335
496
  * task-1543: resolve the session engine. claude = the original path (null here). ollama = the user's local model,
336
497
  * allowed only when the local profile allows autotrade (paper) — checked before the run lock.
337
498
  */
338
- async function localEngine(opts, io) {
499
+ export async function localEngine(opts, io) {
339
500
  const eng = await import('../../../mcp/src/engine/index.js');
340
501
  const loaded = eng.loadAiConfig(io.env);
341
502
  const flag = typeof opts.engine === 'string' ? eng.parseEngineChoice(opts.engine) : null;
@@ -411,3 +572,86 @@ export function larpVet(g, check, timeoutMs = 20_000) {
411
572
  return { ok: true, note: `LARP score ${score}${band ? ` (${band})` : ''} ≤ ${g.maxScore}` };
412
573
  };
413
574
  }
575
+ const SOL_MINT = 'So11111111111111111111111111111111111111112';
576
+ /**
577
+ * task-1645: real mode's preconditions (realModeCheck: armed at this hash, kill off, caps, an executor channel) and
578
+ * SOL/USD for sizes in SOL: `--sol-usd N`, else the SOL pair on DexScreener (keyless), refreshed every minute.
579
+ * No SOL price, no run: a signal must state its size in SOL.
580
+ */
581
+ async function prepareReal(home, s, opts, io) {
582
+ const sc = loadSignalsConfig(home);
583
+ if (sc.problems.length)
584
+ io.stderr.write(`warning: signal channels turned off: ${sc.problems.join('; ')}\n`);
585
+ const check = realModeCheck(home, s, enabledChannels(sc.config));
586
+ if (!check.ok)
587
+ return { error: check.why };
588
+ const fixed = typeof opts['sol-usd'] === 'string' ? Number(opts['sol-usd']) : null;
589
+ if (fixed !== null && !(fixed > 1 && fixed < 100_000))
590
+ return { error: '--sol-usd needs the SOL price in USD' };
591
+ if (fixed)
592
+ return { check, signals: sc.config, solUsd: () => fixed, stop: () => { } };
593
+ const { createDataLayer, defaultDbPath } = await import('../../../data/src/index.js');
594
+ const data = createDataLayer({ dbPath: io.env.DEXTERM_DB_PATH || defaultDbPath(io.env) });
595
+ let px = null;
596
+ const refresh = async () => { try {
597
+ const p = (await data.dexscreener.tokens([SOL_MINT])).get(SOL_MINT)?.pair.priceUsd ?? null;
598
+ if (p && p > 1 && p < 100_000)
599
+ px = p;
600
+ }
601
+ catch { /* keep the last price */ } };
602
+ await refresh();
603
+ if (!px) {
604
+ data.close();
605
+ return { error: 'no SOL/USD price (DexScreener did not answer); pass --sol-usd N' };
606
+ }
607
+ const t = setInterval(() => { void refresh(); }, 60_000);
608
+ t.unref?.();
609
+ return { check, signals: sc.config, solUsd: () => px, stop: () => { clearInterval(t); data.close(); } };
610
+ }
611
+ /**
612
+ * task-1654: the `paper` scope of limits.json for a paper run, or null when there is none (paper ignores the limits
613
+ * then, exactly as before). Sizes are in SOL, so it needs SOL/USD: `--sol-usd N`, else DexScreener on live data; a
614
+ * replay needs --sol-usd (today's price is not the tape's). An unreadable or invalid file is only a warning: a
615
+ * paper run moves no money, and the line says the paper scope was NOT applied.
616
+ */
617
+ async function preparePaperLimits(home, s, opts, io, replay) {
618
+ const l = loadLimits(home);
619
+ if (!l.ok) {
620
+ if (!('missing' in l && l.missing))
621
+ io.stderr.write(`warning: paper limits NOT applied: ${l.why}\n`);
622
+ return null;
623
+ }
624
+ const accountId = typeof opts.account === 'string' ? opts.account : '-';
625
+ const e = effectiveLimits(l.doc, { strategyId: s.id, accountId, mode: 'paper' });
626
+ if (!e.applies)
627
+ return null;
628
+ const fixed = typeof opts['sol-usd'] === 'string' ? Number(opts['sol-usd']) : null;
629
+ if (fixed !== null && !(fixed > 1 && fixed < 100_000))
630
+ return { error: '--sol-usd needs the SOL price in USD' };
631
+ if (fixed)
632
+ return { limits: e.values, solUsd: () => fixed, stop: () => { } };
633
+ if (replay)
634
+ return { error: 'limits.json has a paper scope (sizes in SOL): a replay needs the SOL price of its day, --sol-usd N' };
635
+ const { createDataLayer, defaultDbPath } = await import('../../../data/src/index.js');
636
+ const data = createDataLayer({ dbPath: io.env.DEXTERM_DB_PATH || defaultDbPath(io.env) });
637
+ let px = null;
638
+ const refresh = async () => { try {
639
+ const p = (await data.dexscreener.tokens([SOL_MINT])).get(SOL_MINT)?.pair.priceUsd ?? null;
640
+ if (p && p > 1 && p < 100_000)
641
+ px = p;
642
+ }
643
+ catch { /* keep the last price */ } };
644
+ await refresh();
645
+ if (!px) {
646
+ data.close();
647
+ return { error: 'limits.json has a paper scope (sizes in SOL) and there is no SOL/USD price (DexScreener did not answer); pass --sol-usd N' };
648
+ }
649
+ const t = setInterval(() => { void refresh(); }, 60_000);
650
+ t.unref?.();
651
+ return { limits: e.values, solUsd: () => px, stop: () => { clearInterval(t); data.close(); } };
652
+ }
653
+ /** The local calendar day (the digest schedule's key). */
654
+ function localDayNow(t = Date.now()) {
655
+ const d = new Date(t);
656
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
657
+ }