dexterm-cli 0.2.0 → 0.2.1

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 (138) 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/fleet.js +2 -2
  6. package/dist/packages/autotrade/src/index.js +2 -0
  7. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  8. package/dist/packages/autotrade/src/rugproof.js +231 -0
  9. package/dist/packages/autotrade/src/runner.js +132 -17
  10. package/dist/packages/autotrade/src/service-install.js +6 -4
  11. package/dist/packages/autotrade/src/sessions.js +16 -7
  12. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  13. package/dist/packages/autotrade/src/strategy.js +12 -1
  14. package/dist/packages/classifier/src/calibrate.js +138 -0
  15. package/dist/packages/classifier/src/classify.js +107 -0
  16. package/dist/packages/classifier/src/config.js +220 -0
  17. package/dist/packages/classifier/src/dossier.js +95 -0
  18. package/dist/packages/classifier/src/hardware.js +63 -0
  19. package/dist/packages/classifier/src/index.js +18 -0
  20. package/dist/packages/classifier/src/models.js +111 -0
  21. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  22. package/dist/packages/classifier/src/providers/http.js +63 -0
  23. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  24. package/dist/packages/classifier/src/providers/llm.js +89 -0
  25. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  26. package/dist/packages/classifier/src/questions.js +97 -0
  27. package/dist/packages/classifier/src/registry.js +42 -0
  28. package/dist/packages/classifier/src/snapshot.js +31 -0
  29. package/dist/packages/classifier/src/systemone.js +88 -0
  30. package/dist/packages/classifier/src/types.js +18 -0
  31. package/dist/packages/cli/assets/dash/dash.css +139 -0
  32. package/dist/packages/cli/assets/dash/dash.js +200 -0
  33. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  34. package/dist/packages/cli/assets/dash/index.html +36 -0
  35. package/dist/packages/cli/src/app-help-gen.js +12 -0
  36. package/dist/packages/cli/src/autotrade-cmd/fleet.js +9 -2
  37. package/dist/packages/cli/src/autotrade-cmd/run.js +11 -1
  38. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  39. package/dist/packages/cli/src/classifier-cmd.js +353 -0
  40. package/dist/packages/cli/src/classifier-live.js +81 -0
  41. package/dist/packages/cli/src/dash/data.js +217 -0
  42. package/dist/packages/cli/src/dash/server.js +92 -0
  43. package/dist/packages/cli/src/dash-cmd.js +113 -0
  44. package/dist/packages/cli/src/doctor.js +34 -0
  45. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  46. package/dist/packages/cli/src/live/signals.js +8 -0
  47. package/dist/packages/cli/src/main.js +98 -6
  48. package/dist/packages/cli/src/mcp-follow.js +21 -1
  49. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  50. package/dist/packages/cli/src/narratives.js +114 -0
  51. package/dist/packages/cli/src/overlay/model.js +147 -0
  52. package/dist/packages/cli/src/overlay/page.js +146 -0
  53. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  54. package/dist/packages/cli/src/overlay/server.js +170 -0
  55. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  56. package/dist/packages/cli/src/record-cmd.js +82 -11
  57. package/dist/packages/cli/src/rules-cmd.js +2 -1
  58. package/dist/packages/cli/src/rules.js +24 -4
  59. package/dist/packages/cli/src/screens/narrative.js +154 -2
  60. package/dist/packages/cli/src/state.js +1 -0
  61. package/dist/packages/cli/src/version.js +1 -1
  62. package/dist/packages/custom/src/rules.js +5 -1
  63. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  64. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  65. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  66. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  67. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  68. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  69. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  71. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  72. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  73. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  74. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  75. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  76. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  77. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  78. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  80. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  85. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  87. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  88. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  90. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  93. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  94. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  95. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  96. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  97. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  98. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  99. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  105. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  107. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  109. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  110. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  111. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  112. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  113. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  114. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  117. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  119. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  120. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  121. package/dist/packages/mcp/src/server.js +104 -0
  122. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  123. package/dist/packages/narratives/src/cluster.js +193 -0
  124. package/dist/packages/narratives/src/embed.js +184 -0
  125. package/dist/packages/narratives/src/export.js +60 -0
  126. package/dist/packages/narratives/src/index.js +10 -0
  127. package/dist/packages/narratives/src/label.js +48 -0
  128. package/dist/packages/narratives/src/normalize.js +179 -0
  129. package/dist/packages/narratives/src/recorder.js +93 -0
  130. package/dist/packages/narratives/src/tracker.js +328 -0
  131. package/dist/packages/recorder/src/exit-probes.js +215 -0
  132. package/dist/packages/recorder/src/index.js +1 -0
  133. package/dist/packages/recorder/src/recorder.js +11 -1
  134. package/dist/packages/recorder/src/retention.js +4 -0
  135. package/dist/packages/recorder/src/schema.js +20 -1
  136. package/dist/packages/tui/src/keymap.js +5 -0
  137. package/package.json +3 -1
  138. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -0,0 +1,328 @@
