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,330 @@
1
+ // dexterm autotrade — trade-fire receipts, drift and the read-only wallet reconcile (task-1645, 0.2.0 card A).
2
+ //
3
+ // The user's executor reports what it did with a signal, as DATA only: `dexterm autotrade receipt …`, or by
4
+ // appending a `dexterm-trade-receipt/1` line to $DEXTERM_HOME/autotrade/real/receipts.jsonl itself.
5
+ // status executed | rejected | failed, optional fill {priceUsd, sizeSol, tokens, txSig}, a short reason
6
+ // A receipt never makes dexterm do anything: it only moves a real-mode position from `unconfirmed` to
7
+ // executed / rejected / failed in the ledger, the Q screen and `autotrade audit`, and measures the drift between
8
+ // what was signalled and what was filled. Exits keep firing on the monitor's own (paper-model) view regardless.
9
+ //
10
+ // The wallet reconcile reads a PUBLIC address the user supplies (SOL balance and token holdings, two read-only RPC
11
+ // methods on an allowlist) and lists the mints dexterm thinks are held that the wallet does not show, and back.
12
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { realDir } from "./outbox.js";
15
+ import { ACCOUNT_ID_RE } from "./signal.js";
16
+ export const RECEIPT_SCHEMA = 'dexterm-trade-receipt/1';
17
+ export const RECEIPT_STATUSES = ['executed', 'rejected', 'failed'];
18
+ export const receiptsPath = (home) => join(realDir(home), 'receipts.jsonl');
19
+ const TX_SIG = /^[1-9A-HJ-NP-Za-km-z]{64,90}$/;
20
+ const HEX32 = /^[0-9a-f]{32}$/;
21
+ const pos = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
22
+ /** Free text from an executor is data: printable ASCII only, 120 chars. */
23
+ const text = (v, max) => { if (typeof v !== 'string')
24
+ return null; const t = v.replace(/[^\x20-\x7e]/g, '').trim().slice(0, max); return t || null; };
25
+ export function validateReceipt(x) {
26
+ const r = x;
27
+ if (!r || typeof r !== 'object')
28
+ return { ok: false, error: 'not an object' };
29
+ if (r.schema !== RECEIPT_SCHEMA)
30
+ return { ok: false, error: 'schema' };
31
+ if (typeof r.signalId !== 'string' || !HEX32.test(r.signalId))
32
+ return { ok: false, error: 'signalId' };
33
+ if (!Number.isSafeInteger(r.nonce) || r.nonce < 1)
34
+ return { ok: false, error: 'nonce' };
35
+ if (typeof r.accountId !== 'string' || !ACCOUNT_ID_RE.test(r.accountId))
36
+ return { ok: false, error: 'accountId' };
37
+ if (!RECEIPT_STATUSES.includes(r.status))
38
+ return { ok: false, error: 'status' };
39
+ if (typeof r.at !== 'number' || !Number.isFinite(r.at))
40
+ return { ok: false, error: 'at' };
41
+ let fill = null;
42
+ if (r.fill !== undefined && r.fill !== null) {
43
+ const f = r.fill;
44
+ if (typeof f !== 'object')
45
+ return { ok: false, error: 'fill' };
46
+ if (f.txSig !== undefined && f.txSig !== null && (typeof f.txSig !== 'string' || !TX_SIG.test(f.txSig)))
47
+ return { ok: false, error: 'fill.txSig is not a base58 signature' };
48
+ for (const k of ['priceUsd', 'sizeSol', 'tokens'])
49
+ if (f[k] !== undefined && f[k] !== null && pos(f[k]) === null)
50
+ return { ok: false, error: `fill.${k}` };
51
+ fill = { priceUsd: pos(f.priceUsd), sizeSol: pos(f.sizeSol), tokens: pos(f.tokens), txSig: typeof f.txSig === 'string' ? f.txSig : null };
52
+ }
53
+ if (r.status !== 'executed' && fill && (fill.sizeSol || fill.tokens))
54
+ return { ok: false, error: 'only an executed receipt carries a fill size' };
55
+ return { ok: true, receipt: { schema: RECEIPT_SCHEMA, signalId: r.signalId, nonce: r.nonce, accountId: r.accountId, status: r.status, at: r.at, fill, reason: text(r.reason, 120), executor: text(r.executor, 32) } };
56
+ }
57
+ export function appendReceipt(home, r) {
58
+ const v = validateReceipt(r);
59
+ if (!v.ok)
60
+ throw new Error(`receipt: ${v.error}`);
61
+ mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
62
+ const fd = openSync(receiptsPath(home), 'a', 0o600);
63
+ try {
64
+ writeSync(fd, `${JSON.stringify(v.receipt)}\n`);
65
+ fsyncSync(fd);
66
+ }
67
+ finally {
68
+ closeSync(fd);
69
+ }
70
+ }
71
+ /** Why a receipt does not belong to a known signal (or null): unknown nonce, another account, an id mismatch. */
72
+ export function receiptMismatch(r, s) {
73
+ if (!s)
74
+ return 'unknown-signal';
75
+ if (s.id !== r.signalId)
76
+ return 'signal-id-mismatch';
77
+ if (s.accountId !== r.accountId)
78
+ return 'wrong-account';
79
+ // task-1654: a receipt for a `signal test` proves the round trip (signal test reports it); it is not an orphan.
80
+ if (s.type === 'test')
81
+ return 'test-signal';
82
+ if (s.type !== 'trade')
83
+ return 'not-a-trade-signal';
84
+ return null;
85
+ }
86
+ /**
87
+ * Reads receipts.jsonl incrementally (byte offset). Every valid receipt for a known signal is returned once; the
88
+ * rest are counted (`invalid`, `orphan`) and never acted on. A partial last line waits for its newline.
89
+ */
90
+ export class ReceiptsTail {
91
+ offset = 0;
92
+ carry = '';
93
+ invalid = 0;
94
+ orphan = 0;
95
+ path;
96
+ lookup;
97
+ constructor(home, lookup) { this.path = receiptsPath(home); this.lookup = lookup; }
98
+ poll() {
99
+ if (!existsSync(this.path))
100
+ return [];
101
+ const size = statSync(this.path).size;
102
+ if (size < this.offset) {
103
+ this.offset = 0;
104
+ this.carry = '';
105
+ } // truncated: read again
106
+ if (size === this.offset)
107
+ return [];
108
+ const fd = openSync(this.path, 'r');
109
+ const buf = Buffer.alloc(Math.min(size - this.offset, 4 << 20));
110
+ let n = 0;
111
+ try {
112
+ n = readSync(fd, buf, 0, buf.length, this.offset);
113
+ }
114
+ finally {
115
+ closeSync(fd);
116
+ }
117
+ this.offset += n;
118
+ const chunk = this.carry + buf.subarray(0, n).toString('utf8');
119
+ const lines = chunk.split('\n');
120
+ this.carry = lines.pop() ?? '';
121
+ const out = [];
122
+ for (const l of lines) {
123
+ if (!l.trim())
124
+ continue;
125
+ let raw;
126
+ try {
127
+ raw = JSON.parse(l);
128
+ }
129
+ catch {
130
+ this.invalid++;
131
+ continue;
132
+ }
133
+ const v = validateReceipt(raw);
134
+ if (!v.ok) {
135
+ this.invalid++;
136
+ continue;
137
+ }
138
+ const mm = receiptMismatch(v.receipt, this.lookup(v.receipt.nonce));
139
+ if (mm === 'test-signal')
140
+ continue;
141
+ if (mm) {
142
+ this.orphan++;
143
+ continue;
144
+ }
145
+ out.push(v.receipt);
146
+ }
147
+ return out;
148
+ }
149
+ }
150
+ export function readReceipts(home) {
151
+ const p = receiptsPath(home);
152
+ if (!existsSync(p))
153
+ return { receipts: [], invalid: 0 };
154
+ const out = [];
155
+ let invalid = 0;
156
+ for (const l of readFileSync(p, 'utf8').split('\n')) {
157
+ if (!l.trim())
158
+ continue;
159
+ try {
160
+ const v = validateReceipt(JSON.parse(l));
161
+ if (v.ok)
162
+ out.push(v.receipt);
163
+ else
164
+ invalid++;
165
+ }
166
+ catch {
167
+ invalid++;
168
+ }
169
+ }
170
+ return { receipts: out, invalid };
171
+ }
172
+ const r1 = (v) => Math.round(v * 10) / 10;
173
+ /** Signals vs receipts: one row per trade signal. */
174
+ export function driftReport(signals, receipts, now, tolPct = 5) {
175
+ const trades = signals.filter((s) => s.type === 'trade');
176
+ const byNonce = new Map();
177
+ let orphanReceipts = 0;
178
+ const known = new Map(trades.map((s) => [s.nonce, s]));
179
+ const tests = new Set(signals.filter((s) => s.type === 'test').map((s) => s.nonce));
180
+ for (const r of receipts) {
181
+ if (tests.has(r.nonce))
182
+ continue;
183
+ if (receiptMismatch(r, known.get(r.nonce))) {
184
+ orphanReceipts++;
185
+ continue;
186
+ }
187
+ const l = byNonce.get(r.nonce) ?? [];
188
+ l.push(r);
189
+ byNonce.set(r.nonce, l);
190
+ }
191
+ const counts = { unconfirmed: 0, executed: 0, rejected: 0, failed: 0, 'expired-unconfirmed': 0 };
192
+ let sizeDrift = 0;
193
+ const rows = trades.map((s) => {
194
+ const rs = (byNonce.get(s.nonce) ?? []).sort((a, b) => a.at - b.at);
195
+ const last = rs[rs.length - 1];
196
+ const state = last ? last.status : now > s.expiresAt ? 'expired-unconfirmed' : 'unconfirmed';
197
+ counts[state]++;
198
+ const filled = last?.status === 'executed' ? last.fill?.sizeSol ?? null : null;
199
+ const sizeDriftPct = filled !== null && s.sizeSol > 0 ? r1(((filled - s.sizeSol) / s.sizeSol) * 100) : null;
200
+ const px = last?.status === 'executed' ? last.fill?.priceUsd ?? null : null;
201
+ const priceDriftPct = px !== null && s.price.usd ? r1(((px - s.price.usd) / s.price.usd) * 100) : null;
202
+ if (sizeDriftPct !== null && Math.abs(sizeDriftPct) > tolPct)
203
+ sizeDrift++;
204
+ return {
205
+ nonce: s.nonce, id: s.id, runId: s.runId, side: s.side, mint: s.mint, symbol: s.symbol, issuedAt: s.issuedAt, state, signalSol: s.sizeSol, filledSol: filled, sizeDriftPct, priceDriftPct,
206
+ txSig: last?.fill?.txSig ?? null, conflict: new Set(rs.map((r) => r.status)).size > 1, reason: last?.reason ?? null,
207
+ };
208
+ });
209
+ return { rows, counts, unconfirmed: counts.unconfirmed + counts['expired-unconfirmed'], sizeDrift, orphanReceipts };
210
+ }
211
+ const r2 = (v) => Math.round(v * 100) / 100;
212
+ /** One receipt against its trade signal (null for anything that is not a known trade signal). */
213
+ export function divergenceOf(s, r) {
214
+ if (!s || s.type !== 'trade' || receiptMismatch(r, s))
215
+ return null;
216
+ const f = r.status === 'executed' ? r.fill : null;
217
+ const px = f?.priceUsd ?? (f?.sizeSol && f.tokens ? (f.sizeSol * s.price.solUsd) / f.tokens : null);
218
+ const adverse = (ref) => (px && ref && ref > 0 ? r2(((s.side === 'buy' ? px - ref : ref - px) / ref) * 100) : null);
219
+ const modelTokens = s.model?.tokens ?? null;
220
+ const fillRatio = !f ? null : s.side === 'buy' ? (f.sizeSol && s.sizeSol > 0 ? Math.round((f.sizeSol / s.sizeSol) * 1000) / 1000 : null) : (f.tokens && modelTokens ? Math.round((f.tokens / modelTokens) * 1000) / 1000 : null);
221
+ return {
222
+ nonce: s.nonce, side: s.side, status: r.status, slippageVsPaperPct: adverse(s.model?.priceUsd ?? null), slippageVsQuotePct: adverse(s.price.usd),
223
+ latencyMs: Math.max(0, r.at - s.issuedAt), fillRatio, partial: fillRatio !== null && fillRatio < 0.95, reason: r.reason,
224
+ };
225
+ }
226
+ const quantile = (xs, q) => { if (!xs.length)
227
+ return null; const a = [...xs].sort((x, y) => x - y); return a[Math.min(a.length - 1, Math.floor(q * a.length))]; };
228
+ /** Every trade signal's newest receipt, summarized. */
229
+ export function divergenceReport(signals, receipts) {
230
+ const bySig = new Map(signals.filter((s) => s.type === 'trade').map((s) => [s.nonce, s]));
231
+ const newest = new Map();
232
+ for (const r of receipts) {
233
+ const s = bySig.get(r.nonce);
234
+ if (!s || receiptMismatch(r, s))
235
+ continue;
236
+ const p = newest.get(r.nonce);
237
+ if (!p || r.at >= p.at)
238
+ newest.set(r.nonce, r);
239
+ }
240
+ const rows = [...newest.values()].map((r) => divergenceOf(bySig.get(r.nonce), r)).filter((d) => !!d).sort((a, b) => a.nonce - b.nonce);
241
+ const ex = rows.filter((d) => d.status === 'executed');
242
+ const sl = ex.map((d) => d.slippageVsPaperPct ?? d.slippageVsQuotePct).filter((v) => v !== null);
243
+ const lat = rows.map((d) => d.latencyMs);
244
+ const reasons = {};
245
+ for (const d of rows)
246
+ if (d.status !== 'executed') {
247
+ const k = `${d.status}: ${(d.reason ?? 'no reason').slice(0, 40)}`;
248
+ reasons[k] = (reasons[k] ?? 0) + 1;
249
+ }
250
+ const n = ex.length;
251
+ return {
252
+ rows, trades: bySig.size, receipted: rows.length, executed: n, rejected: rows.filter((d) => d.status === 'rejected').length, failed: rows.filter((d) => d.status === 'failed').length, partial: rows.filter((d) => d.partial).length,
253
+ slippage: { n: sl.length, medianPct: quantile(sl, 0.5), p90Pct: quantile(sl, 0.9), meanPct: sl.length ? r2(sl.reduce((a, b) => a + b, 0) / sl.length) : null },
254
+ latency: { n: lat.length, medianMs: quantile(lat, 0.5), p90Ms: quantile(lat, 0.9) },
255
+ reasons,
256
+ note: n === 0 ? 'no executed receipt yet: nothing to compare paper with' : n < 30 ? `only ${n} executed fill(s): too few to calibrate paper fills with confidence` : `${n} executed fills`,
257
+ };
258
+ }
259
+ export function divergenceLines(d) {
260
+ const out = [`paper vs real: ${d.receipted}/${d.trades} trade signals have a receipt · executed ${d.executed} · rejected ${d.rejected} · failed ${d.failed} · partial ${d.partial}`];
261
+ if (d.slippage.n)
262
+ out.push(` slippage vs paper fill: median ${d.slippage.medianPct}% · p90 ${d.slippage.p90Pct}% · mean ${d.slippage.meanPct}% (positive = the real fill was worse than paper)`);
263
+ if (d.latency.n)
264
+ out.push(` latency signal -> receipt: median ${Math.round((d.latency.medianMs ?? 0) / 100) / 10} s · p90 ${Math.round((d.latency.p90Ms ?? 0) / 100) / 10} s`);
265
+ for (const [k, v] of Object.entries(d.reasons).slice(0, 5))
266
+ out.push(` ${v}× ${k}`);
267
+ out.push(` ${d.note}`);
268
+ return out;
269
+ }
270
+ // ───────────────────────────── read-only wallet reconcile ─────────────────────────────
271
+ /** The only RPC methods the reconcile may call. Anything else is refused before it leaves the process. */
272
+ export const RECONCILE_RPC_METHODS = ['getBalance', 'getTokenAccountsByOwner'];
273
+ const TOKEN_PROGRAMS = ['TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'];
274
+ const PUBKEY = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
275
+ /** Read a public wallet (balance + token holdings) and compare with the mints the real-mode book holds / closed. */
276
+ export async function reconcileWallet(wallet, rpc, book) {
277
+ if (!PUBKEY.test(wallet))
278
+ throw new Error('reconcile: the wallet is a public base58 address (never a key)');
279
+ const call = (m, p) => { if (!RECONCILE_RPC_METHODS.includes(m))
280
+ return Promise.reject(new Error(`rpc method ${m} is not on the read-only allowlist`)); return rpc(m, p); };
281
+ const errors = [];
282
+ let sol = null;
283
+ try {
284
+ const b = (await call('getBalance', [wallet, { commitment: 'confirmed' }]));
285
+ sol = typeof b?.value === 'number' ? b.value / 1e9 : null;
286
+ }
287
+ catch (e) {
288
+ errors.push(`getBalance: ${e.message.slice(0, 120)}`);
289
+ }
290
+ const holdings = {};
291
+ for (const programId of TOKEN_PROGRAMS) {
292
+ try {
293
+ const r = (await call('getTokenAccountsByOwner', [wallet, { programId }, { encoding: 'jsonParsed', commitment: 'confirmed' }]));
294
+ for (const a of r?.value ?? []) {
295
+ const info = a.account?.data?.parsed?.info;
296
+ if (!info?.mint || !PUBKEY.test(info.mint))
297
+ continue;
298
+ holdings[info.mint] = (holdings[info.mint] ?? 0) + (Number(info.tokenAmount?.uiAmount) || 0);
299
+ }
300
+ }
301
+ catch (e) {
302
+ errors.push(`getTokenAccountsByOwner: ${e.message.slice(0, 120)}`);
303
+ }
304
+ }
305
+ const ok = errors.length === 0;
306
+ return {
307
+ wallet, sol, holdings,
308
+ missing: ok ? book.held.filter((m) => !(holdings[m] > 0)) : [],
309
+ stillHeld: ok ? book.closed.filter((m) => holdings[m] > 0 && !book.held.includes(m)) : [],
310
+ errors,
311
+ };
312
+ }
313
+ /** From the trade signals alone (nonce order): the mints a real-mode book holds, and those it closed. */
314
+ export function heldFromSignals(signals) {
315
+ const held = new Set();
316
+ const closed = new Set();
317
+ for (const s of [...signals].sort((a, b) => a.nonce - b.nonce)) {
318
+ if (s.type !== 'trade')
319
+ continue;
320
+ if (s.side === 'buy') {
321
+ held.add(s.mint);
322
+ closed.delete(s.mint);
323
+ }
324
+ else if ((s.sellFraction ?? 1) >= 0.999) {
325
+ held.delete(s.mint);
326
+ closed.add(s.mint);
327
+ }
328
+ }
329
+ return { held: [...held], closed: [...closed] };
330
+ }
@@ -0,0 +1,168 @@
1
+ // dexterm autotrade — the daily / weekly digest (task-1654, 0.2.0 card E). Read-only over the run ledgers.
2
+ //
3
+ // Per strategy and mode (paper | real) over a period: closed trades, the win rate with its 95% interval (Wilson),
4
+ // net PnL, the max drawdown of the closed-trade curve, refusals, the limits that were hit, and for real mode the
5
+ // executor's health and the paper-vs-real fill divergence from receipts. Every digest ends with an honest sample
6
+ // note: a handful of trades says almost nothing, and the interval shows how little.
7
+ //
8
+ // `dexterm autotrade report` prints it (or --json, or --telegram through the user's own bot). A run with the
9
+ // Telegram bot on sends it by itself when the user turned a schedule on (`report --schedule daily|weekly|off`,
10
+ // saved in $DEXTERM_HOME/autotrade/digest.json; off by default).
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { readLedgerFile, runsDir, writeJsonAtomic } from "./ledger.js";
14
+ import { localDay } from "./chat/audit.js";
15
+ import { allOutboxSignals } from "./outbox.js";
16
+ import { divergenceReport, readReceipts } from "./receipts.js";
17
+ const r2 = (v) => Math.round(v * 100) / 100;
18
+ /** The 95% Wilson score interval of k wins in n, in %. */
19
+ export function wilson(k, n, z = 1.96) {
20
+ if (n <= 0)
21
+ return null;
22
+ const p = k / n;
23
+ const d = 1 + (z * z) / n;
24
+ const c = (p + (z * z) / (2 * n)) / d;
25
+ const h = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
26
+ return [Math.round(Math.max(0, c - h) * 1000) / 10, Math.round(Math.min(1, c + h) * 1000) / 10];
27
+ }
28
+ export function periodBounds(period, now) {
29
+ return { from: now - (period === 'day' ? 1 : 7) * 86_400_000, to: now };
30
+ }
31
+ /** The digest of every run ledger with events in [from, to]. */
32
+ export function buildDigest(home, o) {
33
+ const b = periodBounds(o.period, o.now);
34
+ const from = o.from ?? b.from;
35
+ const to = o.to ?? b.to;
36
+ const by = new Map();
37
+ const dir = runsDir(home);
38
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.jsonl')) : [];
39
+ let anyReal = false;
40
+ for (const f of files) {
41
+ let ev;
42
+ try {
43
+ ev = readLedgerFile(join(dir, f));
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ const start = ev.find((e) => e.kind === 'run_start');
49
+ if (!start || typeof start.strategy !== 'string')
50
+ continue;
51
+ if (o.strategyId && start.strategy !== o.strategyId)
52
+ continue;
53
+ const inPeriod = ev.filter((e) => e.t >= from && e.t <= to);
54
+ if (!inPeriod.length)
55
+ continue;
56
+ const mode = start.mode === 'real' || start.tag === 'real-signal' ? 'real' : 'paper';
57
+ if (mode === 'real')
58
+ anyReal = true;
59
+ const key = `${start.strategy}|${mode}`;
60
+ let d = by.get(key);
61
+ if (!d) {
62
+ d = { strategyId: start.strategy, mode, runs: 0, trades: 0, wins: 0, winRatePct: null, winRateCi: null, netUsd: 0, maxDrawdownUsd: 0, refusals: 0, limitsHit: {}, clamped: {}, receipts: 0, closes: [] };
63
+ by.set(key, d);
64
+ }
65
+ d.runs++;
66
+ for (const e of inPeriod) {
67
+ if (e.kind === 'trade_closed') {
68
+ const pnl = Number(e.trade?.pnlUsd);
69
+ if (Number.isFinite(pnl)) {
70
+ d.trades++;
71
+ if (pnl > 0)
72
+ d.wins++;
73
+ d.netUsd += pnl;
74
+ d.closes.push({ t: e.t, pnl });
75
+ }
76
+ }
77
+ else if (e.kind === 'refused') {
78
+ d.refusals++;
79
+ const rule = String(e.rule ?? '');
80
+ const m = /^(?:fill-)?(real-[a-z-]+)/.exec(rule);
81
+ if (m)
82
+ d.limitsHit[m[1]] = (d.limitsHit[m[1]] ?? 0) + 1;
83
+ }
84
+ else if (e.kind === 'fill' && Array.isArray(e.limitClamps)) {
85
+ for (const c of e.limitClamps)
86
+ d.clamped[c] = (d.clamped[c] ?? 0) + 1;
87
+ }
88
+ else if (e.kind === 'receipt')
89
+ d.receipts++;
90
+ }
91
+ }
92
+ const strategies = [...by.values()].map(({ closes, ...d }) => {
93
+ closes.sort((a, b2) => a.t - b2.t);
94
+ let cum = 0;
95
+ let peak = 0;
96
+ let dd = 0;
97
+ for (const c of closes) {
98
+ cum += c.pnl;
99
+ peak = Math.max(peak, cum);
100
+ dd = Math.max(dd, peak - cum);
101
+ }
102
+ return { ...d, netUsd: r2(d.netUsd), maxDrawdownUsd: r2(dd), winRatePct: d.trades ? Math.round((d.wins / d.trades) * 1000) / 10 : null, winRateCi: wilson(d.wins, d.trades) };
103
+ }).sort((a, b2) => a.strategyId.localeCompare(b2.strategyId) || a.mode.localeCompare(b2.mode));
104
+ let divergence = null;
105
+ if (anyReal) {
106
+ const sig = allOutboxSignals(home).filter((s) => s.issuedAt >= from && s.issuedAt <= to);
107
+ const d = divergenceReport(sig, readReceipts(home).receipts);
108
+ const { rows: _rows, ...rest } = d;
109
+ divergence = rest;
110
+ }
111
+ const n = strategies.reduce((a, s) => a + s.trades, 0);
112
+ const note = n === 0 ? 'no closed trades in this period'
113
+ : n < 30 ? `${n} closed trade(s) in total: a small sample. The win-rate intervals show how wide the truth may be; read no trend into it.`
114
+ : `${n} closed trades: still check the interval before trusting a win rate, and past results do not predict future ones`;
115
+ return { period: o.period, from, to, strategies, divergence, executor: o.executor ?? null, note };
116
+ }
117
+ const usd = (v) => `${v < 0 ? '-' : '+'}$${Math.abs(v).toFixed(2)}`;
118
+ /** Plain lines (CLI and Telegram; no token text: strategy ids and engine numbers only). */
119
+ export function digestLines(d) {
120
+ const out = [`dexterm ${d.period === 'day' ? 'daily' : 'weekly'} digest · ${new Date(d.from).toISOString().slice(0, 16).replace('T', ' ')} to ${new Date(d.to).toISOString().slice(0, 16).replace('T', ' ')} UTC · not financial advice`];
121
+ if (!d.strategies.length)
122
+ out.push(' no runs in this period');
123
+ for (const s of d.strategies) {
124
+ out.push(`${s.mode === 'real' ? 'REAL' : 'paper'} ${s.strategyId} · ${s.runs} run(s) · ${s.trades} trade(s)${s.trades ? ` · win ${s.winRatePct}% (95% interval ${s.winRateCi[0]}-${s.winRateCi[1]}%)` : ''} · net ${usd(s.netUsd)} · max drawdown $${s.maxDrawdownUsd.toFixed(2)}`);
125
+ const hit = Object.entries(s.limitsHit).map(([k, v]) => `${k} ×${v}`).join(', ');
126
+ const cl = Object.entries(s.clamped).map(([k, v]) => `${k} ×${v}`).join(', ');
127
+ out.push(` refusals ${s.refusals}${hit ? ` · limits hit: ${hit}` : ''}${cl ? ` · clamped by: ${cl}` : ''}${s.mode === 'real' ? ` · receipts ${s.receipts}` : ''}`);
128
+ }
129
+ if (d.divergence) {
130
+ const v = d.divergence;
131
+ out.push(`paper vs real fills: ${v.receipted}/${v.trades} receipted · executed ${v.executed} · rejected ${v.rejected} · failed ${v.failed} · partial ${v.partial}${v.slippage.n ? ` · slippage vs paper median ${v.slippage.medianPct}% (p90 ${v.slippage.p90Pct}%)` : ''}${v.latency.n ? ` · latency median ${Math.round((v.latency.medianMs ?? 0) / 100) / 10} s` : ''}`);
132
+ out.push(` ${v.note}`);
133
+ }
134
+ if (d.executor)
135
+ out.push(`executor: ${d.executor.alive === false ? 'SILENT' : d.executor.alive ? 'alive' : d.executor.lastSeenAt ? 'seen' : 'never seen'}${d.executor.silentSec !== null ? ` · last heard ${Math.round(d.executor.silentSec / 60)} min ago` : ''}${d.executor.lastReceipt ? ` · last receipt #${d.executor.lastReceipt.nonce} ${d.executor.lastReceipt.status}` : ''}`);
136
+ out.push(d.note);
137
+ return out;
138
+ }
139
+ // ───────────────────────────── the schedule (opt-in) ─────────────────────────────
140
+ export const DIGEST_SCHEMA = 'dexterm-digest/1';
141
+ export const digestStatePath = (home) => join(home, 'autotrade', 'digest.json');
142
+ export function loadDigestState(home) {
143
+ try {
144
+ const d = JSON.parse(readFileSync(digestStatePath(home), 'utf8'));
145
+ if (d.schema === DIGEST_SCHEMA && ['off', 'daily', 'weekly'].includes(d.schedule))
146
+ return d;
147
+ }
148
+ catch { /* none */ }
149
+ return { schema: DIGEST_SCHEMA, schedule: 'off', lastSentDay: null, lastSentAt: null };
150
+ }
151
+ export function saveDigestState(home, s) {
152
+ mkdirSync(join(home, 'autotrade'), { recursive: true, mode: 0o700 });
153
+ writeJsonAtomic(digestStatePath(home), s);
154
+ }
155
+ /**
156
+ * Is a scheduled digest due? Daily: once per local day, for the day before (first check after midnight). Weekly: on
157
+ * a Monday, for the week before. Never twice for the same day.
158
+ */
159
+ export function digestDue(s, now) {
160
+ if (s.schedule === 'off')
161
+ return null;
162
+ const today = localDay(now);
163
+ if (s.lastSentDay === today)
164
+ return null;
165
+ if (s.schedule === 'weekly' && new Date(now).getDay() !== 1)
166
+ return null;
167
+ return s.schedule === 'daily' ? 'day' : 'week';
168
+ }
@@ -12,7 +12,8 @@
12
12
  // never sleep (the source paces itself) and replies land when they land.
13
13
  //
14
14
  // Every step lands in the hash-chained run ledger (ledger.ts) with the exact inputs (the tick, the message text, the
15
- // raw reply, the clamp). Paper only: a strategy whose mode is not "paper" is refused before the first tick. The
15
+ // raw reply, the clamp). Paper, or real (task-1645: the paper engine plus a trade-fire signal per fill, through the
16
+ // real-mode hook only); any other mode is refused before the first tick. The
16
17
  // sessions' day caps and allowance guard live in SessionGate (sessions.ts); with none given, the strategy's own
17
18
  // limits apply; the gate counts real usage per wall-clock day, so a fast --replay over many tape days never resets
18
19
  // the $/day cap. The host part at the end of this file (lock, rendezvous, heartbeat) is what the CLI uses.
@@ -39,8 +40,16 @@ export async function runAutotrade(o) {
39
40
  const s = o.strategy;
40
41
  // task-1571: autotrade runs on paper only. dexterm holds no key and places no live order; live trading happens in the
41
42
  // Telegram trading bot of the user's choice.
42
- if (s.mode !== 'paper')
43
- throw new Error(`strategy ${s.id} is mode "${s.mode}": autotrade runs paper only (dexterm holds no key and places no live order)`);
43
+ // task-1645: mode "real" = the same paper engine, plus a LIVE TRADE FIRE signal per fill for the user's own executor;
44
+ // it needs the real-mode hook (armed on the user's terminal, checked by the host) and never runs without it.
45
+ if (s.mode === 'real' && !o.real)
46
+ throw new Error(`strategy ${s.id} is mode "real": it runs only through the real-mode host (dexterm autotrade run ${s.id} --real, once armed on your own terminal)`);
47
+ if (s.mode !== 'paper' && s.mode !== 'real')
48
+ throw new Error(`strategy ${s.id} is mode "${s.mode}": autotrade runs paper only (dexterm holds no key and places no live order); mode real runs only through the real-mode host`);
49
+ if (o.real && s.mode !== 'real')
50
+ throw new Error(`strategy ${s.id} is mode "${s.mode}": the real-mode hook is for a strategy in mode "real" only`);
51
+ if (o.real && (!o.executor || o.executor !== o.real))
52
+ throw new Error('real mode: the executor must be the real-mode signal executor itself');
44
53
  if (!o.tape && !o.source)
45
54
  throw new Error('runAutotrade needs a tape or a live source');
46
55
  const ledger = o.ledger ?? new Ledger();
@@ -86,7 +95,29 @@ export async function runAutotrade(o) {
86
95
  const pumps = new Set();
87
96
  /** Proposals waiting for an answer, by mint (task-1527). */
88
97
  const approvalsOpen = new Map();
89
- ledger.add(t0, 'run_start', { strategy: s.id, version: s.version, strategyHash: s.hash, mode: s.mode, ...(s.highRisk ? { highRisk: true } : {}), tape: o.tape?.source ?? o.source.name, ticks: tapeTicks?.length ?? null, decide: decideAuto ? 'auto' : s.entry.decide, ...(o.approvals ? { approvals: o.approvals.required(s) ? 'required' : 'when-asked' } : {}), pacing: pacing.mode, executor: exec.name, caps: capsView(gate), ...(o.account ? { account: o.account.id } : {}) });
98
+ ledger.add(t0, 'run_start', { strategy: s.id, version: s.version, strategyHash: s.hash, mode: s.mode, ...(o.real ? { tag: 'real-signal', accountId: o.real.accountId } : {}), ...(s.highRisk ? { highRisk: true } : {}), tape: o.tape?.source ?? o.source.name, ticks: tapeTicks?.length ?? null, decide: decideAuto ? 'auto' : s.entry.decide, ...(o.approvals ? { approvals: o.approvals.required(s) ? 'required' : 'when-asked' } : {}), pacing: pacing.mode, executor: exec.name, caps: capsView(gate), ...(o.account ? { account: o.account.id } : {}) });
99
+ // task-1645 r2: a real run takes over the real positions an earlier run of this strategy left open (held.json),
100
+ // so their exits keep firing. A profile the strategy no longer has falls back to its default profile.
101
+ for (const p of o.real?.inherited() ?? []) {
102
+ const from = p.profileId;
103
+ if (!s.exits.profiles[p.profileId])
104
+ p.profileId = s.exits.default;
105
+ if (!book.adopt(p))
106
+ continue;
107
+ ledger.add(t0, 'position_adopted', { tag: 'real-signal', positionId: p.id, mint: p.mint, symbol: p.symbol, ...(from !== p.profileId ? { profileFrom: from } : {}), position: { ...p, fills: p.fills } });
108
+ say(`CARRIED ${p.symbol} (${p.mint.slice(0, 4)}…): real position from an earlier run, ${p.profileId}, cost $${(p.costUsd - p.realizedUsd).toFixed(2)} still open; its exits keep running`);
109
+ }
110
+ // 0.2.0 card B: positions a crashed run left open. A profile the (re-read) strategy no longer has falls back to
111
+ // its default profile, so every adopted position has exits.
112
+ for (const p of o.adopt ?? []) {
113
+ const from = p.profileId;
114
+ if (!s.exits.profiles[p.profileId])
115
+ p.profileId = s.exits.default;
116
+ if (!book.adopt(p))
117
+ continue;
118
+ ledger.add(t0, 'position_adopted', { positionId: p.id, mint: p.mint, symbol: p.symbol, ...(from !== p.profileId ? { profileFrom: from } : {}), position: { ...p, fills: p.fills } });
119
+ say(`RESUMED ${p.symbol} (${p.mint.slice(0, 4)}…): ${p.profileId}, cost $${(p.costUsd - p.realizedUsd).toFixed(2)} still open; its exits keep running`);
120
+ }
90
121
  const count = (rule) => { refusals[rule] = (refusals[rule] ?? 0) + 1; };
91
122
  const refuse = (rule, data) => { count(rule); ledger.add(now, 'refused', { rule, ...data }); };
92
123
  // A vet that throws or hangs is a check that could not run: the strategy's `unknown` decides (reject by default).
@@ -149,12 +180,12 @@ export async function runAutotrade(o) {
149
180
  return;
150
181
  }
151
182
  p.stuck = false;
152
- ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, ...fillMcaps(fillView(f), k), detail });
183
+ ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, ...fillMcaps(fillView(f), k), detail, ...sigTag(f) });
153
184
  say(`SELL ${p.symbol} ${reason}: $${f.usd.toFixed(2)} at ${lvl(f.price, k)} (impact ${f.impactPct}%) — ${detail}`);
