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,200 @@
1
+ // dexterm dash (task-1749): the page. Plain JS, no dependency, no external request. Reads the JSON the local server
2
+ // computes (or data.json in a static snapshot), renders five views by hash route and refreshes every 30 s.
3
+ 'use strict';
4
+ (() => {
5
+ const SRC = document.querySelector('meta[name="dash-data"]').content;
6
+ const LIVE = document.body.dataset.mode === 'live';
7
+ const REFRESH_MS = 30_000;
8
+ const $ = (s) => document.querySelector(s);
9
+ const view = $('#view');
10
+ let D = null; let lastOk = 0; let lastSig = '';
11
+
12
+ // ---------- formatting ----------
13
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
14
+ const fin = (x) => typeof x === 'number' && Number.isFinite(x);
15
+ const MINUS = '−';
16
+ const sgn = (x, d = 1) => (fin(x) ? `${x > 0 ? '+' : x < 0 ? MINUS : ''}${Math.abs(x).toFixed(d)}` : '–');
17
+ const pct = (x, d = 1) => (fin(x) ? `<span class="num ${x > 0 ? 'up' : x < 0 ? 'down' : ''}">${sgn(x, d)}%</span>` : '<span class="muted">–</span>');
18
+ const pp = (x) => (fin(x) ? `${sgn(x)} pp` : '–');
19
+ const money = (x, unit = 'USD', d) => (!fin(x) ? '–' : unit === 'SOL' ? `${x < 0 ? MINUS : ''}${Math.abs(x).toFixed(d ?? 4)} SOL` : `${x < 0 ? MINUS : ''}$${Math.abs(x).toFixed(d ?? 2)}`);
20
+ const smoney = (x, unit) => (fin(x) ? `<span class="num ${x > 0 ? 'up' : x < 0 ? 'down' : ''}">${x > 0 ? '+' : ''}${money(x, unit)}</span>` : '–');
21
+ const ci = (lo, hi) => (fin(lo) && fin(hi) ? `<span class="num muted">(${sgn(lo)} … ${sgn(hi)})</span>` : '');
22
+ const dtf = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false });
23
+ const when = (t) => (fin(t) ? dtf.format(new Date(t)) : '–');
24
+ const ago = (t) => { if (!fin(t)) return '–'; const s = Math.max(0, Math.round((Date.now() - t) / 1000)); return s < 90 ? `${s} s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : s < 172800 ? `${Math.round(s / 3600)} h ago` : `${Math.round(s / 86400)} d ago`; };
25
+ const coin = (c) => `${esc(c.symbol ?? '?')}${c.mint ? ` <span class="muted mono">${esc(c.mint.slice(0, 4))}…${esc(c.mint.slice(-4))}</span>` : ''}`;
26
+ const byId = (id) => D.strategies.find((s) => s.id === id);
27
+ const minN = () => D.definitions.minClosed;
28
+ const badge = (s) => {
29
+ const r = s.row;
30
+ const b = r.sample === 'none' ? '<span class="badge none">no closed trades</span>' : r.sample === 'exploratory' ? `<span class="badge expl" title="Fewer than ${minN()} closed trades: read nothing into the numbers yet">exploratory · ${r.closed} closed &lt; ${minN()}</span>` : `<span class="badge ok">${r.closed} closed ≥ ${minN()}</span>`;
31
+ return b + (s.baseline ? ' <span class="badge base">baseline</span>' : '');
32
+ };
33
+ const eta = (r) => (r.closed >= minN() ? 'reached' : fin(r.etaDays) ? `≈ ${r.etaDays < 1 ? `${Math.round(r.etaDays * 24)} h` : `${r.etaDays.toFixed(1)} d`}` : 'no entries yet');
34
+
35
+ // ---------- charts (inline SVG, one series per chart, one y-axis) ----------
36
+ // the SVG is drawn at its container's width (320-720 user units) so 11 px axis text stays 11 px on a phone
37
+ let W = 720; let H = 240; const P = { l: 58, r: 12, t: 12, b: 26 };
38
+ const narrow = () => W < 520;
39
+ const fitWidth = () => { W = Math.round(Math.min(720, Math.max(320, view.clientWidth - 34))); H = narrow() ? 200 : 240; };
40
+ const ticks = (lo, hi, n = 4) => { if (lo === hi) { lo -= 1; hi += 1; } const step0 = (hi - lo) / n; const mag = 10 ** Math.floor(Math.log10(step0)); const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= step0); const out = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(+v.toFixed(10)); return out; };
41
+ function spark(s) {
42
+ const pts = [{ v: s.equity.start }, ...s.equity.points.map((p) => ({ v: p.usd }))];
43
+ if (pts.length < 2) return '<svg class="spark" viewBox="0 0 120 40" aria-hidden="true"><line class="ref" x1="0" x2="120" y1="20" y2="20"/></svg>';
44
+ const vs = pts.map((p) => p.v); const lo = Math.min(...vs); const hi = Math.max(...vs); const span = hi - lo || 1;
45
+ const x = (i) => (i / (pts.length - 1)) * 116 + 2; const y = (v) => 36 - ((v - lo) / span) * 32;
46
+ return `<svg class="spark" viewBox="0 0 120 40" preserveAspectRatio="none" role="img" aria-label="Realized equity, ${esc(money(s.equity.start, s.unit))} to ${esc(money(s.equity.end, s.unit))}"><line class="ref" x1="0" x2="120" y1="${y(s.equity.start).toFixed(1)}" y2="${y(s.equity.start).toFixed(1)}"/><path class="ln" vector-effect="non-scaling-stroke" d="${pts.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.v).toFixed(1)}`).join('')}"/></svg>`;
47
+ }
48
+ /** A time-series line (or drawdown area) with a crosshair + tooltip. pts: [{t, v, tip}] */
49
+ function timeChart(id, pts, { ref = null, fmt = (v) => v.toFixed(2), kind = 'line', label }) {
50
+ if (pts.length < 2) return `<p class="empty">Not enough closed trades for a curve yet (${pts.length}).</p>`;
51
+ const t0 = pts[0].t; const t1 = pts[pts.length - 1].t; const tspan = t1 - t0 || 1;
52
+ const vs = pts.map((p) => p.v).concat(ref === null ? [] : [ref]); let lo = Math.min(...vs); let hi = Math.max(...vs);
53
+ if (kind === 'dd') { hi = 0; lo = Math.min(lo, -1); }
54
+ const pad = (hi - lo || 1) * 0.08; lo -= kind === 'dd' ? pad : pad; hi += kind === 'dd' ? 0 : pad;
55
+ const X = (t) => P.l + ((t - t0) / tspan) * (W - P.l - P.r); const Y = (v) => P.t + (1 - (v - lo) / (hi - lo)) * (H - P.t - P.b);
56
+ const yt = ticks(lo, hi); const path = pts.map((p, i) => `${i ? 'L' : 'M'}${X(p.t).toFixed(1)},${Y(p.v).toFixed(1)}`).join('');
57
+ const xt = [t0, t0 + tspan / 2, t1];
58
+ let body = `<g class="grid">${yt.map((v) => `<line x1="${P.l}" x2="${W - P.r}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}"/>`).join('')}</g>`;
59
+ body += `<g class="axis">${yt.map((v) => `<text x="${P.l - 6}" y="${(Y(v) + 4).toFixed(1)}" text-anchor="end">${esc(fmt(v))}</text>`).join('')}${xt.map((t, i) => `<text x="${X(t).toFixed(1)}" y="${H - 6}" text-anchor="${['start', 'middle', 'end'][i]}">${esc(when(t))}</text>`).join('')}</g>`;
60
+ if (ref !== null) body += `<line class="ref" x1="${P.l}" x2="${W - P.r}" y1="${Y(ref).toFixed(1)}" y2="${Y(ref).toFixed(1)}"/>`;
61
+ if (kind === 'dd') body += `<path class="dd-area" d="${path}L${X(t1).toFixed(1)},${Y(0).toFixed(1)}L${X(t0).toFixed(1)},${Y(0).toFixed(1)}Z"/><path class="dd" d="${path}"/>`;
62
+ else body += `<path class="area" d="${path}L${X(t1).toFixed(1)},${(H - P.b).toFixed(1)}L${X(t0).toFixed(1)},${(H - P.b).toFixed(1)}Z"/><path class="ln" d="${path}"/>`;
63
+ const last = pts[pts.length - 1];
64
+ if (kind !== 'dd') body += `<circle class="dot" r="4.5" cx="${X(last.t).toFixed(1)}" cy="${Y(last.v).toFixed(1)}"/>`;
65
+ body += `<line class="xh" id="${id}-xh" y1="${P.t}" y2="${H - P.b}" x1="-10" x2="-10"/><rect class="hit" x="${P.l}" y="0" width="${W - P.l - P.r}" height="${H}"/>`;
66
+ chartData[id] = { pts: pts.map((p) => ({ x: X(p.t), y: Y(p.v), tip: p.tip })) };
67
+ return `<div class="chartbox"><svg class="chart" id="${id}" viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(label)}">${body}</svg><div class="tip" id="${id}-tip"></div></div>`;
68
+ }
69
+ const chartData = {};
70
+ function wireCharts() {
71
+ for (const [id, cd] of Object.entries(chartData)) {
72
+ const svg = document.getElementById(id); if (!svg) continue;
73
+ const tip = document.getElementById(`${id}-tip`); const xh = document.getElementById(`${id}-xh`);
74
+ const move = (ev) => {
75
+ const r = svg.getBoundingClientRect(); const vx = ((ev.clientX - r.left) / r.width) * W;
76
+ let best = cd.pts[0]; for (const p of cd.pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;
77
+ xh.setAttribute('x1', best.x); xh.setAttribute('x2', best.x);
78
+ tip.innerHTML = best.tip; tip.style.display = 'block';
79
+ const box = svg.parentElement.getBoundingClientRect();
80
+ tip.style.left = `${Math.min(Math.max((best.x / W) * r.width + (r.left - box.left), 70), box.width - 70)}px`; tip.style.top = `${(best.y / H) * r.height + (r.top - box.top)}px`;
81
+ };
82
+ const out = () => { tip.style.display = 'none'; xh.setAttribute('x1', -10); xh.setAttribute('x2', -10); };
83
+ svg.addEventListener('pointermove', move); svg.addEventListener('pointerdown', move); svg.addEventListener('pointerleave', out);
84
+ }
85
+ }
86
+ /** Exit reasons as horizontal bars (count), the mean % per reason as text. */
87
+ function exitBars(exits) {
88
+ if (!exits.length) return '<p class="empty">No closed trades yet.</p>';
89
+ const max = Math.max(...exits.map((e) => e.n)); const rowH = 30; const h = exits.length * rowH + 8; const L = narrow() ? 92 : 150; const R = narrow() ? 92 : 150;
90
+ const rows = exits.map((e, i) => { const w = Math.max(4, (e.n / max) * (W - L - R)); const y = 4 + i * rowH;
91
+ return `<g><title>${esc(e.reason)}: ${e.n} closed, mean ${sgn(e.meanPct)}%, ${e.wins} wins</title><text class="lbl" x="${L - 8}" y="${y + 18}" text-anchor="end">${esc(e.reason.slice(0, narrow() ? 12 : 20))}</text><rect class="bar${fin(e.meanPct) && e.meanPct < 0 ? ' neg' : ''}" x="${L}" y="${y + 5}" width="${w.toFixed(1)}" height="18" rx="4"/><text class="lbl" x="${L + w + 8}" y="${y + 18}">${e.n} · ${esc(sgn(e.meanPct))}%</text></g>`; }).join('');
92
+ return `<div class="chartbox"><svg class="chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="Closed trades by exit reason">${rows}</svg></div>`;
93
+ }
94
+ /** Compare: the difference in mean % per entry vs the baseline with its 95% interval (a dot and whisker per variant). */
95
+ function forest(cs) {
96
+ const ok = cs.filter((c) => fin(c.diff.mean)); if (!ok.length) return '<p class="empty">Nothing to compare yet.</p>';
97
+ const vals = ok.flatMap((c) => [c.diff.lo, c.diff.hi, c.diff.mean]).filter(fin).concat([0]); let lo = Math.min(...vals); let hi = Math.max(...vals); const pad = (hi - lo || 1) * 0.06; lo -= pad; hi += pad;
98
+ const L = narrow() ? 118 : 150; const R = 16; const rowH = 30; const h = ok.length * rowH + 34; const X = (v) => L + ((v - lo) / (hi - lo)) * (W - L - R);
99
+ const xt = ticks(lo, hi, 5);
100
+ let b = `<g class="grid">${xt.map((v) => `<line x1="${X(v).toFixed(1)}" x2="${X(v).toFixed(1)}" y1="4" y2="${h - 24}"/>`).join('')}</g><g class="axis">${xt.map((v) => `<text x="${X(v).toFixed(1)}" y="${h - 8}" text-anchor="middle">${esc(sgn(v, 0))}</text>`).join('')}</g><line class="zero" x1="${X(0).toFixed(1)}" x2="${X(0).toFixed(1)}" y1="4" y2="${h - 24}"/>`;
101
+ ok.forEach((c, i) => { const y = 4 + i * rowH + 14;
102
+ b += `<g><title>${esc(c.id)} − ${esc(c.base)}: ${pp(c.diff.mean)} (95% ${sgn(c.diff.lo)} … ${sgn(c.diff.hi)}), ${c.n} vs ${c.baseN} closed. ${esc(c.verdict)}</title><text class="lbl" x="${L - 8}" y="${y + 4}" text-anchor="end">${esc(c.id.slice(0, narrow() ? 15 : 18))}</text>${fin(c.diff.lo) && fin(c.diff.hi) ? `<line class="ci" x1="${X(c.diff.lo).toFixed(1)}" x2="${X(c.diff.hi).toFixed(1)}" y1="${y}" y2="${y}"/>` : ''}<circle class="mean" r="5.5" cx="${X(c.diff.mean).toFixed(1)}" cy="${y}"/></g>`; });
103
+ return `<div class="chartbox"><svg class="chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="Difference in mean % per entry against the baseline, with 95% intervals">${b}</svg></div><p class="muted note">Dot = difference in mean % on cost per entry (variant minus baseline, percentage points); line = its 95% interval. A line that crosses 0 means the data cannot tell them apart.</p>`;
104
+ }
105
+
106
+ // ---------- views ----------
107
+ const disclaimer = () => `<p class="disclaimer"><strong>Paper trading, not financial advice.</strong> Simulated fills on paper books. Small samples say almost nothing: every number carries its sample size, and anything under ${minN()} closed trades is exploratory.</p>`;
108
+ function overview() {
109
+ const S = D.strategies;
110
+ if (!S.length) return `<h1>Overview</h1>${disclaimer()}<p class="empty">No strategies found. Run a paper strategy with <code>dexterm autotrade run</code>, then come back.</p>${notes()}`;
111
+ const cards = S.map((s) => { const r = s.row;
112
+ return `<a class="card${s.baseline ? ' base' : ''}" href="#/strategy/${encodeURIComponent(s.id)}"><div class="head"><span class="id">${esc(s.id)}</span><span class="grp">${esc(s.group)}</span></div><div class="label">${esc(s.label)}</div><div>${badge(s)}</div>
113
+ <div class="kv"><div><div class="k">mean / trade</div><div class="v">${pct(r.meanPct)}</div></div><div><div class="k">closed · open</div><div class="v">${r.closed} · ${r.open}</div></div><div><div class="k">wins · rugs</div><div class="v">${r.wins} · ${r.rugs}</div></div>
114
+ <div><div class="k">95% interval</div><div class="v sm">${fin(r.lo) ? `${sgn(r.lo)} … ${sgn(r.hi)}` : '–'}</div></div><div><div class="k">realized</div><div class="v md">${smoney(r.realizedUsd, s.unit)}</div></div><div><div class="k">ETA to ${minN()}</div><div class="v md">${eta(r)}</div></div></div>${spark(s)}</a>`; }).join('');
115
+ const rows = S.map((s) => { const r = s.row; return `<tr${s.baseline ? ' class="base"' : ''}><td><a href="#/strategy/${encodeURIComponent(s.id)}">${esc(s.id)}</a>${s.baseline ? ' <span class="badge base">baseline</span>' : ''}</td><td>${esc(s.group)}</td><td class="r num">${r.entries}</td><td class="r num">${r.closed}</td><td class="r num">${r.open}</td><td class="r num">${r.writtenOff}</td><td class="r num">${r.wins} / ${r.losses}</td><td class="r num">${r.rugs}${r.closed ? ` (${Math.round(r.rugRate * 100)}%)` : ''}</td><td class="r">${pct(r.meanPct)} ${ci(r.lo, r.hi)}</td><td class="r">${smoney(r.realizedUsd, s.unit)}</td><td class="r num">${fin(s.equity.maxDrawdownPct) ? `${s.equity.maxDrawdownPct.toFixed(1)}%` : '–'}</td><td class="r num">${fin(r.entriesPerDay) ? r.entriesPerDay.toFixed(1) : '–'}</td><td>${eta(r)}</td><td>${r.sample === 'exploratory' ? 'exploratory' : r.sample === 'none' ? '–' : 'n ≥ ' + minN()}</td></tr>`; }).join('');
116
+ return `<h1>Overview</h1><p class="lead">${S.length} strategies side by side${D.baseline ? `; the baseline is <strong class="mono">${esc(D.baseline)}</strong>` : ''}. Tap one for its detail.</p>${disclaimer()}<div class="grid g4">${cards}</div>
117
+ <h2>Table view</h2><div class="tw"><table><caption>Mean % on cost per closed trade with its 95% interval; rugs = closed at ${D.definitions.rugPct}% or worse; drawdown of the realized equity curve.</caption><thead><tr><th>strategy</th><th>group</th><th class="r">entries</th><th class="r">closed</th><th class="r">open</th><th class="r">written off</th><th class="r">wins / losses</th><th class="r">rugs</th><th class="r">mean % (95%)</th><th class="r">realized</th><th class="r">max DD</th><th class="r">entries/day</th><th>ETA to ${minN()}</th><th>sample</th></tr></thead><tbody>${rows}</tbody></table></div>${notes()}`;
118
+ }
119
+ function strategy(id) {
120
+ const S = D.strategies; if (!S.length) return overview();
121
+ const s = byId(id) ?? S[0]; const r = s.row; const u = s.unit;
122
+ const pick = `<label class="pick" for="pick">Strategy <select id="pick">${S.map((x) => `<option value="${esc(x.id)}"${x.id === s.id ? ' selected' : ''}>${esc(x.id)}${x.baseline ? ' (baseline)' : ''} · ${esc(x.group)}</option>`).join('')}</select></label>`;
123
+ const tiles = [['mean % on cost', pct(r.meanPct), fin(r.lo) ? `95% ${sgn(r.lo)} … ${sgn(r.hi)}` : 'no interval yet'], ['closed', `<span class="num">${r.closed}</span>`, `${r.entries} entries · ${r.open} open`], ['wins / losses', `<span class="num">${r.wins} / ${r.losses}</span>`, r.closed ? `${Math.round((r.wins / r.closed) * 100)}% won` : '–'],
124
+ ['rugs (≤ ' + D.definitions.rugPct + '%)', `<span class="num">${r.rugs}</span>`, r.closed ? `${Math.round(r.rugRate * 100)}% (95% ${Math.round((r.rugCi[0] ?? 0) * 100)}–${Math.round((r.rugCi[1] ?? 0) * 100)}%)` : '–'], ['realized', smoney(r.realizedUsd, u), `book ${money(s.equity.start, u)} → ${money(s.equity.end, u)}`], ['max drawdown', `<span class="num">${s.equity.maxDrawdownPct.toFixed(1)}%</span>`, `${fin(r.entriesPerDay) ? r.entriesPerDay.toFixed(1) : '–'} entries/day · ETA ${eta(r)}`]]
125
+ .map(([k, v, x]) => `<div class="tile"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${x}</div></div>`).join('');
126
+ const eqPts = [{ t: s.firstEventAt ?? (s.equity.points[0]?.t ?? 0), v: s.equity.start, tip: `start ${money(s.equity.start, u)}` }, ...s.equity.points.map((p, i) => { const c = s.trades[i]; return { t: p.t, v: p.usd, tip: `${esc(when(p.t))} · ${money(p.usd, u)}${c ? ` · ${esc(c.symbol ?? '?')} ${sgn(c.pnlPct)}%` : ''}` }; })];
127
+ const ddPts = [{ t: eqPts[0].t, v: 0, tip: 'peak' }, ...s.equity.points.map((p) => ({ t: p.t, v: -p.drawdownPct, tip: `${esc(when(p.t))} · drawdown ${p.drawdownPct.toFixed(1)}%` }))];
128
+ const open = s.open.length ? `<div class="tw"><table><thead><tr><th>coin</th><th>profile</th><th class="r">cost</th><th class="r">engine mark</th><th class="r">pool liquidity</th><th class="r">realizable</th><th>state</th></tr></thead><tbody>${s.open.map((o) => `<tr><td>${coin(o)}</td><td>${esc(o.profile ?? '–')}</td><td class="r num">${money(o.costUsd, u)}</td><td class="r">${money(o.markUsd, u)} ${pct(o.markPct)}</td><td class="r num">${fin(o.liqUsd) ? `$${Math.round(o.liqUsd).toLocaleString()} <span class="muted">(${ago(o.liqAt)})</span>` : '<span class="muted">not read</span>'}</td><td class="r">${fin(o.realizableUsd) ? `${money(o.realizableUsd, u)} ${pct(o.realizablePct)}` : '<span class="muted">unknown (no pool read)</span>'}</td><td>${o.stuck ? '<strong class="down">stuck: no pool to sell into</strong>' : 'open'}</td></tr>`).join('')}</tbody></table></div>` : '<p class="empty">No open positions.</p>';
129
+ const trades = s.trades.length ? `<div class="tw"><table><thead><tr><th>closed</th><th>coin</th><th>profile</th><th class="r">% on cost</th><th class="r">P/L</th><th class="r">cost</th><th class="r">held</th><th>exit</th></tr></thead><tbody>${[...s.trades].reverse().slice(0, 300).map((c) => `<tr><td class="num">${esc(when(c.t))}</td><td>${coin(c)}</td><td>${esc(c.profile ?? '–')}</td><td class="r">${pct(c.pnlPct)}</td><td class="r">${smoney(c.pnlUsd, u)}</td><td class="r num">${money(c.costUsd, u)}</td><td class="r num">${fin(c.openedAt) ? `${Math.max(0, Math.round((c.t - c.openedAt) / 60000))} min` : '–'}</td><td>${c.writtenOff ? '<strong>written off</strong>' : esc(c.exitReason ?? '–')}</td></tr>`).join('')}</tbody></table></div>` : '<p class="empty">No closed trades yet.</p>';
130
+ return `<h1 class="mono">${esc(s.id)}</h1><p class="lead">${esc(s.label)} · ${esc(s.group)} · ${esc(s.mode ?? 'paper')} · ${badge(s)} · last event ${ago(s.lastEventAt)}</p>${pick}${disclaimer()}<div class="tiles">${tiles}</div>
131
+ <h2>Realized equity</h2>${timeChart('eq', eqPts, { ref: s.equity.start, fmt: (v) => (u === 'SOL' ? v.toFixed(2) : `$${v.toFixed(0)}`), label: `Realized equity of ${s.id} over time` })}
132
+ <h2>Drawdown from the running peak</h2>${timeChart('dd', ddPts, { fmt: (v) => `${v.toFixed(0)}%`, kind: 'dd', label: `Drawdown of ${s.id}` })}
133
+ <h2>Exit reasons</h2>${exitBars(r.exits)}
134
+ <h2>Open positions</h2><p class="lead">Engine mark = what the engine values it at. Realizable = that mark sold into the last pool read from the local cache (price impact on half the liquidity, 2.3% fees and slippage). Stuck = 0.</p>${open}
135
+ <h2>Trades (${s.trades.length})</h2>${trades}`;
136
+ }
137
+ function rugs() {
138
+ const all = D.strategies.flatMap((s) => s.rugs.map((c) => ({ s, c }))).sort((a, b) => b.c.t - a.c.t);
139
+ const stuckOpen = D.strategies.flatMap((s) => s.open.filter((o) => o.stuck).map((o) => ({ s, o })));
140
+ const rows = all.map(({ s, c }) => `<tr><td><a href="#/strategy/${encodeURIComponent(s.id)}">${esc(s.id)}</a></td><td>${coin(c)}</td><td class="num">${esc(when(c.t))}</td><td class="r">${pct(c.pnlPct)}</td><td class="r num">${money(c.costUsd, s.unit)}</td><td class="r num">${c.writtenOff ? money(c.realizedUsd, s.unit) : '–'}</td><td class="r num">${fin(c.entryAgeMin) ? `${c.entryAgeMin < 120 ? `${Math.round(c.entryAgeMin)} min` : `${(c.entryAgeMin / 60).toFixed(1)} h`}` : '–'}</td><td class="r num">${fin(c.entryLiqUsd) ? `$${Math.round(c.entryLiqUsd).toLocaleString()}` : '–'}</td><td class="wrap">${c.writtenOff ? `<strong>written off</strong>: ${esc(c.reason ?? '')}` : `closed ${esc(c.exitReason ?? '')}`}</td></tr>`).join('');
141
+ const age = (c) => (fin(c.entryAgeMin) ? (c.entryAgeMin < 120 ? `${Math.round(c.entryAgeMin)} min` : `${(c.entryAgeMin / 60).toFixed(1)} h`) : '–');
142
+ const cards = all.map(({ s, c }) => `<div class="card rug"><div class="head"><span class="id">${coin(c)}</span><span class="grp">${esc(s.id)}</span><span class="r">${pct(c.pnlPct)}</span></div><dl class="items"><dt>closed</dt><dd>${esc(when(c.t))}</dd><dt>cost → sold</dt><dd>${money(c.costUsd, s.unit)} → ${c.writtenOff ? money(c.realizedUsd, s.unit) : '–'}</dd><dt>pool at entry</dt><dd>${age(c)} old${fin(c.entryLiqUsd) ? ` · $${Math.round(c.entryLiqUsd).toLocaleString()} liq` : ''}</dd><dt>why</dt><dd>${c.writtenOff ? `<strong>written off</strong>: ${esc(c.reason ?? '')}` : `closed ${esc(c.exitReason ?? '')}`}</dd></dl></div>`).join('');
143
+ return `<h1>Rugs &amp; stuck</h1><p class="lead">Closed at ${D.definitions.rugPct}% or worse, plus every stuck position written off: the engine could not sell it (no pool to sell into) for ${D.definitions.writeOffMin} min or more, so it is closed here at what it actually sold; the unsold rest counts 0. The engine's own files are never edited.</p>${disclaimer()}
144
+ ${stuckOpen.length ? `<h2>Stuck now (not yet written off)</h2><div class="tw"><table><thead><tr><th>strategy</th><th>coin</th><th class="r">cost</th><th class="r">engine mark</th></tr></thead><tbody>${stuckOpen.map(({ s, o }) => `<tr><td>${esc(s.id)}</td><td>${coin(o)}</td><td class="r num">${money(o.costUsd, s.unit)}</td><td class="r">${money(o.markUsd, s.unit)}</td></tr>`).join('')}</tbody></table></div>` : ''}
145
+ <h2>Rugs and write-offs (${all.length})</h2>${all.length ? `<div class="only-narrow">${cards}</div><div class="tw only-wide"><table><thead><tr><th>strategy</th><th>coin</th><th>closed</th><th class="r">% on cost</th><th class="r">cost</th><th class="r">sold before stuck</th><th class="r">pool age at entry</th><th class="r">liquidity at entry</th><th>why</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="empty">None so far.</p>'}`;
146
+ }
147
+ function compare() {
148
+ if (!D.baseline) return `<h1>Compare</h1>${disclaimer()}<p class="empty">No baseline set. Start the dashboard with <code>--baseline ID</code> to compare every strategy with it.</p>`;
149
+ const rows = D.comparisons.map((c) => `<tr><td><a href="#/strategy/${encodeURIComponent(c.id)}">${esc(c.id)}</a></td><td class="r num">${c.n} / ${c.baseN}</td><td class="r num">${pp(c.diff.mean)}</td><td class="r">${ci(c.diff.lo, c.diff.hi)}</td><td>${esc(c.verdict)}</td></tr>`).join('');
150
+ return `<h1>Compare with the baseline</h1><p class="lead">Each strategy against <strong class="mono">${esc(D.baseline)}</strong>: the difference in mean % on cost per closed trade, with its 95% bootstrap interval.</p>${disclaimer()}<div class="rule"><strong>Decision rule.</strong> ${esc(D.decisionRule)}</div>${forest(D.comparisons)}
151
+ <h2>Table view</h2><div class="tw"><table><thead><tr><th>strategy</th><th class="r">closed (strategy / baseline)</th><th class="r">difference</th><th class="r">95% interval</th><th>verdict</th></tr></thead><tbody>${rows}</tbody></table></div>`;
152
+ }
153
+ function live() {
154
+ const cards = D.status.map((c) => `<section class="card status ${esc(c.state)}"><div class="head"><h3>${esc(c.title)}</h3><span class="st ${esc(c.state)}">${esc({ ok: 'up', warn: 'attention', down: 'down', idle: 'idle' }[c.state] ?? c.state)}</span></div><dl class="items">${c.items.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${/^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(v) ? `${esc(when(Date.parse(v)))} <span class="muted">(${ago(Date.parse(v))})</span>` : esc(v)}</dd>`).join('')}</dl>${fin(c.updatedAt) ? `<p class="muted note">updated ${ago(c.updatedAt)}</p>` : ''}</section>`).join('');
155
+ const last = D.strategies.map((s) => `<tr><td>${esc(s.id)}</td><td>${esc(s.group)}</td><td class="num">${ago(s.lastEventAt)}</td><td class="r num">${s.open.length}</td></tr>`).join('');
156
+ return `<h1>Live status</h1><p class="lead">${LIVE ? `What is running now. This page re-reads the ledgers every ${REFRESH_MS / 1000} s.` : 'A snapshot: what was running when it was taken.'} Data computed ${ago(D.generatedAt)}.</p>${disclaimer()}<div class="grid">${cards || '<p class="empty">No status sources.</p>'}</div>
157
+ <h2>Last ledger event per strategy</h2><div class="tw"><table><thead><tr><th>strategy</th><th>group</th><th>last event</th><th class="r">open</th></tr></thead><tbody>${last}</tbody></table></div>${notes()}`;
158
+ }
159
+ const notes = () => (D.notes?.length ? `<h2>Notes</h2><ul class="muted">${D.notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>` : '');
160
+ function foot() {
161
+ $('#foot').innerHTML = `<p><strong>Paper trading, not financial advice.</strong> Simulated trades on paper books; no real funds, no wallet, no keys. Simulated results say nothing about future results.</p><details><summary>What the numbers mean</summary><ul>${D.definitions.text.map((t) => `<li>${esc(t)}</li>`).join('')}</ul></details><p>${LIVE ? 'Served by <code>dexterm dash</code> on this machine (read-only).' : `Read-only snapshot taken ${esc(when(D.generatedAt))}.`}</p>`;
162
+ }
163
+
164
+ // ---------- routing, refresh, theme ----------
165
+ const route = () => { const h = location.hash.replace(/^#\/?/, '').split('/'); return { name: h[0] || 'overview', arg: h[1] ? decodeURIComponent(h[1]) : null }; };
166
+ function render() {
167
+ if (!D) return;
168
+ const r = route(); const focusPick = document.activeElement?.id === 'pick'; const sy = window.scrollY;
169
+ for (const k of Object.keys(chartData)) delete chartData[k];
170
+ fitWidth();
171
+ view.innerHTML = r.name === 'strategy' ? strategy(r.arg) : r.name === 'rugs' ? rugs() : r.name === 'compare' ? compare() : r.name === 'live' ? live() : overview();
172
+ for (const a of document.querySelectorAll('nav.tabs a')) { if (a.dataset.view === (['strategy', 'rugs', 'compare', 'live'].includes(r.name) ? r.name : 'overview')) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); }
173
+ const pick = $('#pick'); if (pick) { pick.addEventListener('change', () => { location.hash = `#/strategy/${encodeURIComponent(pick.value)}`; }); if (focusPick) pick.focus(); }
174
+ wireCharts(); foot(); window.scrollTo(0, sy);
175
+ }
176
+ function stamp(err) {
177
+ const el = $('#updated'); const t = $('#updated-text');
178
+ if (!D) { t.textContent = err ? `cannot read data (${err})` : 'loading…'; el.classList.toggle('stale', !!err); return; }
179
+ const stale = LIVE ? (Date.now() - lastOk > REFRESH_MS * 3 || !!err) : true;
180
+ el.classList.toggle('stale', LIVE && stale);
181
+ t.innerHTML = LIVE ? `updated ${esc(ago(D.generatedAt))}<span class="long"> · auto-refresh ${REFRESH_MS / 1000} s</span>${err ? ` · <strong>refresh failed</strong>` : ''}` : `snapshot ${esc(when(D.generatedAt))}`;
182
+ }
183
+ async function load() {
184
+ try {
185
+ const res = await fetch(SRC, { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`);
186
+ const d = await res.json(); lastOk = Date.now(); D = d;
187
+ const sig = JSON.stringify([d.strategies, d.status, d.comparisons]);
188
+ if (sig !== lastSig) { lastSig = sig; render(); }
189
+ stamp(null);
190
+ } catch (e) { stamp(String(e.message ?? e)); }
191
+ }
192
+ const THEMES = ['auto', 'dark', 'light'];
193
+ function setTheme(t) { if (t === 'auto') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; const b = $('#theme'); b.textContent = t; b.setAttribute('aria-label', `Theme: ${t}. Switch theme`); try { localStorage.setItem('dexterm-dash-theme', t); } catch { /* private mode */ } }
194
+ $('#theme').addEventListener('click', () => { const cur = document.documentElement.dataset.theme ?? 'auto'; setTheme(THEMES[(THEMES.indexOf(cur) + 1) % 3]); });
195
+ try { const t = new URLSearchParams(location.search).get('theme') ?? localStorage.getItem('dexterm-dash-theme'); if (THEMES.includes(t)) setTheme(t); } catch { /* none */ }
196
+ let lastW = 0; window.addEventListener('resize', () => { const w = view.clientWidth; if (Math.abs(w - lastW) > 40) { lastW = w; clearTimeout(window.__dashRs); window.__dashRs = setTimeout(render, 150); } });
197
+ window.addEventListener('hashchange', () => { render(); view.focus({ preventScroll: true }); window.scrollTo(0, 0); });
198
+ load();
199
+ if (LIVE) { setInterval(load, REFRESH_MS); setInterval(() => stamp(null), 10_000); }
200
+ })();
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#020507"/><path d="M5 22 L11 16 L16 19 L22 10 L27 13" fill="none" stroke="#39ff14" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="dash-data" content="api/dash.json">
7
+ <meta name="robots" content="noindex, nofollow">
8
+ <meta name="referrer" content="no-referrer">
9
+ <title>dexterm dash · strategy performance (PAPER)</title>
10
+ <link rel="icon" href="icon.svg" type="image/svg+xml">
11
+ <link rel="stylesheet" href="dash.css">
12
+ </head>
13
+ <body data-mode="live">
14
+ <a class="skip" href="#view">Skip to content</a>
15
+ <header class="top">
16
+ <div class="bar">
17
+ <span class="brand" aria-label="dexterm dash">dexterm<b>/</b>dash</span>
18
+ <span class="paper" title="Every number here is a simulation">PAPER</span>
19
+ <div class="updated" id="updated" role="status" aria-live="polite"><span class="dot" aria-hidden="true"></span><span id="updated-text">loading…</span></div>
20
+ <button class="theme" id="theme" type="button" aria-label="Theme: automatic. Switch theme">auto</button>
21
+ </div>
22
+ <nav class="tabs" aria-label="Views">
23
+ <a href="#/overview" data-view="overview">Overview</a>
24
+ <a href="#/strategy" data-view="strategy">Strategy</a>
25
+ <a href="#/rugs" data-view="rugs">Rugs &amp; stuck</a>
26
+ <a href="#/compare" data-view="compare">Compare</a>
27
+ <a href="#/live" data-view="live">Live<span class="wide">&nbsp;status</span></a>
28
+ </nav>
29
+ </header>
30
+ <main id="view" tabindex="-1"><p class="empty">Loading the run ledgers…</p></main>
31
+ <footer id="foot">
32
+ <p><strong>Paper trading, not financial advice.</strong> Simulated trades on a paper book; no real funds, no wallet, no keys. Past simulated results say nothing about future results.</p>
33
+ </footer>
34
+ <script src="dash.js"></script>
35
+ </body>
36
+ </html>
@@ -191,6 +191,9 @@ const TOPICS = [
191
191
  { id: 'use-codex-analyst', title: 'Run the analyst on Codex instead of Claude', screen: 'all', tags: ['codex', 'openai', 'chatgpt', 'engine', 'analyst', 'claude', 'switch', 'backend'],
192
192
  flags: ['--analyst-engine'], feature: 'analyst',
193
193
  body: 'The analyst can run on your own Codex CLI login instead of Claude (0.1.5). Pick it with `--analyst-engine codex`, `DEXTERM_ANALYST_ENGINE=codex`, `"analyst": {"engine": "codex"}` in `~/.dexterm/config.json`, or type `/engine codex` in the chat box (new conversations; `/engine` alone shows the engine and what is installed). The default `auto` uses claude when it is installed, else codex. dexterm stores no credentials: your `codex login` does the work. The Codex run is locked read-only: only the read-only dexterm tools (plus the UI actions the session allows, none in scan mode), no shell, no file edits, no web search, no other MCP server; a turn that tries anything else is stopped. `dexterm doctor` shows a Codex CLI line (installed, version, logged in). Codex is also supported as an MCP client: `dexterm mcp install codex`.' },
194
+ { id: 'classification-models', title: 'Pick a classification model (Kev, hosted Jev, or the built-in scores)', screen: 'all', tags: ['classifier', 'classification', 'kev', 'jev', 'model', 'probability', 'rug', 'drain', 'llama', 'calibrated', 'typesafe', 'experimental'],
195
+ commands: ['classifier'], tools: ['classify_coin', 'classify_batch', 'classifier_status'], feature: 'classifier',
196
+ body: 'EXPERIMENTAL. A classification model answers typed questions about one coin with a probability per answer: p(drain within 6 h) and RUG / WIN / DUD over 6 h, or a narrative category. `dexterm classifier list` shows the choices: `heuristic` (the built-in H1 rug score and LARP link score; the default, no download), `kev-local` (an open Kev decision model on your own machine through llama.cpp: `dexterm classifier pull kev-4b-q4` shows the size and source and asks first, verifies the sha256, then `dexterm classifier pick kev-local kev-4b-q4` and `dexterm classifier serve`), `jev-hosted` (TypeSafe\'s hosted Jev with your own API key: `dexterm classifier key set`; per-call cost and a daily call cap), `jev-open` (the open JEV students on a vLLM server), and `claude` / `ollama-llm` zero-shot baselines. `dexterm classifier doctor` reads RAM, CPU and GPU/VRAM and recommends a tier. If the picked model is unreachable the built-in scores answer and say so. Agents use the `classify_coin`, `classify_batch` (triage: flag / pass / escalate) and `classifier_status` MCP tools; a watch rule can use `rugp` (e.g. `when rugp > 0.8 then alert(warn, "...")`). How each model scored on dexterm\'s own labelled history is on the docs page. Every answer is a model estimate with the model id, never advice. Docs: https://dexterm.xyz/docs/classifier/' },
194
197
  { id: 'use-local-model', title: 'Run dexterm\'s AI on a free local model (Ollama)', screen: 'all', tags: ['ollama', 'local', 'model', 'free', 'offline', 'engine', 'llama', 'qwen', 'restrictions', 'privacy'],
195
198
  commands: ['ai', 'ask'], flags: ['--analyst-engine'], feature: 'analyst',
196
199
  body: 'dexterm\'s AI can run on a free model on your own machine through Ollama: no login, no key, $0 per answer, and it talks only to http://127.0.0.1:11434 by default. Set it up in two minutes: install Ollama (https://ollama.com/download), `ollama pull qwen3:8b`, `dexterm ai engine ollama`, `dexterm ai model qwen3:8b`, then `dexterm doctor` (the Ollama line should pass). In the chat box `/engine ollama` switches new conversations, `/engine claude` switches back; `--analyst-engine ollama` and `DEXTERM_ANALYST_ENGINE=ollama` work too, and `dexterm ai engine ollama --surface autotrade` picks one surface. The local engine uses the same dexterm tools and the same firewalled context as Claude. What it may do is your restriction profile: `observe` (analyse only), `propose` (the default for a new setup: UI actions, a PAPER ticket you confirm, strategy proposals you apply), `paper` (adds paper autotrade sessions and agent paper trading in a mandate you armed: `dexterm ai restrictions paper`), `full` (only after `dexterm ai engine ollama --full` and typing ENABLE FULL, for one model). Caps, the kill switch, mandate tiers, live gating and Telegram buttons are enforced outside the model for every engine. `dexterm ai probe --json` checks tool calling, context and decision formatting; a model without tools answers from the dossier dexterm fetches and says so. Paper autotrade on it: `dexterm autotrade run <strategy> --paper --engine ollama`. Headless: `dexterm ask "question"`, `dexterm ai agent "goal"`. Docs: https://dexterm.xyz/docs/ollama/' },
@@ -244,7 +247,7 @@ const TOPICS = [
244
247
  body: '`dexterm autotrade run --all --paper` runs every strategy listed in ~/.dexterm/autotrade/autotrade.json at the same time on one data feed, to forward-test several ideas side by side. Each entry has an `id`, a `preset` (a shipped preset or your own strategy) or an absolute `file`, a `bankroll` in USD or SOL, optional `caps` that can only tighten it, and `enabled`. Each strategy gets its own paper book, run file, loss limits and breaker, sessions and model spend budget; a breaker or a stop on one never stops another. The screener fetches once for all of them, and the `global` limits (sessions open at once, model spend and sessions per day, one check of how busy the computer is) count across all of them. `dexterm autotrade status --all` and the runner view on the AUTOTRADE screen (`Q`) compare them: P/L, win rate with the closed count, rug exits and exposure, missed runners, spend per entry. `dexterm autotrade stop|flatten|pause|resume` act on every strategy; add `--strategy ID` to act on one, and `dexterm autotrade start ID` starts a listed one that is not running. An MCP agent can list and compare them and start or stop one listed strategy, never add one or change a size or a cap. Paper only: live is refused. Not financial advice. Full docs: https://dexterm.xyz/docs/autotrade/#several-strategies-at-once' },
245
248
  { id: 'autotrade-telegram', title: 'Your own Telegram bot for the autotrade runner: notify, control, one-tap approvals (dexterm telegram)', screen: 'all', tags: ['telegram', 'bot', 'phone', 'mobile', 'notify', 'notifications', 'approve', 'approval', 'approvals', 'pair', 'pairing', 'kill', 'pause', 'remote', 'botfather'],
246
249
  commands: ['telegram'], feature: 'autotrade-telegram',
247
- body: 'Create a bot with @BotFather, then run `dexterm telegram setup`: paste the token at the hidden prompt (never as an argument), dexterm checks it and keeps it in your keychain (or the system keyring, or a 0600 file), then shows a 6-digit code; send `/pair <code>` to your bot from your own private chat and confirm the account it shows (@username and id) with `y` on your computer. Pairing is refused while a webhook or another program polls the bot. Only that account in that chat is answered. `dexterm autotrade run <id> --paper` then starts the bot by itself (`--no-telegram` keeps it off): entries, exits, PnL cards, halts and a daily summary reach your phone; `/status`, `/positions`, `/pause`, `/resume` (a pause only), `/kill`, `/flatten` and `/use` (each after a confirm button, `/use` only to an equal-or-tighter strategy; a change it cannot rank counts as looser) and `/approvals on` work from chat, which can make things safer and never less safe. With `entry.decide: "agent+approve"` or approvals on, every entry is a card with [Approve] [Half size] [Skip]: only a button approves, once, for that proposal, your account and your private chat; the price and your limits are checked again at the tap; no answer is a skip. Typed messages are never trades. `dexterm telegram status|test|audit --verify|revoke`. Nothing is hosted; paper only; never send keys or seed phrases in chat. Not financial advice. Full docs: https://dexterm.xyz/docs/telegram/' },
250
+ body: 'Create a bot with @BotFather, then run `dexterm telegram setup`: paste the token at the hidden prompt (never as an argument), dexterm checks it and keeps it in your keychain (or the system keyring, or a 0600 file), then shows a 6-digit code; send `/pair <code>` to your bot from your own private chat and confirm the account it shows (@username and id) with `y` on your computer. Pairing is refused while a webhook or another program polls the bot. Only that account in that chat is answered. `dexterm autotrade run <id> --paper` then starts the bot by itself (`--no-telegram` keeps it off): entries, exits, PnL cards, halts and a daily summary reach your phone; `/status`, `/positions`, `/pause`, `/resume` (a pause only), `/kill`, `/flatten` and `/use` (each after a confirm button, `/use` only to an equal-or-tighter strategy; a change it cannot rank counts as looser) and `/approvals on` work from chat, which can make things safer and never less safe. Read-only from chat: `/check <CA>` (one coin\'s LARP score and risk facts: authorities, top-10 holders, dev share, LP lock, liquidity, each with the age of its data), `/top [N]` (the screener\'s trending list, 1 to 10, the runner\'s own coins marked) and `/signals [N]` (the last 1 to 20 signals the runner raised: screen matches, safety refusals, the drain guard, stuck bags, halts, fills); data, not advice, and they cannot reach any write. With `entry.decide: "agent+approve"` or approvals on, every entry is a card with [Approve] [Half size] [Skip]: only a button approves, once, for that proposal, your account and your private chat; the price and your limits are checked again at the tap; no answer is a skip. Typed messages are never trades. `dexterm telegram status|test|audit --verify|revoke`. Nothing is hosted; paper only; never send keys or seed phrases in chat. Not financial advice. Full docs: https://dexterm.xyz/docs/telegram/' },
248
251
  { id: 'autotrade-real-signal', title: 'Real mode: send a strategy\'s trades to your own executor as LIVE TRADE FIRE signals (dexterm autotrade signal)', screen: 'all', tags: ['real', 'mode', 'live', 'trade', 'fire', 'signal', 'signals', 'executor', 'webhook', 'hook', 'jsonl', 'receipt', 'receipts', 'arm', 'caps', 'kill', 'outbox', 'nonce'],
249
252
  commands: ['autotrade'], tools: ['trade_fire_feed'], feature: 'autotrade-real-signal',
250
253
  body: 'Real mode runs a strategy exactly like paper (the same clamp, exits, monitor and run file) and sends every buy and sell it fills, first, as a LIVE TRADE FIRE signal (`dexterm-trade-fire/1`: buy or sell, mint, symbol, size in SOL and % of book, the sell fraction and exit reason, strategy, run, account, an always-growing nonce, an idempotency id and an expiry) to YOUR executor: a Telegram trading bot of your choice or any script. dexterm holds no key and signs nothing. Set channels with `dexterm autotrade signal channels --webhook URL | --signal-cmd \'["/abs/path"]\' | --jsonl on | --mcp on | --telegram on` (they live in connectors.json next to the Telegram setup; a bot cannot read another bot, so the Telegram copy alone is not enough), a webhook HMAC secret with `signal secret generate --out FILE`, hard caps with `signal caps set` (raising needs a typed confirmation), then arm on your own terminal with `dexterm autotrade signal arm <id>` (evidence panel + a typed phrase with a one-time code; any later edit disarms; below the evidence floor, a completed forward test with your `minForwardTrades` closed paper trades and a backtest of these exact rules, or past a missing rung of the ladder, it refuses unless you first type a separate OVERRIDE phrase, which is journaled) and run `dexterm autotrade run <id> --real` (live data only, in the foreground: in 0.2.0 the always-on service runs paper strategies only). Delivery is at least once from a restart-safe outbox, in nonce order, retried until acked or expired; the executor dedupes by nonce, ignores expired signals and acts only on its own account. Every run ends with a `halted` signal that lists the positions still open; the run\'s last lines and the Telegram copy name them too. Open positions are carried to the next real run of the same strategy and account, whose exits keep firing; while no real run is running NO signal goes out, sells included, so manage them in your executor. Positions stay unconfirmed until `dexterm autotrade receipt <nonce> executed|rejected|failed` (data only). `signal kill` stops every signal; `signal status`, `signal tail`, `receipt list` and `autotrade audit` show the outbox, receipts and drift; agents can read the MCP feed with trade_fire_feed. No model, tool or chat tap can arm real mode, raise a cap or lift the kill switch. Experimental; not financial advice.' },
@@ -264,6 +267,9 @@ const TOPICS = [
264
267
  { id: 'trade-ledger', title: 'See every trade in one ledger: win rate, profiles, exit reasons, strategy versions (dexterm ledger)', screen: 'all', tags: ['ledger', 'journal', 'trades', 'history', 'results', 'stats', 'win', 'rate', 'drawdown', 'profile', 'exit', 'reason', 'compare', 'export', 'csv', 'equity', 'mfe', 'mae'],
265
268
  commands: ['ledger'], tools: ['ledger_trades', 'ledger_positions', 'ledger_summary', 'ledger_equity', 'ledger_compare', 'candidate_dossier', 'outcomes_lookup', 'trading_status', 'telegram_status', 'backtest_requests'], feature: 'trade-ledger',
266
269
  body: '`dexterm ledger trades` lists every round trip dexterm booked, from the paper ticket, the agent paper desk and autotrade runs, newest first: strategy version, the exit profile and entry reason it was opened with (marked * when not declared with the fill), the exit reason, size and % of book, entry and exit market cap, P/L, hold and MFE/MAE. `positions` shows the open ones. `summary` gives the win rate with its 95% interval, P/L, profit factor, drawdown and rug rate by profile, exit reason, strategy, source and mode; `equity` the realized equity curve; `compare` strategy versions side by side (for strategies run in parallel); `export csv|json|parquet-lite [--out FILE]`; `card <tradeId>` a PnL card from a row. Filters: --source, --mode paper|devnet|live|real (real = a real-mode run: dexterm\'s model of the trades it signalled), --strategy id[@version], --profile, --exit, --mint, --run, --days, --status. Reads never write; `dexterm ledger sync` appends to ~/.dexterm/ledger/trades.jsonl (sha256-chained) and `dexterm ledger verify` names the first edited line. Agents read the same rows with the read-only ledger_* tools; `trading_status` and `telegram_status` show settings and state, never a key, address, exact balance, bot token or chat id. A backtest an agent asks for (backtest_request) only runs when you run it: `dexterm autotrade requests`. PAPER rows are simulated; filter by --mode before comparing. Not financial advice. Full docs: https://dexterm.xyz/docs/paper-trading/#the-trade-ledger' },
270
+ { id: 'strategy-dashboard', title: 'Track how your autotrade strategies perform in a browser, on this machine only (dexterm dash)', screen: 'all', tags: ['dash', 'dashboard', 'performance', 'strategies', 'track', 'equity', 'drawdown', 'compare', 'baseline', 'rugs', 'stuck', 'browser', 'web', 'phone'],
271
+ commands: ['dash'],
272
+ body: '`dexterm dash` reads your own autotrade run ledgers ($DEXTERM_HOME/autotrade/runs) and serves a dashboard on http://127.0.0.1:4317/ (loopback only; no account, no cloud, nothing leaves the machine; read-only). Views: Overview (every strategy side by side with an equity sparkline), Strategy (equity curve, drawdown, exit reasons, open positions with the engine mark and the realizable value from the last cached pool read, every trade), Rugs & stuck (closed at -80% or worse and stuck positions written off at what they actually sold), Compare (each strategy minus a --baseline with the 95% interval of the difference) and Live status; it refreshes every 30 s. Every number carries its sample size; under 20 closed trades a strategy is exploratory. Flags: --port, --home (repeat for several homes), --baseline ID, --books FILE (your own script\'s books), --allow-host NAME (a private proxy in front), --json, --export DIR [--redact-mints] (a static read-only snapshot). PAPER results are simulated. Not financial advice.' },
267
273
  { id: 'coin-images', title: 'Turn coin images on or off', screen: 'all', tags: ['images', 'image', 'pictures', 'icons', 'logos', 'off'],
268
274
  flags: ['--no-images'], feature: 'coin-images',
269
275
  body: 'Coin pictures render as real pixels in image-capable terminals (kitty/iTerm2/sixel) and as block art everywhere else. Turn them off with `--no-images` (or `DEXTERM_IMAGES=0`, or `enabled:false` in `~/.dexterm/images.json`). `dexterm doctor` has a Coin images line that says which path you get.' },
@@ -283,6 +289,9 @@ const TOPICS = [
283
289
  { id: 'spectate-an-agent', title: 'Watch an agent drive dexterm (dexterm spectate)', screen: 'all', tags: ['spectate', 'spectator', 'watch', 'agent', 'cook', 'observe', 'replay', 'stream', 'record', 'mcp', 'follow'],
284
290
  commands: ['spectate'], flags: ['--replay', '--spectate-mcp', '--spectate-thoughts'], feature: 'spectator',
285
291
  body: 'Watch an agent work. For an agent on the loopback bus, `dexterm agent serve --spectator-popup` opens the AGENT ACTIVITY window, `--spectate` starts the read-only, token-gated, loopback stream, and `--record FILE` saves a redacted session. For an agent connected over MCP (`dexterm mcp install`), run the app with `dexterm --spectate-mcp`: the running terminal follows the agent and an agent dock appears under the screen — a phase rail (SCANNING > VETTING > SIZING > EXECUTING > HOLDING > EXITING) with a heartbeat, the call timeline with result chips, the rows it scans glow and a cursor marks the coin it opens, a vetting card fills its checks and LARP evidence, and a PAPER trade shows its ticket, the fill, a live position with the mark PnL and a result card. `--spectate-thoughts FILE` streams the agent\'s own words into a thought ribbon from a JSONL file it writes (for example `claude -p --output-format stream-json`); display only. PAPER is always on screen; ctrl+p pauses, ctrl+k kills (a hard stop) and any key you press takes control; calm mode drops the motion. While the agent is active, the token screen\'s analyst panel is its BRAIN VIEW: the reasoning stream with phase tags (or dexterm\'s narration when there is no thoughts feed), the vetting checklist with the app\'s numbers and pass/warn/fail/pending, a data strip, the PAPER plan and position, and the session\'s decision log with a simulated scoreboard. While it holds a PAPER position, a PnL chip shows coin, PnL, entry to mark, peak, drawdown, time held and the take-profit it said, on every terminal screen (in the dock, or on the band under the header on terminals under 24 rows) and on the chart popup; display only. `dexterm spectate --replay FILE` replays a saved session step by step and `dexterm spectate --info` shows the live stream endpoint. Every shared copy is redacted — a tool name, refusal, agent line or navigation can never leak a wallet or key — and a spectator can only watch, never act. Full docs: https://dexterm.xyz/docs/spectator/' },
292
+ { id: 'streamer-overlay', title: 'Streamer overlay for OBS (dexterm overlay)', screen: 'all', tags: ['overlay', 'obs', 'stream', 'streamer', 'browser source', 'scene', 'twitch', 'spectator', 'pnl', 'coin card', 'activity'],
293
+ commands: ['overlay'], feature: 'streamer-overlay',
294
+ body: 'Put the agent session on a live stream. `dexterm overlay` serves three transparent scenes for an OBS Browser source on 127.0.0.1: the agent activity feed, the coin card the agent is on (name, LARP evidence and the deterministic verdict, a top-10 holder bar, the live holder/rug signals) and the PnL card (the same renderer as the H key; stamped PAPER for the agent\'s simulated fills, or PAPER / LIVE by wallet mode with `--ledger <tradeId>`). `--scene all` puts all three in one source, `--theme cyberspace|ice|neon` picks a preset. It watches the read-only spectator stream a running `dexterm --spectate-mcp` (or `dexterm agent serve --spectate`) opens; `--replay FILE` plays a saved session and every scene then says SAMPLE DATA. Each printed URL works once (a single-use ticket, 10 minutes to paste it; press n for fresh ones). Addresses are always shortened, every scene shows the age of its data, and the overlay can only watch: it sends nothing back and cannot pause, trade, arm or sign. Full docs: https://dexterm.xyz/docs/overlay/' },
286
295
  { id: 'recorder-history', title: 'Work with the history recorder (dexterm recorder)', screen: 'all', tags: ['recorder', 'record', 'history', 'stats', 'label', 'prune', 'outcomes', 'database', 'session', 'capture', 'data'],
287
296
  commands: ['recorder'], flags: ['--record', '--no-recorder'], feature: 'recorder',
288
297
  body: '`dexterm recorder stats|label|prune|export` works with the local history recorder\'s tables (the outcome-labelled data the rug model and backtests learn from). No network. Recording is on by default; `--no-recorder` disables it for a run and `--record FILE` captures a data session to a file for replay.' },
@@ -292,6 +301,9 @@ const TOPICS = [
292
301
  { id: 'narrative-board', title: 'See the narrative board (dexterm narrative)', screen: 'trending', tags: ['narrative', 'narratives', 'board', 'trending', 'movers', 'window', 'summary'],
293
302
  commands: ['narrative'], feature: 'trending-board',
294
303
  body: '`dexterm narrative [--window 1h|24h] [--summary]` prints the narrative board — what is trending across the recorded feed — with no network and no model. In-app it is the trending screen.' },
304
+ { id: 'narratives-tracker', title: 'Track memes and narratives (dexterm narratives, experimental)', screen: 'narrative', tags: ['narratives', 'narrative', 'memes', 'meme', 'copycat', 'lifecycle', 'ignition', 'heat', 'saturated', 'experimental'],
305
+ actions: [{ action: 'narrative.view', screen: 'narrative' }, { action: 'narrative.drill', screen: 'narrative' }], commands: ['narratives'], tools: ['narratives_top', 'narrative_detail', 'narrative_of'], feature: 'narratives',
306
+ body: 'EXPERIMENTAL. The narratives tracker groups coins by the theme their name and ticker carry (ticker puns, animals, celebrities, AI agents, seasonal events, copy-cats of a runner) and measures each theme per window: coins, new-coin rate, volume share, unique-trader growth, top runner and drawdown, a lifecycle state (spark / ignition / peak / fading / dead, with the rule that set it) and a copy-cat saturation flag. On the N screen, n flips the board into the NARRATIVES table (the first switch reads the last 26 h of recorded history and takes tens of seconds), enter to see a narrative\'s coins in spawn order, esc to go back. `dexterm narratives [top|detail ID|of MINT|export]` prints the same from the recorder; the analyst uses narratives_top, narrative_detail and narrative_of. Rules can use narrative_rank, narrative_score and the tags narrative:ignition / narrative:saturated. Keyless and local; measured heat, not advice.' },
295
307
  { id: 'kol-lists', title: 'Manage KOL wallet lists (dexterm kol)', screen: 'kol', tags: ['kol', 'kols', 'influencer', 'leader', 'list', 'lists', 'wallet', 'mute', 'import'],
296
308
  commands: ['kol'], feature: 'kol-lists',
297
309
  body: '`dexterm kol list|create|add|remove|mute|unmute|enable|disable|import` manages key-opinion-leader wallet lists on the shared DB (official / authorized sources only). In-app the KOL screen shows the lists and their activity.' },
@@ -25,6 +25,7 @@ import { appendFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { dextermHome } from '../../../custom/src/index.js';
27
27
  import { KillSwitch, createDataLayer, defaultDbPath, resolveRpcUrl } from '../../../data/src/index.js';
28
+ import { DrainPoller, RugProbe, chainVets, defaultFetchJson, readOnlyCall, rugproofVet } from '../../../autotrade/src/index.js';
28
29
  import { FleetGuard, HEARTBEAT_MS, accountTable, LiveSource, RUNNER_RENDEZVOUS_SCHEMA, RUN_SUMMARY_SCHEMA, RunnerSafety, acquireRunLock, bankrollUsd, claudeSessionFactory, clearControl, clearMemberControls, clearStaleControl, compareRows, compareTable, controlPath, fileLedger, fleetStatusPath, fleetTotals, loadFleetConfig, loadRunnerConfig, loadTape, memberStrategy, newRunId, prepareAccount, releaseRunLock, renderDossierRecord, runFleet, runFiles, runnerRendezvousPath, runnerSystemPrompt, sharedDossier, stubSession, writeJsonAtomic, writeRendezvous, } from '../../../autotrade/src/index.js';
29
30
  import { parseArgs, resolveStrategyArg } from "./strategies.js";
30
31
  import { larpVet } from "./run.js";
@@ -121,7 +122,8 @@ export async function fleetRunCmd(argv, io, over = {}) {
121
122
  const rpc = resolveRpcUrl({ env: io.env });
122
123
  data = createDataLayer({ dbPath: io.env.DEXTERM_DB_PATH || defaultDbPath(io.env), rpcUrl: rpc.url, rpcFallbackUrl: rpc.fallbackUrl });
123
124
  }
124
- const closeData = () => { try {
125
+ const closeData = () => { if (data)
126
+ over.onReadTools?.(null); try {
125
127
  data?.close();
126
128
  }
127
129
  catch { /* closing */ } };
@@ -218,6 +220,7 @@ export async function fleetRunCmd(argv, io, over = {}) {
218
220
  let source;
219
221
  let dossier;
220
222
  let vet;
223
+ let drain;
221
224
  if (data) {
222
225
  live = new LiveSource({ store: data.store, dexscreener: data.dexscreener }, { onLog: (l) => fleetLedger.add(Date.now(), 'source_error', { error: l.slice(0, 300) }) });
223
226
  const ls = live;
@@ -225,6 +228,7 @@ export async function fleetRunCmd(argv, io, over = {}) {
225
228
  // One dossier record per coin per 30 s, whichever strategy asks; each strategy renders its own text.
226
229
  const { McpTools } = await import('../../../mcp/src/index.js');
227
230
  const mcp = new McpTools(data, () => ({ screen: 'autotrade-runner', actions: { mints: [], feeds: [], sortKeys: [] } }));
231
+ over.onReadTools?.(readOnlyCall(mcp));
228
232
  const shared = sharedDossier(async (mint) => {
229
233
  try {
230
234
  return await mcp.call('get_dossier', { mint, interval: '5m' });
@@ -235,7 +239,12 @@ export async function fleetRunCmd(argv, io, over = {}) {
235
239
  });
236
240
  dossier = () => async (k, h, matched, b) => renderDossierRecord(await shared.get(k.mint), { mint: k.mint, symbol: k.symbol, matched }, k, h, b);
237
241
  // A member's LARP gate (entry.safety.larp, the yolo preset's) on live data, as `autotrade run` has it.
238
- vet = fleetLarpVet((mint) => mcp.call('larp_check', { mint }));
242
+ const larpVets = fleetLarpVet((mint) => mcp.call('larp_check', { mint }));
243
+ // task-1748: a member's rug-proof gates (entry.safety.rugproof) run first, then its LARP gate; one probe (and its
244
+ // caches) for the whole fleet. Read-only: DexScreener, the user's RPC (or the public one), Jupiter quotes.
245
+ const probe = new RugProbe({ fetchJson: defaultFetchJson(), rpcUrl: resolveRpcUrl({ env: io.env }).url });
246
+ vet = (mm) => chainVets(mm.strategy.entry.safety.rugproof ? rugproofVet(mm.strategy.entry.safety.rugproof, { probe }) : undefined, larpVets(mm));
247
+ drain = (mm) => { const g = mm.strategy.entry.safety.rugproof?.drainGuard; return g ? new DrainPoller(g, probe, (rec) => fleetLedger.add(Date.now(), 'drain_probe', { member: mm.strategy.id, ...rec })) : undefined; };
239
248
  }
240
249
  // Heartbeat: the aggregate rendezvous, the fleet status, every summary.
241
250
  const states = new Map();
@@ -302,7 +311,7 @@ export async function fleetRunCmd(argv, io, over = {}) {
302
311
  const res = await runFleet({
303
312
  fleetId, home, members, tape, source, runner: runnerCfg, guard, fleetLedger, fleetLedgerPath: fleetFiles.ledger, ...(accounts.length ? { accounts } : {}),
304
313
  pacing: replay ? { mode: 'paced', msPerTick: num(opts['ms-per-tick'], 2000) } : { mode: 'live' },
305
- sessions, dossier, vet: over.vetWrap ? (m) => over.vetWrap(m, vet?.(m)) : vet, newRunId: (id) => newRunId(id), verbose: true, onLine: say,
314
+ sessions, dossier, vet: over.vetWrap ? (m) => over.vetWrap(m, vet?.(m)) : vet, drain, newRunId: (id) => newRunId(id), verbose: true, onLine: say,
306
315
  globalSafety: () => {
307
316
  if (Date.now() >= stopAt)
308
317
  stop('max-minutes');
@@ -29,6 +29,7 @@ import { join } from 'node:path';
29
29
  import { dextermHome, loadConfig } from '../../../custom/src/index.js';
30
30
  import { pickBasis } from '../../../data/src/basis.js';
31
31
  import { KillSwitch, createDataLayer, defaultDbPath, resolveRpcUrl } from '../../../data/src/index.js';
32
+ import { DrainPoller, RugProbe, chainVets, defaultFetchJson, readOnlyCall, rugproofVet } from '../../../autotrade/src/index.js';
32
33
  import { HEARTBEAT_MS, LiveSource, badgeOf, RUNNER_RENDEZVOUS_SCHEMA, RunnerSafety, SessionGate, acquireRunLock, clearControl, clearStaleControl, controlPath, claudeSessionFactory, effectiveCaps, fileLedger, loadRunnerConfig, loadTape, newRunId, prepareAccount, releaseRunLock, renderDossierRecord, runAutotrade, runnerRendezvousPath, runnerSystemPrompt, simExecutor, stubSession, writeJsonAtomic, writeRendezvous, RUN_SUMMARY_SCHEMA, builtinTapePath, } from '../../../autotrade/src/index.js';
33
34
  import { assertAutotradeAllowed, ollamaSessionFactory } from '../../../autotrade/src/ollama-sessions.js';
34
35
  import { Outbox, RealSignalExecutor, channelsFromConfig, enabledChannels, loadSignalsConfig, openRealRun, realModeCheck } from '../../../autotrade/src/index.js';
@@ -297,6 +298,7 @@ export async function runCmd(argv, io, over = {}) {
297
298
  let live = null;
298
299
  let dossier;
299
300
  let vet;
301
+ let drain;
300
302
  let closeData = () => { };
301
303
  if (replay) {
302
304
  try {
@@ -311,13 +313,15 @@ export async function runCmd(argv, io, over = {}) {
311
313
  else {
312
314
  const rpc = resolveRpcUrl({ env: io.env });
313
315
  const data = createDataLayer({ dbPath: io.env.DEXTERM_DB_PATH || defaultDbPath(io.env), rpcUrl: rpc.url, rpcFallbackUrl: rpc.fallbackUrl });
314
- closeData = () => data.close();
316
+ closeData = () => { host?.setReadTools(null); data.close(); };
315
317
  liveData = data.dexscreener;
316
318
  live = new LiveSource({ store: data.store, dexscreener: data.dexscreener }, { onLog: (l) => log('source_error', { error: l.slice(0, 300) }) });
317
319
  source = { name: 'live keyless data layer (Store + DexScreener tokens/v1 fast lane)', ticks: live.ticks(), startT: startedAt };
318
320
  // v1 dossier: the MCP get_dossier assembly (market, holders, security, candles, tape, creator) for the mint.
319
321
  const { McpTools } = await import('../../../mcp/src/index.js');
320
322
  const mcp = new McpTools(data, () => ({ screen: 'autotrade-runner', actions: { mints: [], feeds: [], sortKeys: [] } }));
323
+ // task-1426: the bot's /check and /top read the same tools (read tools only), while this run's data layer is open.
324
+ host?.setReadTools(readOnlyCall(mcp));
321
325
  dossier = async (k, h, matched, b) => {
322
326
  let rec;
323
327
  try {
@@ -332,6 +336,14 @@ export async function runCmd(argv, io, over = {}) {
332
336
  const larp = strategy.entry.safety.larp;
333
337
  if (larp)
334
338
  vet = larpVet(larp, (mint) => mcp.call('larp_check', { mint }));
339
+ // task-1748: the rug-proof gates first (read-only probes), then the LARP gate; the drain guard polls held pools.
340
+ const rp = strategy.entry.safety.rugproof;
341
+ if (rp) {
342
+ const probe = new RugProbe({ fetchJson: defaultFetchJson(), rpcUrl: rpc.url });
343
+ vet = chainVets(rugproofVet(rp, { probe }), vet);
344
+ if (rp.drainGuard)
345
+ drain = new DrainPoller(rp.drainGuard, probe, (rec) => log('drain_probe', rec));
346
+ }
335
347
  }
336
348
  // Heartbeat + rendezvous: on every state change (throttled to 1 s) and every 10 s regardless.
337
349
  let last = null;
@@ -394,7 +406,7 @@ export async function runCmd(argv, io, over = {}) {
394
406
  let code = 0;
395
407
  try {
396
408
  const res = await runAutotrade({
397
- strategy, basis, tape, source, sessions, gate, dossier, vet, verbose: true, onLine: say, ledger, runId,
409
+ strategy, basis, tape, source, sessions, gate, dossier, vet, drain, verbose: true, onLine: say, ledger, runId,
398
410
  ...(realExec ? { executor: realExec, real: realExec, ...(host ? { approvals: host.service.broker } : {}) } : paperExec ? { executor: paperExec, maxHoldMinutes: () => paperExec.maxHoldMinutes(), ...(host ? { approvals: host.service.broker } : {}) } : host ? { executor: host.port, approvals: host.service.broker } : { executor: baseExec }),
399
411
  pacing: replay ? { mode: 'paced', msPerTick: num(opts['ms-per-tick'], 2000) } : { mode: 'live' },
400
412
  // The panic file (`dexterm wallet panic`), a stop / flatten request and a local stop (signal, --max-minutes) all
@@ -470,6 +470,8 @@ async function runServiceCmd(argv, io, deps) {
470
470
  const over = {
471
471
  ...deps.fleetOver,
472
472
  extraSafety: () => ({ pauseEntries: lastGuard?.pauseEntries ?? null }),
473
+ // task-1426: the fleet's read tools for the bot's /check and /top (the service opens no data layer of its own).
474
+ onReadTools: (call) => { tg?.setReadTools(call); deps.fleetOver?.onReadTools?.(call); },
473
475
  vetWrap: takenVet,
474
476
  adopt: (id) => adopt.get(id),
475
477
  onStarted: (id, fl) => {
@@ -587,7 +589,7 @@ async function startTelegram(home, runId, say) {
587
589
  } };
588
590
  const svc = new TelegramService({ home, connector, port, knownToken: () => token, runId, state, strategy: () => null, alert: (l) => say(`! ${l}`) });
589
591
  await svc.start();
590
- say('telegram: your bot is on (notifications, /status, /pause, /stop)');
592
+ say('telegram: your bot is on (notifications, /status, /pause, /stop, /check, /top, /signals)');
591
593
  return svc;
592
594
  }
593
595
  // ───────────────────────────── status / logs ─────────────────────────────
@@ -21,7 +21,7 @@
21
21
  // reconcile read-only: a PUBLIC wallet address's SOL balance and holdings vs the real-mode book
22
22
  import { randomBytes } from 'node:crypto';
23
23
  import { readFileSync, writeFileSync } from 'node:fs';
24
- import { join } from 'node:path';
24
+ import { isAbsolute, join } from 'node:path';
25
25
  import { dextermHome } from '../../../custom/src/index.js';
26
26
  import { DEFAULT_REAL_CAPS, EXECUTOR_CHANNELS, Outbox, buildTestSignal, capsProblem, capsRaised, channelsFromConfig, codeHash, confirmPhrase, deadManSweep, disarm, driftReport, enabledChannels, engageRealKill, evidenceFloor, heldFromSignals, journalReal, journeyView, markRealApplied, renderFloor, hmacStore, loadArmings, loadCaps, loadHoldings, releaseHolding, allOutboxSignals, loadSignalsConfig, newChallenge, paperTwinHash, phraseMatches, readReceipts, realKill, reconcileWallet, releaseRealKill, planCaps, saveArming, saveCaps, saveSignalsConfig, argvProblem, webhookUrlProblem, ACCOUNT_ID_RE, runLockPath, capsOfValues, divergenceLines, divergenceReport, effectiveLimits, emptyLimits, executorHealth, healthLine, limitsHash, limitsLines, limitsSummary, loadLimits, changeLine, planLimitsSave, raises, saveLimits, readRealJournal, } from '../../../autotrade/src/index.js';
27
27
  import { parseArgs, storeFor } from "./strategies.js";
@@ -380,7 +380,7 @@ function secretCmd(argv, io, home) {
380
380
  };
381
381
  if (argv[0] === 'generate') {
382
382
  const { opts } = parseArgs(argv.slice(1));
383
- if (typeof opts.out !== 'string' || !opts.out.startsWith('/')) {
383
+ if (typeof opts.out !== 'string' || !isAbsolute(opts.out)) {
384
384
  err(io, 'secret generate --out /absolute/file (0600; your executor reads it there; the secret is never printed)');
385
385
  return 2;
386
386
  }
@@ -73,7 +73,7 @@ export function listCmd(argv, io) {
73
73
  io.stdout.write(`${head} INVALID: ${(r.errors ?? []).slice(0, 2).map(fmtIssue).join('; ')}\n`);
74
74
  continue;
75
75
  }
76
- io.stdout.write(`${head} v${r.version.padEnd(7)} ${r.hash} ${r.mode.padEnd(5)} profiles ${r.profiles.join(',')} · ${bt}${r.highRisk ? ' · ⚠ HIGH RISK' : ''}\n`);
76
+ io.stdout.write(`${head} v${r.version.padEnd(7)} ${r.hash} ${r.mode.padEnd(5)} profiles ${r.profiles.join(',')} · ${bt}${r.highRisk ? ' · ⚠ HIGH RISK' : ''}${r.experimentalTiers ? ' · EXPERIMENTAL tiers (unproven, paper only)' : ''}\n`);
77
77
  }
78
78
  if (rows.some((r) => r.shadowsPreset))
79
79
  io.stdout.write(' * your file shadows the shipped preset of the same id\n');
@@ -47,6 +47,8 @@ export class TelegramHost {
47
47
  if (solUsdFor)
48
48
  this.solUsdFor = solUsdFor;
49
49
  }
50
+ /** task-1426: the run's read tools for /check and /top (null when its data layer closes, or on a replay). */
51
+ setReadTools(call) { this.service.setReadTools(call); }
50
52
  onLedger = (e) => this.service.onLedger(e);
51
53
  onState = (st) => { this.state = st; this.service.onState(st); };
52
54
  /** The strategy a confirmed /use asked for, once (null when none). */