dexterm-cli 0.2.0 → 0.2.2

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 (157) hide show
  1. package/dist/packages/agents/src/spectator.js +28 -2
  2. package/dist/packages/agents/src/tiers.js +3 -0
  3. package/dist/packages/agents/src/tools.js +3 -0
  4. package/dist/packages/autotrade/src/backtest.js +6 -2
  5. package/dist/packages/autotrade/src/chat/connector.js +5 -4
  6. package/dist/packages/autotrade/src/chat/control.js +3 -0
  7. package/dist/packages/autotrade/src/chat/hub.js +37 -2
  8. package/dist/packages/autotrade/src/chat/index.js +3 -2
  9. package/dist/packages/autotrade/src/chat/readonly.js +530 -0
  10. package/dist/packages/autotrade/src/chat/service.js +12 -1
  11. package/dist/packages/autotrade/src/engine.js +75 -29
  12. package/dist/packages/autotrade/src/fleet.js +2 -2
  13. package/dist/packages/autotrade/src/index.js +4 -0
  14. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  15. package/dist/packages/autotrade/src/rugproof.js +231 -0
  16. package/dist/packages/autotrade/src/runner.js +203 -30
  17. package/dist/packages/autotrade/src/service-install.js +6 -4
  18. package/dist/packages/autotrade/src/sessions.js +16 -7
  19. package/dist/packages/autotrade/src/store.js +1 -1
  20. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  21. package/dist/packages/autotrade/src/strategy.js +29 -1
  22. package/dist/packages/autotrade/src/tiers.js +194 -0
  23. package/dist/packages/classifier/src/calibrate.js +138 -0
  24. package/dist/packages/classifier/src/classify.js +107 -0
  25. package/dist/packages/classifier/src/config.js +298 -0
  26. package/dist/packages/classifier/src/dossier.js +95 -0
  27. package/dist/packages/classifier/src/hardware.js +63 -0
  28. package/dist/packages/classifier/src/index.js +19 -0
  29. package/dist/packages/classifier/src/models.js +111 -0
  30. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  31. package/dist/packages/classifier/src/providers/http.js +63 -0
  32. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  33. package/dist/packages/classifier/src/providers/llm.js +89 -0
  34. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  35. package/dist/packages/classifier/src/questions.js +97 -0
  36. package/dist/packages/classifier/src/registry.js +42 -0
  37. package/dist/packages/classifier/src/snapshot.js +31 -0
  38. package/dist/packages/classifier/src/systemone.js +88 -0
  39. package/dist/packages/classifier/src/types.js +18 -0
  40. package/dist/packages/classifier/src/win-acl.js +71 -0
  41. package/dist/packages/cli/assets/dash/dash.css +139 -0
  42. package/dist/packages/cli/assets/dash/dash.js +200 -0
  43. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  44. package/dist/packages/cli/assets/dash/index.html +36 -0
  45. package/dist/packages/cli/src/app-help-gen.js +13 -1
  46. package/dist/packages/cli/src/autotrade-cmd/fleet.js +12 -3
  47. package/dist/packages/cli/src/autotrade-cmd/run.js +14 -2
  48. package/dist/packages/cli/src/autotrade-cmd/service.js +3 -1
  49. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  50. package/dist/packages/cli/src/autotrade-cmd/strategies.js +1 -1
  51. package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +2 -0
  52. package/dist/packages/cli/src/classifier-cmd.js +361 -0
  53. package/dist/packages/cli/src/classifier-live.js +81 -0
  54. package/dist/packages/cli/src/dash/data.js +217 -0
  55. package/dist/packages/cli/src/dash/server.js +92 -0
  56. package/dist/packages/cli/src/dash-cmd.js +113 -0
  57. package/dist/packages/cli/src/doctor.js +34 -0
  58. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  59. package/dist/packages/cli/src/live/signals.js +8 -0
  60. package/dist/packages/cli/src/main.js +98 -6
  61. package/dist/packages/cli/src/mcp-follow.js +21 -1
  62. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  63. package/dist/packages/cli/src/narratives.js +114 -0
  64. package/dist/packages/cli/src/overlay/model.js +147 -0
  65. package/dist/packages/cli/src/overlay/page.js +146 -0
  66. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  67. package/dist/packages/cli/src/overlay/server.js +170 -0
  68. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  69. package/dist/packages/cli/src/record-cmd.js +82 -11
  70. package/dist/packages/cli/src/rules-cmd.js +2 -1
  71. package/dist/packages/cli/src/rules.js +24 -4
  72. package/dist/packages/cli/src/screens/narrative.js +154 -2
  73. package/dist/packages/cli/src/skill-eval-build.js +7 -2
  74. package/dist/packages/cli/src/state.js +1 -0
  75. package/dist/packages/cli/src/version.js +1 -1
  76. package/dist/packages/custom/src/rules.js +5 -1
  77. package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +1 -1
  78. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  79. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  80. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  81. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-confidence-tiers-experimental-unproven-paper-only.md +56 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  85. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  87. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  88. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  90. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  92. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  93. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  94. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  95. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  96. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +36 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  98. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  105. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  107. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  109. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  110. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  111. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  112. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  113. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  114. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  117. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  119. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  120. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  121. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  122. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  123. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  124. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  125. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  126. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  127. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  128. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  129. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  130. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  131. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  132. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  133. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  134. package/dist/packages/mcp/assets/kb/docs/docs-telegram-read-only-data-check-top-and-signals-experimental.md +48 -0
  135. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +3 -1
  136. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +1 -1
  137. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  138. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  139. package/dist/packages/mcp/src/server.js +106 -1
  140. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  141. package/dist/packages/narratives/src/cluster.js +193 -0
  142. package/dist/packages/narratives/src/embed.js +184 -0
  143. package/dist/packages/narratives/src/export.js +60 -0
  144. package/dist/packages/narratives/src/index.js +10 -0
  145. package/dist/packages/narratives/src/label.js +48 -0
  146. package/dist/packages/narratives/src/normalize.js +179 -0
  147. package/dist/packages/narratives/src/recorder.js +93 -0
  148. package/dist/packages/narratives/src/tracker.js +328 -0
  149. package/dist/packages/packs/src/skill-care.js +10 -2
  150. package/dist/packages/recorder/src/exit-probes.js +215 -0
  151. package/dist/packages/recorder/src/index.js +1 -0
  152. package/dist/packages/recorder/src/recorder.js +11 -1
  153. package/dist/packages/recorder/src/retention.js +4 -0
  154. package/dist/packages/recorder/src/schema.js +20 -1
  155. package/dist/packages/tui/src/keymap.js +5 -0
  156. package/package.json +3 -1
  157. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -0,0 +1,361 @@