154
185
  const ct = book.sell(p, f);
155
186
  if (ct) {
156
187
  // task-1530: the price extremes the monitor saw while held (the trade ledger's MFE / MAE)
157
- ledger.add(now, 'trade_closed', { trade: { ...ct, fills: undefined }, entryPrice: p.entryPrice, peakPrice: p.peakPrice, troughPrice: p.troughPrice ?? null });
188
+ ledger.add(now, 'trade_closed', { trade: { ...ct, fills: undefined }, entryPrice: p.entryPrice, peakPrice: p.peakPrice, troughPrice: p.troughPrice ?? null, ...(o.real ? { tag: 'real-signal' } : {}) });
158
189
  say(`CLOSED ${ct.symbol}: ${ct.pnlUsd >= 0 ? '+' : ''}$${ct.pnlUsd.toFixed(2)} (${ct.pnlPct}%) by ${ct.exitReason}`);
159
190
  }
160
191
  };
@@ -436,14 +467,14 @@ export async function runAutotrade(o) {
436
467
  f.reason = 'ladder-add';
437
468
  const bookUsd = round4(book.bookEquity()); // task-1530: the book before the fill (the trade ledger's % of book)
438
469
  book.addTranche(p, f);
439
- ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, ...fillMcaps(fillView(f), k), bookUsd });
470
+ ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, ...fillMcaps(fillView(f), k), bookUsd, ...sigTag(f) });
440
471
  say(`ADD ${k.symbol}: $${f.usd.toFixed(2)} at ${lvl(f.price, k)}`);
