pump-anomaly 0.1.0

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/README.md ADDED
@@ -0,0 +1,678 @@
1
+ # 🧿 Pump Anomaly
2
+
3
+ > Pump signals detection Β· Author-cluster deduplication Β· Path-aware exit replay Β· Liquidation-cascade detection.
4
+
5
+ <p align="center">
6
+ <br>
7
+ <img src="https://github.com/tripolskypetr/pump-anomaly/raw/master/assets/logo.png" height="325px" alt="pump-anomaly" />
8
+ </p>
9
+
10
+ <p align="center">
11
+ <b>Demons to some angels to others</b>
12
+ </p>
13
+
14
+ ## Overview
15
+
16
+ > **Emergency!** [The box. You opened it. We Came.](https://hellraiser.fandom.com/wiki/Lament_Configuration) β€” Be aware of meeting the Tax Officers or Telegram channel owner while using it
17
+
18
+ A black box for detecting **synchronized pump signals** in a stream of trading recommendations from Telegram channels, and turning that detection into a ready-to-execute trade plan.
19
+
20
+ It solves three problems:
21
+
22
+ 1. **Separates real capital inflow** β€” several independent authors hitting the same ticker in sync β€” from a single actor manipulating multiple anonymous channels.
23
+ 2. **Separates a pump from stop hunting** β€” traps where a signal leads the crowd into leverage so it can be wiped out by a liquidation cascade. The training label comes from a simulation of *your* prod exit on 1m candles, not close-to-close.
24
+ 3. **Produces a ready-to-trade plan** with trained exit parameters (trailing take / hard stop / impact horizon), tuned separately per source.
25
+
26
+ ---
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install pump-anomaly
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Quick start
37
+
38
+ ```ts
39
+ import { PumpMatrix } from "pump-anomaly";
40
+ import * as fs from "fs";
41
+
42
+ // 1) train once on history (the label comes from a replay of your prod exit)
43
+ const model = await PumpMatrix.fit(history, getCandles);
44
+ fs.writeFileSync("model.json", model.save());
45
+
46
+ // 2) in prod β€” no training needed
47
+ const model = PumpMatrix.load(fs.readFileSync("model.json", "utf8"));
48
+
49
+ // signals() returns ONLY what's executable β€” veto is already filtered out
50
+ const trades = model.signals(liveItems);
51
+
52
+ // plan() is the live decision (no look-ahead): adds volRegime + cascade detection
53
+ // from candles STRICTLY BEFORE the signal. Source = a getCandles (async) or a
54
+ // preloaded { symbol: candles } map (sync).
55
+ const trades = await model.plan(liveItems, getCandles);
56
+
57
+ for (const s of trades) {
58
+ openPosition(s.symbol, s.direction, s.exit); // direction is already inverted if needed; exit is ready
59
+ }
60
+ ```
61
+
62
+ `signals`/`plan` do the thinking: they pick the mode, compute `volRegime`, evaluate the cascade, filter veto, and apply inversion. The application just executes `s.direction` with `s.exit` β€” no `if` statements about veto, inversion, or mode.
63
+
64
+ Three execution methods, by what candles they're allowed to see:
65
+
66
+ | method | candles | use |
67
+ |---|---|---|
68
+ | `signals(items, policy?)` | none | fast path; cascade not evaluated β†’ every outcome is `enter` |
69
+ | `plan(items, source, policy?)` | **before** the signal | **live** decision, no look-ahead (`squeezePressureBefore`) |
70
+ | `backtest(items, source, policy?)` | **after** the signal | replay forward over closed history (realized pnl/cascade) |
71
+
72
+ `plan` and `backtest` each accept either a `getCandles` (async β†’ returns a `Promise`) or a `{ symbol: candles }` map (sync). A broken symbol (data gap) degrades gracefully to a no-candle signal instead of crashing the whole call.
73
+
74
+ ---
75
+
76
+ ## Per-asset grids
77
+
78
+ Tuned `TrainGrid`s per asset live in [`config/`](config/) β€” one `*-grid.mjs` each, set from how that coin actually pumps. See [config/README.md](config/README.md) for the full rationale. Summary (fastest β†’ slowest):
79
+
80
+ | Asset | Pump speed | `staleMinutes` | `hardStop` % | `trailingTake` % | `stalenessSinceProfit` % | Noise | Matrix strictness |
81
+ |---|---|---|---|---|---|---|---|
82
+ | [Fartcoin](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/fartcoin-grid.mjs) | Very fast | 25m – 4h | 0.65–2.0 | 0.5–2.4 | 0.3–1.0 | Very high | Low |
83
+ | [HYPE](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/hype-grid.mjs) | Very fast | 30m – 4h | 0.7–2.0 | 0.5–2.5 | 0.3–1.0 | High | Low |
84
+ | [Solana](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/solana-grid.mjs) | Fast | 45m – 8h | 0.8–2.5 | 0.6–2.2 | 0.4–1.3 | High | Low–Med |
85
+ | [TRX](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/tron-grid.mjs) | Medium | 1.5h – 15h | 1.0–3.0 | 0.7–3.5 | 0.5–1.4 | Medium | Medium |
86
+ | [TON](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/gram-grid.mjs) | Medium-fast | 1h – 12h | 1.0–3.0 | 0.7–3.5 | 0.5–1.4 | Medium | Medium |
87
+ | [DOGE](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/doge-grid.mjs) | Medium | 1.5h – 16h | 1.1–3.2 | 0.8–4.0 | 0.5–1.5 | Medium+ | Medium+ |
88
+ | [BNB](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/bnb-grid.mjs) | Medium | 3h – 24h | 1.2–3.5 | 0.9–4.5 | 0.6–1.6 | Medium | Medium+ |
89
+ | [Ethereum](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/ethereum-grid.mjs) | Slow | 2h – 24h | 1.2–3.5 | 0.5–2.5 | 0.3–1.0 | Low | High |
90
+ | [Ripple (XRP)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/ripple-grid.mjs) | Medium-slow | 3h – 24h | 1.3–4.0 | 0.9–5.0 | 0.6–1.7 | Low–Med | High |
91
+ | [Litecoin (LTC)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/litecoin-grid.mjs) | Medium-slow | 4h – 30h | 1.3–3.8 | 0.9–5.0 | 0.7–1.8 | Low–Med | High |
92
+ | [Zcash (ZEC)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/zec-grid.mjs) | Medium-slow | 4h – 28h | 1.4–4.2 | 0.9–5.5 | 0.6–1.7 | Low–Med | High |
93
+ | [Stellar (XLM)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/stellar-grid.mjs) | Medium-slow | 4h – 30h | 1.4–4.0 | 1.0–5.0 | 0.7–1.8 | Low | High |
94
+ | [Chainlink (LINK)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/link-grid.mjs) | Medium-slow | 5h – 32h | 1.4–4.0 | 1.0–5.5 | 0.7–1.8 | Low–Med | High |
95
+ | [Polkadot (DOT)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/dot-grid.mjs) | Medium-slow | 5h – 36h | 1.5–4.2 | 1.0–5.5 | 0.7–1.9 | Low–Med | High |
96
+ | [Bitcoin (BTC)](https://github.com/tripolskypetr/pump-anomaly/blob/master/config/btc-grid.mjs) | Slow | 6h – 48h+ | 1.8–5.0 | 1.2–7.0 | 0.8–2.2 | Low | Very high |
97
+
98
+ `staleMinutes` / `hardStop` / `trailingTake` / `stalenessSinceProfit` show the **range spanned by the grid** for that asset β€” `fit` picks within it.
99
+
100
+ ---
101
+
102
+ ## Input contract
103
+
104
+ ### `ParserItem` (channel signal)
105
+
106
+ ```ts
107
+ interface ParserItem {
108
+ channel: string;
109
+ symbol: string;
110
+ direction: "long" | "short";
111
+ ts: number; // unix time of publication, ms
112
+ entryFromPrice?: number; // lower bound of the entry zone (entry.from)
113
+ entryToPrice?: number; // upper bound of the entry zone (entry.to)
114
+ id?: string | number; // optional source id β€” threaded through to dump() for traceback
115
+ [extra: string]: unknown; // targets/stoploss/… are allowed and ignored
116
+ }
117
+ ```
118
+
119
+ `channel` is required β€” it is the key into the exit tensor. The entry zone (`entryFromPrice`/`entryToPrice`) maps from `entry: {from, to}` of your parser-items; if absent, entry is at the open of the first candle. An optional `id` (string or number β†’ normalized to string) is carried untouched all the way to each `dump()` record, so a realized trade can be traced back to the exact post it came from.
120
+
121
+ ### `getCandles` (candle source)
122
+
123
+ ```ts
124
+ type CandleInterval = "1m"|"3m"|"5m"|"15m"|"30m"|"1h"|"2h"|"4h"|"6h"|"8h"|"1d";
125
+
126
+ interface ICandleData {
127
+ timestamp: number; // unix ms, candle OPEN time
128
+ open: number; high: number; low: number; close: number; volume: number;
129
+ }
130
+
131
+ type GetCandles = (
132
+ symbol: string,
133
+ interval: CandleInterval,
134
+ limit?: number,
135
+ sDate?: number, // inclusive
136
+ eDate?: number, // exclusive
137
+ ) => Promise<ICandleData[]>;
138
+ ```
139
+
140
+ Range semantics:
141
+
142
+ ```
143
+ (limit) β†’ [align(when) βˆ’ limitΒ·step, align(when))
144
+ (limit, sDate) β†’ [align(sDate), align(sDate) + limitΒ·step)
145
+ (limit, _, eDate) β†’ [align(eDate) βˆ’ limitΒ·step, eDate)
146
+ (_, sDate, eDate) β†’ [align(sDate), eDate), limit from range
147
+ (limit, sDate, eDate) β†’ [align(sDate), …), exactly limit candles
148
+ ```
149
+
150
+ Training labels on `1m` candles, so your `getCandles` must be able to serve them.
151
+
152
+ ---
153
+
154
+ ## How the label is set (stop hunting won't slip through)
155
+
156
+ The training label comes from an **exact replay of your prod exit on 1m candles** (`replayExit`), not close-to-close. Ported from your code one-to-one:
157
+
158
+ - **moonbag** (long) β€” hard stop below entry; **gravebag** (short) β€” above.
159
+ - **trailing take** β€” pullback from peak PnL once `currentProfit β‰₯ 0`, fixed at the achieved peak.
160
+ - **peak staleness** β€” peak reached the profit threshold, but went stale for `stalenessSinceMinutes` without a new high (price may never reach the target at all).
161
+ - **life-cap** (`staleMinutes`) β€” ceiling on position lifetime = **empirical impact horizon**, tuned by the grid. Exits at the close of the last candle in the window (the realized pnl can be negative).
162
+ - A stop-out realizes the **honest `-hardStop%`** β€” the actual result of the trade. The peak is kept separately for diagnostics, but the pnl is the loss. (An earlier version rolled the metric back to the last positive peak, which meant a stop-out never showed a loss and silently inflated pnl/RR β€” fixed.)
163
+
164
+ Why this catches stop hunts: a wick into the trap never reaches `trailingTake`, and the pullback hits the hard stop β†’ the label is negative **even if** `close[t+H]` happens to be positive. Path-aware replay sees the whole OHLC path, not just two points, so the optimizer actually sees the risk of stops.
165
+
166
+ **Entry without look-ahead.** The candle that *contains* the signal is still forming β€” its close/high/low are only known at the end of the minute, after the signal. Entering it would be peeking ahead. So the entry search starts at the next fully-closed candle (`entryStartTs`); a signal exactly on a candle boundary is tradeable and not skipped.
167
+
168
+ **Candles and chop.** For each candidate, `labelBurst` requests `1m` candles forward from the event for `staleMinutesΒ·2+5` (buffer for a late entry into the zone). If this exceeds the chunk limit (500), the library **chunks the request itself** (`fetchCandlesChunked`), advancing `since` and deduplicating by timestamp β€” independent of whether your adapter paginates. Two safety nets:
169
+
170
+ - **Adapter error** (look-ahead guard at the end of history, a data gap for the symbol β€” common for meme-coins) is caught: the candidate is skipped, training does not crash. One broken symbol does not bring down the whole `fit`.
171
+ - **Truncated horizon.** In a long chop, entry can happen late, and there may not be enough candles left for the full life-cap. Such a label is marked `truncated` and **dropped per-exit** (only for entered trades) β€” otherwise a 24h horizon would be compared against a 1h one on a clipped path, corrupting `impactHorizonMinutes`. Shorter horizons of the same candidate are kept; a clean `no-entry` is kept as a valid "didn't enter" label.
172
+
173
+ ---
174
+
175
+ ## Training
176
+
177
+ `PumpMatrix.fit(history, getCandles, opts)` tunes the detector thresholds AND the prod-exit parameters in a single grid, validated by time-series K-fold (expanding window). The objective is **shrinkage-expectancy** `mean Β· N/(N+k)` (k=5 by default): shrinkage toward zero on small samples prevents falling in love with one fat outlier.
178
+
179
+ ```ts
180
+ interface TrainOptions {
181
+ grid?: Partial<TrainGrid>;
182
+ folds?: number; // K-fold folds, default 4
183
+ shrinkageK?: number; // objective shrinkage strength, default 5
184
+ maxBurstWindowMs?: number; // burst window ceiling
185
+ reliability?: Partial<ReliabilityConfig>;
186
+ mode?: "auto" | "matrix" | "single"; // entry-selection mode
187
+ viability?: Partial<ViabilityConfig>; // matrix-viability thresholds
188
+ onProgress?: ProgressFn; // defaults to a stdout bar
189
+ policy?: SignalPolicy; // allowed outcomes, baked into the model
190
+ selection?: Partial<SelectionConfig>; // SE corridor + nested-CV (see selection.ts)
191
+ }
192
+ ```
193
+
194
+ Default grid (everything is searched empirically β€” minimal analytical math):
195
+
196
+ ```ts
197
+ const DEFAULT_GRID = {
198
+ // detector (authorship matrix)
199
+ windowK: [2, 3, 5],
200
+ minClusters: [2, 3],
201
+ jaccardThreshold: [0.3, 0.4], // 0.2 almost never won β€” dropped to shrink the grid
202
+ lagPeakThreshold: [0.4, 0.5], // 0.6 rarely better β€” dropped to shrink the grid
203
+ // prod exit (label set by replay)
204
+ trailingTake: [0.5, 1.0, 2.0],
205
+ hardStop: [1.0, 2.0, 3.0],
206
+ stalenessSinceProfit: [0.5, 1.0, 2.0], // profit threshold that arms the staleness exit β€” searched, not fixed
207
+ stalenessSinceMinutes:[60, 120, 240], // minutes without a new high before a staleness exit
208
+ staleMinutes: [60, 240, 720], // impact horizon: 1h / 4h / 12h (24h rarely optimal for short pumps)
209
+ // liquidation-cascade detector
210
+ volZThreshold: [1.5, 2.5], // when volume is anomalous
211
+ squeezePolicy: ["none", "tighten", "veto", "invert"],
212
+ squeezeThreshold: [0.55, 0.7],
213
+ volBaselineWindow:[20],
214
+ cascadeWindowMinutes: [15, 30, 60], // cascade-detection window β€” NOT the holding horizon
215
+ // stationarity window (long horizon)
216
+ stationarityWindowMs: [7 * 24 * 3600_000, 14 * 24 * 3600_000, 28 * 24 * 3600_000, 56 * 24 * 3600_000],
217
+ };
218
+ ```
219
+
220
+ **Winner selection** uses the **one-standard-error rule** (Breiman), not argmax over the CV score. A pure maximum over thousands of configurations is systematically inflated (winner's curse): the max of noisy estimates is biased upward by roughly `sigmaΒ·sqrt(2Β·ln N)`, and the larger the grid, the worse the overfit to noise. The rule picks the most **conservative** configuration among those whose score is within 1 SE of the maximum β€” a difference within 1 SE is not statistically significant, so robustness beats luck. "More conservative" = smaller `hardStop`, shorter holding horizon, softer reaction to a cascade. This makes a larger grid less dangerous: extra points don't drag the choice toward a lucky outlier.
221
+
222
+ **Nested CV** (`selection.nestedOuterFolds`, default 4) gives an unbiased out-of-sample estimate of the chosen configuration in `meta.nestedScore` β€” an honest "what to expect in prod" without winner's curse. Model selection itself still uses 1-SE; nested CV only evaluates. On 3 months of data, full grid + nested takes ~50s, with progress ticking on every outer fold (the terminal doesn't go silent). Selection parameters (conservatism ordering, SE corridor, number of folds) live in `selection.ts` β€” no magic literals in the logic.
223
+
224
+ `fit` returns a trained model: `save()` β†’ JSON string, `PumpMatrix.load(json)` restores it without retraining. The params format is version 3; old v1/v2 won't load (the exit structure is incompatible β€” retrain).
225
+
226
+ ---
227
+
228
+ ## Two entry-selection modes
229
+
230
+ The mode changes the **entry condition**, but the exit is **not shared** β€” it's tuned separately per cell of the tensor (see below).
231
+
232
+ - **matrix** β€” entry = synchronous burst across independent author clusters (filters out single-actor manipulation). Requires β‰₯2 channels and a viable correlation.
233
+ - **single** (fallback) β€” correlation isn't available (one channel), but even a single post moves the market: the audience enters. Every post is an entry; the trained exit decides the outcome.
234
+ - **auto** (default) β€” matrix kicks in only if the correlation is viable AND actually produced a signal; otherwise β†’ single.
235
+
236
+ ```ts
237
+ predict(items, { mode: "auto" }); // default
238
+ predict(items, { mode: "matrix" }); // force correlation
239
+ predict(items, { mode: "single" }); // force fallback
240
+ // result.usedMode β€” which mode actually ran
241
+ // result.viability β€” why: { viable, maxSharedEvents, strongEdges, multiChannelClusters, reason }
242
+ ```
243
+
244
+ ### Matrix viability: two channels β‰  matrix mode
245
+
246
+ Two channels do **not** guarantee matrix mode. If their overlap is noisy (Jaccard randomly crossed the threshold on 1-2 events, no sharp edges, a trivial graph) β€” `viability.viable = false`, and `auto` falls back to `single` so it doesn't emit a false signal from a random coincidence. Strict criterion (`DEFAULT_VIABILITY`):
247
+
248
+ ```ts
249
+ { minSharedEvents: 3, minPeakShare: 0.6, minStrongEdges: 1, minStructure: 2 }
250
+ ```
251
+
252
+ Override via `viability` in `fit`/`predict`. All conditions must hold simultaneously: sufficient event overlap, non-random edge sharpness, a non-trivial graph (siblings found, or β‰₯2 independent clusters).
253
+
254
+ ---
255
+
256
+ ## Training reliability
257
+
258
+ ```
259
+ confidence = support Γ— stability Γ— significance (each in [0, 1])
260
+ reliable = confidence β‰₯ 0.6 AND totalN β‰₯ 40
261
+ ```
262
+
263
+ | axis | grows when |
264
+ |---|---|
265
+ | support | more trades (shrinkage `N/(N+30)`) |
266
+ | stability | edge holds in every fold, not just one |
267
+ | significance | edge is statistically β‰  0 |
268
+
269
+ On a small sample, `reliable: false` β€” the library still works, but honestly warns you. As data grows, all three axes grow β†’ `confidence β†’ 1`, `reliable` flips to `true` **without code changes**. A single channel β†’ empty authorship matrix β†’ the matrix itself is `reliable: false` by construction, but single mode still produces tradeable signals. Thresholds (`supportK: 30`, `confidenceThreshold: 0.6`, `minN: 40`) are configurable via `reliability` in `fit`.
270
+
271
+ ---
272
+
273
+ ## Statistical certificate β€” edge vs. brute-force artifact
274
+
275
+ `reliable` answers "did training have enough stable, significant data?". It does **not** answer the harder question: a grid search is `argmax` over thousands of CV scores, and **the max of N noisy estimates is biased upward by β‰ˆ ΟƒΒ·βˆš(2Β·ln N) even when the true edge is zero.** The 1-SE rule (winner selection) softens this, but it does not *prove* the surviving edge is real. The certificate does β€” it is an independent **judge applied to the already-selected configuration**, never an input to selection (using it to pick configs would make it overfittable, defeating the point).
276
+
277
+ Five barriers from the literature (LΓ³pez de Prado, White, Hansen, Politis-Romano). `certified: true` only if the edge survives **all** of them:
278
+
279
+ | barrier | function | catches | threshold |
280
+ |---|---|---|---|
281
+ | **DSR** (Deflated Sharpe) | `deflatedSharpe` | edge doesn't survive the correction for N trials + skew/kurtosis/length | β‰₯ 0.95 |
282
+ | **PBO** (CSCV overfit) | `probabilityOfBacktestOverfitting` | the IS-best config is systematically poor OOS | ≀ 0.10 |
283
+ | **SPA / Reality Check** | `realityCheckPValue` | the whole edge is explainable by data-snooping (stationary bootstrap) | p ≀ 0.05 |
284
+ | **minTRL** | `minTrackRecordLength` | the sample is physically too small for significance | N β‰₯ minTRL |
285
+ | **nested OOS** | (from `train`) | the unbiased out-of-sample forecast isn't positive | > 0 |
286
+
287
+ ```ts
288
+ model.certification;
289
+ // {
290
+ // certified: boolean; // false β†’ the model should NOT trade
291
+ // dsr: number; // β‰₯ 0.95
292
+ // pbo: number; // ≀ 0.10
293
+ // spaPValue: number; // ≀ 0.05
294
+ // minTRL: number; actualN: number; // actualN β‰₯ minTRL
295
+ // nestedScore: number | null; // > 0
296
+ // reasons: string[]; // WHY it was not certified (empty when certified)
297
+ // }
298
+ ```
299
+
300
+ `certified: false` is the **honest refusal**: training still ran and `argmax` still picked a winner, but the certificate says the winner is a brute-force artifact, not a real edge. The e2e test `fit-noise-rejection` proves it β€” a full `fit` on a pure random walk *does* learn a "best" config, yet `certified: false`. This is the layer `reliable` cannot provide, because `reliable` never sees the winner's curse of the search itself.
301
+
302
+ All functions are pure over arrays of per-trade returns, no external dependencies, and exported from the package: `sharpe`, `deflatedSharpe`, `expectedMaxSharpe`, `minTrackRecordLength`, `probabilityOfBacktestOverfitting`, `realityCheckPValue`, `stationaryBootstrapResample`, `mulberry32`, plus moment stats (`mean`/`variance` via Welford/`skewness`/`kurtosis`) and `normalCdf`/`normalInv`. `certifyStrategy(input, thresholds?)` composes them; thresholds (`dsr`/`pbo`/`spa`) are overridable.
303
+
304
+ ---
305
+
306
+ ## Toward a self-learning loop
307
+
308
+ The engine is a **stateless learner + judge**, not a running system β€” which is exactly what makes it safe to wrap in an automation loop (e.g. a scheduled agent + MCP data/broker adapters). The pieces line up:
309
+
310
+ - **`fit` β†’ `save()` β†’ `load()`** β€” training is separated from inference; the model is a JSON blob.
311
+ - **`signals`/`plan`/`backtest`** β€” pure, no hidden state; `plan` is look-ahead-free by construction.
312
+ - **`dump()`** β€” full signal history (including non-entered) for the loop's own analytics.
313
+ - **`certification`** β€” the automatable gate: re-fit on a rolling window, and **only promote to live when `certified: true`**; otherwise hold and surface `reasons[]`.
314
+
315
+ A loop then closes itself: a scheduler ticks β†’ fresh `ParserItem[]` + `getCandles` arrive (e.g. via MCP) β†’ `fit` retrains on the recent window β†’ `certification` decides whether the model may trade β†’ if so, `plan()` emits ready signals β†’ execution β†’ `dump()` feeds the next tick. The system **retrains itself and refuses to trade when the edge has decayed** (a previously-certified model going `certified: false` is a regime-shift alarm).
316
+
317
+ Two invariants keep this honest rather than dangerous:
318
+
319
+ 1. **The certificate stays out of the optimization loop.** An orchestrator (or LLM operator) may decide *whether* to retrain or escalate, but must never tune the grid/thresholds to *pass* the certificate β€” that would turn the independent judge back into an overfitter.
320
+ 2. **Re-fitting multiplies trials at the meta level.** DSR penalizes N *within* one `fit`, but not the fact that a loop runs `fit` hundreds of times and trades only when one comes back certified β€” each "certified" run can itself be the outlier among, say, 720 monthly attempts. A single-`fit` certificate is blind to this chain.
321
+
322
+ ### Meta-overfitting guard (`meta-ledger.ts`)
323
+
324
+ Invariant 2 is **enforced in code**, not left to operator discipline. A serializable `MetaLedgerState` records *every* `fit` attempt (the loop's state between ticks), and two mechanisms close the meta-curse:
325
+
326
+ - **Cadence guard** β€” `canRefit(ledger, now, policy?)` refuses a `fit` that comes too soon after the last one (`minRefitMs`, default **1 week**). Frequent re-fitting *is* trial multiplication, so it is simply disallowed, with a human-readable `reason` and `nextAllowedTs`.
327
+ - **Family-wise correction** β€” pass `metaLedger` to `fit` and DSR's N becomes `effectiveTrials` = Ξ£ configs across **all** past attempts, not just the current grid. The denominator is honest only because **every** attempt is logged (`recordAttempt` stores `certifiedNaive: false` runs too) β€” logging only the successes would understate N and make the correction lie.
328
+
329
+ ```ts
330
+ import { emptyLedger, recordAttempt, canRefit, effectiveTrials } from "pump-anomaly";
331
+
332
+ let ledger = emptyLedger(); // persist between ticks (loop state)
333
+ const gate = canRefit(ledger, Date.now()); // too-frequent refit? β†’ { allowed, reason, nextAllowedTs }
334
+ if (gate.allowed) {
335
+ const model = await PumpMatrix.fit(history, getCandles, { metaLedger: ledger });
336
+ ledger = recordAttempt(ledger, { // log EVERY attempt, certified or not
337
+ ts: Date.now(),
338
+ innerTrials: model.innerTrials, // grid size of this fit
339
+ certifiedNaive: model.certification.certified,
340
+ });
341
+ // model.effectiveTrials / model.fitAttempts expose the meta-trial count for audit
342
+ }
343
+ ```
344
+
345
+ The guarantee is verified: 720 `fit` runs on pure noise produce false naive certificates, and the family-wise correction drops them to **0** β€” while a genuine 0.75Οƒ edge survives the same correction (`meta-ledger.test.ts`). So the loop *cannot* "click" its way to a certificate by re-running, and the engine becomes safe-by-construction rather than safe-by-discipline.
346
+
347
+ ---
348
+
349
+ ## Exit tensor `[mode][channel][symbol][direction][volRegime]`
350
+
351
+ The model does NOT duplicate the stoploss/targets from the post, and does NOT mix exit math across sources. trailing/hardStop/impact-horizon are trained **separately per cell** of the tensor β€” every channel moves every symbol differently, a long-trap and a short-trap have different dynamics, and anomalous volume requires a tighter trailing.
352
+
353
+ Per-signal resolution with hierarchical fallback:
354
+
355
+ ```
356
+ [mode][channel][symbol][direction][volRegime] (cell)
357
+ β†’ [mode][symbol][direction] (symbol-dir, volRegime collapsed)
358
+ β†’ [mode] (mode)
359
+ β†’ global (root)
360
+ ```
361
+
362
+ - **matrix and single are kept separate** β€” different entry expectancy β†’ different exit. In matrix mode the burst is cross-channel (no single owner), so cells are stored under the canonical `_matrix` channel key.
363
+ - **long and short are different cells** (cascade symmetry).
364
+ - **calm and anomalous are kept separate** β€” trailing is tighter in anomalous volume.
365
+ - **a new channel with no history** falls back to mode/global β€” the fallback is trained too, no magic constants.
366
+
367
+ `origin.exitSource` shows which level the exit was resolved from: `cell` | `symbol-dir` | `mode` | `global`.
368
+
369
+ ---
370
+
371
+ ## Liquidation-cascade detector (symmetric long/short)
372
+
373
+ Stop hunting is symmetric: a short squeeze and a long cascade are mirrors of the same mechanism.
374
+
375
+ - **short squeeze:** the crowd shorts on leverage β†’ a wall of liquidations above β†’ a cascade of forced buys pushes the price up (against the short).
376
+ - **long cascade:** the crowd longs on leverage β†’ a wall of liquidations below β†’ a cascade of forced sells pushes the price down (against the long).
377
+
378
+ No need to parse leverage β€” the cumulative effect is visible in `volume`:
379
+
380
+ - **`volZ`** β€” the z-score of the entry candle's volume against the baseline. High = the crowd synchronously entered on leverage (fuel accumulated).
381
+ - **`squeezePressure`** β€” the share of volume on candles where price moves **against** the position. Symmetric: for long, "against" = down (a sell cascade); for short, = up (a buy cascade). High = the move is fed by liquidations, not honest flow β†’ a trap. The **live** variant (`squeezePressureBefore`) measures it over candles strictly *before* the entry, since in live there are no candles after the signal yet.
382
+
383
+ The reaction (`squeezePolicy`) is tuned by training via CV, or fixed in the grid:
384
+
385
+ - **none** β€” a normal entry.
386
+ - **tighten** β€” tighten the trailing, exit before the reversal (`p.trailingTake` is returned already tightened by `tightenFactor`, 0.5 by default).
387
+ - **veto** β€” don't enter when squeeze pressure is high (the signal never makes it into the output).
388
+ - **invert** β€” enter AGAINST the post (the strategy from 1028592): a channel posted short β†’ the cascade squeezes upward β†’ `signals` returns a signal with `action: "invert"`, `direction: "long"` (already flipped), and the exit from the inverse cell of the tensor. `origin.invertedFrom` holds the original channel direction. The exit `reason` keeps the real mechanism (hard-stop/trailing-take/life-cap) of the inverted position; the fact of inversion is carried by a flag, not by overwriting the reason.
389
+ - **ignore** β€” the cascade is noticed but **deliberately not acted on**: enter in the original direction anyway, realizing the real (usually bad) pnl. This gives the counterfactual "what if we don't react to the cascade" directly in the output, not only in offline analysis. Behaves like `none` for entry, but is labeled distinctly.
390
+
391
+ The calm/anomalous threshold (`volZThreshold`) and the firing threshold (`squeezeThreshold`) are both grid axes.
392
+
393
+ **Cascade detection window** (`cascadeWindowMinutes`) is a separate axis, NOT tied to the holding horizon `staleMinutes`. A squeeze is a fast event (minutes): measuring it over a 24h window is wrong β€” a long window smears out a sharp reversal. Previously the detection window was derived from `staleMinutes`, conflating two unrelated concerns (position lifetime and detector sensitivity); now they're independent (it falls back to `staleMinutes` only for backward compatibility when unset).
394
+
395
+ ---
396
+
397
+ ## Prod API β€” single contract
398
+
399
+ `signals()` returns **only what's executable**. veto (liquidation cascade) never makes it into the output β€” it's filtered internally. Prod code never writes `if (veto) continue` or looks at flags.
400
+
401
+ ```ts
402
+ for (const s of model.signals(liveItems)) {
403
+ openPosition(s.symbol, s.direction, s.exit); // direction is already flipped if inverted
404
+ }
405
+ ```
406
+
407
+ One signal = one decision. Discriminator `action`, provenance in a single `origin` (not flags):
408
+
409
+ ```ts
410
+ interface TradeSignal {
411
+ symbol: string;
412
+ direction: "long" | "short"; // FINAL (inversion already applied)
413
+ action: "enter" | "invert" | "tighten";
414
+ ts: number;
415
+ exit: { // flat, ready for openPosition
416
+ trailingTake: number; // tightened if action="tighten"
417
+ hardStop: number;
418
+ impactHorizonMinutes: number;
419
+ stalenessSinceProfit: number;
420
+ stalenessSinceMinutes: number;
421
+ };
422
+ origin: { // audit, not for branching
423
+ detector: "matrix" | "single";
424
+ channel: string | null;
425
+ invertedFrom: "long" | "short" | null; // what the channel said (null = no inversion)
426
+ exitSource: "cell" | "symbol-dir" | "mode" | "global";
427
+ volRegime: "calm" | "anomalous" | null;
428
+ confidence: number;
429
+ independentClusters: number;
430
+ modelConfidence: number;
431
+ modelReliable: boolean;
432
+ id?: string; // anchor parser-item id (traceback to the source post)
433
+ ids?: string[]; // all parser-item ids folded into this signal
434
+ };
435
+ }
436
+ ```
437
+
438
+ ### Execution methods
439
+
440
+ ```ts
441
+ // no candles β€” cascade not evaluated, every outcome is "enter":
442
+ model.signals(items, policy?) // TradeSignal[]
443
+
444
+ // LIVE β€” candles strictly BEFORE the signal (no look-ahead), source = getCandles | map:
445
+ await model.plan(items, getCandles, policy?) // Promise<TradeSignal[]>
446
+ model.plan(items, { SOLUSDT: candles }, policy?) // TradeSignal[]
447
+
448
+ // BACKTEST β€” replay forward over closed history, source = getCandles | map:
449
+ await model.backtest(items, getCandles, policy?) // Promise<TradeSignal[]>
450
+ model.backtest(items, { SOLUSDT: candles }, policy?) // TradeSignal[]
451
+
452
+ // single-position helpers:
453
+ model.planFor(symbol, dir, channel, candles, policy?) // live, null on veto
454
+ model.planForAt(symbol, dir, channel, candles, ts, policy?) // backtest, null on veto
455
+
456
+ // full report (all verdicts + author map) for debugging:
457
+ model.explain(items)
458
+ ```
459
+
460
+ `plan` vs `backtest` differ only in *which* candles they see: `plan` measures the cascade from candles before the entry (live-safe), `backtest` from candles after the entry (forward replay over already-closed history). Use `backtest` for analysis of the completed past, `plan` for a live "should I enter now" decision.
461
+
462
+ ### Permissions β€” allow-list, serialized at training time, readonly at runtime
463
+
464
+ What's allowed (entries/inversions) is fixed at `fit` time and **baked into model.json**. In prod this is readonly β€” the second argument to `signals()`/`plan()`/`backtest()` can only NARROW it, never widen it:
465
+
466
+ ```ts
467
+ // at training time β€” bake the policy into the model:
468
+ fit(history, getCandles, { policy: { allow: ["enter", "tighten"] } }); // no inversion
469
+
470
+ // in prod β€” narrow it for one call (never wider than trained):
471
+ model.signals(items, { allow: ["enter"] }); // direct entries only
472
+ ```
473
+
474
+ `allow` without `"invert"` β†’ inversion signals are never returned (treated like veto β€” don't walk into the trap). This replaced the runtime flags `disableInvert`/`disableSqueeze`: instead of state smeared across training-and-prod, there's one serializable policy with the invariant "execution never permits what training forbade."
475
+
476
+ ### Model introspection
477
+
478
+ ```ts
479
+ model.reliable; // did training have enough data
480
+ model.confidence; // 0..1 trust in the model
481
+ model.certification; // five-barrier edge certificate (DSR/PBO/SPA/minTRL/nested)
482
+ model.effectiveTrials; // family-wise meta-trial count (Ξ£ configs over all fit attempts)
483
+ model.innerTrials; // grid size of this fit
484
+ model.fitAttempts; // how many times fit has run in the chain
485
+ model.impactHorizonMinutes; // empirical post impact horizon (global level)
486
+ model.mode; // "matrix" | "single" β€” how the model was trained
487
+ model.modeReason; // honest diagnostics: WHY this mode was chosen
488
+ model.minClusters; // min independent clusters for a matrix burst
489
+ model.minSharedEvents; // min shared events for a viable author matrix
490
+ model.lookbackMinutes; // how many 1m candles BEFORE the signal plan() needs
491
+ model.exit; // the full exit tensor (audit)
492
+ model.policy; // the baked-in allow-list (readonly copy)
493
+ ```
494
+
495
+ `lookbackMinutes` = `max(volBaselineWindow, cascadeWindowMinutes) + 5` β€” the amount of pre-signal 1m history `plan()` pulls per signal (strictly in the past, no look-ahead). In prod, keep at least this much history available for every fresh signal.
496
+
497
+ ---
498
+
499
+ ## Signal history (`dump`) β€” for external analytics
500
+
501
+ `fit` records the full signal history of the selected configuration β€” one record per candidate, labeled with the chosen exit: entry/exit price, realized pnl, peak, reason, held minutes, inversion flag, volRegime, independent clusters. It includes signals that did NOT enter (`no-entry` / `cascade-veto`, `entered: false`), so analytics can count skips, not just realized trades. Serialized in `save()`/`load()`.
502
+
503
+ ```ts
504
+ model.dump(); // SignalRecord[] (array of plain objects)
505
+ model.dump(true); // JSON string
506
+ model.historySize; // number of records (0 if loaded without history)
507
+ ```
508
+
509
+ ```ts
510
+ interface SignalRecord {
511
+ id?: string; // anchor parser-item id (traceback to the source post)
512
+ ids?: string[]; // all parser-item ids in the burst (matrix may have several)
513
+ symbol: string;
514
+ direction: "long" | "short";
515
+ channel: string;
516
+ ts: number; // signal time (burst ts), ms
517
+ entered: boolean; // false for no-entry / cascade-veto
518
+ entryPrice: number;
519
+ exitPrice: number;
520
+ pnl: number; // realized pnl, fraction (0.05 = +5%)
521
+ peak: number; // peak pnl over the position's life
522
+ reason: string;
523
+ heldMinutes: number;
524
+ inverted: boolean;
525
+ volRegime: "calm" | "anomalous";
526
+ independentClusters: number;
527
+ }
528
+ ```
529
+
530
+ ---
531
+
532
+ ## Risk-reward & PnL (research output + runtime filter)
533
+
534
+ ### Risk-reward
535
+
536
+ RR per trade = `pnl / hardStop` β€” realized in units of risk (how many R were captured). Computed on the backtest across folds and baked into the model: **per-symbol** (for the runtime filter) and **global** (report), alongside `impactHorizonMinutes`.
537
+
538
+ ```ts
539
+ model.riskReward.global; // { mean, p95, p99, n }
540
+ model.riskReward.bySymbol.SOLUSDT; // { mean, p95, p99, n }
541
+ ```
542
+
543
+ At runtime β€” a **readonly filter following the same pattern as `allow`**: it cuts symbols whose backtest RR is below the threshold. It does not recompute RR in prod, only compares against the saved statistics:
544
+
545
+ ```ts
546
+ model.signals(items, { minRiskReward: 1.5 }); // mean RR >= 1.5
547
+ model.signals(items, { minRiskReward: 5.0, rrMetric: "p99" }); // tail P99 >= 5.0
548
+ ```
549
+
550
+ A symbol with no RR statistics is cut conservatively (nothing to confirm it with). `rrMetric`: `mean` (default), `p95`, `p99` β€” p99 filters by the right tail, keeping symbols with explosive upside. A runtime `minRiskReward` can only *tighten* the baked-in threshold (the max of the two is taken), never loosen it.
551
+
552
+ ### PnL (outlier-robust)
553
+
554
+ Realized-pnl statistics complement the mean with the median and percentiles, so a single bad (or single fat) trade doesn't define the system's edge:
555
+
556
+ ```ts
557
+ model.pnl.global; // { mean, median, p5, p95, p99, n }
558
+ model.pnl.bySymbol.SOLUSDT; // { mean, median, p5, p95, p99, n }
559
+ ```
560
+
561
+ `median` is the outlier-immune center, `p5` is the lower tail (how bad the worst 5% are), `p95`/`p99` the upper tail.
562
+
563
+ ---
564
+
565
+ ## Stationarity window (long horizon)
566
+
567
+ On 5 months of data, statistics get corrupted: Ο„ and the author matrix are aggregated over the ENTIRE history, while the regime drifts over that time β€” channels appear/go quiet, "sibling" pairs break up. One global set averages incomparable periods, and the matrix "remembers" a January correlation in May.
568
+
569
+ The fix needs no new math: statistics are computed over a local window ending at the current moment. The window size is a grid axis, tuned by `train` via CV:
570
+
571
+ ```ts
572
+ stationarityWindowMs: [7*24*3600_000, 14*24*3600_000, 28*24*3600_000, 56*24*3600_000]
573
+ ```
574
+
575
+ `Infinity` = the whole history. On a long horizon a finite window wins β€” it drops stale connections. In `predict`/live, the window is applied automatically to the most recent period up to the latest event. Affects only matrix mode (author matrix); single mode is independent of it.
576
+
577
+ ---
578
+
579
+ ## Training progress bar
580
+
581
+ `fit`/`train` write progress to stdout **by default** (casual API):
582
+
583
+ ```ts
584
+ await PumpMatrix.fit(history, getCandles); // bar is on automatically
585
+ // [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 47% (42/90) label TRXUSDT
586
+ // [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 100% (27/27) score 5|0.4|0.6|all
587
+ ```
588
+
589
+ Three phases: `label` (slow per-candle labeling, IO-bound), `score` (grid scoring from cache), and `nested` (one tick per outer nested-CV fold). Silence or replace it:
590
+
591
+ ```ts
592
+ import { silentProgress } from "pump-anomaly";
593
+ fit(history, getCandles, { onProgress: silentProgress }); // silent
594
+ fit(history, getCandles, { onProgress: (e) => log(`${e.done}/${e.total}`) }); // custom
595
+ ```
596
+
597
+ ---
598
+
599
+ ## Architecture (matrix-mode detector layers)
600
+
601
+ 1. **selfTuneLag** β€” self-estimates the characteristic lag Ο„ from the histogram of pairwise delays between channels. No magic constants.
602
+ 2. **jaccardScreen** β€” coarse sieve of channel proximity over a sliding window of raw timestamps.
603
+ 3. **lagXCorr** β€” directed graph of "who follows whom" from a sharp cross-correlation peak.
604
+ 4. **clusterAuthors** β€” union-find: merges channels belonging to the same author.
605
+ 5. **earlyWarning** β€” density over INDEPENDENT clusters (deduplicating N channels of one actor).
606
+
607
+ All five are computed over the stationarity window. In single mode the matrix isn't needed β€” every post becomes an entry directly (`singleChannelSignals`).
608
+
609
+ **Honest auto-diagnostics.** `model.modeReason` explains WHY `single` or `matrix` was chosen β€” no guessing. Examples: `auto β†’ single: one channel β€” correlation impossible`, `auto β†’ matrix: 3 strong edges, overlap 5, clusters >1: 2`. Matrix requires β‰₯2 INDEPENDENT author clusters on the same ticker; echo channels (always firing together) correctly collapse into 1 cluster and don't produce a false matrix signal. On single-channel data it's always single fallback.
610
+
611
+ ---
612
+
613
+ ## Tests
614
+
615
+ **518 tests** across **50 test files**. All passing.
616
+
617
+ | File | Tests | What is covered |
618
+ |------|-------|-----------------|
619
+ | `predict.test.ts` | 10 | Public facade: Ο„ self-estimation, author-cluster merging, catching a real pump vs skipping a single-actor pump, determinism, garbage-input robustness |
620
+ | `layers.test.ts` | 10 | Detector layers in isolation: `buildTable` indexing, `jaccardPair` sliding window, `selfTuneLag` peak/default, `lagXCorr` leadership + peak sharpness, `clusterAuthors` union-find |
621
+ | `viability.test.ts` | 6 | Two channels β‰  matrix: noisy pair falls back to single, systematic siblings stay matrix, strict-threshold override, single channel not viable |
622
+ | `fallback.test.ts` | 6 | Mode resolution: auto/forced single & matrix, post deduplication in window, single-channel history training into a reliable model |
623
+ | `modes-synthetic.test.ts` | 16 | `enumerateBursts` clustering, honest auto single/matrix choice + diagnostics, single fallback out of the box, matrix on known clusters (not just a flag) |
624
+ | `reliability.test.ts` | 6 | Confidence axes: small→low, large/stable→high, monotonic growth, reliable false→true flip, zero/noisy/negative edge stays unreliable |
625
+ | `exit-tensor.test.ts` | 8 | Hierarchical resolution: exact cell hit, long/short symmetry as different cells, calm vs anomalous, volRegime→symbol-dir→mode→global fallbacks |
626
+ | `matrix-cell.test.ts` | 2 | Regression: matrix cell-exit resolves via the canonical `_matrix` channel key |
627
+ | `replay.test.ts` | 14 | `replayExit` over all window sequences (long), short (gravebag), priorities and window edges |
628
+ | `volume.test.ts` | 10 | `volumeZScore` anomaly, `squeezePressure` long/short symmetry, veto/tighten cascade symmetry in replay, `volRegimeOf` threshold |
629
+ | `volume-metrics.test.ts` | 34 | Deterministic volZ across per-symbol baselines, volZ regime threshold boundary, squeezePressure against-position shares |
630
+ | `entry-zone.test.ts` | 8 | Entry-price resolution: close-in-zone refinement vs clamped midpoint of the entry zone |
631
+ | `label-robustness.test.ts` | 6 | `labelBurst` survives adapter throw / empty result; `replayExit` truncated horizon in chop; truncated exit dropped, full kept |
632
+ | `chunked-candles.test.ts` | 9 | `fetchCandlesChunked` pagination, since-advance, timestamp dedup keeping the first (authoritative) occurrence |
633
+ | `train.test.ts` | 9 | `shrinkageExpectancy` objective, v-params with tuned exit + impact horizon, JSON round-trip, version guard, casual fit→save→load→signals flow |
634
+ | `one-se.test.ts` | 14 | `standardError`, one-standard-error rule against winner's curse, integration: train picks the robust configuration within the SE corridor |
635
+ | `nested-cv.test.ts` | 13 | Conservatism ordering (no magic literals), nested-CV unbiased out-of-sample estimate + progress ticking |
636
+ | `pump-objective.test.ts` | 6 | Honest pump up β€” deterministic positive outcome through the replay label |
637
+ | `stophunt-objective.test.ts` | 11 | Stop hunting: deterministic stop on a wick against the position, cascade squeeze + policy reaction, inversion (strategy 1028592) |
638
+ | `stophunt-vs-falsepositive.test.ts` | 11 | Inversion saves on a real cascade but hurts on a false one; live decision is identical on the past, correctness lives in the future |
639
+ | `matrix-signal-objective.test.ts` | 5 | Matrix signals β€” objective outcomes by price shape |
640
+ | `matrix-signal-timing.test.ts` | 7 | Matrix signals under extreme time distance between events |
641
+ | `matrix-signal-long-short.test.ts` | 12 | Long & short matrix signals across price/trend/time spread; long↔short symmetry on the same shape |
642
+ | `honest-pnl.test.ts` | 13 | Regression: hard-stop realizes an honest loss (not a fictitious peak), inversion keeps the real exit reason, percentile NaN/Inf-robust, facade veto depends on volRegime |
643
+ | `pnl-stats.test.ts` | 10 | `pnlStats` outlier robustness (one trade doesn't define the edge) + integration into the model |
644
+ | `risk-reward.test.ts` | 11 | `percentile`, `riskRewardStats` (pnl/hardStop), runtime RR-filter readonly pattern |
645
+ | `invert.test.ts` | 9 | `replayExit` invert (stop hunt β†’ reversal), inversion transparent to prod via signals/plan, allow-policy turning inversion off without retraining |
646
+ | `invert-edge.test.ts` | 10 | Invert edges: squeezePressure threshold, losing inverse position, no forward candles (live), ambiguous cascade via `planForAt`, detection window decoupled from holding horizon |
647
+ | `squeeze-ignore.test.ts` | 8 | `squeezePolicy=ignore`: replay enters despite the cascade (takes the bad pnl), facade keeps the signal (unlike veto/invert), conservatism-axis placement |
648
+ | `plan.test.ts` | 5 | `planFor` candles-in/plan-out, `plan` batch + candle dictionary, `signals` still works with no candles |
649
+ | `plan-getcandles.test.ts` | 4 | `plan(getCandles)` overload: candles fetched via getCandles, no dictionary |
650
+ | `live-vs-backtest.test.ts` | 11 | `squeezePressureBefore` (cascade from candles before entry), live vs backtest cascade window, `lookbackMinutes`, `minClusters`/`minSharedEvents` from config |
651
+ | `no-lookahead.test.ts` | 6 | `entryStartTs` excludes the forming signal candle; fit and live both request candles strictly without look-ahead |
652
+ | `lookahead-adversarial.test.ts` | 7 | Future cascade with calm past (guessable only by peeking); swapping the future doesn't change the live decision; live never requests a candle with `ts β‰₯ entryStart` |
653
+ | `lookahead-intervals.test.ts` | 16 | Look-ahead guard across intervals (3/5/15m + sub-minute): intra-minute signals never enter the still-forming candle |
654
+ | `stationarity.test.ts` | 5 | Stationarity window vs regime drift: `windowEvents` Infinity vs slice, a false A↔C link persists without a window but disappears with a 4-week one, real links preserved early |
655
+ | `dump.test.ts` | 8 | `dump()` signal-history export (including non-entered no-entry/veto records) |
656
+ | `contract.test.ts` | 10 | Single `TradeSignal` contract, allow-policy, `intersectPolicy` readonly invariant, backward compatibility |
657
+ | `boundary.test.ts` | 43 | Boundary conditions across every module: degenerate replay paths, tensor fallback on holes, selfTuneLag clamps, objective numeric edges, volume thresholds, reliability exactly at thresholds, windowEvents strict bounds, chunked pagination, facade degenerate inputs |
658
+ | `coverage-gaps.test.ts` | 17 | `resolveExitNoRegime` fallback, `volumeFeatures` combined helper, facade getters/methods, `planFor` live path, facade tighten path, `??` default branches, RR-filter branches |
659
+ | `progress.test.ts` | 5 | Training progress: both phases with monotonic `done`, score phase reaches 100%, default stdout writer, `silentProgress` no-op, `stdoutProgress` ignores `total ≀ 0` |
660
+ | `attack-round3.test.ts` | 11 | Regression: significance not maximized on zero variance, `intersectPolicy` minRiskReward only tightens |
661
+ | `statistics-attack.test.ts` | 31 | Adversarial stats: `normalCdf`/`normalInv` vs tables, float-dust on a constant series β†’ Sharpe 0 (not astronomical), `minTRL`=∞ for a losing strategy, PBO NaN on odd folds / empty matrix (no false 0.5), Welford catastrophic-cancellation, NaN/Inf fail-closed across DSR/skew/kurt, out-of-bounds `entryIdx` doesn't crash volume |
662
+ | `statistics-robustness.test.ts` | 5 | Not seed-tuned: real +0.4Οƒ edge certifies on β‰₯22/30 independent seeds, pure noise 0/30 false positives, monotone edgeβ†’certification rate, brute-force N=280k penalized stricter than N=50, `minTRL` grows as edge weakens |
663
+ | `e2e/certification.test.ts` | 14 | 500-signal scenarios with known truth: DSR certifies a real edge, rejects noise / single-outlier edge / regime-shift; `minTRL`, PBO, SPA, full `certifyStrategy` five-barrier gate |
664
+ | `e2e/fit-certification.test.ts` | 3 | `fit` attaches the certificate: small sample (17 trades) β†’ `certified:false` with reasons, survives `save`/`load`, present on the model facade |
665
+ | `e2e/fit-noise-rejection.test.ts` | 1 | Full `fit` on a pure random walk β†’ `certified:false` even though grid argmax picks a "best" config (the certificate catches the brute-force artifact `reliable` alone would miss) |
666
+ | `meta-ledger.test.ts` | 9 | Meta-overfitting guard: cadence guard blocks too-frequent refits, `effectiveTrials` sums ALL fit attempts (not only certified ones), family-wise DSR drops false certificates from 720 noise refits to 0 while a strong edge survives the correction |
667
+ | `staleness-and-id.test.ts` | 7 | `stalenessSinceProfit`/`stalenessSinceMinutes` are searched in `DEFAULT_GRID` (not pinned); a parser-item `id` threads through to every `dump()` record (numeric→string, matches the source post by `ts`, survives save/load, `undefined` without an id) |
668
+ | `id-threading-attack.test.ts` | 6 | `id` threading is leak-proof: time-separated bursts on one symbol both survive (no best-per-symbol loss), collapsed posts keep their `id` in `ids` (`enumeratePosts` + `singleChannelSignals`), and `id`/`ids` reach the LIVE `plan` signal's `origin` (not only `dump`) |
669
+
670
+ ```bash
671
+ npm test
672
+ ```
673
+
674
+ ---
675
+
676
+ ## License
677
+
678
+ MIT