1
+ // task-1822: `dexterm classifier …`: pick and manage the classification model (Kev local, hosted Jev with your own
2
+ // key, JEV students, LLM baselines, or the built-in heuristic). Outputs are model estimates, never advice.
3
+ // Nothing downloads without a typed yes (or --yes) for that model; nothing calls the network without a key/consent.
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import readline from 'node:readline';
8
+ import { CATALOG, COIN_RISK, EXTERNAL_MODELS, PROVIDERS, PROVIDER_IDS, catalogEntry, classify, classifyBatch, coinDossier, describeHardware, describeResult, detectHardware, dextermHome, fits, freeBytes, fromReasonRunner, gb, isInstalled, loadClassifierConfig, modelPath, modelsDir, providerFromConfig, pullModel, readTypesafeKey, recommendTier, removeTypesafeKey, saveClassifierConfig, serveArgv, typesafeKeyRefusal, typesafeKeyState, writeTypesafeKey, } from '../../classifier/src/index.js';
9
+ import { spawnClaudeRunner } from '../../packs/src/index.js';
10
+ export const CLASSIFIER_USAGE = `dexterm classifier [<command>]
11
+ list providers, local models (installed / size / licence / fits this machine) and the current pick
12
+ pick <provider> [model] heuristic | kev-local [kev-0.8b-q8|kev-4b-q4|…] | jev-hosted | jev-open | claude | ollama-llm
13
+ pull <model> [--yes] download a Kev GGUF (shows size + source, asks first, checks disk, verifies sha256)
14
+ serve [--model M] [--port P] [--threads N] run llama-server for the picked Kev model on 127.0.0.1 (foreground)
15
+ doctor [--json] hardware (RAM, CPU, GPU/VRAM), the recommended tier, provider readiness
16
+ bench [--n 20] time the current provider on N synthetic coins (latency per coin, per 1,000)
17
+ try classify one example coin with the current provider (shows the labelled estimate)
18
+ key set|clear|status your own TypeSafe API key for hosted Jev (stored owner-only under ~/.dexterm/keys, never printed)
19
+ Every answer is a model estimate with the model id and a calibration note; never advice. Docs: /docs/classifier/`;
20
+ function flags(argv, bools) {
21
+ const pos = [];
22
+ const opts = {};
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (a.startsWith('--')) {
26
+ const k = a.slice(2);
27
+ if (bools.includes(k) || i + 1 >= argv.length)
28
+ opts[k] = true;
29
+ else
30
+ opts[k] = argv[++i];
31
+ }
32
+ else
33
+ pos.push(a);
34
+ }
35
+ return { pos, opts };
36
+ }
37
+ const keyLine = (s) => s.source === 'refused' ? 'refused (dexterm classifier key status says why; TYPESAFE_API_KEY works instead)' : s.source === 'none' ? 'none (hosted Jev is optional)' : 'configured';
38
+ async function ask(io, question) {
39
+ if (!io.stdin.isTTY)
40
+ return '';
41
+ const rl = readline.createInterface({ input: io.stdin, output: io.stdout });
42
+ try {
43
+ return await new Promise((r) => rl.question(question, r));
44
+ }
45
+ finally {
46
+ rl.close();
47
+ }
48
+ }
49
+ /** Read a secret from the TTY without echoing it. Non-TTY: read one line from stdin (for `… | dexterm classifier key set`). */
50
+ async function readSecret(io, prompt) {
51
+ if (!io.stdin.isTTY) {
52
+ let s = '';
53
+ for await (const c of io.stdin) {
54
+ s += c.toString();
55
+ if (s.includes('\n'))
56
+ break;
57
+ }
58
+ return s.split('\n')[0];
59
+ }
60
+ io.stdout.write(prompt);
61
+ const stdin = io.stdin;
62
+ stdin.setRawMode?.(true);
63
+ stdin.resume();
64
+ return await new Promise((resolve) => {
65
+ let s = '';
66
+ const on = (b) => {
67
+ for (const ch of b.toString('utf8')) {
68
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
69
+ stdin.off('data', on);
70
+ stdin.setRawMode?.(false);
71
+ stdin.pause();
72
+ io.stdout.write('\n');
73
+ resolve(s);
74
+ return;
75
+ }
76
+ if (ch === '\u0003') {
77
+ stdin.off('data', on);
78
+ stdin.setRawMode?.(false);
79
+ stdin.pause();
80
+ io.stdout.write('\n');
81
+ resolve('');
82
+ return;
83
+ }
84
+ if (ch === '\u007f')
85
+ s = s.slice(0, -1);
86
+ else
87
+ s += ch;
88
+ }
89
+ };
90
+ stdin.on('data', on);
91
+ });
92
+ }
93
+ function deps(c, io) {
94
+ return { env: io.env, fetch: io.fetch, claudeRunner: fromReasonRunner(spawnClaudeRunner, c.llm.claudeModel) };
95
+ }
96
+ /** Where llama-server is: $DEXTERM_LLAMA_SERVER, else on PATH. */
97
+ export function llamaServerBin(env) {
98
+ if (env.DEXTERM_LLAMA_SERVER && fs.existsSync(env.DEXTERM_LLAMA_SERVER))
99
+ return env.DEXTERM_LLAMA_SERVER;
100
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
101
+ const p = path.join(dir, 'llama-server');
102
+ try {
103
+ fs.accessSync(p, fs.constants.X_OK);
104
+ return p;
105
+ }
106
+ catch { /* next */ }
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * Does this llama-server serve decision models (`/v1/systemone`, llama.cpp PR #29818, 2026-10-02)? Older builds (e.g.
112
+ * Homebrew's 0.4.1) start fine and then 404 every request, so `serve` and `doctor` check the help text first.
113
+ */
114
+ export async function llamaSupportsDecisions(bin, exec) {
115
+ const run = exec ?? ((cmd, args) => new Promise((resolve) => {
116
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
117
+ let out = '';
118
+ const t = setTimeout(() => child.kill('SIGKILL'), 8000);
119
+ child.stdout.on('data', (d) => (out += d));
120
+ child.stderr.on('data', (d) => (out += d));
121
+ child.on('error', () => { clearTimeout(t); resolve(''); });
122
+ child.on('close', () => { clearTimeout(t); resolve(out); });
123
+ }));
124
+ try {
125
+ return /decision/i.test(await run(bin, ['--help']));
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ const LLAMA_TOO_OLD = 'this llama-server has no /v1/systemone (decision models need a llama.cpp build from 2026-10-02 or later): update llama.cpp, or point DEXTERM_LLAMA_SERVER at a newer llama-server';
132
+ export async function classifierCommand(argv, io) {
133
+ const out = (s) => io.stdout.write(s + '\n');
134
+ const err = (s) => io.stderr.write(s + '\n');
135
+ const loaded = loadClassifierConfig(io.env);
136
+ for (const e of loaded.errors)
137
+ err(`classifier.json: ${e}`);
138
+ const c = loaded.config;
139
+ const home = dextermHome(io.env);
140
+ const sub = argv[0] ?? 'list';
141
+ const { pos, opts } = flags(argv.slice(1), ['yes', 'json']);
142
+ if (sub === 'help' || sub === '--help' || sub === '-h') {
143
+ out(CLASSIFIER_USAGE);
144
+ return 0;
145
+ }
146
+ if (sub === 'list') {
147
+ const hw = await detectHardware({ exec: io.exec });
148
+ out(`current: ${c.provider}${c.provider === 'kev-local' ? ` (${c.kev.model})` : ''} · shipped default model: ${c.default}`);
149
+ out('\nproviders:');
150
+ for (const p of PROVIDERS)
151
+ out(` ${p.id.padEnd(11)} ${p.title} [network: ${p.network}; needs ${p.needs}]`);
152
+ out('\nlocal models (llama.cpp GGUF, pinned revisions, sha256-verified):');
153
+ for (const e of CATALOG) {
154
+ const inst = isInstalled(home, e) ? 'installed' : 'not downloaded';
155
+ out(` ${e.id.padEnd(12)} ${gb(e.bytes).padStart(7)} ${e.license} ${inst.padEnd(14)} ${fits(e.id, hw) ? 'fits this machine' : 'too big for this machine'} (${e.authorNote})`);
156
+ }
157
+ out('\nlisted, not pulled by dexterm:');
158
+ for (const x of EXTERNAL_MODELS)
159
+ out(` ${x.id.padEnd(12)} ${x.repo} (${x.license}): ${x.note}`);
160
+ return 0;
161
+ }
162
+ if (sub === 'pick') {
163
+ const id = pos[0];
164
+ if (!id || !PROVIDER_IDS.includes(id)) {
165
+ err(`pick one of: ${PROVIDER_IDS.join(', ')}`);
166
+ return 2;
167
+ }
168
+ const next = { ...c, provider: id };
169
+ if (id === 'kev-local') {
170
+ const model = pos[1] ?? c.kev.model;
171
+ try {
172
+ catalogEntry(model);
173
+ }
174
+ catch (e) {
175
+ err(e.message);
176
+ return 2;
177
+ }
178
+ next.kev = { ...c.kev, model };
179
+ if (!isInstalled(home, catalogEntry(model)))
180
+ out(`note: ${model} is not downloaded yet: dexterm classifier pull ${model}. Until it is served, answers fall back to the heuristic.`);
181
+ }
182
+ if (id === 'jev-hosted' && !readTypesafeKey(io.env, { platform: io.platform, acl: io.acl }))
183
+ out('note: no TypeSafe key yet: dexterm classifier key set. Until then answers fall back to the heuristic.');
184
+ const p = saveClassifierConfig(next, io.env);
185
+ out(`classifier.provider = ${id}${id === 'kev-local' ? ` (${next.kev.model})` : ''} (${p})`);
186
+ return 0;
187
+ }
188
+ if (sub === 'pull') {
189
+ const id = pos[0] ?? c.kev.model;
190
+ let e;
191
+ try {
192
+ e = catalogEntry(id);
193
+ }
194
+ catch (x) {
195
+ err(x.message);
196
+ return 2;
197
+ }
198
+ if (isInstalled(home, e)) {
199
+ out(`${e.id} is already installed: ${modelPath(home, e)}`);
200
+ return 0;
201
+ }
202
+ const free = (io.free ?? freeBytes)(modelsDir(home));
203
+ out(`${e.title}: ${gb(e.bytes)} from huggingface.co/${e.repo} (revision ${e.revision.slice(0, 8)}, ${e.license}, base ${e.base})`);
204
+ out(`to ${modelsDir(home)} · ${gb(free)} free · sha256 ${e.sha256.slice(0, 16)}… checked before use`);
205
+ let yes = opts.yes === true;
206
+ if (!yes) {
207
+ const a = (await ask(io, 'Download it now? Type yes to continue: ')).trim().toLowerCase();
208
+ yes = a === 'yes';
209
+ }
210
+ if (!yes) {
211
+ out('not downloaded (no consent). Re-run with --yes to skip the question.');
212
+ return 1;
213
+ }
214
+ let lastPct = -1;
215
+ try {
216
+ const r = await pullModel(e.id, { home, consent: true, fetch: io.pullFetch, free: io.free, onProgress: (d, t) => { const pct = Math.floor((d / t) * 20) * 5; if (pct !== lastPct) {
217
+ lastPct = pct;
218
+ io.stdout.write(`\r ${pct}%`);
219
+ } } });
220
+ io.stdout.write('\n');
221
+ saveClassifierConfig({ ...c, consent: { ...c.consent, [e.id]: new Date().toISOString() } }, io.env);
222
+ out(`verified ${r.sha256.slice(0, 16)}… → ${r.path}`);
223
+ out(`next: dexterm classifier pick kev-local ${e.id} && dexterm classifier serve`);
224
+ return 0;
225
+ }
226
+ catch (x) {
227
+ io.stdout.write('\n');
228
+ err(`pull failed: ${x.message}`);
229
+ return 1;
230
+ }
231
+ }
232
+ if (sub === 'serve') {
233
+ const id = typeof opts.model === 'string' ? opts.model : c.kev.model;
234
+ let e;
235
+ try {
236
+ e = catalogEntry(id);
237
+ }
238
+ catch (x) {
239
+ err(x.message);
240
+ return 2;
241
+ }
242
+ if (!isInstalled(home, e)) {
243
+ err(`${id} is not downloaded: dexterm classifier pull ${id}`);
244
+ return 1;
245
+ }
246
+ const bin = llamaServerBin(io.env);
247
+ if (!bin) {
248
+ err('llama-server not found. Install llama.cpp (a build with /v1/systemone, 2026-10-02 or later) or set DEXTERM_LLAMA_SERVER.');
249
+ return 1;
250
+ }
251
+ if (!(await llamaSupportsDecisions(bin, io.exec))) {
252
+ err(LLAMA_TOO_OLD);
253
+ return 1;
254
+ }
255
+ const port = Number(typeof opts.port === 'string' ? opts.port : new URL(c.kev.endpoint).port || 8031);
256
+ const threads = typeof opts.threads === 'string' ? Number(opts.threads) : c.kev.threads ?? undefined;
257
+ const args = serveArgv(e, modelPath(home, e), port, threads);
258
+ out(`serving ${e.id} on http://127.0.0.1:${port} (Ctrl+C to stop): ${path.basename(bin)} ${args.join(' ')}`);
259
+ return await new Promise((resolve) => {
260
+ const child = spawn(bin, args, { stdio: 'inherit' });
261
+ child.on('error', (x) => { err(`llama-server: ${x.message}`); resolve(1); });
262
+ child.on('close', (code) => resolve(code ?? 0));
263
+ });
264
+ }
265
+ if (sub === 'doctor' || sub === 'status') {
266
+ const hw = await detectHardware({ exec: io.exec });
267
+ const rec = recommendTier(hw);
268
+ let provStatus;
269
+ try {
270
+ provStatus = await providerFromConfig(c, c.provider, deps(c, io)).status();
271
+ }
272
+ catch (x) {
273
+ provStatus = { ready: false, detail: x.message };
274
+ }
275
+ const llama = llamaServerBin(io.env);
276
+ const llamaOk = llama ? await llamaSupportsDecisions(llama, io.exec) : false;
277
+ if (opts.json === true) {
278
+ out(JSON.stringify({ hardware: hw, recommendation: rec, provider: c.provider, model: c.provider === 'kev-local' ? c.kev.model : null, ready: provStatus.ready, detail: provStatus.detail, llamaServer: Boolean(llama), llamaServerDecisions: llamaOk, typesafeKey: Boolean(readTypesafeKey(io.env, { platform: io.platform, acl: io.acl })) }, null, 2));
279
+ return 0;
280
+ }
281
+ out(`hardware: ${describeHardware(hw)}`);
282
+ out(`recommended: ${rec.model ?? 'the built-in heuristic'}${rec.bestQuality ? ` (best quality here: ${rec.bestQuality})` : ''}: ${rec.why}`);
283
+ out(`provider: ${c.provider}${c.provider === 'kev-local' ? ` (${c.kev.model})` : ''}: ${provStatus.ready ? 'ready' : 'not ready'}: ${provStatus.detail}`);
284
+ out(`llama-server: ${!llama ? 'not found (needed for kev-local)' : llamaOk ? 'found, serves decision models' : `found but too old: ${LLAMA_TOO_OLD}`} · TypeSafe key: ${keyLine(typesafeKeyState(io.env, { platform: io.platform, acl: io.acl, fresh: true }))}`);
285
+ if (c.fallback)
286
+ out('fallback: the heuristic answers when the provider is unreachable (labelled as a fallback)');
287
+ return 0;
288
+ }
289
+ if (sub === 'bench') {
290
+ const n = Math.max(1, Math.min(500, Number(opts.n ?? 20) || 20));
291
+ let provider;
292
+ try {
293
+ provider = providerFromConfig(c, c.provider, deps(c, io));
294
+ }
295
+ catch (x) {
296
+ err(x.message);
297
+ return 1;
298
+ }
299
+ const coins = Array.from({ length: n }, (_, i) => coinDossier({ mint: `Bench${String(i).padStart(4, '0')}`.padEnd(32, '1'), symbol: `B${i}`, age_min: 5 + i * 7, liquidity_usd: 2000 + i * 1500, mcap_usd: 9000 + i * 7000, holders: 50 + i * 20, top10_pct: 10 + (i % 40), mint_authority: i % 9 === 0, freeze_authority: i % 13 === 0 }));
300
+ const r = await classifyBatch(provider, COIN_RISK, coins, { fallback: null });
301
+ const ok = r.results.filter((x) => !('error' in x));
302
+ const per = ok.length ? r.ms / ok.length : NaN;
303
+ out(`${provider.id} ${provider.model()}: ${ok.length}/${n} answered in ${(r.ms / 1000).toFixed(1)} s · ${per.toFixed(0)} ms per coin · ~${((per * 1000) / 60_000).toFixed(1)} min per 1,000 coins (one at a time)`);
304
+ const failed = r.results.find((x) => 'error' in x);
305
+ if (failed)
306
+ err(`first failure: ${failed.error}`);
307
+ return ok.length ? 0 : 1;
308
+ }
309
+ if (sub === 'key') {
310
+ const action = pos[0] ?? 'status';
311
+ const keyOpts = { platform: io.platform, acl: io.acl };
312
+ if (action === 'status') {
313
+ const s = typesafeKeyState(io.env, { ...keyOpts, fresh: true });
314
+ if (s.source === 'refused') {
315
+ out('TypeSafe key: refused');
316
+ err(typesafeKeyRefusal(s));
317
+ return 1;
318
+ }
319
+ out(s.source === 'env' ? 'TypeSafe key: configured from TYPESAFE_API_KEY (not shown)' : s.source === 'file' ? 'TypeSafe key: configured (not shown)' : 'TypeSafe key: none');
320
+ return 0;
321
+ }
322
+ if (action === 'clear') {
323
+ out(removeTypesafeKey(io.env) ? 'TypeSafe key removed' : 'no key file to remove');
324
+ return 0;
325
+ }
326
+ if (action === 'set') {
327
+ const k = await readSecret(io, 'Paste your TypeSafe API key (input hidden): ');
328
+ if (!k.trim()) {
329
+ err('no key entered');
330
+ return 1;
331
+ }
332
+ try {
333
+ const p = writeTypesafeKey(k, io.env, keyOpts);
334
+ out(`saved to ${p} (${(io.platform ?? process.platform) === 'win32' ? 'owner-only Windows access list' : '0600'}). It is only sent to ${c.jev.endpoint}.`);
335
+ return 0;
336
+ }
337
+ catch (x) {
338
+ err(x.message);
339
+ return 1;
340
+ }
341
+ }
342
+ err('key set|clear|status');
343
+ return 2;
344
+ }
345
+ if (sub === 'try') {
346
+ // A one-off classification of a hand-written coin (no network unless the picked provider is remote).
347
+ let provider;
348
+ try {
349
+ provider = providerFromConfig(c, c.provider, deps(c, io));
350
+ }
351
+ catch (x) {
352
+ err(x.message);
353
+ return 1;
354
+ }
355
+ const r = await classify(provider, COIN_RISK, coinDossier({ mint: 'Example11111111111111111111111111', symbol: 'EXMPL', liquidity_usd: 12000, mcap_usd: 40000, age_min: 20, holders: 300, top10_pct: 35, freeze_authority: false, mint_authority: false }), { fallback: c.fallback ? undefined : null });
356
+ out(describeResult(r));
357
+ return 0;
358
+ }
359
+ err(`unknown classifier command: ${sub}\n${CLASSIFIER_USAGE}`);
360
+ return 2;
361
+ }
@@ -0,0 +1,81 @@
1
+ // task-1822: keep `state.classifierByMint` filled for the `rugp` watch-rule field. It does nothing (no model call,
2
+ // no network) unless an ENABLED rule mentions `rugp`. Then, each rules tick:
3
+ // - provider `heuristic`: every rule row is scored at once (local arithmetic, H1 p(rug)).
4
+ // - any other provider: one coin at a time in the background, each re-scored at most every RESCORE_MS, the open
5
+ // token first; a failure falls back to the heuristic (labelled by the provider id stored with the estimate).
6
+ // Estimates are model output for alerts only: nothing here trades, sizes, arms or writes config.
7
+ export const RESCORE_MS = 10 * 60_000;
8
+ const LIVE = new WeakMap();
9
+ export function rulesUseRugp(state) {
10
+ return !!state.rulesUi?.engine.rules.some((r) => r.enabled && /\brugp\b/i.test(r.whenText));
11
+ }
12
+ /** The dossier fields a rule row carries (the screener row is structurally a RuleRow). */
13
+ export function coinInputFromRuleRow(r) {
14
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
15
+ const b = (v) => (v === 1 ? true : v === 0 ? false : null);
16
+ return {
17
+ mint: r.mint, symbol: r.symbol ?? null, age_min: n(r.ageMin), liquidity_usd: n(r.liq), mcap_usd: n(r.mcap),
18
+ vol5m_usd: n(r.vol5m), vol1h_usd: n(r.vol1h), holders: n(r.holders), top10_pct: n(r.top10), dev_pct: n(r.dev),
19
+ organic_score: n(r.org), change5m_pct: n(r.chg?.m5), change1h_pct: n(r.chg?.h1), buys1h: n(r.buys), sells1h: n(r.sells),
20
+ mint_authority: b(r.mintable), freeze_authority: b(r.freezable), launchpad: r.launchpad ?? null,
21
+ };
22
+ }
23
+ async function load(state, live, env) {
24
+ const C = await import('../../classifier/src/index.js');
25
+ const cfg = C.loadClassifierConfig(env).config;
26
+ live.fallback = cfg.fallback;
27
+ live.calibration = cfg.calibration;
28
+ try {
29
+ live.provider = C.providerFromConfig(cfg, cfg.provider, { env });
30
+ }
31
+ catch {
32
+ live.provider = C.heuristicProvider();
33
+ }
34
+ void state;
35
+ }
36
+ /** One step; call from the rules tick with the rows it is about to evaluate. Never throws. */
37
+ export function classifierLiveTick(state, rows, now, env = process.env) {
38
+ if (!rows.length || !rulesUseRugp(state))
39
+ return;
40
+ let live = LIVE.get(state);
41
+ if (!live) {
42
+ live = { provider: null, loading: null, inflight: false, fallback: true, calibration: {} };
43
+ LIVE.set(state, live);
44
+ }
45
+ if (!live.provider) {
46
+ live.loading ??= load(state, live, env).catch(() => { live.loading = null; });
47
+ return;
48
+ }
49
+ const map = (state.classifierByMint ??= new Map());
50
+ const due = (m) => { const e = map.get(m); return !e || now - e.at >= RESCORE_MS; };
51
+ const provider = live.provider;
52
+ if (live.inflight)
53
+ return;
54
+ live.inflight = true;
55
+ const open = state.live?.token?.view.mint ?? null;
56
+ const queue = rows.filter((r) => due(r.mint)).sort((a, b) => (a.mint === open ? -1 : b.mint === open ? 1 : 0));
57
+ const batch = provider.id === 'heuristic' ? queue : queue.slice(0, 1);
58
+ if (!batch.length) {
59
+ live.inflight = false;
60
+ return;
61
+ }
62
+ void (async () => {
63
+ try {
64
+ const C = await import('../../classifier/src/index.js');
65
+ for (const r of batch) {
66
+ try {
67
+ const res = await C.classify(provider, C.COIN_RISK, C.coinDossier(coinInputFromRuleRow(r)), { calibration: live.calibration, fallback: live.fallback ? undefined : null });
68
+ const p = res.answers.drain_6h?.probs.yes;
69
+ if (typeof p === 'number')
70
+ map.set(r.mint, { p, model: `${res.provider} ${res.model}`, at: now });
71
+ }
72
+ catch {
73
+ map.set(r.mint, { p: NaN, model: 'error', at: now });
74
+ }
75
+ }
76
+ }
77
+ finally {
78
+ live.inflight = false;
79
+ }
80
+ })();
81
+ }
@@ -0,0 +1,217 @@
1
+ // `dexterm dash` (task-1749): the DATA behind the strategy performance dashboard. Read-only, local, no network:
2
+ // - every autotrade home given (a DEXTERM_HOME): its run ledgers (autotrade/runs/*.jsonl) through the strategy-stats
3
+ // definitions (@dexterm/autotrade strategy-stats.ts: the challenge control's math), its service heartbeat and the
4
+ // fleet status (open positions with the engine's mark, spend, halts)
5
+ // - the market cache DB (read-only) for the last pool liquidity of an open coin, so an open position shows its
6
+ // REALIZABLE value next to the engine mark, with the age of that pool read; nothing is fetched
7
+ // - optional "books" files (dexterm-dash-books/1): other books (e.g. a SOL challenge run by hand or by an agent),
8
+ // labels, groups, a baseline and status cards, produced by the user's own scripts; their trades go through the
9
+ // same definitions
10
+ // The output never carries an absolute path, a key, a wallet address or a token: homes are named by their label.
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { basename, dirname, join } from 'node:path';
13
+ import { DatabaseSync } from 'node:sqlite';
14
+ import { readStrategyLedgers, strategyRow, equityCurve, compareToBase, decisionRule, realizableUsd, MIN_CLOSED, RUG_PCT, STUCK_WRITE_OFF_MIN, } from '../../../autotrade/src/strategy-stats.js';
15
+ export const PAPER_LABEL = 'Paper trading, not financial advice.';
16
+ export const DASH_SCHEMA = 'dexterm-dash/1';
17
+ export const BOOKS_SCHEMA = 'dexterm-dash-books/1';
18
+ const readJson = (p) => { try {
19
+ return JSON.parse(readFileSync(p, 'utf8'));
20
+ }
21
+ catch {
22
+ return null;
23
+ } };
24
+ const fin = (x) => typeof x === 'number' && Number.isFinite(x);
25
+ const r2 = (x) => Math.round(x * 100) / 100;
26
+ const r1 = (x) => Math.round(x * 10) / 10;
27
+ const alive = (pid) => { if (!fin(pid))
28
+ return false; try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch (e) {
33
+ return e.code === 'EPERM';
34
+ } };
35
+ /** A home's label: the folder that holds `.dexterm` (e.g. c100-control), else the folder's own name. */
36
+ export function homeLabel(dir) { const b = basename(dir); return b === '.dexterm' ? basename(dirname(dir)) || b : b; }
37
+ /** The last cached pool read of each mint (ns ds:token) from the market cache DB, read-only. Missing DB: empty. */
38
+ export function cachedPools(dbPath, mints) {
39
+ const out = new Map();
40
+ if (!dbPath || !mints.length || !existsSync(dbPath))
41
+ return out;
42
+ let db = null;
43
+ try {
44
+ db = new DatabaseSync(dbPath, { readOnly: true });
45
+ const q = db.prepare('SELECT json, fetched_at FROM cache WHERE ns = ? AND key = ?');
46
+ for (const m of new Set(mints)) {
47
+ const r = q.get('ds:token', m);
48
+ if (!r)
49
+ continue;
50
+ try {
51
+ const p = JSON.parse(r.json)?.pair;
52
+ if (fin(p?.liquidityUsd))
53
+ out.set(m, { liqUsd: p.liquidityUsd, at: fin(p.fetchedAt) ? p.fetchedAt : r.fetched_at });
54
+ }
55
+ catch { /* torn row */ }
56
+ }
57
+ }
58
+ catch { /* locked or not a dexterm cache: no pool reads */ }
59
+ finally {
60
+ try {
61
+ db?.close();
62
+ }
63
+ catch { /* closed */ }
64
+ }
65
+ return out;
66
+ }
67
+ /** The service heartbeat + fleet status of one home as a status card (no paths, no pids beyond alive/dead). */
68
+ export function homeStatus(h, now) {
69
+ const at = join(h.dir, 'autotrade');
70
+ const hb = readJson(join(at, 'service', 'heartbeat.json'));
71
+ const fl = readJson(join(at, 'fleet-status.json'));
72
+ const items = [];
73
+ let state = 'idle';
74
+ if (hb) {
75
+ const age = fin(hb.heartbeatAt) ? Math.round((now - hb.heartbeatAt) / 1000) : null;
76
+ const up = hb.state === 'running' && alive(hb.pid) && age !== null && age < 120;
77
+ state = up ? 'ok' : hb.state === 'running' ? 'down' : 'idle';
78
+ items.push(['Service', up ? 'running' : hb.state === 'running' ? 'NOT running (stale heartbeat)' : String(hb.state ?? 'unknown')]);
79
+ if (age !== null)
80
+ items.push(['Heartbeat', `${age < 120 ? `${age} s` : `${Math.round(age / 60)} min`} ago`]);
81
+ if (fin(hb.dataAgeMs))
82
+ items.push(['Market data age', `${Math.round(hb.dataAgeMs / 1000)} s`]);
83
+ }
84
+ else
85
+ items.push(['Service', 'no service heartbeat (not installed or never run)']);
86
+ if (fl) {
87
+ const ms = fl.members ?? [];
88
+ if (fin(fl.startedAt))
89
+ items.push(['Fleet started', new Date(fl.startedAt).toISOString()]);
90
+ if (fl.haltReason) {
91
+ items.push(['Fleet halt', String(fl.haltReason)]);
92
+ if (state === 'ok')
93
+ state = 'warn';
94
+ }
95
+ const running = ms.filter((m) => m.state === 'running').length;
96
+ items.push(['Strategies running', `${running} of ${ms.length}`]);
97
+ const halted = ms.filter((m) => m.haltReason).map((m) => `${m.id}: ${m.haltReason}`);
98
+ if (halted.length) {
99
+ items.push(['Halted', halted.join('; ')]);
100
+ if (state === 'ok')
101
+ state = 'warn';
102
+ }
103
+ const spend = ms.reduce((s, m) => s + (fin(m.spend?.todayUsd) ? m.spend.todayUsd : 0), 0);
104
+ items.push(['Model spend today', `$${spend.toFixed(2)}`]);
105
+ }
106
+ return { title: `Autotrade · ${h.label}`, state, items, updatedAt: fin(hb?.heartbeatAt) ? hb.heartbeatAt : null };
107
+ }
108
+ function openFromFleet(h, now) {
109
+ const fl = readJson(join(h.dir, 'autotrade', 'fleet-status.json'));
110
+ const out = new Map();
111
+ if (!fl?.members)
112
+ return out;
113
+ const mints = fl.members.flatMap((m) => (m.open ?? []).map((o) => o.mint)).filter(Boolean);
114
+ const pools = cachedPools(h.db, mints);
115
+ for (const m of fl.members) {
116
+ out.set(m.id, (m.open ?? []).map((o) => {
117
+ const cost = fin(o.costUsd) ? o.costUsd : null;
118
+ const mark = cost !== null ? cost + (fin(o.pnlUsd) ? o.pnlUsd : 0) : null;
119
+ const pool = o.mint ? pools.get(o.mint) : undefined;
120
+ // a stuck position has no pool to sell into: realizable 0 whatever the last read said
121
+ const rv = o.stuck ? 0 : mark !== null ? realizableUsd(mark, pool?.liqUsd) : null;
122
+ return { symbol: o.symbol ?? null, mint: o.mint ?? null, profile: o.profile ?? null, costUsd: cost, markUsd: mark === null ? null : r2(mark), markPct: fin(o.pnlPct) ? o.pnlPct : null, stuck: !!o.stuck,
123
+ liqUsd: pool ? Math.round(pool.liqUsd) : null, liqAt: pool?.at ?? null, realizableUsd: rv === null ? null : r2(rv), realizablePct: rv === null || !cost ? null : r1((rv / cost - 1) * 100), openedAt: null };
124
+ }));
125
+ }
126
+ return out;
127
+ }
128
+ const rugsOf = (closed) => closed.filter((c) => c.writtenOff || (fin(c.pnlPct) && c.pnlPct <= RUG_PCT) || (c.stuck && !c.writtenOff));
129
+ function fromLedger(id, m, h, fleetOpen, now) {
130
+ const row = strategyRow(id, m, { now });
131
+ // the engine's book is the live truth for what is open; the ledger fold is the fallback when no fleet status exists
132
+ // a stuck bag the engine still holds but the ledger fold already wrote off is CLOSED here (at its realizable value),
133
+ // so it is not listed open a second time
134
+ const heldOff = new Set(m.writtenOff.filter((w) => w.held).map((w) => w.mint));
135
+ const open = fleetOpen ? fleetOpen.filter((o) => !(o.stuck && heldOff.has(o.mint))) : m.openPositions.map((p) => ({ symbol: p.symbol, mint: p.mint, profile: p.profile, costUsd: p.costUsd, markUsd: null, markPct: null, stuck: p.stuckSince !== null, liqUsd: null, liqAt: null, realizableUsd: p.stuckSince !== null ? r2(p.realizedUsd) : null, realizablePct: null, openedAt: p.openedAt }));
136
+ return { id, label: m.basedOn?.version ? `v${m.basedOn.version}` : '', group: h.label, source: 'autotrade', home: h.label, unit: 'USD', mode: m.mode, version: m.basedOn?.version ?? null, baseline: false,
137
+ row, equity: equityCurve(m.closed, m.startUsd), trades: m.closed, open, rugs: rugsOf(m.closed), lastEventAt: m.lastT, firstEventAt: m.firstT };
138
+ }
139
+ function fromBook(b, now) {
140
+ const closed = (b.trades ?? []).filter((t) => fin(t.t) && fin(t.pnlPct)).map((t) => ({ t: t.t, openedAt: t.openedAt ?? null, mint: t.mint ?? null, symbol: t.symbol ?? null, pnlPct: t.pnlPct, pnlUsd: fin(t.pnl) ? t.pnl : fin(t.pnlUsd) ? t.pnlUsd : null,
141
+ costUsd: fin(t.cost) ? t.cost : null, exitReason: t.exitReason ?? null, stuck: !!t.writtenOff, writtenOff: !!t.writtenOff, reason: t.reason, profile: t.profile ?? null, entryAgeMin: fin(t.entryAgeMin) ? t.entryAgeMin : null }))
142
+ .sort((a, b2) => a.t - b2.t);
143
+ const ts = closed.map((c) => c.openedAt ?? c.t);
144
+ const firstT = fin(b.firstT) ? b.firstT : ts.length ? Math.min(...ts) : null;
145
+ const open = (b.open ?? []).map((o) => ({ symbol: o.symbol ?? null, mint: o.mint ?? null, profile: o.profile ?? null, costUsd: fin(o.cost) ? o.cost : null, markUsd: fin(o.mark) ? o.mark : null, markPct: fin(o.markPct) ? o.markPct : null, stuck: !!o.stuck,
146
+ liqUsd: null, liqAt: null, realizableUsd: fin(o.realizable) ? o.realizable : null, realizablePct: fin(o.realizable) && fin(o.cost) && o.cost ? r1((o.realizable / o.cost - 1) * 100) : null, openedAt: o.openedAt ?? null }));
147
+ const m = { entries: fin(b.entries) ? b.entries : closed.length + open.length, closed, open: open.length, writtenOff: closed.filter((c) => c.writtenOff), stuckOpen: [], abandoned: [], adopted: 0, firstT };
148
+ return { id: b.id, label: b.label ?? '', group: b.group ?? 'books', source: 'book', home: b.group ?? 'books', unit: b.unit ?? 'USD', mode: b.mode ?? 'paper', version: b.version ?? null, baseline: false,
149
+ row: strategyRow(b.id, m, { now }), equity: equityCurve(closed, fin(b.start) ? b.start : null), trades: closed, open, rugs: rugsOf(closed),
150
+ lastEventAt: closed.length ? closed[closed.length - 1].t : firstT, firstEventAt: firstT };
151
+ }
152
+ export function readBooksFile(p) {
153
+ const j = readJson(p);
154
+ return j && j.schema === BOOKS_SCHEMA ? j : null;
155
+ }
156
+ /** Everything the dashboard shows, from the homes and books files given. */
157
+ export function collectDash({ homes, books = [], baseline = null, now = Date.now() }) {
158
+ const strategies = [];
159
+ const status = [];
160
+ const notes = [];
161
+ const labels = {};
162
+ const groups = {};
163
+ let order = [];
164
+ let base = baseline;
165
+ for (const h of homes) {
166
+ const runs = join(h.dir, 'autotrade', 'runs');
167
+ if (!existsSync(runs)) {
168
+ notes.push(`${h.label}: no autotrade run ledgers yet`);
169
+ continue;
170
+ }
171
+ const M = readStrategyLedgers(runs, { now });
172
+ const fo = openFromFleet(h, now);
173
+ for (const [id, m] of Object.entries(M))
174
+ strategies.push(fromLedger(id, m, h, fo.get(id), now));
175
+ status.push(homeStatus(h, now));
176
+ }
177
+ for (const f of books) {
178
+ const b = readBooksFile(f);
179
+ if (!b) {
180
+ notes.push(`books file ${basename(f)}: missing or not ${BOOKS_SCHEMA}`);
181
+ continue;
182
+ }
183
+ for (const x of b.books ?? [])
184
+ strategies.push(fromBook(x, now));
185
+ Object.assign(labels, b.labels ?? {});
186
+ Object.assign(groups, b.groups ?? {});
187
+ if (b.order)
188
+ order = [...order, ...b.order];
189
+ base ??= b.baseline ?? null;
190
+ status.unshift(...(b.status ?? [])); // the books' own cards first (what the user runs by hand)
191
+ notes.push(...(b.notes ?? []));
192
+ }
193
+ for (const s of strategies) {
194
+ if (labels[s.id])
195
+ s.label = labels[s.id];
196
+ if (groups[s.id])
197
+ s.group = groups[s.id];
198
+ s.baseline = s.id === base;
199
+ }
200
+ const rank = (id) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
201
+ strategies.sort((a, b) => Number(b.baseline) - Number(a.baseline) || rank(a.id) - rank(b.id) || a.group.localeCompare(b.group) || a.id.localeCompare(b.id));
202
+ const baseS = strategies.find((s) => s.baseline) ?? null;
203
+ const pcts = (s) => s.trades.map((c) => c.pnlPct).filter(fin);
204
+ const comparisons = baseS ? strategies.filter((s) => s !== baseS).map((s) => compareToBase(s.id, pcts(s), baseS.id, pcts(baseS))) : [];
205
+ return {
206
+ schema: DASH_SCHEMA, generatedAt: now, paper: PAPER_LABEL,
207
+ definitions: { minClosed: MIN_CLOSED, rugPct: RUG_PCT, writeOffMin: STUCK_WRITE_OFF_MIN, text: [
208
+ `Closed = trades the engine closed plus stuck positions written off: no sell for >= ${STUCK_WRITE_OFF_MIN} min (or dropped stuck at a run end) closes at what was actually sold; the unsold rest counts 0.`,
209
+ `Mean % on cost = the average return per closed trade, with a 95% bootstrap interval (4000 resamples). Wide interval = the data cannot tell yet.`,
210
+ `Rug = a closed trade at ${RUG_PCT}% or worse on cost. Rug rate interval: Wilson 95%.`,
211
+ `Under ${MIN_CLOSED} closed trades a strategy is EXPLORATORY: read nothing into it. ETA to ${MIN_CLOSED} = the closed trades still needed / entries per day so far.`,
212
+ `Realizable = the engine's mark sold into the last pool read from the local cache (impact on half the liquidity, 2.3% fees and slippage); a stuck position is 0.`,
213
+ `Equity = the starting book plus realized P/L in close order; drawdown is from the running peak.`,
214
+ ] },
215
+ baseline: baseS?.id ?? null, decisionRule: baseS ? decisionRule(baseS.id) : null, strategies, comparisons, status, notes,
216
+ };
217
+ }