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.
- package/dist/packages/agents/src/spectator.js +28 -2
- package/dist/packages/agents/src/tiers.js +3 -0
- package/dist/packages/agents/src/tools.js +3 -0
- package/dist/packages/autotrade/src/backtest.js +6 -2
- package/dist/packages/autotrade/src/fleet.js +2 -2
- package/dist/packages/autotrade/src/index.js +2 -0
- package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
- package/dist/packages/autotrade/src/rugproof.js +231 -0
- package/dist/packages/autotrade/src/runner.js +132 -17
- package/dist/packages/autotrade/src/service-install.js +6 -4
- package/dist/packages/autotrade/src/sessions.js +16 -7
- package/dist/packages/autotrade/src/strategy-stats.js +344 -0
- package/dist/packages/autotrade/src/strategy.js +12 -1
- package/dist/packages/classifier/src/calibrate.js +138 -0
- package/dist/packages/classifier/src/classify.js +107 -0
- package/dist/packages/classifier/src/config.js +220 -0
- package/dist/packages/classifier/src/dossier.js +95 -0
- package/dist/packages/classifier/src/hardware.js +63 -0
- package/dist/packages/classifier/src/index.js +18 -0
- package/dist/packages/classifier/src/models.js +111 -0
- package/dist/packages/classifier/src/providers/heuristic.js +77 -0
- package/dist/packages/classifier/src/providers/http.js +63 -0
- package/dist/packages/classifier/src/providers/jev-open.js +86 -0
- package/dist/packages/classifier/src/providers/llm.js +89 -0
- package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
- package/dist/packages/classifier/src/questions.js +97 -0
- package/dist/packages/classifier/src/registry.js +42 -0
- package/dist/packages/classifier/src/snapshot.js +31 -0
- package/dist/packages/classifier/src/systemone.js +88 -0
- package/dist/packages/classifier/src/types.js +18 -0
- package/dist/packages/cli/assets/dash/dash.css +139 -0
- package/dist/packages/cli/assets/dash/dash.js +200 -0
- package/dist/packages/cli/assets/dash/icon.svg +1 -0
- package/dist/packages/cli/assets/dash/index.html +36 -0
- package/dist/packages/cli/src/app-help-gen.js +12 -0
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +9 -2
- package/dist/packages/cli/src/autotrade-cmd/run.js +11 -1
- package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
- package/dist/packages/cli/src/classifier-cmd.js +353 -0
- package/dist/packages/cli/src/classifier-live.js +81 -0
- package/dist/packages/cli/src/dash/data.js +217 -0
- package/dist/packages/cli/src/dash/server.js +92 -0
- package/dist/packages/cli/src/dash-cmd.js +113 -0
- package/dist/packages/cli/src/doctor.js +34 -0
- package/dist/packages/cli/src/live/screens/token-live.js +4 -0
- package/dist/packages/cli/src/live/signals.js +8 -0
- package/dist/packages/cli/src/main.js +98 -6
- package/dist/packages/cli/src/mcp-follow.js +21 -1
- package/dist/packages/cli/src/narratives-cmd.js +147 -0
- package/dist/packages/cli/src/narratives.js +114 -0
- package/dist/packages/cli/src/overlay/model.js +147 -0
- package/dist/packages/cli/src/overlay/page.js +146 -0
- package/dist/packages/cli/src/overlay/pnl.js +75 -0
- package/dist/packages/cli/src/overlay/server.js +170 -0
- package/dist/packages/cli/src/overlay-cmd.js +367 -0
- package/dist/packages/cli/src/record-cmd.js +82 -11
- package/dist/packages/cli/src/rules-cmd.js +2 -1
- package/dist/packages/cli/src/rules.js +24 -4
- package/dist/packages/cli/src/screens/narrative.js +154 -2
- package/dist/packages/cli/src/state.js +1 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/custom/src/rules.js +5 -1
- package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
- package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
- package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
- package/dist/packages/mcp/src/server.js +104 -0
- package/dist/packages/mcp/src/spectator-sink.js +27 -0
- package/dist/packages/narratives/src/cluster.js +193 -0
- package/dist/packages/narratives/src/embed.js +184 -0
- package/dist/packages/narratives/src/export.js +60 -0
- package/dist/packages/narratives/src/index.js +10 -0
- package/dist/packages/narratives/src/label.js +48 -0
- package/dist/packages/narratives/src/normalize.js +179 -0
- package/dist/packages/narratives/src/recorder.js +93 -0
- package/dist/packages/narratives/src/tracker.js +328 -0
- package/dist/packages/recorder/src/exit-probes.js +215 -0
- package/dist/packages/recorder/src/index.js +1 -0
- package/dist/packages/recorder/src/recorder.js +11 -1
- package/dist/packages/recorder/src/retention.js +4 -0
- package/dist/packages/recorder/src/schema.js +20 -1
- package/dist/packages/tui/src/keymap.js +5 -0
- package/package.json +3 -1
- 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
|
-
|
|
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
|
`;
|