441
472
  continue;
442
473
  }
443
474
  const prof = s.exits.profiles[q.profile];
444
475
  const bookUsd = round4(book.bookEquity()); // task-1530: the book before the fill (the trade ledger's % of book)
445
476
  const p = book.openPosition(k, f, prof, q.sessionId, q.trigger, q.ladder);
446
- ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, profile: prof.id, ...fillMcaps(fillView(f), k), bookUsd, ...(q.ctx?.proposalId ? { proposalId: q.ctx.proposalId } : {}) });
477
+ ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, profile: prof.id, ...fillMcaps(fillView(f), k), bookUsd, ...(q.ctx?.proposalId ? { proposalId: q.ctx.proposalId } : {}), ...sigTag(f) });
447
478
  say(`BUY ${k.symbol} [${prof.id}]: $${f.usd.toFixed(2)} at ${lvl(f.price, k)}${f.partial ? ' (PARTIAL: impact cap)' : ''} · liq $${Math.round(k.liq ?? 0)}`);
448
479
  const c = cands.get(q.mint);
449
480
  if (c) {
@@ -525,18 +556,25 @@ export async function runAutotrade(o) {
525
556
  sessions: live.map((c) => ({ id: c.session.id, kind: c.sessionKind, mint: c.mint, symbol: c.symbol, turns: c.turns, costUsd: round4(c.session.costUsd()), budgetUsd: gate.caps.budgetUsdPerSession, lastDecision: c.lastDecision })),
526
557
  positions: [...book.open.values()].map((p) => {
527
558
  const pnl = liquidationMark(p) + p.realizedUsd - p.costUsd;
528
- return { mint: p.mint, symbol: p.symbol, profile: p.profileId, costUsd: round2(p.costUsd), pnlUsd: round2(pnl), pnlPct: p.costUsd ? Math.round((pnl / p.costUsd) * 1000) / 10 : 0, next: p.stuck ? 'stuck: retried every tick' : null, ...(p.costRecovered ? { freeBag: true } : {}) };
559
+ return { mint: p.mint, symbol: p.symbol, profile: p.profileId, costUsd: round2(p.costUsd), pnlUsd: round2(pnl), pnlPct: p.costUsd ? Math.round((pnl / p.costUsd) * 1000) / 10 : 0, next: p.stuck ? 'stuck: retried every tick' : null, ...(p.costRecovered ? { freeBag: true } : {}), ...(o.real ? { confirm: o.real.confirmOf(p.mint) ?? 'unconfirmed' } : {}) };
529
560
  }),
530
561
  ledgerTail: ledger.events.slice(-12).map((e) => ({ seq: e.seq, t: e.t, kind: e.kind, text: tailText(e) })),
531
562
  ledgerHead: ledger.head(),
532
563
  events: ledger.events.length,
564
+ ...(o.real ? { real: o.real.status() } : {}),
533
565
  };
534
566
  };
