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,344 @@
1
+ // dexterm autotrade — STRATEGY STATS for the performance dashboard (task-1749). Read-only over the run ledgers
2
+ // ($DEXTERM_HOME/autotrade/runs/*.jsonl): no network, no model, no key, never writes.
3
+ //
4
+ // The definitions are the challenge control's (launch/send-out/challenge-100x/control/report.mjs + mine.mjs, task-1697 /
5
+ // task-1733), so the dashboard and the control report count the same things the same way:
6
+ // entries each position once across restarts (its first fill or adoption), keyed per run FILE + engine id
7
+ // closed `trade_closed` (deduped) + WRITE-OFFS: a position the engine marked `stuck` (no pool to sell into) with no
8
+ // sell for >= 30 min, or dropped stuck at a clean run end, closes at its REALIZABLE value: the sells it made
9
+ // are kept, the unsold remainder counts 0. Derived here; the engine's ledgers are never edited.
10
+ // abandoned still open when a run ended cleanly and not stuck: the engine does not carry it, its outcome never enters
11
+ // the closed sample
12
+ // rugs a closed position at <= -80% on cost (a write-off is a rug only at <= -80%)
13
+ // mean mean % on cost with a 95% seeded percentile bootstrap (4000 resamples, seed 7); a difference of two
14
+ // strategies is a bootstrap of independent groups (seed 11); rates use the Wilson 95% interval
15
+ // sample under 20 closed is EXPLORATORY; the ETA to 20 closed = (20 - closed) / entries per day since the first event
16
+ // One difference from report.mjs (a fix, test/strategy-stats-1749.test.ts): the engine's crash recovery names an adopted
17
+ // position `<runId>/pN` while that run's own fills said `pN`; report.mjs keys `pN` and so counted an adopted position
18
+ // two or three times. Here `<runId>/pN` maps onto the position `pN` of the file `<runId>.jsonl`.
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ export const RUG_PCT = -80;
22
+ export const MIN_CLOSED = 20;
23
+ export const STUCK_WRITE_OFF_MIN = 30;
24
+ const DAY = 86_400_000;
25
+ /** mulberry32: the seeded generator of control/mine.mjs (same seed, same intervals). */
26
+ export function rng(seed = 7) {
27
+ let a = seed >>> 0;
28
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
29
+ }
30
+ const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
31
+ const pctile = (xs, p) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.floor(p * s.length)))]; };
32
+ /** Mean with a 95% percentile-bootstrap interval (control/mine.mjs meanCi). */
33
+ export function meanCi(xs, { B = 4000, seed = 7 } = {}) {
34
+ if (!xs.length)
35
+ return { n: 0, mean: null, lo: null, hi: null };
36
+ const r = rng(seed);
37
+ const ms = [];
38
+ for (let b = 0; b < B; b++) {
39
+ let s = 0;
40
+ for (let i = 0; i < xs.length; i++)
41
+ s += xs[Math.floor(r() * xs.length)];
42
+ ms.push(s / xs.length);
43
+ }
44
+ return { n: xs.length, mean: mean(xs), lo: pctile(ms, 0.025), hi: pctile(ms, 0.975) };
45
+ }
46
+ /** Difference of means (a - b), independent groups, bootstrap 95% (control/mine.mjs diffCi). */
47
+ export function diffCi(a, b, { B = 4000, seed = 11 } = {}) {
48
+ if (!a.length || !b.length)
49
+ return { mean: null, lo: null, hi: null };
50
+ const r = rng(seed);
51
+ const ds = [];
52
+ for (let k = 0; k < B; k++) {
53
+ let sa = 0, sb = 0;
54
+ for (let i = 0; i < a.length; i++)
55
+ sa += a[Math.floor(r() * a.length)];
56
+ for (let i = 0; i < b.length; i++)
57
+ sb += b[Math.floor(r() * b.length)];
58
+ ds.push(sa / a.length - sb / b.length);
59
+ }
60
+ return { mean: mean(a) - mean(b), lo: pctile(ds, 0.025), hi: pctile(ds, 0.975) };
61
+ }
62
+ /** Wilson 95% interval for k of n, as fractions (control/mine.mjs wilson). */
63
+ export function wilsonCi(k, n) {
64
+ if (!n)
65
+ return [null, null];
66
+ const z = 1.96, p = k / n, d = 1 + z * z / n, c = p + z * z / (2 * n), h = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n));
67
+ return [Math.max(0, (c - h) / d), Math.min(1, (c + h) / d)];
68
+ }
69
+ /**
70
+ * Every strategy (fleet member) in one runs directory, its positions tracked across run files (control/report.mjs
71
+ * readMembers, rules in the header). `now` decides which stuck positions are already written off.
72
+ */
73
+ export function readStrategyLedgers(runsDir, { now = Date.now(), writeOffMin = STUCK_WRITE_OFF_MIN } = {}) {
74
+ const M = {};
75
+ if (!existsSync(runsDir))
76
+ return M;
77
+ const symbols = new Map(); // mint -> symbol, from the candidate lines (fills carry no symbol)
78
+ const entryTick = new Map(); // mint -> the candidate tick that preceded the entry (pool age, liquidity, mcap)
79
+ const state = {};
80
+ const fin = (x) => typeof x === 'number' && Number.isFinite(x);
81
+ for (const f of readdirSync(runsDir).filter((x) => x.endsWith('.jsonl') && !/-fleet-/.test(x)).sort()) {
82
+ let member = null;
83
+ let text;
84
+ try {
85
+ text = readFileSync(join(runsDir, f), 'utf8');
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ for (const line of text.split('\n')) {
91
+ if (!line)
92
+ continue;
93
+ let e;
94
+ try {
95
+ e = JSON.parse(line);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (e.kind === 'fleet_member' || e.kind === 'run_start') {
101
+ member ??= e.member ?? e.strategy ?? null;
102
+ if (member && !M[member]) {
103
+ M[member] = { files: 0, entries: 0, closed: [], open: 0, stuckOpen: [], openPositions: [], abandoned: [], writtenOff: [], adopted: 0, lastClampT: null, firstT: null, lastT: null, candidates: 0, entryTimes: [], startUsd: null, mode: null, basedOn: null };
104
+ state[member] = { pos: new Map(), openNow: new Set(), closedKeys: new Set(), local: new Map(), file: null, carry: new Set() };
105
+ }
106
+ if (member) {
107
+ const m = M[member];
108
+ if (fin(e.bankroll?.usd) && m.startUsd === null)
109
+ m.startUsd = e.bankroll.usd;
110
+ m.mode ??= e.mode ?? null;
111
+ if (e.basedOn)
112
+ m.basedOn = e.basedOn;
113
+ if (state[member].file !== f) {
114
+ state[member].file = f;
115
+ state[member].local = new Map();
116
+ state[member].carry = new Set(state[member].openNow);
117
+ m.files++;
118
+ }
119
+ }
120
+ continue;
121
+ }
122
+ if (!member)
123
+ continue;
124
+ const m = M[member];
125
+ const s = state[member];
126
+ if (typeof e.t === 'number') {
127
+ m.firstT = m.firstT === null ? e.t : Math.min(m.firstT, e.t);
128
+ m.lastT = m.lastT === null ? e.t : Math.max(m.lastT, e.t);
129
+ }
130
+ // a restart adopts what it carries at the head of its file; a position of the previous (crashed) file it did not
131
+ // adopt is gone from the engine's book: "abandoned", not open forever
132
+ if (s.carry.size && e.kind !== 'position_adopted') {
133
+ for (const key of s.carry) {
134
+ const p = s.pos.get(key);
135
+ s.openNow.delete(key);
136
+ m.abandoned.push({ positionId: p.positionId, mint: p.mint, openedT: p.firstT, stoppedT: e.t, notCarried: true, _p: p });
137
+ }
138
+ s.carry.clear();
139
+ }
140
+ if (e.kind === 'clamp' && typeof e.t === 'number')
141
+ m.lastClampT = Math.max(m.lastClampT ?? 0, e.t);
142
+ if (e.kind === 'candidate') {
143
+ m.candidates++;
144
+ if (e.mint && e.symbol)
145
+ symbols.set(e.mint, e.symbol);
146
+ if (e.mint && e.tick)
147
+ entryTick.set(e.mint, e.tick);
148
+ continue;
149
+ }
150
+ // the open position of an earlier file this id + mint carries (an adoption), newest first; `<runId>/pN` names the
151
+ // position pN of the file <runId>.jsonl exactly
152
+ const carried = (id, mint) => {
153
+ const slash = id.lastIndexOf('/');
154
+ if (slash > 0) {
155
+ const k = `${id.slice(0, slash)}.jsonl#${id.slice(slash + 1)}`;
156
+ if (s.pos.has(k) && !s.closedKeys.has(k))
157
+ return k;
158
+ }
159
+ return [...s.openNow].reverse().find((k) => { const p = s.pos.get(k); return p.positionId === id && (!mint || !p.mint || p.mint === mint); }) ?? null;
160
+ };
161
+ const open = (id, mint, t, profile = null) => {
162
+ const key = `${f}#${id}`;
163
+ s.pos.set(key, { key, positionId: id, mint: mint ?? null, symbol: null, firstT: t, costUsd: 0, proceedsUsd: 0, stuckSince: null, stuckWhy: null, lastSellT: null, profile });
164
+ s.openNow.add(key);
165
+ s.local.set(id, key);
166
+ m.entries++;
167
+ if (t !== null)
168
+ m.entryTimes.push(t);
169
+ return key;
170
+ };
171
+ const keyOf = (id, mint, t, { create = true, profile = null } = {}) => {
172
+ if (s.local.has(id))
173
+ return s.local.get(id);
174
+ const c = carried(id, mint);
175
+ if (c) {
176
+ s.local.set(id, c);
177
+ return c;
178
+ }
179
+ return create ? open(id, mint, t, profile) : null;
180
+ };
181
+ if (e.kind === 'position_adopted' && e.positionId) {
182
+ const c = carried(e.positionId, e.mint);
183
+ const key = c ?? open(e.positionId, e.mint, e.position?.openedAt ?? e.t); // its fills were in a file that is gone: it counts here
184
+ s.carry.delete(key);
185
+ s.local.set(e.positionId, key);
186
+ m.adopted++;
187
+ const p = s.pos.get(key);
188
+ const snap = e.position ?? {};
189
+ if (!c && fin(snap.costUsd))
190
+ p.costUsd = snap.costUsd;
191
+ if (snap.stuck && p.stuckSince === null)
192
+ p.stuckSince = e.t;
193
+ p.symbol ??= e.symbol ?? null;
194
+ continue;
195
+ }
196
+ if (e.kind === 'fill' && e.positionId) {
197
+ const key = keyOf(e.positionId, e.mint, e.t, { profile: e.profile ?? null });
198
+ if (s.closedKeys.has(key))
199
+ continue;
200
+ const p = s.pos.get(key);
201
+ p.profile ??= e.profile ?? null;
202
+ if (e.side === 'sell') {
203
+ p.proceedsUsd += fin(e.usd) ? e.usd : 0;
204
+ p.lastSellT = e.t;
205
+ p.stuckSince = null;
206
+ p.stuckWhy = null;
207
+ } // a sale ends a stuck spell
208
+ else
209
+ p.costUsd += fin(e.usd) ? e.usd : 0;
210
+ continue;
211
+ }
212
+ if (e.kind === 'stuck' && e.positionId) {
213
+ const key = keyOf(e.positionId, e.mint, e.t, { create: false });
214
+ const p = key && s.pos.get(key);
215
+ if (p && p.stuckSince === null) {
216
+ p.stuckSince = e.t;
217
+ p.stuckWhy = `${e.reason ?? 'stuck'} (${e.why ?? 'cannot sell'})`;
218
+ }
219
+ continue;
220
+ }
221
+ if (e.kind === 'wake' && e.positionId && e.event === 'mechanical_exit') { // the engine's own words for the stuck exit
222
+ const key = s.local.get(e.positionId);
223
+ const p = key && s.pos.get(key);
224
+ if (p && p.stuckSince === e.t && e.detail)
225
+ p.stuckWhy = e.detail;
226
+ continue;
227
+ }
228
+ if (e.kind === 'trade_closed' && e.trade) {
229
+ const key = keyOf(e.trade.id, e.trade.mint, e.trade.openedAt ?? e.t);
230
+ if (s.closedKeys.has(key))
231
+ continue;
232
+ s.closedKeys.add(key);
233
+ s.openNow.delete(key);
234
+ const p = s.pos.get(key);
235
+ const tick = e.trade.mint ? entryTick.get(e.trade.mint) : undefined;
236
+ m.closed.push({ t: e.trade.closedAt ?? e.t, openedAt: e.trade.openedAt ?? null, mint: e.trade.mint ?? null, symbol: e.trade.symbol ?? null, pnlPct: e.trade.pnlPct, pnlUsd: e.trade.pnlUsd ?? null,
237
+ costUsd: fin(e.trade.costUsd) ? e.trade.costUsd : p?.costUsd ?? null, exitReason: e.trade.exitReason ?? null, stuck: !!e.trade.stuck, profile: e.trade.profileId ?? p?.profile ?? null,
238
+ entryAgeMin: fin(tick?.ageMin) ? tick.ageMin : null, entryLiqUsd: fin(tick?.liq) ? tick.liq : null, entryMcap: fin(tick?.mcap) ? tick.mcap : null });
239
+ continue;
240
+ }
241
+ if (e.kind === 'run_end') {
242
+ for (const key of s.openNow) {
243
+ const p = s.pos.get(key);
244
+ m.abandoned.push({ positionId: p.positionId, mint: p.mint, openedT: p.firstT, stoppedT: e.t, _p: p });
245
+ }
246
+ s.openNow.clear();
247
+ }
248
+ }
249
+ }
250
+ const writeOff = (m, p, endT, how) => {
251
+ const pnlUsd = Math.round((p.proceedsUsd - p.costUsd) * 1e4) / 1e4;
252
+ const pnlPct = p.costUsd > 0 ? Math.round((pnlUsd / p.costUsd) * 1e4) / 100 : null;
253
+ const tick = p.mint ? entryTick.get(p.mint) : undefined;
254
+ const w = { t: p.stuckSince, openedAt: p.firstT, mint: p.mint, symbol: p.symbol ?? (p.mint ? symbols.get(p.mint) ?? null : null), pnlPct, pnlUsd, costUsd: Math.round(p.costUsd * 1e4) / 1e4, realizedUsd: Math.round(p.proceedsUsd * 1e4) / 1e4,
255
+ exitReason: 'written-off', stuck: true, writtenOff: true, held: how === 'still held', profile: p.profile,
256
+ reason: `${how}: stuck ${Math.round((endT - p.stuckSince) / 60_000)} min with no sell (${p.stuckWhy ?? 'illiquid'}); unsold remainder valued 0`, stuckSince: p.stuckSince,
257
+ entryAgeMin: typeof tick?.ageMin === 'number' ? tick.ageMin : null, entryLiqUsd: typeof tick?.liq === 'number' ? tick.liq : null, entryMcap: typeof tick?.mcap === 'number' ? tick.mcap : null };
258
+ m.writtenOff.push(w);
259
+ if (pnlPct !== null)
260
+ m.closed.push(w);
261
+ };
262
+ for (const [id, m] of Object.entries(M)) {
263
+ const s = state[id];
264
+ m.abandoned = m.abandoned.filter((a) => { const p = a._p; delete a._p; if (p.stuckSince === null)
265
+ return true; writeOff(m, p, a.stoppedT ?? now, a.notCarried ? 'dropped stuck (not carried over by the restart)' : 'dropped stuck at a clean run end'); return false; });
266
+ for (const key of s.openNow) {
267
+ const p = s.pos.get(key);
268
+ if (p.stuckSince !== null && now - p.stuckSince >= writeOffMin * 60_000)
269
+ writeOff(m, p, now, 'still held');
270
+ else {
271
+ m.open++;
272
+ m.openPositions.push({ positionId: p.positionId, mint: p.mint, symbol: p.symbol ?? (p.mint ? symbols.get(p.mint) ?? null : null), openedAt: p.firstT, costUsd: Math.round(p.costUsd * 1e4) / 1e4, realizedUsd: Math.round(p.proceedsUsd * 1e4) / 1e4, stuckSince: p.stuckSince, profile: p.profile });
273
+ if (p.stuckSince !== null)
274
+ m.stuckOpen.push({ positionId: p.positionId, mint: p.mint, stuckSince: p.stuckSince });
275
+ }
276
+ }
277
+ for (const c of m.closed)
278
+ c.symbol ??= c.mint ? symbols.get(c.mint) ?? null : null;
279
+ m.closed.sort((a, b) => a.t - b.t);
280
+ }
281
+ return M;
282
+ }
283
+ /**
284
+ * REALIZABLE value of an open position (agent-v4/scoreboard.mjs): the engine's mark sold into the pool with the
285
+ * engine's cost model (constant-product impact on half the liquidity, fee + slippage bps, network fee). null = the
286
+ * pool's liquidity is unknown.
287
+ */
288
+ export function realizableUsd(markUsd, liqUsd, ex = { feeBps: 30, slippageBps: 200, networkFeeUsd: 0.02 }) {
289
+ if (!Number.isFinite(markUsd) || typeof liqUsd !== 'number' || !Number.isFinite(liqUsd) || liqUsd <= 0)
290
+ return null;
291
+ const side = liqUsd / 2;
292
+ const impact = markUsd / (side + markUsd);
293
+ return Math.max(0, markUsd * (1 - impact) * (1 - (ex.feeBps + ex.slippageBps) / 10_000) - ex.networkFeeUsd);
294
+ }
295
+ /** The realized equity curve over the closed sample (start + cumulative P/L in close order) and its drawdown from the running peak. */
296
+ export function equityCurve(closed, startUsd) {
297
+ const start = startUsd ?? 100;
298
+ let eq = start;
299
+ let peak = start;
300
+ let maxDd = 0;
301
+ const points = [];
302
+ for (const c of [...closed].sort((a, b) => a.t - b.t)) {
303
+ eq += typeof c.pnlUsd === 'number' && Number.isFinite(c.pnlUsd) ? c.pnlUsd : 0;
304
+ peak = Math.max(peak, eq);
305
+ const dd = peak > 0 ? (1 - eq / peak) * 100 : 0;
306
+ maxDd = Math.max(maxDd, dd);
307
+ points.push({ t: c.t, usd: Math.round(eq * 100) / 100, drawdownPct: Math.round(dd * 10) / 10 });
308
+ }
309
+ return { points, maxDrawdownPct: Math.round(maxDd * 10) / 10, start, end: Math.round(eq * 100) / 100 };
310
+ }
311
+ /** One strategy's row (control/report.mjs summarize: rugs, means, ETA), plus the exits breakdown and the sample badge. */
312
+ export function strategyRow(id, m, { now = Date.now(), minClosed = MIN_CLOSED } = {}) {
313
+ const xs = m.closed.map((c) => c.pnlPct).filter((x) => typeof x === 'number' && Number.isFinite(x));
314
+ const rugs = m.closed.filter((c) => (c.pnlPct ?? 0) <= RUG_PCT || (c.stuck && !c.writtenOff)).length;
315
+ const days = m.firstT === null ? 0 : (now - m.firstT) / DAY;
316
+ const perDay = days > 0 ? m.entries / days : null;
317
+ const ci = meanCi(xs);
318
+ const by = new Map();
319
+ for (const c of m.closed) {
320
+ const r = c.writtenOff ? 'written-off' : c.exitReason ?? 'unknown';
321
+ if (!by.has(r))
322
+ by.set(r, []);
323
+ if (typeof c.pnlPct === 'number')
324
+ by.get(r).push(c.pnlPct);
325
+ }
326
+ const exits = [...by].map(([reason, v]) => ({ reason, n: v.length, meanPct: v.length ? Math.round(mean(v) * 10) / 10 : null, wins: v.filter((x) => x > 0).length })).sort((a, b) => b.n - a.n);
327
+ const sample = xs.length === 0 ? 'none' : xs.length < minClosed ? 'exploratory' : 'minimum';
328
+ return {
329
+ id, entries: m.entries, closed: xs.length, open: m.open, writtenOff: m.writtenOff.length, stuckOpen: m.stuckOpen.length, abandoned: m.abandoned.length, adopted: m.adopted,
330
+ wins: xs.filter((x) => x > 0).length, losses: xs.filter((x) => x <= 0).length, rugs, rugRate: xs.length ? rugs / xs.length : null, rugCi: wilsonCi(rugs, xs.length),
331
+ meanPct: ci.mean, lo: ci.lo, hi: ci.hi, days, entriesPerDay: perDay, etaDays: perDay ? Math.max(0, (minClosed - xs.length) / perDay) : null,
332
+ sample, sampleText: sample === 'none' ? 'no closed trades yet' : sample === 'exploratory' ? `exploratory: ${xs.length} closed, < ${minClosed}` : `${xs.length} closed (>= ${minClosed})`,
333
+ realizedUsd: Math.round(m.closed.reduce((s, c) => s + (typeof c.pnlUsd === 'number' ? c.pnlUsd : 0), 0) * 100) / 100, exits,
334
+ };
335
+ }
336
+ /** The decision rule the control applies to a variant (control/report.mjs DECISION_RULE, baseline named). */
337
+ export const decisionRule = (base, minClosed = MIN_CLOSED) => `A filter or exit change is adopted only if it beats ${base} over >= ${minClosed} closed entries in BOTH strategies with the 95% interval of the difference in mean % per entry clear of zero (above 0). Until then nothing changes.`;
338
+ /** A variant against the baseline (control/report.mjs verdicts). */
339
+ export function compareToBase(id, a, base, b, minClosed = MIN_CLOSED) {
340
+ const d = diffCi(a, b);
341
+ const enough = a.length >= minClosed && b.length >= minClosed;
342
+ const verdict = !enough ? `not yet: ${a.length} / ${b.length} closed (needs ${minClosed} each)` : (d.lo ?? 0) > 0 ? 'passes the rule: beats the baseline' : (d.hi ?? 0) < 0 ? 'worse than the baseline' : 'no clear difference: keep running';
343
+ return { id, base, n: a.length, baseN: b.length, diff: d, enough, verdict };
344
+ }
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync } from 'node:fs';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { validate } from '../../custom/src/index.js';
15
15
  import { parseFilter } from "../../cli/src/screener/filter.js";
16
+ import { TIER_TABLE_IDS, TIERS_VERDICT, tierTable, tierTableLines } from "./tiers.js";
16
17
  export const STRATEGY_SCHEMA = 'dexterm-strategy/1';
17
18
  export const EXIT_PROFILE_VERSION = '1.2.0';
18
19
  // ───────────────────────────── schema ─────────────────────────────
@@ -72,6 +73,13 @@ export const SCHEMAS = {
72
73
  youngVertical: nullable(obj({ maxAgeHours: num(0, 168), maxH1Pct: num(0, 100000) })),
73
74
  unknownHolders: { type: 'string', enum: ['reject', 'allow'] },
74
75
  larp: nullable(obj({ maxScore: num(0, 100), unknown: { type: 'string', enum: ['reject', 'allow'] } })),
76
+ rugproof: nullable(obj({
77
+ minPoolAgeMin: num(0, 1440), requireSafePool: { type: 'boolean' }, maxHolderLpPct: num(0, 100), maxWithdraws: num(0, 100, true), withdrawScanTx: num(1, 100, true),
78
+ liquidityRecheck: nullable(obj({ afterSec: num(5, 600), floorX: num(1, 100), maxDropPct: num(0, 90) })),
79
+ routeX: nullable(num(1, 20)),
80
+ youngPool: nullable(obj({ underMin: num(1, 1440), maxSizePct: num(1, 100) })),
81
+ drainGuard: nullable(obj({ pollSec: num(2, 10), liqDropPct: num(5, 90), exitOnNoRoute: { type: 'boolean' }, minReserveSol: num(0, 1000) })),
82
+ })),
75
83
  }, ['maxTop10Pct', 'maxDevPct', 'requireNoMintAuthority', 'requireNoFreezeAuthority', 'maxMcapSplitX', 'youngVertical', 'unknownHolders']),