1
+ // dexterm narratives (task-1818): the tracker. Coins come in (first sighting: name/ticker/description), market
2
+ // samples come in (price, liquidity, rolling volume, traders), and on each tick it recomputes, per narrative and
3
+ // per window (5m / 1h / 6h / 24h): coin count, new-coin rate, volume share, unique-trader growth, the top runner
4
+ // and its drawdown, a lifecycle state, a copy-cat saturation flag and a memetic traction score.
5
+ //
6
+ // Everything is MEASURED HEAT from recorded/live market data, never advice. Point-in-time by construction: a tick
7
+ // at `now` only reads samples with t <= now that were already ingested (the replay feeds rows in time order).
8
+ // Lifecycle rules (LIFECYCLE_RULES below, also in the docs page) are evaluated in order; the first match wins.
9
+ import { Clusterer, clusterLabel } from "./cluster.js";
10
+ import { LexicalEmbedder } from "./embed.js";
11
+ import { normalize, Vocab } from "./normalize.js";
12
+ export const WINDOWS = { '5m': 300_000, '1h': 3_600_000, '6h': 21_600_000, '24h': 86_400_000 };
13
+ /** Thresholds of the lifecycle and saturation rules (owner: maintainers; changing one changes the docs page). */
14
+ export const RULES = {
15
+ deadVol1h: 1_000, // narrative 1 h volume under this and no new coin in 6 h -> dead
16
+ sparkAgeMs: 3_600_000, // a narrative younger than 1 h is a spark
17
+ ignitionVol1h: 5_000, // ignition needs at least this much 1 h volume ...
18
+ ignitionGrowth: 2, // ... at least 2x its 1 h volume an hour ago (floored at deadVol1h) ...
19
+ ignitionTraders: 0.5, // ... and a new coin in the last hour OR +50% unique traders (1 h)
20
+ fadeFromMax: 0.4, // 1 h volume <= 40% of its 24 h max -> fading
21
+ fadeGrowth: 0.6, // or <= 60% of an hour ago
22
+ saturationMembers: 5, // copy-cat saturation: >= 5 coins,
23
+ saturationNew6h: 3, // >= 3 of them new in 6 h,
24
+ saturationCopyShare: 0.25, // and the coins after the 3 oldest hold < 25% of the 1 h volume
25
+ runnerMinLiq: 10_000, // a top runner must have a sellable pool
26
+ staleMs: 6 * 3_600_000, // a coin sample older than this does not count as live volume
27
+ };
28
+ export const LIFECYCLE_RULES = [
29
+ ['dead', 'no member sampled in 6 h, or 1 h volume < $1k with no new coin in 6 h'],
30
+ ['spark', 'first coin < 1 h ago (just formed)'],
31
+ ['ignition', '1 h volume >= $5k and >= 2x the 1 h volume an hour ago, with a new coin in the hour or unique traders (1 h) +50%'],
32
+ ['fading', '1 h volume <= 40% of its 24 h max, or <= 60% of an hour ago'],
33
+ ['peak', 'otherwise: hot, near its 24 h high, growth under 2x'],
34
+ ];
35
+ const clip01 = (x) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0);
36
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
37
+ export class NarrativeTracker {
38
+ clusterer;
39
+ embedder;
40
+ vocab = new Vocab();
41
+ coins = new Map();
42
+ hist = new Map();
43
+ snap = new Map();
44
+ lastTick = -Infinity;
45
+ tickMs;
46
+ listeners = [];
47
+ recent = new Set(); // coins sampled in the last 24 h
48
+ stats = { coins: 0, samples: 0, ticks: 0, embedMs: 0, tickMs: 0 };
49
+ constructor(opts = {}) {
50
+ this.embedder = opts.embedder ?? new LexicalEmbedder();
51
+ // the lexical embedder only links coins that share a word, so the "hot clusters" candidate set is wasted work there
52
+ this.clusterer = new Clusterer({ ...opts, hotMax: opts.hotMax ?? (this.embedder.id.startsWith('lexical') ? 0 : 300) });
53
+ this.tickMs = opts.tickMs ?? 60_000;
54
+ }
55
+ onTransition(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn); }; }
56
+ /** First sightings, in time order. Social links are used only as extra theme words (their handle), never fetched. */
57
+ async addCoins(inputs) {
58
+ const fresh = inputs.filter((c) => !this.coins.has(c.mint));
59
+ if (!fresh.length)
60
+ return;
61
+ const norms = fresh.map((c) => {
62
+ const handle = c.socials ? Object.values(c.socials).filter(Boolean).map((u) => String(u).split(/[/?#@]/).filter(Boolean).pop() ?? '').join(' ') : '';
63
+ const desc = [c.description ?? '', handle].filter(Boolean).join(' ') || null;
64
+ const n = normalize({ symbol: c.symbol, name: c.name, description: desc }, this.vocab);
65
+ this.vocab.learn(n.tokens.filter((t) => t.from === 'name' || t.from === 'symbol').map((t) => t.t));
66
+ return n;
67
+ });
68
+ const t0 = performance.now();
69
+ const vecs = await this.embedder.embed(norms);
70
+ this.stats.embedMs += performance.now() - t0;
71
+ fresh.forEach((c, i) => {
72
+ if (this.coins.has(c.mint))
73
+ return;
74
+ const a = this.clusterer.assign(c.mint, c.t, norms[i], vecs[i]);
75
+ this.coins.set(c.mint, { mint: c.mint, firstT: c.t, narrative: a.id, spawn: a.spawn, symbol: norms[i].symbol, name: norms[i].name, core: norms[i].core, last: null, firstLiqPrice: null, peakPrice: null, lastT: c.t });
76
+ this.stats.coins++;
77
+ });
78
+ }
79
+ /** A market sample for a known coin (unknown mints are ignored: call addCoins first). */
80
+ addSample(s) {
81
+ const c = this.coins.get(s.mint);
82
+ if (!c)
83
+ return;
84
+ this.stats.samples++;
85
+ c.last = s;
86
+ c.lastT = s.t;
87
+ this.recent.add(s.mint);
88
+ const p = s.price ?? null;
89
+ if (p != null && p > 0 && num(s.liq) >= RULES.runnerMinLiq) {
90
+ if (c.firstLiqPrice == null)
91
+ c.firstLiqPrice = p;
92
+ c.peakPrice = Math.max(c.peakPrice ?? 0, p);
93
+ }
94
+ }
95
+ /** Recompute every narrative's metrics if at least tickMs passed (or force). Returns true when it ran. */
96
+ tick(now, force = false) {
97
+ if (!force && now - this.lastTick < this.tickMs)
98
+ return false;
99
+ const t0 = performance.now();
100
+ this.lastTick = now;
101
+ this.stats.ticks++;
102
+ const acc = new Map();
103
+ const tot = { '5m': 0, '1h': 0, '6h': 0, '24h': 0 };
104
+ const vkey = { '5m': 'vol5m', '1h': 'vol1h', '6h': 'vol6h', '24h': 'vol24h' };
105
+ const get = (id) => {
106
+ let a = acc.get(id);
107
+ if (!a)
108
+ acc.set(id, (a = { w: { '5m': { coins: 0, newCoins: 0, volume: 0 }, '1h': { coins: 0, newCoins: 0, volume: 0 }, '6h': { coins: 0, newCoins: 0, volume: 0 }, '24h': { coins: 0, newCoins: 0, volume: 0 } }, traders: 0, vol1hByMember: [], runner: null, runnerX: 0, repeated: 0, liveMembers: 0, newest6h: 0 }));
109
+ return a;
110
+ };
111
+ for (const mint of this.recent) {
112
+ const c = this.coins.get(mint);
113
+ if (now - c.lastT > WINDOWS['24h']) {
114
+ this.recent.delete(mint);
115
+ continue;
116
+ }
117
+ const s = c.last;
118
+ const a = get(c.narrative);
119
+ const age = now - s.t;
120
+ const live = age <= RULES.staleMs;
121
+ if (live)
122
+ a.liveMembers++;
123
+ for (const k of Object.keys(WINDOWS)) {
124
+ if (age <= Math.max(WINDOWS[k], 900_000))
125
+ a.w[k].coins++;
126
+ if (live) {
127
+ const v = num(s[vkey[k]]);
128
+ a.w[k].volume += v;
129
+ tot[k] += v;
130
+ }
131
+ }
132
+ if (live) {
133
+ a.traders += num(s.traders1h);
134
+ a.vol1hByMember.push([c, num(s.vol1h)]);
135
+ }
136
+ if (live && c.firstLiqPrice && s.price && num(s.liq) >= RULES.runnerMinLiq) {
137
+ const x = s.price / c.firstLiqPrice;
138
+ if (x > a.runnerX) {
139
+ a.runnerX = x;
140
+ a.runner = c;
141
+ }
142
+ }
143
+ }
144
+ // new-coin counts and repeated-name velocity come from the member lists (first sightings), not samples
145
+ for (const [id, a] of acc) {
146
+ const cl = this.clusterer.clusters.get(id);
147
+ const main = mainCore(cl);
148
+ for (let i = cl.members.length - 1; i >= 0; i--) {
149
+ const m = cl.members[i];
150
+ const age = now - m.t;
151
+ if (age < 0)
152
+ continue;
153
+ if (age > WINDOWS['24h'])
154
+ break;
155
+ for (const k of Object.keys(WINDOWS))
156
+ if (age <= WINDOWS[k])
157
+ a.w[k].newCoins++;
158
+ if (age <= WINDOWS['1h'] && main && m.core && squashEq(m.core, main))
159
+ a.repeated++;
160
+ if (age <= WINDOWS['6h'])
161
+ a.newest6h++;
162
+ }
163
+ }
164
+ const next = new Map();
165
+ const ranked = [];
166
+ const newCoinVol1h = [...this.recent].reduce((s, m) => { const c = this.coins.get(m); return s + (now - c.firstT <= WINDOWS['24h'] && now - c.lastT <= RULES.staleMs ? num(c.last?.vol1h) : 0); }, 0);
167
+ for (const [id, a] of acc) {
168
+ const cl = this.clusterer.clusters.get(id);
169
+ // history only for real narratives (>= 2 coins): a singleton's 24 h history was 2/3 of the replay's memory
170
+ const h = cl.members.length > 1 ? this.pushHist(id, now, a.w['1h'].volume, a.traders) : [];
171
+ const ago = histAt(h, now - WINDOWS['1h']);
172
+ const max24 = h.reduce((m, x) => (now - x.t <= WINDOWS['24h'] ? Math.max(m, x.vol1h) : m), 0);
173
+ const V1 = a.w['1h'].volume;
174
+ const windows = {};
175
+ for (const k of Object.keys(WINDOWS)) {
176
+ const tr = histAt(h, now - WINDOWS[k]);
177
+ // history points are 15 min apart, so a 5-min trader growth would really be ~15-20 min: null there, never mislabelled
178
+ windows[k] = { coins: a.w[k].coins, newCoins: a.w[k].newCoins, newPerHour: a.w[k].newCoins / (WINDOWS[k] / 3_600_000), volume: a.w[k].volume, volumeShare: tot[k] > 0 ? a.w[k].volume / tot[k] : null, traders: a.traders, tradersGrowth: k !== '5m' && tr && tr.traders > 0 ? a.traders / tr.traders - 1 : null };
179
+ }
180
+ const growth = ago ? V1 / Math.max(ago.vol1h, RULES.deadVol1h) : null;
181
+ const age = now - cl.firstT;
182
+ let lc, rule;
183
+ if (a.liveMembers === 0 || (V1 < RULES.deadVol1h && a.newest6h === 0))
184
+ [lc, rule] = LIFECYCLE_RULES[0];
185
+ else if (age < RULES.sparkAgeMs)
186
+ [lc, rule] = LIFECYCLE_RULES[1];
187
+ else if (V1 >= RULES.ignitionVol1h && growth != null && growth >= RULES.ignitionGrowth && (windows['1h'].newCoins >= 1 || (windows['1h'].tradersGrowth ?? 0) >= RULES.ignitionTraders))
188
+ [lc, rule] = LIFECYCLE_RULES[2];
189
+ else if ((max24 > 0 && V1 <= RULES.fadeFromMax * max24) || (growth != null && growth <= RULES.fadeGrowth))
190
+ [lc, rule] = LIFECYCLE_RULES[3];
191
+ else
192
+ [lc, rule] = LIFECYCLE_RULES[4];
193
+ // copy-cat saturation: many recent spawns, and the spawns after the first three are not getting the volume
194
+ const byAge = [...a.vol1hByMember].sort((x, y) => x[0].spawn - y[0].spawn);
195
+ const copyVol = byAge.slice(3).reduce((s, x) => s + x[1], 0);
196
+ const saturated = cl.members.length >= RULES.saturationMembers && a.newest6h >= RULES.saturationNew6h && V1 > 0 && copyVol / V1 < RULES.saturationCopyShare;
197
+ const comp = {
198
+ growth: growth == null ? null : clip01(Math.log2(Math.max(growth, 1e-6)) / 2 + 0.5),
199
+ spawnRate: clip01(windows['1h'].newCoins / 5),
200
+ nameVelocity: clip01(a.repeated / 5),
201
+ newCoinVolumeShare: newCoinVol1h > 0 ? clip01((V1 / newCoinVol1h) * 10) : null,
202
+ breadth: clip01(a.vol1hByMember.filter(([c]) => num(c.last?.liq) >= RULES.runnerMinLiq).length / 5),
203
+ holderOverlap: null,
204
+ };
205
+ const W = { growth: 0.3, spawnRate: 0.2, nameVelocity: 0.15, newCoinVolumeShare: 0.25, breadth: 0.1, holderOverlap: 0.1 };
206
+ let sw = 0, sv = 0;
207
+ for (const [k, v] of Object.entries(comp))
208
+ if (v != null) {
209
+ sw += W[k];
210
+ sv += W[k] * v;
211
+ }
212
+ const score = lc === 'dead' ? 0 : Math.round((sw > 0 ? sv / sw : 0) * 100);
213
+ const r = a.runner;
214
+ const snap = {
215
+ id, label: cl.members.length > 1 ? clusterLabel(cl) : (cl.members[0].symbol || cl.seedCore || '?'), modelLabel: cl.modelLabel ?? null, theme: this.clusterer.topTheme(cl), members: cl.members.length, firstT: cl.firstT,
216
+ ageMin: Math.round(age / 60_000), lifecycle: lc, lifecycleRule: rule, saturated, score, components: comp, rank: null, windows,
217
+ vol1hAgo: ago ? ago.vol1h : null, vol1hMax24h: max24 || null,
218
+ topRunner: r && r.last?.price && r.peakPrice ? { mint: r.mint, symbol: r.symbol, x: a.runnerX, drawdownPct: (r.last.price / r.peakPrice - 1) * 100, liq: num(r.last.liq) } : null,
219
+ repeatedName1h: a.repeated, holderOverlap: null,
220
+ };
221
+ next.set(id, snap);
222
+ if (cl.members.length >= 2 && lc !== 'dead')
223
+ ranked.push(snap);
224
+ }
225
+ ranked.sort((x, y) => y.score - x.score || y.windows['1h'].volume - x.windows['1h'].volume || (x.id < y.id ? -1 : 1));
226
+ ranked.forEach((s, i) => { s.rank = i + 1; });
227
+ for (const [id, s] of next) {
228
+ const prev = this.snap.get(id)?.lifecycle ?? null;
229
+ if (prev !== s.lifecycle && s.members >= 2)
230
+ for (const fn of this.listeners)
231
+ fn({ id, label: s.label, from: prev, to: s.lifecycle, t: now, rank: s.rank, members: s.members });
232
+ }
233
+ // narratives with no recent member keep their last state as dead
234
+ for (const [id, s] of this.snap)
235
+ if (!next.has(id) && s.lifecycle !== 'dead')
236
+ next.set(id, { ...s, lifecycle: 'dead', lifecycleRule: LIFECYCLE_RULES[0][1], rank: null, score: 0 });
237
+ else if (!next.has(id))
238
+ next.set(id, s);
239
+ this.snap = next;
240
+ if (this.stats.ticks % 12 === 0)
241
+ for (const [id, h] of this.hist)
242
+ if (!h.length || now - h[h.length - 1].t > WINDOWS['24h'] + 3_600_000)
243
+ this.hist.delete(id);
244
+ this.stats.tickMs += performance.now() - t0;
245
+ return true;
246
+ }
247
+ pushHist(id, t, vol1h, traders) {
248
+ let h = this.hist.get(id);
249
+ if (!h)
250
+ this.hist.set(id, (h = []));
251
+ // one point per 15 min is enough for "an hour ago" and the 24 h max (the newest point is overwritten in between)
252
+ const last = h[h.length - 1];
253
+ if (last && h.length > 1 && t - h[h.length - 2].t < 900_000) {
254
+ last.t = t;
255
+ last.vol1h = vol1h;
256
+ last.traders = traders;
257
+ }
258
+ else
259
+ h.push({ t, vol1h, traders });
260
+ while (h.length && t - h[0].t > WINDOWS['24h'] + 3_600_000)
261
+ h.shift();
262
+ return h;
263
+ }
264
+ /** Narratives by rank (ranked first, then by 1 h volume). */
265
+ top(limit = 20, opts = {}) {
266
+ const min = opts.minMembers ?? 2;
267
+ return [...this.snap.values()].filter((s) => s.members >= min && (opts.includeDead || s.lifecycle !== 'dead'))
268
+ .sort((a, b) => (a.rank ?? 1e9) - (b.rank ?? 1e9) || b.windows['1h'].volume - a.windows['1h'].volume).slice(0, limit);
269
+ }
270
+ detail(id) {
271
+ const cl = this.clusterer.clusters.get(id);
272
+ if (!cl)
273
+ return null;
274
+ const s = this.snap.get(id) ?? this.emptySnap(cl);
275
+ const memberList = cl.members.map((m) => {
276
+ const c = this.coins.get(m.mint);
277
+ const p = c.last?.price ?? null;
278
+ return { mint: m.mint, symbol: m.symbol, name: m.name, spawn: m.spawn, firstT: m.t, price: p, liq: c.last?.liq ?? null, vol1h: c.last?.vol1h ?? null, x: p && c.firstLiqPrice ? p / c.firstLiqPrice : null };
279
+ });
280
+ return { ...s, memberList };
281
+ }
282
+ /** Narrative of one coin at the last tick (point-in-time in a replay). */
283
+ narrativeOf(mint) {
284
+ const c = this.coins.get(mint);
285
+ if (!c)
286
+ return null;
287
+ const cl = this.clusterer.clusters.get(c.narrative);
288
+ const s = this.snap.get(c.narrative) ?? null;
289
+ return {
290
+ mint, narrative: c.narrative, label: s?.label ?? clusterLabel(cl), theme: this.clusterer.topTheme(cl), spawn: c.spawn,
291
+ members: cl.members.filter((m) => m.t <= this.lastTick || this.lastTick === -Infinity).length,
292
+ lifecycle: s?.lifecycle ?? null, rank: s?.rank ?? null, score: s?.score ?? null, saturated: s?.saturated ?? false,
293
+ isTopRunner: s?.topRunner?.mint === mint, vol1hShare: s?.windows['1h'].volumeShare ?? null, newPerHour1h: s?.windows['1h'].newPerHour ?? null,
294
+ growth1h: s && s.vol1hAgo != null ? s.windows['1h'].volume / Math.max(s.vol1hAgo, RULES.deadVol1h) : null,
295
+ };
296
+ }
297
+ setModelLabel(id, label) { const c = this.clusterer.clusters.get(id); if (c)
298
+ c.modelLabel = label; }
299
+ get lastTickAt() { return Number.isFinite(this.lastTick) ? this.lastTick : null; }
300
+ emptySnap(cl) {
301
+ const z = () => ({ coins: 0, newCoins: 0, newPerHour: 0, volume: 0, volumeShare: null, traders: 0, tradersGrowth: null });
302
+ return { id: cl.id, label: clusterLabel(cl), modelLabel: cl.modelLabel ?? null, theme: this.clusterer.topTheme(cl), members: cl.members.length, firstT: cl.firstT, ageMin: 0, lifecycle: 'spark', lifecycleRule: LIFECYCLE_RULES[1][1], saturated: false, score: 0, components: {}, rank: null, windows: { '5m': z(), '1h': z(), '6h': z(), '24h': z() }, vol1hAgo: null, vol1hMax24h: null, topRunner: null, repeatedName1h: 0, holderOverlap: null };
303
+ }
304
+ }
305
+ function mainCore(c) {
306
+ let best = null, n = 0;
307
+ for (const [k, v] of c.cores)
308
+ if (v > n) {
309
+ best = k;
310
+ n = v;
311
+ }
312
+ return best;
313
+ }
314
+ const squashEq = (a, b) => a.replace(/(.)\1+/gu, '$1') === b;
315
+ /** The newest history point at or before t (null if none within 2 ticks' tolerance of the window start). */
316
+ function histAt(h, t) {
317
+ let lo = 0, hi = h.length - 1, r = null;
318
+ while (lo <= hi) {
319
+ const mid = (lo + hi) >> 1;
320
+ if (h[mid].t <= t) {
321
+ r = h[mid];
322
+ lo = mid + 1;
323
+ }
324
+ else
325
+ hi = mid - 1;
326
+ }
327
+ return r;
328
+ }
@@ -0,0 +1,215 @@
1
+ // dexterm history recorder: exit probes (task-1786, research/winner-recall-2026-10/DATA-GAPS.md item 2). DexScreener's
2
+ // `liquidity.usd` says a pool is there; it does not say the coin can be sold (the control's $NOTHING ran, sold TP1, then
3
+ // sat at liquidity $0 with no route). This lane asks Jupiter, once a minute per HELD focus coin, for a keyless sell
4
+ // quote of a fixed USD size (default $50 at the last tick price) into SOL and writes what came back: the SOL out, the
5
+ // price impact and the hop count, or the error ("no route" is the drain). Quotes only: nothing is built, signed or sent.
6
+ //
7
+ // who focus coins with role 'held' (the tick lane's focus: the collector's DB, focus files, the focus module)
8
+ // cadence one quote per coin per `everyMs` (60 s), at most `maxPerPoll` (1) per poll and `maxPerMinute` (4) in any
9
+ // 60 s whatever the number of held coins (the IP's keyless Jupiter budget is shared with the feed and with
10
+ // any process that sells); a per-UTC-day ceiling on top
11
+ // budget the data layer's shared keyless `jupiter` bucket at 'high' priority: the feed's own Jupiter calls keep
12
+ // that bucket's queue minutes long, so a 'low' or 'normal' probe times out behind them (task-1786 smoke);
13
+ // 'high' only jumps the queue, the bucket's 60 s ceiling is unchanged. Skipped while it is paused (429)
14
+ // A quote may wait up to 75 s (one full window) for a slot; the host runs it detached from the ticks.
15
+ // sizing tokens = usd / the coin's last tick price, in base units (decimals from the cached mint info)
16
+ //
17
+ // Retention: rows go with the ticks (14 days) and the cap may evict them (retention.ts).
18
+ import { HttpError, withPriority, systemClock } from '../../data/src/index.js';
19
+ import { mergeFocus } from "./ticks.js";
20
+ export const WSOL_MINT = 'So11111111111111111111111111111111111111112';
21
+ export const DEFAULT_EXIT_PROBES = { everyMs: 60_000, maxPerPoll: 1, maxPerMinute: 4, usd: 50, slippageBps: 500, maxCallsPerDay: 8_000 };
22
+ const fin = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : null);
23
+ const msg = (err) => (err instanceof Error ? err.message : String(err));
24
+ /** Message-text fallback for errors that carry no status (a dropped socket, DNS, our own limiter). Never applied to
25
+ * an `HttpError`: its message embeds the quote URL (`slippageBps=500`, the amount), so a digit pattern matches every
26
+ * answer (verify-1791). URLs are stripped before the test either way. */
27
+ export const TRANSIENT_PROBE_ERROR = /rate.?limit|abort|timed? ?out|timeout|ECONN|ENOTFOUND|EAI_AGAIN|fetch failed|network|unavailable|paused/i;
28
+ const URL_RE = /https?:\/\/\S+/g;
29
+ /** The error text for a row: query strings dropped (the mint and amount are columns already), cut to 200 chars. */
30
+ export const probeErrorText = (err) => msg(err).replace(URL_RE, (u) => u.replace(/[?#][^\s)]*/, '')).slice(0, 200);
31
+ /** A miss that says nothing about the coin, so no row is written and the coin is retried next minute: a 429 or 5xx,
32
+ * a 401/403/408, a non-JSON answer, our own limiter or health gate, a timeout or abort, the network. Anything else (any other
33
+ * 4xx, or a 2xx carrying an `error` such as "No routes found") means Jupiter answered and had no quote for this
34
+ * sell, which is the drain signal: an `error` row. Classified on the error's type and status, not its text. */
35
+ export function isTransientProbeError(err) {
36
+ if (err instanceof HttpError || (err instanceof Error && err.name === 'HttpError')) {
37
+ const status = Number(err.status);
38
+ // 401/403: a keyless refusal of this IP (the hosts already failed over); 408: the server's timeout. Not the coin.
39
+ if (status === 401 || status === 403 || status === 408 || status === 429 || (status >= 500 && status <= 599) || !(status > 0))
40
+ return true;
41
+ return /^invalid JSON from /.test(err.message);
42
+ }
43
+ if (!(err instanceof Error))
44
+ return TRANSIENT_PROBE_ERROR.test(String(err).replace(URL_RE, ''));
45
+ if (['AbortError', 'TimeoutError', 'JupiterRateLimitedError', 'SourceUnavailableError', 'RateDeferredError'].includes(err.name))
46
+ return true;
47
+ if (err instanceof TypeError)
48
+ return true; // undici's `fetch failed` (DNS, reset, refused)
49
+ const code = err.code;
50
+ if (typeof code === 'string' && /^(ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EPIPE|UND_ERR)/.test(code))
51
+ return true;
52
+ return TRANSIENT_PROBE_ERROR.test(err.message.replace(URL_RE, ''));
53
+ }
54
+ /**
55
+ * The lane's deps over a live data layer (the collector's `record run --exit-probes`).
56
+ *
57
+ * A no-route answer (a 4xx such as 400 COULD_NOT_FIND_ANY_ROUTE, or a 200 carrying an `error`) is a real answer from
58
+ * Jupiter about ONE coin, but the HTTP client records it as a `jupiter-swap` health failure. Three in a row (or one
59
+ * while probing) degrade the source, and the gate then refuses every later quote as `JupiterRateLimitedError(0)`: the
60
+ * drain signal muted the lane and every healthy held coin with it (verify-1795). So after an answer like that, the
61
+ * adapter records the source as answering (`ok`). A 429, 5xx, timeout or network miss is left alone, so the gate
62
+ * still backs off on real rate limits.
63
+ * Collector-only: in that process nothing else quotes on `jupiter-swap`, and the shared data layer's behaviour is
64
+ * unchanged for every other caller (the challenge, the TUI).
65
+ */
66
+ export function exitProbeDeps(dl) {
67
+ return {
68
+ async quote(req, signal) {
69
+ try {
70
+ const q = await dl.jupiterSwap.quote(req, signal);
71
+ return { outAmount: q.outAmount, priceImpactPct: q.priceImpactPct, routeHops: q.route.length };
72
+ }
73
+ catch (err) {
74
+ if (err instanceof HttpError && !isTransientProbeError(err))
75
+ dl.health.record('jupiter-swap', { ok: true });
76
+ throw err;
77
+ }
78
+ },
79
+ decimals: async (mint) => (await dl.detail.mintInfo(mint)).value.decimals,
80
+ limiter: dl.limiter,
81
+ };
82
+ }
83
+ export class ExitProbeLane {
84
+ policy;
85
+ db;
86
+ focus;
87
+ clock;
88
+ onLog;
89
+ last = new Map();
90
+ dec = new Map();
91
+ polling = false;
92
+ /** Times of the quotes sent in the last minute (the `maxPerMinute` window). */
93
+ recent = [];
94
+ day = '';
95
+ dayCalls = 0;
96
+ counters = { probes: 0, ok: 0, failed: 0 };
97
+ stmts;
98
+ constructor(db, opts) {
99
+ this.db = db;
100
+ this.focus = opts.focus;
101
+ this.policy = { ...DEFAULT_EXIT_PROBES, ...opts.policy };
102
+ this.clock = opts.clock ?? systemClock;
103
+ this.onLog = opts.onLog;
104
+ this.stmts = {
105
+ insert: db.prepare(`INSERT OR IGNORE INTO exit_probes (mint, t, role, usd_in, price_usd, liquidity_usd, tokens_in, sol_out, price_impact_pct, route_hops, error)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
107
+ price: db.prepare('SELECT price_usd, liquidity_usd FROM autotrade_ticks WHERE mint = ? AND price_usd IS NOT NULL ORDER BY t DESC LIMIT 1'),
108
+ meta: db.prepare("INSERT OR REPLACE INTO recorder_meta (key, value) VALUES ('exit_probe_calls', ?)"),
109
+ };
110
+ }
111
+ callsToday(now = this.clock.now()) {
112
+ const day = new Date(now).toISOString().slice(0, 10);
113
+ if (day !== this.day) {
114
+ this.day = day;
115
+ this.dayCalls = 0;
116
+ const r = this.db.prepare("SELECT value FROM recorder_meta WHERE key = 'exit_probe_calls'").get();
117
+ try {
118
+ const v = r ? JSON.parse(r.value) : null;
119
+ if (v && v.day === day)
120
+ this.dayCalls = Number(v.calls) || 0;
121
+ }
122
+ catch { /* reset */ }
123
+ }
124
+ return this.dayCalls;
125
+ }
126
+ /** Held coins due a probe, the longest-unprobed first. */
127
+ due(now = this.clock.now()) {
128
+ return mergeFocus(this.focus()).filter((e) => e.role === 'held' && e.mint !== WSOL_MINT && now - (this.last.get(e.mint) ?? -Infinity) >= this.policy.everyMs)
129
+ .sort((a, b) => (this.last.get(a.mint) ?? 0) - (this.last.get(b.mint) ?? 0));
130
+ }
131
+ /** One poll (the collector's tick loop calls it right after the tick poll). Never throws. */
132
+ async poll(deps, now = this.clock.now()) {
133
+ const none = (skipped) => ({ probed: 0, ok: 0, failed: 0, skipped });
134
+ if (this.polling)
135
+ return none('in-flight');
136
+ let due;
137
+ try {
138
+ due = this.due(now);
139
+ }
140
+ catch (err) {
141
+ this.log(`exit probe focus failed: ${msg(err)}`);
142
+ return none('no-held');
143
+ }
144
+ if (!due.length)
145
+ return none(mergeFocus(this.focus()).some((e) => e.role === 'held') ? 'not-due' : 'no-held');
146
+ if (this.callsToday(now) >= this.policy.maxCallsPerDay)
147
+ return none('day-cap');
148
+ this.recent = this.recent.filter((t) => now - t < 60_000);
149
+ if (this.recent.length >= this.policy.maxPerMinute)
150
+ return none('minute-cap');
151
+ if (deps.limiter.bucket('jupiter').stats().pausedUntil > now)
152
+ return none('paused');
153
+ this.polling = true;
154
+ const res = { probed: 0, ok: 0, failed: 0, skipped: null };
155
+ try {
156
+ for (const e of due.slice(0, Math.min(this.policy.maxPerPoll, this.policy.maxPerMinute - this.recent.length))) {
157
+ this.last.set(e.mint, now);
158
+ const px = this.stmts.price.get(e.mint);
159
+ if (!px || !(px.price_usd > 0))
160
+ continue; // no tick price yet: nothing to size the probe with
161
+ let tokens = null, solOut = null, impact = null, hops = null, error = null;
162
+ // Decimals come from the mint-info source, not Jupiter: a failure there says nothing about selling, so defer.
163
+ let d = this.dec.get(e.mint);
164
+ if (d === undefined) {
165
+ try {
166
+ d = await deps.decimals(e.mint);
167
+ this.dec.set(e.mint, d);
168
+ }
169
+ catch {
170
+ res.deferred = (res.deferred ?? 0) + 1;
171
+ continue;
172
+ }
173
+ }
174
+ try {
175
+ tokens = BigInt(Math.max(1, Math.round((this.policy.usd / px.price_usd) * 10 ** d)));
176
+ this.dayCalls++;
177
+ this.recent.push(now);
178
+ this.stmts.meta.run(JSON.stringify({ day: this.day, calls: this.dayCalls }));
179
+ const q = await withPriority('high', () => deps.quote({ inputMint: e.mint, outputMint: WSOL_MINT, amount: tokens, slippageBps: this.policy.slippageBps }, AbortSignal.timeout(75_000)));
180
+ solOut = Number(q.outAmount) / 1e9;
181
+ impact = fin(q.priceImpactPct ?? null);
182
+ hops = fin(q.routeHops ?? null);
183
+ res.ok++;
184
+ }
185
+ catch (err) {
186
+ if (isTransientProbeError(err)) {
187
+ res.deferred = (res.deferred ?? 0) + 1;
188
+ continue;
189
+ }
190
+ error = probeErrorText(err);
191
+ res.failed++;
192
+ }
193
+ res.probed++;
194
+ this.stmts.insert.run(e.mint, this.clock.now(), e.role, this.policy.usd, px.price_usd, fin(px.liquidity_usd), tokens === null ? null : tokens.toString(), solOut, impact, hops, error);
195
+ }
196
+ }
197
+ catch (err) {
198
+ this.log(`exit probe failed: ${msg(err)}`);
199
+ }
200
+ finally {
201
+ this.polling = false;
202
+ this.counters.probes += res.probed;
203
+ this.counters.ok += res.ok;
204
+ this.counters.failed += res.failed;
205
+ }
206
+ if (this.last.size > 500)
207
+ for (const [m, lt] of this.last)
208
+ if (now - lt > 3_600_000)
209
+ this.last.delete(m);
210
+ return res;
211
+ }
212
+ log(line) {
213
+ this.onLog?.(`recorder: ${line}\n`);
214
+ }
215
+ }
@@ -10,6 +10,7 @@ export * from "./verdicts.js";
10
10
  export * from "./schedule.js";
11
11
  export * from "./dev-history.js";
12
12
  export * from "./ticks.js";
13
+ export * from "./exit-probes.js";
13
14
  export * from "./retention.js";
14
15
  export { recorderCli, RECORDER_HELP } from "./cli.js";
15
16
  /** `--no-recorder` / `DEXTERM_RECORDER=0` resolution: the recorder is on in live mode unless either says no. */
@@ -22,6 +22,7 @@ import { COLUMN_NAMES, fromDsView, fromEnrichedRow, isMaterialChange } from "./s
22
22
  import { LABELER_VERSION, labelAtHorizon } from "./labels.js";
23
23
  import { dueBounds, selectDue } from "./sweep.js";
24
24
  import { TickLane } from "./ticks.js";
25
+ import { ExitProbeLane } from "./exit-probes.js";
25
26
  import { DEFAULT_AGE, enforceCap, pruneTicksByAge } from "./retention.js";
26
27
  export const DEFAULT_SWEEP = { everyMs: 60_000, maxCallsPerSweep: 6, maxCallsPerDay: 1500, batch: 30 };
27
28
  export const DEFAULT_RETENTION = { rawDays: 7, columnsDays: 90, downsampleAfterHours: 24, everyMs: 3_600_000, ...DEFAULT_AGE };
@@ -50,6 +51,8 @@ export class Recorder {
50
51
  retention;
51
52
  /** task-1504: the tick lane (null when not configured) and the cap policy (null = no cap). */
52
53
  tickLane;
54
+ /** task-1786: the exit-probe lane (null when not configured). */
55
+ exitProbeLane;
53
56
  cap;
54
57
  onLog;
55
58
  /**
@@ -101,6 +104,7 @@ export class Recorder {
101
104
  insertCreator: this.db.prepare('INSERT OR IGNORE INTO creators (mint, creator, source, first_t, created_at) VALUES (?, ?, ?, ?, ?)'),
102
105
  };
103
106
  this.tickLane = opts.ticks ? new TickLane(this.db, { focus: opts.ticks.focus, policy: opts.ticks.policy, clock: this.clock, onLog: this.onLog }) : null;
107
+ this.exitProbeLane = opts.ticks && opts.exitProbes ? new ExitProbeLane(this.db, { focus: opts.ticks.focus, policy: opts.exitProbes.policy, clock: this.clock, onLog: this.onLog }) : null;
104
108
  this.cap = opts.cap && opts.cap.maxBytes ? opts.cap : null;
105
109
  }
106
110
  /** Attach as the data layer's store listener (and detach any previous one). */
@@ -325,6 +329,12 @@ export class Recorder {
325
329
  return { coins: 0, calls: 0, answered: 0, written: 0, overCap: 0, skipped: 'disabled' };
326
330
  return this.tickLane.poll(deps, now);
327
331
  }
332
+ /** task-1786: one exit-probe poll (exit-probes.ts); the host calls it after each tick poll. */
333
+ async probeExits(deps, now = this.clock.now()) {
334
+ if (!this.enabled || this.closed || !this.exitProbeLane)
335
+ return null;
336
+ return this.exitProbeLane.poll(deps, now);
337
+ }
328
338
  /**
329
339
  * task-1504: keep the DB under the configured cap by evicting the oldest history (retention.ts). A no-op without
330
340
  * a cap or below its high-water mark. Observation never pauses; the collector calls this before each pass.
@@ -555,7 +565,7 @@ export class Recorder {
555
565
  const d = one("SELECT SUM(pgsize) AS b FROM dbstat WHERE name IN ('token_snapshots', 'token_snapshots_mint_t', 'token_snapshots_t', 'token_snapshots_t0_pending')");
556
566
  if (d.b !== null && Number(s.n) > 0)
557
567
  bytesPerSnapshot = Number(d.b) / Number(s.n);
558
- const all = one("SELECT SUM(pgsize) AS b FROM dbstat WHERE name IN ('recorder_meta', 'feed_members', 'feed_members_mint_t', 'token_snapshots', 'token_snapshots_mint_t', 'token_snapshots_t', 'token_snapshots_t0_pending', 'mints', 'mints_last_live', 'sweeps', 'chain_events', 'chain_events_mint_t', 'outcomes', 'outcomes_label', 'verdicts', 'verdicts_mint_t', 'creators', 'creators_creator', 'autotrade_ticks', 'autotrade_ticks_t', 'autotrade_candles_1m', 'autotrade_candles_1m_t')");
568
+ const all = one("SELECT SUM(pgsize) AS b FROM dbstat WHERE name IN ('recorder_meta', 'feed_members', 'feed_members_mint_t', 'token_snapshots', 'token_snapshots_mint_t', 'token_snapshots_t', 'token_snapshots_t0_pending', 'mints', 'mints_last_live', 'sweeps', 'chain_events', 'chain_events_mint_t', 'outcomes', 'outcomes_label', 'verdicts', 'verdicts_mint_t', 'creators', 'creators_creator', 'autotrade_ticks', 'autotrade_ticks_t', 'autotrade_candles_1m', 'autotrade_candles_1m_t', 'exit_probes', 'exit_probes_t')");
559
569
  recorderBytes = all.b === null ? null : Number(all.b);
560
570
  }
561
571
  catch {
@@ -27,6 +27,7 @@ export const DEFAULT_CAP_BYTES = 3_000_000_000;
27
27
  const EVICTABLE = [
28
28
  { table: 'autotrade_ticks', col: 't' },
29
29
  { table: 'autotrade_candles_1m', col: 't' },
30
+ { table: 'exit_probes', col: 't' },
30
31
  { table: 'token_snapshots', col: 't' },
31
32
  { table: 'feed_members', col: 't' },
32
33
  ];
@@ -95,6 +96,9 @@ export function pruneTicksByAge(db, policy, now) {
95
96
  const p = { ...DEFAULT_AGE, ...policy };
96
97
  const day = 86_400_000;
97
98
  const ticks = Number(db.prepare('DELETE FROM autotrade_ticks WHERE t < ?').run(now - p.ticksDays * day).changes);
99
+ // task-1786: exit probes age out with the ticks they were sized on.
100
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'exit_probes'").get())
101
+ db.prepare('DELETE FROM exit_probes WHERE t < ?').run(now - p.ticksDays * day);
98
102
  const candles = Number(db.prepare('DELETE FROM autotrade_candles_1m WHERE t < ?').run(now - p.candlesDays * day).changes);
99
103
  return { ticks, candles };
100
104
  }
@@ -16,7 +16,9 @@
16
16
  // v2 (task-1504, AUTOTRADE-SPEC §3): `autotrade_ticks` (position-grade samples every 5–10 s for the few coins
17
17
  // that are watched, open, a candidate or held; written by the tick lane in ticks.ts) and `autotrade_candles_1m`
18
18
  // (1-minute OHLC built from those samples). Both are additive: a v1 file gains the two tables on open.
19
- export const SCHEMA_VERSION = 2;
19
+ // v3 (task-1786, winner-recall DATA-GAPS item 2): `exit_probes` (a keyless Jupiter sell quote for each held focus coin
20
+ // once a minute, written by exit-probes.ts). Additive: an older file gains the table on open.
21
+ export const SCHEMA_VERSION = 3;
20
22
  export const HORIZONS_SEC = [3600, 7200, 21600, 86400, 259200];
21
23
  /** Every horizon labelled: the bitmask value of `horizons_done` for a finished t0 row. */
22
24
  export const ALL_HORIZONS_MASK = (1 << HORIZONS_SEC.length) - 1;
@@ -176,4 +178,21 @@ CREATE TABLE IF NOT EXISTS autotrade_candles_1m (
176
178
  PRIMARY KEY (mint, t)
177
179
  ) WITHOUT ROWID;
178
180
  CREATE INDEX IF NOT EXISTS autotrade_candles_1m_t ON autotrade_candles_1m (t);
181
+
182
+ -- task-1786: can a held coin be sold, and for what. One row per probe: a fixed-USD sell quote into SOL at the coin's
183
+ -- last tick price. sol_out NULL + error = no quote (error 'no route' style = the drain); price_impact_pct is a percent.
184
+ CREATE TABLE IF NOT EXISTS exit_probes (
185
+ mint TEXT NOT NULL,
186
+ t INTEGER NOT NULL, -- unix ms the answer came back
187
+ role TEXT NOT NULL, -- 'held'
188
+ usd_in REAL NOT NULL, -- probe size in USD at price_usd
189
+ price_usd REAL, liquidity_usd REAL, -- the last tick's DexScreener price / liquidity the probe was sized on
190
+ tokens_in TEXT, -- base units sold (decimal string)
191
+ sol_out REAL, -- SOL the route returns (NULL = no quote)
192
+ price_impact_pct REAL,
193
+ route_hops INTEGER,
194
+ error TEXT,
195
+ PRIMARY KEY (mint, t)
196
+ ) WITHOUT ROWID;
197
+ CREATE INDEX IF NOT EXISTS exit_probes_t ON exit_probes (t);
179
198
  `;