535
567
  const loop = tapeTicks ? fromArray(tapeTicks) : o.source.ticks;
536
568
  for await (const k of loop) {
537
569
  now = k.t;
538
570
  const v = o.safety?.() ?? null;
539
- const haltWhy = v?.halt ?? (o.killSwitch?.() ? 'kill-switch' : null);
571
+ // task-1645: receipts land in the ledger as data; the real-mode kill switch halts like the panic file.
572
+ const rs = o.real?.step() ?? null;
573
+ for (const r of rs?.receipts ?? []) {
574
+ const dv = r.divergence;
575
+ ledger.add(now, 'receipt', { tag: 'real-signal', nonce: r.nonce, signalId: r.signalId, status: r.status, fill: r.fill, reason: r.reason, executor: r.executor, ...(dv ? { divergence: dv } : {}) });
576
+ }
577
+ const haltWhy = v?.halt ?? (o.killSwitch?.() ? 'kill-switch' : null) ?? rs?.halt ?? null;
540
578
  if (!killed && haltWhy) {
541
579
  killed = true;
542
580
  haltReason = haltWhy;
@@ -609,7 +647,10 @@ export async function runAutotrade(o) {
609
647
  }
610
648
  }
611
649
  else if (pos) {
612
- const prof = s.exits.profiles[pos.profileId];
650
+ const prof0 = s.exits.profiles[pos.profileId];
651
+ // task-1654: a real run's (or a paper scope's) maxHoldMinutes limit tightens the profile's holding cap (never loosens it).
652
+ const holdCap = o.real?.maxHoldMinutes?.() ?? o.maxHoldMinutes?.() ?? null;
653
+ const prof = holdCap !== null && (prof0.maxHoldMinutes == null || holdCap < prof0.maxHoldMinutes) ? { ...prof0, maxHoldMinutes: holdCap } : prof0;
613
654
  const acts = monitorTick(pos, prof, k, { minExitLiquidityUsd: x.minExitLiquidityUsd, sellUsd: (tok) => { const f = simSell(k, tok, x, 'estimate'); return 'refused' in f ? 0 : f.usd; } });
614
655
  const wakes = [];
615
656
  for (const a of acts) {
@@ -746,6 +787,14 @@ export async function runAutotrade(o) {
746
787
  while (pumps.size)
747
788
  await Promise.allSettled([...pumps]);
748
789
  const openAtEnd = [...book.open.values()].map((p) => ({ mint: p.mint, symbol: p.symbol, markUsd: round2(liquidationMark(p)), costUsd: p.costUsd, stuck: p.stuck }));
790
+ if (o.real) {
791
+ // The dead-man's graceful half: the executor hears the run is over (its outstanding nonces may be cancelled).
792
+ const why = killed ? `halt:${haltReason ?? 'kill'}` : o.tape ? 'end-of-tape' : 'runner-stopped';
793
+ // r2: what is still open is carried (held.json): the next real run of this strategy adopts it and keeps its exits.
794
+ o.real.carry([...book.open.values()]);
795
+ o.real.sendHalted(why);
796
+ ledger.add(now, 'real_halted', { tag: 'real-signal', reason: why, openAtEnd: openAtEnd.length });
797
+ }
749
798
  ledger.add(now, 'run_end', { trades: book.closed.length, openAtEnd: openAtEnd.length, cashUsd: book.state.cashUsd, markedEquityUsd: book.markedEquity(), sessionCostUsd: sessionCost, sessions: sessionsOpened, positionSessions: positionSessionsOpened, head: ledger.head() });
750
799
  publishFocus();
751
800
  if (o.onState)
@@ -772,6 +821,8 @@ export function exitPlanLine(p) {
772
821
  return `${p.id}: ${parts.join(', ')}`;
773
822
  }
774
823
  const round4 = (v) => Math.round(v * 1e4) / 1e4;
824
+ /** task-1645: a fill that went out as a trade-fire signal: the ledger row is tagged and names the signal. */
825
+ const sigTag = (f) => { const x = f; const g = x.signal; return g ? { tag: 'real-signal', nonce: g.nonce, signalId: g.id, ...(x.limitClamps?.length ? { limitClamps: x.limitClamps } : {}) } : {}; };
775
826
  const fillView = (f) => ({ side: f.side, usd: f.usd, tokens: Number(f.tokens.toPrecision(8)), price: f.price, avgPrice: f.avgPrice, impactPct: f.impactPct, partial: f.partial, reason: f.reason });
776
827
  /** Infinity (no runner cap) is written as null so the ledger stays JSON. */
777
828
  /** The model a session actually runs (a run's --model override wins over the strategy's), for the ledger. */