76
84
  triggers: { type: 'array', max: 8, items: 'trigger' },
77
85
  triggerMode: { type: 'string', enum: ['any', 'all'] },
@@ -85,6 +93,7 @@ export const SCHEMAS = {
85
93
  confidence: obj({ minPct: num(0.1, 25), maxPct: num(0.1, 25), default: num(0, 1) }),
86
94
  ladder: { type: 'array', max: 4, items: obj({ frac: num(0.05, 1), atPct: num(-90, 1000), withinMin: num(1, 1440) }) },
87
95
  maxPctOfBankroll: num(0.1, 25), maxUsd: num(1, 1e6), minUsd: num(0.01, 1e6),
96
+ tiers: nullable(obj({ table: { type: 'string', enum: TIER_TABLE_IDS }, experimental: { type: 'string', enum: ['paper-only'] }, oneOpen: { type: 'boolean' } }, ['table', 'experimental'])),
88
97
  }, ['mode', 'maxPctOfBankroll', 'maxUsd', 'minUsd']),
89
98
  risk: obj({
90
99
  perTrade: obj({ maxLossPctOfBankroll: num(0.1, 25), assumedLossPctNoStop: num(10, 100) }),
@@ -103,7 +112,7 @@ export const SCHEMAS = {
103
112
  onTimeout: { type: 'string', enum: ['skip'] },
104
113
  respawnOnWake: { type: 'boolean' },
105
114
  }, ['model', 'maxSessions', 'budgetUsdPerSession', 'budgetUsdPerDay', 'maxTurns', 'deltaEverySec', 'decideWithinSec', 'sessionMaxMin', 'onTimeout']),
106
- execution: obj({ slippageBps: num(0, 5000), feeBps: num(0, 1000), networkFeeUsd: num(0, 10), latencyMs: num(0, 120000), maxPriceImpactPct: num(0.1, 50), minExitLiquidityUsd: num(0, 1e7) }),
115
+ execution: obj({ slippageBps: num(0, 5000), feeBps: num(0, 1000), networkFeeUsd: num(0, 10), latencyMs: num(0, 120000), maxPriceImpactPct: num(0.1, 50), minExitLiquidityUsd: num(0, 1e7), stuckWriteOffMin: nullable(num(1, 10080)) }, ['slippageBps', 'feeBps', 'networkFeeUsd', 'latencyMs', 'maxPriceImpactPct', 'minExitLiquidityUsd']),
107
116
  notes: { type: 'array', max: 8, items: str(300) },
108
117
  }, ['kind', 'schema', 'id', 'name', 'version', 'description', 'mode', 'bankroll', 'entry', 'exits', 'sizing', 'risk', 'agent', 'execution']),
