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/LICENSE +21 -0
- package/README.md +678 -0
- package/build/index.cjs +2755 -0
- package/build/index.mjs +2680 -0
- package/package.json +58 -0
- package/types.d.ts +1346 -0
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
|