109
118
  };
@@ -200,6 +209,23 @@ export function validateStrategy(doc, library = builtinProfiles()) {
200
209
  errors.push({ path: '/risk/drawdown', msg: 'sizeDownAtPct must not exceed haltAtPct' });
201
210
  if (r.drawdown.recoverBelowPct >= r.drawdown.sizeDownAtPct)
202
211
  errors.push({ path: '/risk/drawdown/recoverBelowPct', msg: 'recoverBelowPct must be below sizeDownAtPct' });
212
+ if (s.execution.stuckWriteOffMin != null && s.mode !== 'paper')
213
+ errors.push({ path: '/execution/stuckWriteOffMin', msg: `the stuck write-off is paper only: in mode ${s.mode} the bag is still in your wallet and keeps its exit signals (remove it or set it to null)` });
214
+ if (z.tiers) {
215
+ // 0.2.2: EXPERIMENTAL confidence tiers are PAPER ONLY (the runner and the engine refuse them outside paper too).
216
+ if (s.mode !== 'paper')
217
+ errors.push({ path: '/sizing/tiers', msg: `confidence tiers are EXPERIMENTAL and PAPER ONLY: refused in mode ${s.mode} (remove sizing.tiers or set it to null)` });
218
+ const tt = tierTable(z.tiers.table);
219
+ const rp = s.entry.safety.rugproof;
220
+ if (!rp || rp.minPoolAgeMin < tt.gates.minPoolAgeMin)
221
+ errors.push({ path: '/entry/safety/rugproof', msg: `confidence tiers need the rug-proof block with minPoolAgeMin ${tt.gates.minPoolAgeMin} or more (the pool age gate of table ${tt.id}; the tick alone cannot tell a pool's age)` });
222
+ warnings.push({ path: '/sizing/tiers', msg: TIERS_VERDICT });
223
+ for (const line of tierTableLines(tt).slice(1))
224
+ warnings.push({ path: '/sizing/tiers', msg: line });
225
+ warnings.push({ path: '/sizing/tiers', msg: `the tier's size replaces the size of sizing mode ${z.mode}; every limit still applies and the stricter wins (sizing.maxPctOfBankroll ${z.maxPctOfBankroll}%, maxUsd $${z.maxUsd}, the profile's max %, per-trade loss, exposure, cash, price impact, drawdown size-down)${z.tiers.oneOpen === false ? '' : '; one open position at a time'}; tier gates: liquidity >= $${tt.gates.minLiqUsd}, pool age >= ${tt.gates.minPoolAgeMin} min, not young and vertical` });
226
+ if (z.ladder)
227
+ warnings.push({ path: '/sizing/ladder', msg: 'with confidence tiers the ladder splits the tier size into tranches (the first fills at entry)' });
228
+ }
203
229
  if (s.agent.budgetUsdPerSession > s.agent.budgetUsdPerDay)
204
230
  errors.push({ path: '/agent/budgetUsdPerSession', msg: 'a session budget above the daily budget' });
205
231
  if (s.entry.ageHours.max !== null && s.entry.ageHours.max < s.entry.ageHours.min)
@@ -211,6 +237,8 @@ export function validateStrategy(doc, library = builtinProfiles()) {
211
237
  warnings.push({ path: `/entry/triggers/${i}`, msg: `${t.kind} triggers need the live ${t.kind} feed; replays and backtests treat them as not firing` });
212
238
  if (s.bankroll.source === 'desk')
213
239
  warnings.push({ path: '/bankroll/source', msg: 'desk reads the paper desk book during a run; backtests use startUsd' });
240
+ if (s.entry.safety.rugproof)
241
+ warnings.push({ path: '/entry/safety/rugproof', msg: 'the rug-proof chain and route gates (pool structure, LP, withdraw history, sell route) and the drain poller need live data; a replay evaluates only the tick gates and the pump.fun-mint proxy' });
214
242
  if (s.entry.safety.larp)
215
243
  warnings.push({ path: '/entry/safety/larp', msg: 'the LARP gate needs the live web (live data runs only); replays and backtests do not evaluate it' });
216
244
  if (s.mode === 'real')
@@ -0,0 +1,194 @@
1
+ // dexterm autotrade — confidence-tiered sizing, EXPERIMENTAL, UNPROVEN and PAPER ONLY (0.2.2, from the rules v4 research).
2
+ //
3
+ // A strategy opts in with `sizing.tiers` (strategy.ts). The ENGINE (clampEntry) then scores every candidate tick on
4
+ // two measured points, maps the score to a tier and the tier to a size in % of the book. Nothing here reads the
5
+ // agent's own confidence: the research found no held-out signal in it. The rules, all enforced in the engine:
6
+ // - T0 (score 0, or a failed tier gate) is a skip: no entry.
7
+ // - No tier runs above 25% of the book (TIERS_HARD_CAP_PCT). The cap is code: the table's evidence or cap fields,
8
+ // the strategy file and the agent cannot lift it, and the engine applies it again on the dollar size and on every
9
+ // ladder add. No tier is proven, so a tier configured above 25% is recorded as capped.
10
+ // - The tick must be fresh and listed: a sweep row, a tick time that is not a number, or a tick older than 5 min at
11
+ // decision time (the bs5m window) is T0.
12
+ // - The tier's size is the strategy's pick; every existing ceiling (strategy and profile max %, max $, per-trade loss,
13
+ // exposure, cash, price impact, drawdown size-down, a young-pool cap) still applies, and the stricter one wins.
14
+ // - One open position at a time unless the block says otherwise.
15
+ // Refused outside paper: validateStrategy (mode real / live), the runner (a real-signal run) and the engine itself.
16
+ //
17
+ // The verdict, shown on every TIER line and in validation: unproven; on the held-out data no tier made money, and no
18
+ // dexterm strategy has shown an edge (strategy synthesis, 2026-11).
19
+ //
20
+ // Pure: no I/O.
21
+ export const TIER_TABLE_IDS = ['v4-1785'];
22
+ /** The one verdict line. Printed by validation and on every TIER line. */
23
+ export const TIERS_VERDICT = 'EXPERIMENTAL, UNPROVEN, PAPER ONLY: on our held-out data no tier made money and no dexterm strategy has shown an edge; every tier runs at 25% of the book at most.';
24
+ /** The engine's ceiling for any tier, in % of the book. Code, not data: nothing a strategy file or table says lifts it. */
25
+ export const TIERS_HARD_CAP_PCT = 25;
26
+ /** A tick older than this at decision time is T0: bs5m describes a 5 min window that has moved on. */
27
+ export const TIER_TICK_MAX_AGE_MS = 5 * 60_000;
28
+ const freeze = (x) => {
29
+ if (x && typeof x === 'object') {
30
+ for (const v of Object.values(x))
31
+ freeze(v);
32
+ Object.freeze(x);
33
+ }
34
+ return x;
35
+ };
36
+ /**
37
+ * The frozen v4 table (schema dexterm-challenge-tiers/1, version 4.0.0), as data. The points were fitted on TRAIN only;
38
+ * the held-out set was scored once. Changing a number here is a new table id, never an edit.
39
+ */
40
+ export const CONFIDENCE_TIERS_V4 = freeze({
41
+ id: 'v4-1785',
42
+ provenance: 'task-1785 rules v4 research (family strict): frozen 2026-10-28T03:17:49Z, held-out scored once 2026-10-28T03:18:28Z',
43
+ unprovenCapPct: 25,
44
+ reservePct: 2,
45
+ points: [
46
+ { id: 'turnover1h-max', signal: 'turnover1h', op: '<=', value: 9.878, what: '1 h volume / liquidity' },
47
+ { id: 'bs5m-min', signal: 'bs5m', op: '>=', value: 3.714, what: 'buys / sells, 5 min' },
48
+ ],
49
+ tiers: [
50
+ { id: 'T0', minScore: 0, sizePct: 0, evidence: 'skip', heldOut: { n: 19, meanPct: -17.0, loPct: -35.7, hiPct: 3.4, wins: 5, rugs: 2 } },
51
+ { id: 'T1', minScore: 1, sizePct: 15, evidence: 'n/a', heldOut: { n: 0, meanPct: null, loPct: null, hiPct: null, wins: null, rugs: null } },
52
+ { id: 'T2', minScore: 1, sizePct: 50, evidence: 'unproven', heldOut: { n: 0, meanPct: null, loPct: null, hiPct: null, wins: null, rugs: null } },
53
+ { id: 'T3', minScore: 1, sizePct: 75, evidence: 'unproven', heldOut: { n: 17, meanPct: -7.6, loPct: -31.8, hiPct: 16.8, wins: 5, rugs: 1 } },
54
+ { id: 'T4', minScore: 2, sizePct: 100, evidence: 'unproven', heldOut: { n: 0, meanPct: null, loPct: null, hiPct: null, wins: null, rugs: null } },
55
+ ],
56
+ gates: { minPoolAgeMin: 10, minLiqUsd: 30_000, youngVertical: { maxAgeHours: 2, maxH1Pct: 150 } },
57
+ });
58
+ export function tierTable(id) {
59
+ if (id === 'v4-1785')
60
+ return CONFIDENCE_TIERS_V4;
61
+ throw new Error(`unknown tier table ${String(id)}`);
62
+ }
63
+ const fin = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : null);
64
+ /** Liquidity, volume and trade counts are never negative: a negative or non-finite figure is unknown. */
65
+ const nonneg = (x) => { const v = fin(x); return v !== null && v >= 0 ? v : null; };
66
+ /**
67
+ * The research's feature definitions, on one tick:
68
+ * turnover1h = vol1h / liquidity (unknown when liquidity is not > 0 or vol1h is unknown)
69
+ * bs5m = buys5m / sells5m; no sells with buys = 99 (all buys); no buys and no sells = 0
70
+ * One difference, on the safe side: the research tape counted a missing sells figure as 0 (so buys alone gave 99);
71
+ * here a missing buys or sells figure makes bs5m unknown, and an unknown signal never scores its point. A negative or
72
+ * non-finite figure (NaN, Infinity) is unknown too.
73
+ */
74
+ export function tierSignals(k) {
75
+ const liq = nonneg(k.liq);
76
+ const vol1h = nonneg(k.vol1h);
77
+ const b = nonneg(k.buys5m);
78
+ const s = nonneg(k.sells5m);
79
+ const turnover1h = liq !== null && liq > 0 && vol1h !== null ? vol1h / liq : null;
80
+ const bs5m = b === null || s === null ? null : s > 0 ? b / s : b > 0 ? 99 : 0;
81
+ return { liq, vol1h, turnover1h, buys5m: b, sells5m: s, bs5m };
82
+ }
83
+ /**
84
+ * The tier gates on the tick. Pool age: the tick carries the token's creation time only, and a pool is never older
85
+ * than its token, so a token younger than minPoolAgeMin (or of unknown age) fails here; the pair's own age is the
86
+ * rug-proof pool-age gate's job (validateStrategy requires it at minPoolAgeMin or more with tiers on).
87
+ * The tick itself: a sweep row (the coin had left the screen), a tick time that is not a number, a tick older than
88
+ * TIER_TICK_MAX_AGE_MS at `nowMs` or stamped after it fails. `nowMs` defaults to the tick's own time (a replay row).
89
+ */
90
+ export function tierGateFailures(k, g, nowMs = k.t) {
91
+ const out = [];
92
+ const t = fin(k.t);
93
+ const now = fin(nowMs);
94
+ if (k.kind !== 'live')
95
+ out.push(`tick not live (${String(k.kind)})`);
96
+ if (t === null || now === null)
97
+ out.push('tick time unknown');
98
+ else if (now - t > TIER_TICK_MAX_AGE_MS)
99
+ out.push(`tick stale (${Math.ceil((now - t) / 1000)} s old > ${TIER_TICK_MAX_AGE_MS / 1000} s)`);
100
+ else if (t > now)
101
+ out.push('tick time after the decision time');
102
+ const created = fin(k.createdAt) !== null && k.createdAt > 0 ? k.createdAt : null;
103
+ const ageMin = t !== null && created !== null ? (t - created) / 60e3 : null;
104
+ if (ageMin === null)
105
+ out.push(`pool age unknown (no token creation time) < ${g.minPoolAgeMin} min`);
106
+ else if (ageMin < g.minPoolAgeMin)
107
+ out.push(`pool age < ${g.minPoolAgeMin} min (token ${ageMin.toFixed(0)} min old)`);
108
+ const liq = fin(k.liq);
109
+ if (liq === null || liq < g.minLiqUsd)
110
+ out.push(`liquidity ${liq === null ? 'unknown' : `$${Math.round(liq)}`} < $${g.minLiqUsd}`);
111
+ // A young coin with no 1 h change figure cannot be cleared of "vertical": it fails (the research tape had the figure).
112
+ const h1 = fin(k.chg1h);
113
+ if (ageMin !== null && ageMin / 60 < g.youngVertical.maxAgeHours) {
114
+ if (h1 === null)
115
+ out.push(`young and h1 unknown (at ${(ageMin / 60).toFixed(1)} h)`);
116
+ else if (h1 >= g.youngVertical.maxH1Pct)
117
+ out.push(`young and vertical (h1 ${h1}% at ${(ageMin / 60).toFixed(1)} h)`);
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * The size a tier runs at: never above TIERS_HARD_CAP_PCT (or the table's own cap when lower), whatever its evidence
123
+ * says (0.2.2 has no proven tier and no path that lifts the cap), never above 100 - reservePct. A non-finite or
124
+ * negative configured size runs at 0.
125
+ */
126
+ export function tierRunsAtPct(t, table) {
127
+ const cfg = Number.isFinite(t.sizePct) && t.sizePct > 0 ? t.sizePct : 0;
128
+ const tableCap = Number.isFinite(table.unprovenCapPct) && table.unprovenCapPct >= 0 ? table.unprovenCapPct : 0;
129
+ const capPct = Math.min(TIERS_HARD_CAP_PCT, tableCap);
130
+ let pct = cfg;
131
+ let capped = false;
132
+ if (pct > capPct) {
133
+ pct = capPct;
134
+ capped = true;
135
+ }
136
+ if (pct > 100 - table.reservePct)
137
+ pct = 100 - table.reservePct;
138
+ return { pct, capped };
139
+ }
140
+ /** Score the tick (one point per rule that holds; an unknown signal never passes), then the highest tier met wins. */
141
+ export function selectConfidenceTier(k, table = CONFIDENCE_TIERS_V4, nowMs = k.t) {
142
+ const sig = tierSignals(k);
143
+ const points = table.points.map((p) => {
144
+ const actual = sig[p.signal];
145
+ const pass = actual !== null && (p.op === '>=' ? actual >= p.value : actual <= p.value);
146
+ return { id: p.id, signal: p.signal, op: p.op, value: p.value, actual: actual === null ? null : Math.round(actual * 1000) / 1000, pass };
147
+ });
148
+ const score = points.filter((p) => p.pass).length;
149
+ const gates = tierGateFailures(k, table.gates, nowMs);
150
+ let row = table.tiers[0];
151
+ if (!gates.length)
152
+ for (const t of table.tiers)
153
+ if (score >= t.minScore)
154
+ row = t;
155
+ const run = row.id === 'T0' ? { pct: 0, capped: false } : tierRunsAtPct(row, table);
156
+ return { table: table.id, tier: row.id, score, of: points.length, points, gates, configuredPct: row.sizePct, effectivePct: run.pct, evidence: row.evidence, capped: run.capped, heldOut: row.heldOut };
157
+ }
158
+ // ───────────────────────────── text ─────────────────────────────
159
+ const sgn = (x) => `${x > 0 ? '+' : ''}${x.toFixed(1)}`;
160
+ /**
161
+ * "unproven: held-out n 17, mean -7.6% per entry, lower bound -31.8% (upper +16.8%), rug 1/17". The text carries the
162
+ * mean and its interval, never a win count or rate (a small-sample win rate reads as a claim the data does not make).
163
+ */
164
+ export function tierEvidenceText(evidence, h) {
165
+ const head = evidence === 'n/a' ? 'n/a (at or under the 25% cap)' : evidence;
166
+ if (h.n === 0 || h.meanPct === null)
167
+ return `${head}: held-out n 0 (too small to say)`;
168
+ return `${head}: held-out n ${h.n}, mean ${h.meanPct.toFixed(1)}% per entry, lower bound ${h.loPct === null ? '?' : `${h.loPct.toFixed(1)}%`}${h.hiPct === null ? '' : ` (upper ${sgn(h.hiPct)}%)`}${h.rugs === null ? '' : `, rug ${h.rugs}/${h.n}`}`;
169
+ }
170
+ const short = (m) => (m.length > 10 ? `${m.slice(0, 4)}…${m.slice(-4)}` : m);
171
+ /** The run log's TIER line: tier, score, size, evidence status and the verdict. `afterUsd` = the size after every limit. */
172
+ export function confidenceTierLine(p, k, afterUsd) {
173
+ const head = `TIER ${p.tier} for ${k.symbol} (${short(k.mint)}): score ${p.score}/${p.of}`;
174
+ const ev = tierEvidenceText(p.evidence, p.heldOut);
175
+ if (p.tier === 'T0')
176
+ return `${head} · SKIP (size 0)${p.gates.length ? ` · gate: ${p.gates.join('; ')}` : ''} · ${ev} · ${TIERS_VERDICT}`;
177
+ const pts = p.points.filter((x) => x.pass).map((x) => x.id).join(', ') || 'none';
178
+ const size = p.capped ? `configured ${p.configuredPct}%, runs at ${p.effectivePct}% of the book (unproven: capped)` : `runs at ${p.effectivePct}% of the book`;
179
+ return `${head} (${pts}) · ${size}${afterUsd !== undefined ? ` · $${afterUsd} after the strategy's limits` : ''} · ${ev} · ${TIERS_VERDICT}`;
180
+ }
181
+ /** Validation / docs text: one line per tier, the verdict first. */
182
+ export function tierTableLines(table = CONFIDENCE_TIERS_V4) {
183
+ const rows = table.tiers.map((t, i) => {
184
+ const run = t.id === 'T0' ? { pct: 0, capped: false } : tierRunsAtPct(t, table);
185
+ const unreachable = i > 0 && table.tiers.slice(i + 1).some((u) => u.minScore === t.minScore);
186
+ const size = t.id === 'T0' ? 'skip (size 0)' : run.capped ? `configured ${t.sizePct}%, runs at ${run.pct}% (unproven: capped)` : `runs at ${run.pct}%`;
187
+ return `${t.id} score >= ${t.minScore}: ${size} · ${tierEvidenceText(t.evidence, t.heldOut)}${unreachable ? ` · never selected (a higher tier has the same score ${t.minScore})` : ''}`;
188
+ });
189
+ return [TIERS_VERDICT, ...rows, `table ${table.id}: ${table.provenance}`];
190
+ }
191
+ /** A compact record of the pick for the ledger. */
192
+ export function tierRecord(p) {
193
+ return { table: p.table, tier: p.tier, score: p.score, of: p.of, points: p.points.map((x) => ({ id: x.id, actual: x.actual, pass: x.pass })), gates: p.gates, configuredPct: p.configuredPct, runsAtPct: p.effectivePct, capped: p.capped, evidence: tierEvidenceText(p.evidence, p.heldOut) };
194
+ }