pump-anomaly 0.1.0 → 1.0.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 CHANGED
@@ -55,7 +55,8 @@ const trades = model.signals(liveItems);
55
55
  const trades = await model.plan(liveItems, getCandles);
56
56
 
57
57
  for (const s of trades) {
58
- openPosition(s.symbol, s.direction, s.exit); // direction is already inverted if needed; exit is ready
58
+ // direction is already inverted if needed; exit is ready; entry zone for the live order
59
+ openPosition(s.symbol, s.direction, { from: s.entryFromPrice, to: s.entryToPrice }, s.exit);
59
60
  }
60
61
  ```
61
62
 
@@ -79,7 +80,6 @@ Tuned `TrainGrid`s per asset live in [`config/`](config/) — one `*-grid.mjs` e
79
80
 
80
81
  | Asset | Pump speed | `staleMinutes` | `hardStop` % | `trailingTake` % | `stalenessSinceProfit` % | Noise | Matrix strictness |
81
82
  |---|---|---|---|---|---|---|---|
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
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
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
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 |
@@ -87,6 +87,7 @@ Tuned `TrainGrid`s per asset live in [`config/`](config/) — one `*-grid.mjs` e
87
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
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
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
+ | [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 |
90
91
  | [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
92
  | [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
93
  | [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 |
@@ -400,7 +401,8 @@ The calm/anomalous threshold (`volZThreshold`) and the firing threshold (`squeez
400
401
 
401
402
  ```ts
402
403
  for (const s of model.signals(liveItems)) {
403
- openPosition(s.symbol, s.direction, s.exit); // direction is already flipped if inverted
404
+ // direction is already flipped if inverted; entry zone + exit are ready for the order
405
+ openPosition(s.symbol, s.direction, { from: s.entryFromPrice, to: s.entryToPrice }, s.exit);
404
406
  }
405
407
  ```
406
408
 
@@ -412,6 +414,8 @@ interface TradeSignal {
412
414
  direction: "long" | "short"; // FINAL (inversion already applied)
413
415
  action: "enter" | "invert" | "tighten";
414
416
  ts: number;
417
+ entryFromPrice?: number; // entry zone from the parser-item (for opening the live position)
418
+ entryToPrice?: number; // undefined → enter at market
415
419
  exit: { // flat, ready for openPosition
416
420
  trailingTake: number; // tightened if action="tighten"
417
421
  hardStop: number;
@@ -446,18 +450,38 @@ await model.plan(items, getCandles, policy?) // Promise<TradeSig
446
450
  model.plan(items, { SOLUSDT: candles }, policy?) // TradeSignal[]
447
451
 
448
452
  // 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[]
453
+ await model.backtest(items, getCandles, policy?) // Promise<BacktestSignal[]>
454
+ model.backtest(items, { SOLUSDT: candles }, policy?) // BacktestSignal[]
451
455
 
452
456
  // 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
457
+ model.planFor(symbol, dir, channel, candles, policy?) // live → TradeSignal, null on veto
458
+ model.planForAt(symbol, dir, channel, candles, ts, policy?) // backtest → BacktestSignal, null on veto
455
459
 
456
460
  // full report (all verdicts + author map) for debugging:
457
461
  model.explain(items)
458
462
  ```
459
463
 
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.
464
+ `plan` and `backtest` differ in two ways. **(1) Which candles they see:** `plan` measures the cascade from candles *before* the entry (live-safe, no look-ahead); `backtest` from candles *after* the entry (forward replay over already-closed history). **(2) What they return:** `plan` returns a `TradeSignal` (a decision — the position isn't closed yet, so there's no pnl); `backtest` returns a **`BacktestSignal`** the same signal plus a `result` that *replays the exit plan forward* and reports the realized pnl. That replayed `result` is the whole point of `backtest`:
465
+
466
+ ```ts
467
+ interface BacktestResult { // present ONLY on BacktestSignal (backtest / planForAt)
468
+ entered: boolean; // false → entry zone never touched on the candle window
469
+ pnl: number; // realized, fraction (hard-stop = honest -hardStop%)
470
+ peak: number; // peak pnl over the position's life
471
+ reason: string; // hard-stop | trailing-take | peak-staleness | life-cap | …
472
+ heldMinutes: number;
473
+ entryPrice: number; // 0 if not entered
474
+ exitPrice: number; // 0 if not entered
475
+ truncated: boolean; // not enough candles after entry for the full life-cap
476
+ }
477
+ interface BacktestSignal extends TradeSignal { result: BacktestResult }
478
+
479
+ for (const s of model.backtest(items, getCandles)) {
480
+ console.log(s.symbol, s.direction, s.result.pnl, s.result.reason); // realized, no join with dump()
481
+ }
482
+ ```
483
+
484
+ The replay uses the signal's FINAL direction (inversion already applied) and its resolved exit plan; the entry zone comes from the parser-item (`entryFromPrice`/`entryToPrice`), falling back to the first candle's open. `dump()` still holds the training-time history; `backtest` is the same machinery applied to whatever items/candles you pass now.
461
485
 
462
486
  ### Permissions — allow-list, serialized at training time, readonly at runtime
463
487
 
@@ -482,6 +506,7 @@ model.certification; // five-barrier edge certificate (DSR/PBO/SPA/minTR
482
506
  model.effectiveTrials; // family-wise meta-trial count (Σ configs over all fit attempts)
483
507
  model.innerTrials; // grid size of this fit
484
508
  model.fitAttempts; // how many times fit has run in the chain
509
+ model.labeling; // labeling diagnostics — WHY a fit came out empty
485
510
  model.impactHorizonMinutes; // empirical post impact horizon (global level)
486
511
  model.mode; // "matrix" | "single" — how the model was trained
487
512
  model.modeReason; // honest diagnostics: WHY this mode was chosen
@@ -494,6 +519,26 @@ model.policy; // the baked-in allow-list (readonly copy)
494
519
 
495
520
  `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
521
 
522
+ ### Troubleshoot
523
+
524
+ A `fit` that produces `totalSamples: 0` is otherwise mute — "no data" and "no entries" look identical. `model.labeling` makes it speak: per **unique** candidate burst, what its labeling outcome was (and the raw `getCandles` exception text, deduped):
525
+
526
+ ```ts
527
+ model.labeling;
528
+ // {
529
+ // candidates: number; // unique bursts seen
530
+ // outcomes: { // only non-zero outcomes present
531
+ // ok?: number; // labeled, has an entry
532
+ // "adapter-error"?: number; // getCandles threw (look-ahead guard / gap / symbol)
533
+ // "no-candles"?: number; // getCandles returned empty (symbol/range gave nothing)
534
+ // "no-entry"?: number; // candles exist, but no exit-set entered the zone
535
+ // };
536
+ // errors: Record<string, number>; // unique getCandles exception messages → count
537
+ // }
538
+ ```
539
+
540
+ So when a trained model is empty, `labeling.outcomes` tells you whether to fix `getCandles` (`adapter-error`), the symbol/range (`no-candles`), or accept there were no entries — and `labeling.errors` carries the exact thrown message (e.g. `{ "ccxt: symbol not found": 32 }`) instead of swallowing it.
541
+
497
542
  ---
498
543
 
499
544
  ## Signal history (`dump`) — for external analytics
@@ -610,9 +655,67 @@ All five are computed over the stationarity window. In single mode the matrix is
610
655
 
611
656
  ---
612
657
 
658
+ ## Integration with backtest-kit
659
+
660
+ `PumpMatrix` needs one thing from your data layer: a `getCandles` that serves 1m candles by range. [`backtest-kit`](https://www.npmjs.com/package/backtest-kit) already provides exactly that contract via `Exchange.getRawCandles(symbol, interval, { exchangeName }, limit, sDate, eDate)` — the argument order matches `GetCandles` one-to-one, so the adapter is a thin pass-through. Register an exchange schema once, then wire the three phases (train → live → backtest).
661
+
662
+ ```ts
663
+ import { addExchangeSchema, Exchange, roundTicks } from "backtest-kit";
664
+ import { singleshot } from "functools-kit";
665
+ import * as pump from "pump-anomaly";
666
+ import ccxt from "ccxt";
667
+
668
+ import signals from "./assets/parser-items.json" with { type: "json" };
669
+ import weights from "./assets/model-weights.json" with { type: "json" };
670
+
671
+ const getExchange = singleshot(async () => {
672
+ const exchange = new ccxt.binance({ enableRateLimit: true, options: { defaultType: "spot" } });
673
+ await exchange.loadMarkets();
674
+ return exchange;
675
+ });
676
+
677
+ // Register the exchange once. getCandles here is backtest-kit's OHLCV fetch;
678
+ // formatPrice/formatQuantity/getOrderBook/getAggregatedTrades omitted for brevity.
679
+ addExchangeSchema({
680
+ exchangeName: "ccxt-exchange",
681
+ getCandles: async (symbol, interval, since, limit) => {
682
+ const exchange = await getExchange();
683
+ const rows = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
684
+ return rows.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume }));
685
+ },
686
+ // ...formatPrice, formatQuantity, getOrderBook, getAggregatedTrades
687
+ });
688
+
689
+ // Adapter: backtest-kit's getRawCandles → pump-anomaly's GetCandles (same arg order).
690
+ const getCandles = (symbol, interval, limit, sDate, eDate) =>
691
+ Exchange.getRawCandles(symbol, interval, { exchangeName: "ccxt-exchange" }, limit, sDate, eDate);
692
+
693
+ // 1) TRAIN once on history → serialize weights (slow: labels replay 1m candles).
694
+ async function trainWeights() {
695
+ const model = await pump.PumpMatrix.fit(signals, getCandles);
696
+ return model.save(); // → write to assets/model-weights.json
697
+ }
698
+
699
+ // 2) LIVE — load weights (no retraining), get ready-to-execute signals.
700
+ async function planLive() {
701
+ const model = pump.PumpMatrix.load(weights);
702
+ return model.plan(signals, getCandles); // TradeSignal[] — direction/entry/exit ready
703
+ }
704
+
705
+ // 3) BACKTEST — same weights, replay forward, realized pnl in result.
706
+ async function runBacktest() {
707
+ const model = pump.PumpMatrix.load(weights);
708
+ return model.backtest(signals, getCandles); // BacktestSignal[] — each has result.pnl
709
+ }
710
+ ```
711
+
712
+ `parser-items.json` is your channel history (`ParserItem[]`), `model-weights.json` is `model.save()` output. The same `getCandles` adapter serves all three phases — `fit` pulls 1m candles forward from each signal to label it, `plan` pulls them strictly before the signal (live, no look-ahead), `backtest` after (forward replay). Other anomaly libraries plug into the same `Exchange` schema independently: [`volume-anomaly`](https://www.npmjs.com/package/volume-anomaly) consumes `Exchange.getAggregatedTrades` for entry timing, [`garch`](https://www.npmjs.com/package/garch) consumes `Exchange.getCandles` for TP/SL sizing — pump-anomaly answers *which post to trade and how to exit it*, and they compose without touching each other.
713
+
714
+ ---
715
+
613
716
  ## Tests
614
717
 
615
- **518 tests** across **50 test files**. All passing.
718
+ **538 tests** across **52 test files**. All passing.
616
719
 
617
720
  | File | Tests | What is covered |
618
721
  |------|-------|-----------------|
@@ -666,6 +769,8 @@ All five are computed over the stationarity window. In single mode the matrix is
666
769
  | `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
770
  | `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
771
  | `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`) |
772
+ | `labeling-diagnostics.test.ts` | 8 | `model.labeling` makes an empty `fit` speak: outcomes per unique burst (ok / adapter-error / no-candles / no-entry), counts not inflated by grid size, sum of outcomes = candidates, and the raw `getCandles` exception text is captured in `errors` (incl. non-`Error` throws) |
773
+ | `backtest-result.test.ts` | 5 | `backtest()` returns `BacktestSignal` with a replayed `result` (realized pnl/reason/prices); no candles → `entered:false` not a crash; `planForAt` carries `result` too; `plan()`/`signals()` do NOT carry `result` |
669
774
 
670
775
  ```bash
671
776
  npm test
package/build/index.cjs CHANGED
@@ -293,6 +293,8 @@ function earlyWarning(tbl, clusterOf, cfg, tau) {
293
293
  `в окне ${(window / 60000).toFixed(0)}м (каналов: ${channels.size})`,
294
294
  source: "matrix",
295
295
  channel: null,
296
+ entryFromPrice: evs[hi].entryFromPrice,
297
+ entryToPrice: evs[hi].entryToPrice,
296
298
  };
297
299
  if (!best || cand.confidence > best.confidence)
298
300
  best = cand;
@@ -359,6 +361,8 @@ function singleChannelSignals(tbl, cfg, tau) {
359
361
  channel: e.channel,
360
362
  id,
361
363
  ids: id != null ? [id] : [],
364
+ entryFromPrice: e.entryFromPrice,
365
+ entryToPrice: e.entryToPrice,
362
366
  };
363
367
  verdicts.push(current);
364
368
  }
@@ -655,10 +659,19 @@ async function fetchCandlesChunked(getCandles, symbol, interval, limit, since, c
655
659
  const chunk = await getCandles(symbol, interval, chunkLimit, currentSince);
656
660
  if (!chunk || chunk.length === 0)
657
661
  break; // край истории / дыра — отдаём собранное
658
- all.push(...chunk);
659
- remaining -= chunkLimit;
660
- if (remaining > 0)
661
- currentSince = currentSince + chunkLimit * step;
662
+ for (const c of chunk)
663
+ all.push(c); // НЕ спред: при большом limit чанк переполнит стек
664
+ // ЧАСТИЧНЫЙ чанк (биржа недодала: вернула < chunkLimit, но не пусто) — двигаем
665
+ // since от ФАКТИЧЕСКИ последней свечи (+step), а remaining уменьшаем на реально
666
+ // полученное (chunk.length). Иначе since прыгает на полный chunkLimit·step, минуя
667
+ // недополученный хвост → дыра в склеенном ряду + недосчёт. Свечи могут прийти
668
+ // неотсортированными — берём max(ts), а не последний элемент.
669
+ let maxTs = currentSince;
670
+ for (const c of chunk)
671
+ if (c.timestamp > maxTs)
672
+ maxTs = c.timestamp;
673
+ remaining -= chunk.length;
674
+ currentSince = maxTs + step;
662
675
  }
663
676
  // дедуп по timestamp (на стыках чанков адаптер может вернуть пограничную свечу
664
677
  // дважды). Оставляем ПЕРВОЕ вхождение: при forward-пагинации первая свеча с данным
@@ -704,10 +717,14 @@ function volumeZScore(candles, entryIdx, baselineWindow) {
704
717
  * это ловушка (stop hunt / squeeze), входить опасно либо выходить раньше.
705
718
  */
706
719
  function squeezePressure(candles, entryIdx, dir, horizon) {
720
+ // КЛАМП нижней границы (симметрично squeezePressureBefore): при отрицательном
721
+ // entryIdx (findIndex вернул -1, битый вызов) старт стал бы < 0 и цикл прочитал
722
+ // candles[-1] = undefined → краш на c.close. max(0, ...) этого не допускает.
723
+ const start = Math.max(0, entryIdx + 1);
707
724
  const end = Math.min(candles.length, entryIdx + horizon + 1);
708
725
  let againstVol = 0;
709
726
  let totalVol = 0;
710
- for (let i = entryIdx + 1; i < end; i++) {
727
+ for (let i = start; i < end; i++) {
711
728
  const c = candles[i];
712
729
  const delta = c.close - c.open; // знак внутрисвечного движения
713
730
  // «против позиции»: long не любит падение (delta<0), short не любит рост (delta>0)
@@ -932,7 +949,11 @@ const exitKey = (p) => `tt${p.trailingTake}|hs${p.hardStop}|sp${p.stalenessSince
932
949
  * если не задана — точка entryFrom=entryTo=open первой свечи.
933
950
  */
934
951
  async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFromPrice, entryToPrice) {
935
- const maxLife = Math.max(...exitSets.map((e) => e.staleMinutes));
952
+ // НЕ Math.max(...arr.map()): spread-в-аргументы переполняет стек на большом наборе.
953
+ let maxLife = 0;
954
+ for (const e of exitSets)
955
+ if (e.staleMinutes > maxLife)
956
+ maxLife = e.staleMinutes;
936
957
  // старт = первая полностью сформированная свеча ПОСЛЕ сигнала (без look-ahead):
937
958
  // свеча, содержащая сигнал, ещё формируется — её OHLC известны только в конце минуты.
938
959
  const since = entryStartTs(ts, "1m");
@@ -945,11 +966,15 @@ async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFrom
945
966
  try {
946
967
  candles = await fetchCandlesChunked(getCandles, symbol, "1m", limit, since);
947
968
  }
948
- catch {
949
- return null;
969
+ catch (e) {
970
+ // НЕ глотаем текст: 32 одинаковых adapter-error немы без него. Сообщение
971
+ // (или String(e) для не-Error) уходит в meta.labeling для диагностики.
972
+ const error = e instanceof Error ? e.message : String(e);
973
+ return { outcome: "adapter-error", burst: null, error };
974
+ }
975
+ if (!candles || candles.length === 0) {
976
+ return { outcome: "no-candles", burst: null };
950
977
  }
951
- if (!candles || candles.length === 0)
952
- return null;
953
978
  const from = entryFromPrice ?? candles[0].open;
954
979
  const to = entryToPrice ?? candles[0].open;
955
980
  const byExit = new Map();
@@ -965,9 +990,10 @@ async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFrom
965
990
  byExit.set(exitKey(ex), r);
966
991
  }
967
992
  const anyEntered = [...byExit.values()].some((r) => r.entered);
968
- if (byExit.size === 0 || !anyEntered)
969
- return null;
970
- return { symbol, direction, ts, byExit };
993
+ if (byExit.size === 0 || !anyEntered) {
994
+ return { outcome: "no-entry", burst: null };
995
+ }
996
+ return { outcome: "ok", burst: { symbol, direction, ts, byExit } };
971
997
  }
972
998
 
973
999
  /**
@@ -1200,6 +1226,8 @@ function computeReliability(input, cfg = DEFAULT_RELIABILITY) {
1200
1226
  const BAR_LENGTH = 30;
1201
1227
  const BAR_FILLED_CHAR = "\u2588";
1202
1228
  const BAR_EMPTY_CHAR = "\u2591";
1229
+ /** \u0424\u0438\u043a\u0441. \u0448\u0438\u0440\u0438\u043d\u0430 \u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u0440\u043e\u0433\u0440\u0435\u0441\u0441\u0430: \u0431\u0430\u0440(30) + \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u044b/\u0441\u0447\u0451\u0442\u0447\u0438\u043a/\u0444\u0430\u0437\u0430 + \u043c\u0435\u0442\u043a\u0430-\u0442\u0438\u043a\u0435\u0440. */
1230
+ const LINE_WIDTH = 80;
1203
1231
  /** Дефолтный stdout-бар в стиле пользователя. */
1204
1232
  const stdoutProgress = (e) => {
1205
1233
  if (e.total <= 0)
@@ -1209,7 +1237,10 @@ const stdoutProgress = (e) => {
1209
1237
  const filled = Math.round(ratio * BAR_LENGTH);
1210
1238
  const empty = BAR_LENGTH - filled;
1211
1239
  const bar = BAR_FILLED_CHAR.repeat(filled) + BAR_EMPTY_CHAR.repeat(empty);
1212
- process.stdout.write(`\r[${bar}] ${percent}% (${e.done}/${e.total}) ${e.phase} ${e.label}`);
1240
+ // фикс. ширина: pad пробелами + slice. Иначе при более короткой новой метке
1241
+ // (SOLUSDT после FARTCOINUSDT) \r не стирает хвост → "BTCUSDTTUSDT".
1242
+ const line = `[${bar}] ${percent}% (${e.done}/${e.total}) ${e.phase} ${e.label}`;
1243
+ process.stdout.write("\r" + line.padEnd(LINE_WIDTH).slice(0, LINE_WIDTH));
1213
1244
  if (e.done >= e.total)
1214
1245
  process.stdout.write("\n");
1215
1246
  };
@@ -1315,7 +1346,13 @@ function sharpe(returns) {
1315
1346
  // (масштаб данных × machine epsilon), а НЕ относительно mean. Прошлый порог
1316
1347
  // |mean|·1e-9 ошибочно убивал ВЫСОКИЙ Sharpe (малый std при большом mean — это и
1317
1348
  // есть высокий Sharpe, не пыль). Масштаб = max|x|.
1318
- const scale = Math.max(...returns.map((x) => Math.abs(x)), Math.abs(m));
1349
+ // НЕ Math.max(...returns): на длинном ряде сделок spread-в-аргументы переполняет стек.
1350
+ let scale = Math.abs(m);
1351
+ for (const x of returns) {
1352
+ const a = Math.abs(x);
1353
+ if (a > scale)
1354
+ scale = a;
1355
+ }
1319
1356
  const dustFloor = scale * 1e-13; // ~500× machine epsilon (2.2e-16) от масштаба данных
1320
1357
  if (s <= dustFloor)
1321
1358
  return 0;
@@ -1798,19 +1835,34 @@ async function train(items, getCandles, opts = {}) {
1798
1835
  });
1799
1836
  const labeledCache = new Map();
1800
1837
  const seenCluster = new Set();
1838
+ // диагностика разметки: исход каждого УНИКАЛЬНОГО всплеска. dedup по (symbol|dir|ts):
1839
+ // один всплеск перечисляется в нескольких проходах грида — считаем исход раз, иначе
1840
+ // счётчики раздуты числом конфигов. Пустой fit перестаёт быть немым: тэлли скажет,
1841
+ // adapter-error / no-candles / no-entry это или реально ok.
1842
+ const outcomeTally = new Map();
1843
+ // уникальные тексты adapter-error → сколько раз встретились (32 одинаковых схлопнутся).
1844
+ const errorTally = new Map();
1845
+ const diagSeen = new Set();
1801
1846
  const labelCandidates = async (cands, onTick) => {
1802
1847
  const labeled = [];
1803
1848
  for (const b of cands) {
1804
1849
  const src = entryIndex.get(`${b.symbol}|${b.direction}|${b.ts}`);
1805
- const lb = await labelBurst(getCandles, b.symbol, b.direction, b.ts, exitSets, src?.entryFromPrice, src?.entryToPrice);
1850
+ const { outcome, burst, error } = await labelBurst(getCandles, b.symbol, b.direction, b.ts, exitSets, src?.entryFromPrice, src?.entryToPrice);
1806
1851
  onTick?.(b.symbol);
1807
- if (!lb)
1852
+ const diagKey = `${b.symbol}|${b.direction}|${b.ts}`;
1853
+ if (!diagSeen.has(diagKey)) {
1854
+ diagSeen.add(diagKey);
1855
+ outcomeTally.set(outcome, (outcomeTally.get(outcome) ?? 0) + 1);
1856
+ if (error)
1857
+ errorTally.set(error, (errorTally.get(error) ?? 0) + 1);
1858
+ }
1859
+ if (!burst)
1808
1860
  continue;
1809
1861
  const byExit = new Map();
1810
1862
  // veto-вход (entered=false, reason=cascade-veto) тоже несёт сигнал: его pnl=0,
1811
1863
  // и он ДОЛЖЕН учитываться как «не вошли и не потеряли», иначе policy=veto нечестно
1812
1864
  // сравнивать с policy=none. Поэтому храним и не-entered, помечая флагом.
1813
- for (const [k, r] of lb.byExit) {
1865
+ for (const [k, r] of burst.byExit) {
1814
1866
  byExit.set(k, {
1815
1867
  pnl: r.pnl, volRegime: r.volRegime, entered: r.entered,
1816
1868
  entryPrice: r.entryPrice, exitPrice: r.exitPrice, reason: r.reason,
@@ -1920,7 +1972,8 @@ async function train(items, getCandles, opts = {}) {
1920
1972
  foldMeans.push(valRet.length ? valRet.reduce((s, x) => s + x, 0) / valRet.length : 0);
1921
1973
  foldWins.push(winrate(valRet));
1922
1974
  foldSupp.push(valRet.length);
1923
- allRet.push(...valRet);
1975
+ for (const r of valRet)
1976
+ allRet.push(r);
1924
1977
  }
1925
1978
  const avg = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0);
1926
1979
  out.push({
@@ -1937,10 +1990,14 @@ async function train(items, getCandles, opts = {}) {
1937
1990
  };
1938
1991
  // основной board — по всем данным, с прогрессом фазы score
1939
1992
  let scoreDone = 0;
1940
- board.push(...buildBoard(() => true, (label) => {
1993
+ // НЕ спред (board.push(...arr)): на полном гриде arr — десятки тысяч элементов,
1994
+ // и spread-в-аргументы переполняет стек вызовов (Maximum call stack size exceeded).
1995
+ const mainBoard = buildBoard(() => true, (label) => {
1941
1996
  scoreDone++;
1942
1997
  progress({ done: scoreDone, total: scoreTotal, phase: "score", label });
1943
- }));
1998
+ });
1999
+ for (const entry of mainBoard)
2000
+ board.push(entry);
1944
2001
  // ── выбор победителя: one-standard-error rule (против winner's curse) ──
1945
2002
  // вместо argmax по cvScore берём самую КОНСЕРВАТИВНУЮ конфигурацию среди тех,
1946
2003
  // чей score в пределах SE от максимума. Это убирает переобучение на шум grid:
@@ -2191,6 +2248,11 @@ async function train(items, getCandles, opts = {}) {
2191
2248
  effectiveTrials: nTrialsEff,
2192
2249
  innerTrials,
2193
2250
  fitAttempts: opts.metaLedger ? fitAttemptCount(opts.metaLedger) + 1 : 1,
2251
+ labeling: {
2252
+ candidates: diagSeen.size,
2253
+ outcomes: Object.fromEntries(outcomeTally),
2254
+ errors: Object.fromEntries(errorTally),
2255
+ },
2194
2256
  },
2195
2257
  };
2196
2258
  const leaderboard = board.slice(0, 20).map(({ config, exit, cvScore, cvWinrate, cvSupport }) => ({ config, exit, cvScore, cvWinrate, cvSupport }));
@@ -2285,6 +2347,16 @@ class PumpMatrix {
2285
2347
  get fitAttempts() {
2286
2348
  return this.params.meta.fitAttempts;
2287
2349
  }
2350
+ /**
2351
+ * Диагностика фазы разметки: { candidates, outcomes, errors }. Если модель пустая
2352
+ * (totalSamples=0), причина в outcomes по LabelOutcome: "adapter-error" (getCandles
2353
+ * бросает), "no-candles" (вернул пусто — символ/диапазон), "no-entry" (свечи есть,
2354
+ * входов в зону нет), "ok" (размечено). errors — уникальные тексты исключений
2355
+ * getCandles со счётчиком (чтобы adapter-error не был немым).
2356
+ */
2357
+ get labeling() {
2358
+ return this.params.meta.labeling;
2359
+ }
2288
2360
  /**
2289
2361
  * Статистический сертификат: прошёл ли эдж пять барьеров (DSR ≥ 0.95, PBO ≤ 0.10,
2290
2362
  * SPA p ≤ 0.05, N ≥ minTRL, nested OOS > 0). certified=false с reasons, если эдж
@@ -2401,7 +2473,15 @@ class PumpMatrix {
2401
2473
  if (typeof source === "function") {
2402
2474
  return this.backtestViaGetCandles(items, source, policy);
2403
2475
  }
2404
- return this.collect(items, (v) => source[v.symbol] ?? null, policy);
2476
+ const eff = intersectPolicy(this.params.policy, policy);
2477
+ const out = [];
2478
+ for (const v of this._predict(items).signals) {
2479
+ // зона входа уже в вердикте (протянута из parser-item) → buildSignal её прокинет
2480
+ const s = this.buildSignal(v, source[v.symbol] ?? null, eff);
2481
+ if (s)
2482
+ out.push(s);
2483
+ }
2484
+ return out;
2405
2485
  }
2406
2486
  async backtestViaGetCandles(items, getCandles, policy) {
2407
2487
  const eff = intersectPolicy(this.params.policy, policy);
@@ -2436,7 +2516,11 @@ class PumpMatrix {
2436
2516
  const eff = intersectPolicy(this.params.policy, policy);
2437
2517
  return this.buildSignalLive(v, candles, eff);
2438
2518
  }
2439
- /** Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему). */
2519
+ /**
2520
+ * Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему).
2521
+ * Возвращает BacktestSignal с реализованным result. Зона входа не задаётся —
2522
+ * replay берёт точку = open первой свечи (как при отсутствии зоны в обучении).
2523
+ */
2440
2524
  planForAt(symbol, direction, channel, candles, entryTs, policy) {
2441
2525
  const v = {
2442
2526
  symbol, direction, action: "open", ts: entryTs,
@@ -2451,11 +2535,13 @@ class PumpMatrix {
2451
2535
  return this._predict(items);
2452
2536
  }
2453
2537
  // ── общий сборщик: predict → buildSignal → отсев null (veto/не разрешено) ──
2538
+ // signals() — без свечей. Используем LIVE-сборку: каскад не оценивается (нет свечей),
2539
+ // и result не доклеивается — signals() отдаёт чистый TradeSignal, не BacktestSignal.
2454
2540
  collect(items, candlesOf, policy) {
2455
2541
  const eff = intersectPolicy(this.params.policy, policy);
2456
2542
  const out = [];
2457
2543
  for (const v of this._predict(items).signals) {
2458
- const s = this.buildSignal(v, candlesOf(v), eff);
2544
+ const s = this.buildSignalLive(v, candlesOf(v), eff);
2459
2545
  if (s)
2460
2546
  out.push(s); // null = veto или исход не в allow → не отдаём
2461
2547
  }
@@ -2472,10 +2558,15 @@ class PumpMatrix {
2472
2558
  }
2473
2559
  /**
2474
2560
  * BACKTEST-сборка сигнала: каскад по свечам ПОСЛЕ входа (forward squeezePressure),
2475
- * допустимо только на истории. Делегирует в общее ядро с mode="backtest".
2561
+ * допустимо только на истории. Возвращает BacktestSignal с реализованным result
2562
+ * (replay exit-плана вперёд) — главное отличие backtest от plan. entryFrom/entryTo
2563
+ * — зона входа для replay (из parser-item); без свечей result.entered=false.
2476
2564
  */
2477
2565
  buildSignal(v, candles, policy) {
2478
- return this.buildSignalCore(v, candles, policy, "backtest");
2566
+ const sig = this.buildSignalCore(v, candles, policy, "backtest");
2567
+ if (!sig)
2568
+ return null;
2569
+ return { ...sig, result: this.replayResult(sig, candles) };
2479
2570
  }
2480
2571
  /**
2481
2572
  * LIVE-сборка сигнала: каскад по свечам ДО входа (backward squeezePressureBefore),
@@ -2484,6 +2575,31 @@ class PumpMatrix {
2484
2575
  buildSignalLive(v, candles, policy) {
2485
2576
  return this.buildSignalCore(v, candles, policy, "live");
2486
2577
  }
2578
+ /**
2579
+ * Реализованный результат для backtest: replay ИТОГОВОГО (с учётом инверсии)
2580
+ * направления и exit-плана сигнала по свечам после входа. Зона входа из parser-item;
2581
+ * если не задана — точка = open первой свечи (как в обучении). Нет свечей → не вошли.
2582
+ */
2583
+ replayResult(sig, candles) {
2584
+ if (!candles || candles.length === 0) {
2585
+ return { entered: false, pnl: 0, peak: 0, reason: "no-candles", heldMinutes: 0, entryPrice: 0, exitPrice: 0, truncated: false };
2586
+ }
2587
+ // зона входа теперь в самом сигнале (протянута из parser-item); нет → open первой свечи.
2588
+ const from = sig.entryFromPrice ?? candles[0].open;
2589
+ const to = sig.entryToPrice ?? candles[0].open;
2590
+ const r = replayExit(candles, sig.direction, from, to, {
2591
+ trailingTake: sig.exit.trailingTake,
2592
+ hardStop: sig.exit.hardStop,
2593
+ stalenessSinceProfit: sig.exit.stalenessSinceProfit,
2594
+ stalenessSinceMinutes: sig.exit.stalenessSinceMinutes,
2595
+ staleMinutes: sig.exit.impactHorizonMinutes,
2596
+ });
2597
+ return {
2598
+ entered: r.entered, pnl: r.pnl, peak: r.peak, reason: r.reason,
2599
+ heldMinutes: r.heldMinutes, entryPrice: r.entryPrice, exitPrice: r.exitPrice,
2600
+ truncated: r.truncated,
2601
+ };
2602
+ }
2487
2603
  /**
2488
2604
  * Строит ЕДИНЫЙ TradeSignal из вердикта. Возвращает null, если исполнять нечего:
2489
2605
  * каскад дал veto ИЛИ получившийся action не в allow-списке. Инверсия здесь же
@@ -2583,7 +2699,11 @@ class PumpMatrix {
2583
2699
  id: v.id,
2584
2700
  ids: v.ids,
2585
2701
  };
2586
- return { symbol: v.symbol, direction: finalDir, action, ts: v.ts, exit: plan, origin };
2702
+ return {
2703
+ symbol: v.symbol, direction: finalDir, action, ts: v.ts,
2704
+ entryFromPrice: v.entryFromPrice, entryToPrice: v.entryToPrice,
2705
+ exit: plan, origin,
2706
+ };
2587
2707
  }
2588
2708
  }
2589
2709
 
package/build/index.mjs CHANGED
@@ -291,6 +291,8 @@ function earlyWarning(tbl, clusterOf, cfg, tau) {
291
291
  `в окне ${(window / 60000).toFixed(0)}м (каналов: ${channels.size})`,
292
292
  source: "matrix",
293
293
  channel: null,
294
+ entryFromPrice: evs[hi].entryFromPrice,
295
+ entryToPrice: evs[hi].entryToPrice,
294
296
  };
295
297
  if (!best || cand.confidence > best.confidence)
296
298
  best = cand;
@@ -357,6 +359,8 @@ function singleChannelSignals(tbl, cfg, tau) {
357
359
  channel: e.channel,
358
360
  id,
359
361
  ids: id != null ? [id] : [],
362
+ entryFromPrice: e.entryFromPrice,
363
+ entryToPrice: e.entryToPrice,
360
364
  };
361
365
  verdicts.push(current);
362
366
  }
@@ -653,10 +657,19 @@ async function fetchCandlesChunked(getCandles, symbol, interval, limit, since, c
653
657
  const chunk = await getCandles(symbol, interval, chunkLimit, currentSince);
654
658
  if (!chunk || chunk.length === 0)
655
659
  break; // край истории / дыра — отдаём собранное
656
- all.push(...chunk);
657
- remaining -= chunkLimit;
658
- if (remaining > 0)
659
- currentSince = currentSince + chunkLimit * step;
660
+ for (const c of chunk)
661
+ all.push(c); // НЕ спред: при большом limit чанк переполнит стек
662
+ // ЧАСТИЧНЫЙ чанк (биржа недодала: вернула < chunkLimit, но не пусто) — двигаем
663
+ // since от ФАКТИЧЕСКИ последней свечи (+step), а remaining уменьшаем на реально
664
+ // полученное (chunk.length). Иначе since прыгает на полный chunkLimit·step, минуя
665
+ // недополученный хвост → дыра в склеенном ряду + недосчёт. Свечи могут прийти
666
+ // неотсортированными — берём max(ts), а не последний элемент.
667
+ let maxTs = currentSince;
668
+ for (const c of chunk)
669
+ if (c.timestamp > maxTs)
670
+ maxTs = c.timestamp;
671
+ remaining -= chunk.length;
672
+ currentSince = maxTs + step;
660
673
  }
661
674
  // дедуп по timestamp (на стыках чанков адаптер может вернуть пограничную свечу
662
675
  // дважды). Оставляем ПЕРВОЕ вхождение: при forward-пагинации первая свеча с данным
@@ -702,10 +715,14 @@ function volumeZScore(candles, entryIdx, baselineWindow) {
702
715
  * это ловушка (stop hunt / squeeze), входить опасно либо выходить раньше.
703
716
  */
704
717
  function squeezePressure(candles, entryIdx, dir, horizon) {
718
+ // КЛАМП нижней границы (симметрично squeezePressureBefore): при отрицательном
719
+ // entryIdx (findIndex вернул -1, битый вызов) старт стал бы < 0 и цикл прочитал
720
+ // candles[-1] = undefined → краш на c.close. max(0, ...) этого не допускает.
721
+ const start = Math.max(0, entryIdx + 1);
705
722
  const end = Math.min(candles.length, entryIdx + horizon + 1);
706
723
  let againstVol = 0;
707
724
  let totalVol = 0;
708
- for (let i = entryIdx + 1; i < end; i++) {
725
+ for (let i = start; i < end; i++) {
709
726
  const c = candles[i];
710
727
  const delta = c.close - c.open; // знак внутрисвечного движения
711
728
  // «против позиции»: long не любит падение (delta<0), short не любит рост (delta>0)
@@ -930,7 +947,11 @@ const exitKey = (p) => `tt${p.trailingTake}|hs${p.hardStop}|sp${p.stalenessSince
930
947
  * если не задана — точка entryFrom=entryTo=open первой свечи.
931
948
  */
932
949
  async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFromPrice, entryToPrice) {
933
- const maxLife = Math.max(...exitSets.map((e) => e.staleMinutes));
950
+ // НЕ Math.max(...arr.map()): spread-в-аргументы переполняет стек на большом наборе.
951
+ let maxLife = 0;
952
+ for (const e of exitSets)
953
+ if (e.staleMinutes > maxLife)
954
+ maxLife = e.staleMinutes;
934
955
  // старт = первая полностью сформированная свеча ПОСЛЕ сигнала (без look-ahead):
935
956
  // свеча, содержащая сигнал, ещё формируется — её OHLC известны только в конце минуты.
936
957
  const since = entryStartTs(ts, "1m");
@@ -943,11 +964,15 @@ async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFrom
943
964
  try {
944
965
  candles = await fetchCandlesChunked(getCandles, symbol, "1m", limit, since);
945
966
  }
946
- catch {
947
- return null;
967
+ catch (e) {
968
+ // НЕ глотаем текст: 32 одинаковых adapter-error немы без него. Сообщение
969
+ // (или String(e) для не-Error) уходит в meta.labeling для диагностики.
970
+ const error = e instanceof Error ? e.message : String(e);
971
+ return { outcome: "adapter-error", burst: null, error };
972
+ }
973
+ if (!candles || candles.length === 0) {
974
+ return { outcome: "no-candles", burst: null };
948
975
  }
949
- if (!candles || candles.length === 0)
950
- return null;
951
976
  const from = entryFromPrice ?? candles[0].open;
952
977
  const to = entryToPrice ?? candles[0].open;
953
978
  const byExit = new Map();
@@ -963,9 +988,10 @@ async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFrom
963
988
  byExit.set(exitKey(ex), r);
964
989
  }
965
990
  const anyEntered = [...byExit.values()].some((r) => r.entered);
966
- if (byExit.size === 0 || !anyEntered)
967
- return null;
968
- return { symbol, direction, ts, byExit };
991
+ if (byExit.size === 0 || !anyEntered) {
992
+ return { outcome: "no-entry", burst: null };
993
+ }
994
+ return { outcome: "ok", burst: { symbol, direction, ts, byExit } };
969
995
  }
970
996
 
971
997
  /**
@@ -1198,6 +1224,8 @@ function computeReliability(input, cfg = DEFAULT_RELIABILITY) {
1198
1224
  const BAR_LENGTH = 30;
1199
1225
  const BAR_FILLED_CHAR = "\u2588";
1200
1226
  const BAR_EMPTY_CHAR = "\u2591";
1227
+ /** \u0424\u0438\u043a\u0441. \u0448\u0438\u0440\u0438\u043d\u0430 \u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u0440\u043e\u0433\u0440\u0435\u0441\u0441\u0430: \u0431\u0430\u0440(30) + \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u044b/\u0441\u0447\u0451\u0442\u0447\u0438\u043a/\u0444\u0430\u0437\u0430 + \u043c\u0435\u0442\u043a\u0430-\u0442\u0438\u043a\u0435\u0440. */
1228
+ const LINE_WIDTH = 80;
1201
1229
  /** Дефолтный stdout-бар в стиле пользователя. */
1202
1230
  const stdoutProgress = (e) => {
1203
1231
  if (e.total <= 0)
@@ -1207,7 +1235,10 @@ const stdoutProgress = (e) => {
1207
1235
  const filled = Math.round(ratio * BAR_LENGTH);
1208
1236
  const empty = BAR_LENGTH - filled;
1209
1237
  const bar = BAR_FILLED_CHAR.repeat(filled) + BAR_EMPTY_CHAR.repeat(empty);
1210
- process.stdout.write(`\r[${bar}] ${percent}% (${e.done}/${e.total}) ${e.phase} ${e.label}`);
1238
+ // фикс. ширина: pad пробелами + slice. Иначе при более короткой новой метке
1239
+ // (SOLUSDT после FARTCOINUSDT) \r не стирает хвост → "BTCUSDTTUSDT".
1240
+ const line = `[${bar}] ${percent}% (${e.done}/${e.total}) ${e.phase} ${e.label}`;
1241
+ process.stdout.write("\r" + line.padEnd(LINE_WIDTH).slice(0, LINE_WIDTH));
1211
1242
  if (e.done >= e.total)
1212
1243
  process.stdout.write("\n");
1213
1244
  };
@@ -1313,7 +1344,13 @@ function sharpe(returns) {
1313
1344
  // (масштаб данных × machine epsilon), а НЕ относительно mean. Прошлый порог
1314
1345
  // |mean|·1e-9 ошибочно убивал ВЫСОКИЙ Sharpe (малый std при большом mean — это и
1315
1346
  // есть высокий Sharpe, не пыль). Масштаб = max|x|.
1316
- const scale = Math.max(...returns.map((x) => Math.abs(x)), Math.abs(m));
1347
+ // НЕ Math.max(...returns): на длинном ряде сделок spread-в-аргументы переполняет стек.
1348
+ let scale = Math.abs(m);
1349
+ for (const x of returns) {
1350
+ const a = Math.abs(x);
1351
+ if (a > scale)
1352
+ scale = a;
1353
+ }
1317
1354
  const dustFloor = scale * 1e-13; // ~500× machine epsilon (2.2e-16) от масштаба данных
1318
1355
  if (s <= dustFloor)
1319
1356
  return 0;
@@ -1796,19 +1833,34 @@ async function train(items, getCandles, opts = {}) {
1796
1833
  });
1797
1834
  const labeledCache = new Map();
1798
1835
  const seenCluster = new Set();
1836
+ // диагностика разметки: исход каждого УНИКАЛЬНОГО всплеска. dedup по (symbol|dir|ts):
1837
+ // один всплеск перечисляется в нескольких проходах грида — считаем исход раз, иначе
1838
+ // счётчики раздуты числом конфигов. Пустой fit перестаёт быть немым: тэлли скажет,
1839
+ // adapter-error / no-candles / no-entry это или реально ok.
1840
+ const outcomeTally = new Map();
1841
+ // уникальные тексты adapter-error → сколько раз встретились (32 одинаковых схлопнутся).
1842
+ const errorTally = new Map();
1843
+ const diagSeen = new Set();
1799
1844
  const labelCandidates = async (cands, onTick) => {
1800
1845
  const labeled = [];
1801
1846
  for (const b of cands) {
1802
1847
  const src = entryIndex.get(`${b.symbol}|${b.direction}|${b.ts}`);
1803
- const lb = await labelBurst(getCandles, b.symbol, b.direction, b.ts, exitSets, src?.entryFromPrice, src?.entryToPrice);
1848
+ const { outcome, burst, error } = await labelBurst(getCandles, b.symbol, b.direction, b.ts, exitSets, src?.entryFromPrice, src?.entryToPrice);
1804
1849
  onTick?.(b.symbol);
1805
- if (!lb)
1850
+ const diagKey = `${b.symbol}|${b.direction}|${b.ts}`;
1851
+ if (!diagSeen.has(diagKey)) {
1852
+ diagSeen.add(diagKey);
1853
+ outcomeTally.set(outcome, (outcomeTally.get(outcome) ?? 0) + 1);
1854
+ if (error)
1855
+ errorTally.set(error, (errorTally.get(error) ?? 0) + 1);
1856
+ }
1857
+ if (!burst)
1806
1858
  continue;
1807
1859
  const byExit = new Map();
1808
1860
  // veto-вход (entered=false, reason=cascade-veto) тоже несёт сигнал: его pnl=0,
1809
1861
  // и он ДОЛЖЕН учитываться как «не вошли и не потеряли», иначе policy=veto нечестно
1810
1862
  // сравнивать с policy=none. Поэтому храним и не-entered, помечая флагом.
1811
- for (const [k, r] of lb.byExit) {
1863
+ for (const [k, r] of burst.byExit) {
1812
1864
  byExit.set(k, {
1813
1865
  pnl: r.pnl, volRegime: r.volRegime, entered: r.entered,
1814
1866
  entryPrice: r.entryPrice, exitPrice: r.exitPrice, reason: r.reason,
@@ -1918,7 +1970,8 @@ async function train(items, getCandles, opts = {}) {
1918
1970
  foldMeans.push(valRet.length ? valRet.reduce((s, x) => s + x, 0) / valRet.length : 0);
1919
1971
  foldWins.push(winrate(valRet));
1920
1972
  foldSupp.push(valRet.length);
1921
- allRet.push(...valRet);
1973
+ for (const r of valRet)
1974
+ allRet.push(r);
1922
1975
  }
1923
1976
  const avg = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0);
1924
1977
  out.push({
@@ -1935,10 +1988,14 @@ async function train(items, getCandles, opts = {}) {
1935
1988
  };
1936
1989
  // основной board — по всем данным, с прогрессом фазы score
1937
1990
  let scoreDone = 0;
1938
- board.push(...buildBoard(() => true, (label) => {
1991
+ // НЕ спред (board.push(...arr)): на полном гриде arr — десятки тысяч элементов,
1992
+ // и spread-в-аргументы переполняет стек вызовов (Maximum call stack size exceeded).
1993
+ const mainBoard = buildBoard(() => true, (label) => {
1939
1994
  scoreDone++;
1940
1995
  progress({ done: scoreDone, total: scoreTotal, phase: "score", label });
1941
- }));
1996
+ });
1997
+ for (const entry of mainBoard)
1998
+ board.push(entry);
1942
1999
  // ── выбор победителя: one-standard-error rule (против winner's curse) ──
1943
2000
  // вместо argmax по cvScore берём самую КОНСЕРВАТИВНУЮ конфигурацию среди тех,
1944
2001
  // чей score в пределах SE от максимума. Это убирает переобучение на шум grid:
@@ -2189,6 +2246,11 @@ async function train(items, getCandles, opts = {}) {
2189
2246
  effectiveTrials: nTrialsEff,
2190
2247
  innerTrials,
2191
2248
  fitAttempts: opts.metaLedger ? fitAttemptCount(opts.metaLedger) + 1 : 1,
2249
+ labeling: {
2250
+ candidates: diagSeen.size,
2251
+ outcomes: Object.fromEntries(outcomeTally),
2252
+ errors: Object.fromEntries(errorTally),
2253
+ },
2192
2254
  },
2193
2255
  };
2194
2256
  const leaderboard = board.slice(0, 20).map(({ config, exit, cvScore, cvWinrate, cvSupport }) => ({ config, exit, cvScore, cvWinrate, cvSupport }));
@@ -2283,6 +2345,16 @@ class PumpMatrix {
2283
2345
  get fitAttempts() {
2284
2346
  return this.params.meta.fitAttempts;
2285
2347
  }
2348
+ /**
2349
+ * Диагностика фазы разметки: { candidates, outcomes, errors }. Если модель пустая
2350
+ * (totalSamples=0), причина в outcomes по LabelOutcome: "adapter-error" (getCandles
2351
+ * бросает), "no-candles" (вернул пусто — символ/диапазон), "no-entry" (свечи есть,
2352
+ * входов в зону нет), "ok" (размечено). errors — уникальные тексты исключений
2353
+ * getCandles со счётчиком (чтобы adapter-error не был немым).
2354
+ */
2355
+ get labeling() {
2356
+ return this.params.meta.labeling;
2357
+ }
2286
2358
  /**
2287
2359
  * Статистический сертификат: прошёл ли эдж пять барьеров (DSR ≥ 0.95, PBO ≤ 0.10,
2288
2360
  * SPA p ≤ 0.05, N ≥ minTRL, nested OOS > 0). certified=false с reasons, если эдж
@@ -2399,7 +2471,15 @@ class PumpMatrix {
2399
2471
  if (typeof source === "function") {
2400
2472
  return this.backtestViaGetCandles(items, source, policy);
2401
2473
  }
2402
- return this.collect(items, (v) => source[v.symbol] ?? null, policy);
2474
+ const eff = intersectPolicy(this.params.policy, policy);
2475
+ const out = [];
2476
+ for (const v of this._predict(items).signals) {
2477
+ // зона входа уже в вердикте (протянута из parser-item) → buildSignal её прокинет
2478
+ const s = this.buildSignal(v, source[v.symbol] ?? null, eff);
2479
+ if (s)
2480
+ out.push(s);
2481
+ }
2482
+ return out;
2403
2483
  }
2404
2484
  async backtestViaGetCandles(items, getCandles, policy) {
2405
2485
  const eff = intersectPolicy(this.params.policy, policy);
@@ -2434,7 +2514,11 @@ class PumpMatrix {
2434
2514
  const eff = intersectPolicy(this.params.policy, policy);
2435
2515
  return this.buildSignalLive(v, candles, eff);
2436
2516
  }
2437
- /** Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему). */
2517
+ /**
2518
+ * Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему).
2519
+ * Возвращает BacktestSignal с реализованным result. Зона входа не задаётся —
2520
+ * replay берёт точку = open первой свечи (как при отсутствии зоны в обучении).
2521
+ */
2438
2522
  planForAt(symbol, direction, channel, candles, entryTs, policy) {
2439
2523
  const v = {
2440
2524
  symbol, direction, action: "open", ts: entryTs,
@@ -2449,11 +2533,13 @@ class PumpMatrix {
2449
2533
  return this._predict(items);
2450
2534
  }
2451
2535
  // ── общий сборщик: predict → buildSignal → отсев null (veto/не разрешено) ──
2536
+ // signals() — без свечей. Используем LIVE-сборку: каскад не оценивается (нет свечей),
2537
+ // и result не доклеивается — signals() отдаёт чистый TradeSignal, не BacktestSignal.
2452
2538
  collect(items, candlesOf, policy) {
2453
2539
  const eff = intersectPolicy(this.params.policy, policy);
2454
2540
  const out = [];
2455
2541
  for (const v of this._predict(items).signals) {
2456
- const s = this.buildSignal(v, candlesOf(v), eff);
2542
+ const s = this.buildSignalLive(v, candlesOf(v), eff);
2457
2543
  if (s)
2458
2544
  out.push(s); // null = veto или исход не в allow → не отдаём
2459
2545
  }
@@ -2470,10 +2556,15 @@ class PumpMatrix {
2470
2556
  }
2471
2557
  /**
2472
2558
  * BACKTEST-сборка сигнала: каскад по свечам ПОСЛЕ входа (forward squeezePressure),
2473
- * допустимо только на истории. Делегирует в общее ядро с mode="backtest".
2559
+ * допустимо только на истории. Возвращает BacktestSignal с реализованным result
2560
+ * (replay exit-плана вперёд) — главное отличие backtest от plan. entryFrom/entryTo
2561
+ * — зона входа для replay (из parser-item); без свечей result.entered=false.
2474
2562
  */
2475
2563
  buildSignal(v, candles, policy) {
2476
- return this.buildSignalCore(v, candles, policy, "backtest");
2564
+ const sig = this.buildSignalCore(v, candles, policy, "backtest");
2565
+ if (!sig)
2566
+ return null;
2567
+ return { ...sig, result: this.replayResult(sig, candles) };
2477
2568
  }
2478
2569
  /**
2479
2570
  * LIVE-сборка сигнала: каскад по свечам ДО входа (backward squeezePressureBefore),
@@ -2482,6 +2573,31 @@ class PumpMatrix {
2482
2573
  buildSignalLive(v, candles, policy) {
2483
2574
  return this.buildSignalCore(v, candles, policy, "live");
2484
2575
  }
2576
+ /**
2577
+ * Реализованный результат для backtest: replay ИТОГОВОГО (с учётом инверсии)
2578
+ * направления и exit-плана сигнала по свечам после входа. Зона входа из parser-item;
2579
+ * если не задана — точка = open первой свечи (как в обучении). Нет свечей → не вошли.
2580
+ */
2581
+ replayResult(sig, candles) {
2582
+ if (!candles || candles.length === 0) {
2583
+ return { entered: false, pnl: 0, peak: 0, reason: "no-candles", heldMinutes: 0, entryPrice: 0, exitPrice: 0, truncated: false };
2584
+ }
2585
+ // зона входа теперь в самом сигнале (протянута из parser-item); нет → open первой свечи.
2586
+ const from = sig.entryFromPrice ?? candles[0].open;
2587
+ const to = sig.entryToPrice ?? candles[0].open;
2588
+ const r = replayExit(candles, sig.direction, from, to, {
2589
+ trailingTake: sig.exit.trailingTake,
2590
+ hardStop: sig.exit.hardStop,
2591
+ stalenessSinceProfit: sig.exit.stalenessSinceProfit,
2592
+ stalenessSinceMinutes: sig.exit.stalenessSinceMinutes,
2593
+ staleMinutes: sig.exit.impactHorizonMinutes,
2594
+ });
2595
+ return {
2596
+ entered: r.entered, pnl: r.pnl, peak: r.peak, reason: r.reason,
2597
+ heldMinutes: r.heldMinutes, entryPrice: r.entryPrice, exitPrice: r.exitPrice,
2598
+ truncated: r.truncated,
2599
+ };
2600
+ }
2485
2601
  /**
2486
2602
  * Строит ЕДИНЫЙ TradeSignal из вердикта. Возвращает null, если исполнять нечего:
2487
2603
  * каскад дал veto ИЛИ получившийся action не в allow-списке. Инверсия здесь же
@@ -2581,7 +2697,11 @@ class PumpMatrix {
2581
2697
  id: v.id,
2582
2698
  ids: v.ids,
2583
2699
  };
2584
- return { symbol: v.symbol, direction: finalDir, action, ts: v.ts, exit: plan, origin };
2700
+ return {
2701
+ symbol: v.symbol, direction: finalDir, action, ts: v.ts,
2702
+ entryFromPrice: v.entryFromPrice, entryToPrice: v.entryToPrice,
2703
+ exit: plan, origin,
2704
+ };
2585
2705
  }
2586
2706
  }
2587
2707
 
package/package.json CHANGED
@@ -1,7 +1,18 @@
1
1
  {
2
2
  "name": "pump-anomaly",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Detector of synchronized trading pump signals via author-cluster deduplication, with path-aware exit replay and liquidation-cascade detection.",
5
+ "author": {
6
+ "name": "Petr Tripolsky",
7
+ "email": "tripolskypetr@gmail.com",
8
+ "url": "https://github.com/tripolskypetr"
9
+ },
10
+ "funding": {
11
+ "type": "individual",
12
+ "url": "http://paypal.me/tripolskypetr"
13
+ },
14
+ "license": "MIT",
15
+ "homepage": "https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html",
5
16
  "keywords": [
6
17
  "trading",
7
18
  "pump",
@@ -19,7 +30,6 @@
19
30
  "typescript"
20
31
  ],
21
32
  "type": "module",
22
- "license": "MIT",
23
33
  "main": "./build/index.cjs",
24
34
  "module": "./build/index.mjs",
25
35
  "types": "./types.d.ts",
package/types.d.ts CHANGED
@@ -36,6 +36,8 @@ interface ParserItem {
36
36
  entryFromPrice?: number;
37
37
  /** верхняя граница зоны входа. */
38
38
  entryToPrice?: number;
39
+ /** идентификатор исходного поста — протягивается до dump() и origin live-сигнала для сопоставления с парсингом. */
40
+ id?: string | number;
39
41
  [extra: string]: unknown;
40
42
  }
41
43
  /** Нормализованное событие, с которым работают внутренние слои. */
@@ -67,6 +69,9 @@ interface PumpVerdict {
67
69
  id?: string;
68
70
  /** id всех parser-item, вошедших в сигнал */
69
71
  ids?: string[];
72
+ /** зона входа из parser-item — нужна для открытия live-позиции */
73
+ entryFromPrice?: number;
74
+ entryToPrice?: number;
70
75
  }
71
76
  /** Карта авторства: канал → id кластера-автора. */
72
77
  type AuthorMap = Map<string, number>;
@@ -473,6 +478,23 @@ interface LabeledBurst {
473
478
  /** ключ exit-набора → результат replay */
474
479
  byExit: Map<string, ReplayResult>;
475
480
  }
481
+ /**
482
+ * Исход разметки одного кандидата. Диагностика «немых» пустых fit: пустой результат
483
+ * выглядит одинаково для «нет данных» и «нет входов», а это РАЗНЫЕ проблемы (битый
484
+ * getCandles vs реально не было входов в зону).
485
+ * - ok — размечен, есть вход (burst != null);
486
+ * - adapter-error — getCandles бросил (look-ahead guard / дыра / count-mismatch);
487
+ * - no-candles — getCandles вернул пусто (символ/диапазон не дали свечей);
488
+ * - no-entry — свечи есть, но ни один exit-набор не вошёл в зону (или все truncated).
489
+ */
490
+ type LabelOutcome = "ok" | "adapter-error" | "no-candles" | "no-entry";
491
+ /** Результат labelBurst: типизированный исход + сам размеченный всплеск (null кроме ok). */
492
+ interface LabelResult {
493
+ outcome: LabelOutcome;
494
+ burst: LabeledBurst | null;
495
+ /** текст брошенного getCandles исключения (только при outcome="adapter-error"). */
496
+ error?: string;
497
+ }
476
498
  /** Стабильный строковый ключ exit-набора для кэша/grid. */
477
499
  declare const exitKey: (p: ExitParams) => string;
478
500
  /**
@@ -480,7 +502,7 @@ declare const exitKey: (p: ExitParams) => string;
480
502
  * прогоняет каждый exit-набор через replay. Зона входа берётся из события;
481
503
  * если не задана — точка entryFrom=entryTo=open первой свечи.
482
504
  */
483
- declare function labelBurst(getCandles: GetCandles, symbol: string, direction: Direction, ts: number, exitSets: ExitParams[], entryFromPrice?: number, entryToPrice?: number): Promise<LabeledBurst | null>;
505
+ declare function labelBurst(getCandles: GetCandles, symbol: string, direction: Direction, ts: number, exitSets: ExitParams[], entryFromPrice?: number, entryToPrice?: number): Promise<LabelResult>;
484
506
 
485
507
  /** Максимум свечей в одном чанке (как CC_MAX_CANDLES_PER_REQUEST в проде). */
486
508
  declare const MAX_CANDLES_PER_CHUNK = 500;
@@ -711,11 +733,46 @@ interface TradeSignal {
711
733
  action: SignalAction;
712
734
  /** unix-время сигнала, мс */
713
735
  ts: number;
736
+ /** нижняя граница зоны входа из parser-item (для открытия live-позиции; undefined = вход по рынку) */
737
+ entryFromPrice?: number;
738
+ /** верхняя граница зоны входа из parser-item */
739
+ entryToPrice?: number;
714
740
  /** готовый exit-план */
715
741
  exit: ExitPlan;
716
742
  /** происхождение (аудит), не для ветвления */
717
743
  origin: SignalOrigin;
718
744
  }
745
+ /**
746
+ * Реализованный результат сделки — РЕПЛЕЙ exit-плана по свечам ПОСЛЕ входа.
747
+ * Существует только в backtest (forward-replay по закрытой истории); plan/signals
748
+ * его НЕ дают (там позиция ещё не закрыта). pnl/peak в долях (0.05 = +5%).
749
+ */
750
+ interface BacktestResult {
751
+ /** вошли ли в позицию (false → зона входа не задета на окне свечей) */
752
+ entered: boolean;
753
+ /** реализованный pnl, доля (при hard-stop = честный -hardStop%) */
754
+ pnl: number;
755
+ /** пиковый pnl за жизнь позиции, доля */
756
+ peak: number;
757
+ /** причина выхода (hard-stop / trailing-take / peak-staleness / life-cap / …) */
758
+ reason: string;
759
+ /** минут от входа до выхода */
760
+ heldMinutes: number;
761
+ /** цена входа (0 если не вошли) */
762
+ entryPrice: number;
763
+ /** цена выхода (0 если не вошли) */
764
+ exitPrice: number;
765
+ /** замер неполный: после входа не хватило свечей на полный life-cap */
766
+ truncated: boolean;
767
+ }
768
+ /**
769
+ * Сигнал backtest = TradeSignal + реализованный result. Тип-потомок: главное
770
+ * отличие backtest от plan — он РЕПЛЕИТ позицию вперёд и возвращает realized pnl.
771
+ * Сигнатура backtest() возвращает именно его, поэтому pnl виден без джойна с dump().
772
+ */
773
+ interface BacktestSignal extends TradeSignal {
774
+ result: BacktestResult;
775
+ }
719
776
  /**
720
777
  * Политика разрешённых исходов (allow-список).
721
778
  *
@@ -1130,6 +1187,19 @@ interface TrainedParams {
1130
1187
  innerTrials: number;
1131
1188
  /** сколько раз всего запускался fit (для прозрачности мета-перебора) */
1132
1189
  fitAttempts: number;
1190
+ /**
1191
+ * Диагностика фазы разметки: сколько УНИКАЛЬНЫХ кандидатов-всплесков и во что они
1192
+ * вылились (outcomes по LabelOutcome — присутствуют только ненулевые исходы).
1193
+ * totalSamples=0 при candidates>0 указывает причину: "adapter-error" — getCandles
1194
+ * бросает (look-ahead/дыра/символ); "no-candles" — пусто (символ/диапазон);
1195
+ * "no-entry" — свечи есть, но входов в зону нет. Без этого пустой fit немой.
1196
+ */
1197
+ labeling: {
1198
+ candidates: number;
1199
+ outcomes: Partial<Record<LabelOutcome, number>>;
1200
+ /** уникальные тексты getCandles-исключений → счётчик (для adapter-error). */
1201
+ errors: Record<string, number>;
1202
+ };
1133
1203
  };
1134
1204
  }
1135
1205
  interface TrainResult {
@@ -1209,6 +1279,18 @@ declare class PumpMatrix {
1209
1279
  get innerTrials(): number;
1210
1280
  /** Сколько раз всего запускался fit (прозрачность мета-перебора). */
1211
1281
  get fitAttempts(): number;
1282
+ /**
1283
+ * Диагностика фазы разметки: { candidates, outcomes, errors }. Если модель пустая
1284
+ * (totalSamples=0), причина в outcomes по LabelOutcome: "adapter-error" (getCandles
1285
+ * бросает), "no-candles" (вернул пусто — символ/диапазон), "no-entry" (свечи есть,
1286
+ * входов в зону нет), "ok" (размечено). errors — уникальные тексты исключений
1287
+ * getCandles со счётчиком (чтобы adapter-error не был немым).
1288
+ */
1289
+ get labeling(): {
1290
+ candidates: number;
1291
+ outcomes: Partial<Record<LabelOutcome, number>>;
1292
+ errors: Record<string, number>;
1293
+ };
1212
1294
  /**
1213
1295
  * Статистический сертификат: прошёл ли эдж пять барьеров (DSR ≥ 0.95, PBO ≤ 0.10,
1214
1296
  * SPA p ≤ 0.05, N ≥ minTRL, nested OOS > 0). certified=false с reasons, если эдж
@@ -1291,20 +1373,26 @@ declare class PumpMatrix {
1291
1373
  *
1292
1374
  * Источник свечей — getCandles (async) или словарь {symbol: candles} (sync).
1293
1375
  */
1294
- backtest(items: ParserItem[], getCandles: GetCandles, policy?: Partial<SignalPolicy>): Promise<TradeSignal[]>;
1295
- backtest(items: ParserItem[], candlesBySymbol: Record<string, ICandleData[]>, policy?: Partial<SignalPolicy>): TradeSignal[];
1376
+ backtest(items: ParserItem[], getCandles: GetCandles, policy?: Partial<SignalPolicy>): Promise<BacktestSignal[]>;
1377
+ backtest(items: ParserItem[], candlesBySymbol: Record<string, ICandleData[]>, policy?: Partial<SignalPolicy>): BacktestSignal[];
1296
1378
  private backtestViaGetCandles;
1297
1379
  /** Точечно под ОДНУ позицию в LIVE (вход = последняя свеча, каскад по прошлому). */
1298
1380
  planFor(symbol: string, direction: Direction, channel: string | null, candles: ICandleData[], policy?: Partial<SignalPolicy>): TradeSignal | null;
1299
- /** Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему). */
1300
- planForAt(symbol: string, direction: Direction, channel: string | null, candles: ICandleData[], entryTs: number, policy?: Partial<SignalPolicy>): TradeSignal | null;
1381
+ /**
1382
+ * Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему).
1383
+ * Возвращает BacktestSignal с реализованным result. Зона входа не задаётся —
1384
+ * replay берёт точку = open первой свечи (как при отсутствии зоны в обучении).
1385
+ */
1386
+ planForAt(symbol: string, direction: Direction, channel: string | null, candles: ICandleData[], entryTs: number, policy?: Partial<SignalPolicy>): BacktestSignal | null;
1301
1387
  /** Полный отчёт (все вердикты + карта авторства) — для разбора. */
1302
1388
  explain(items: ParserItem[]): PredictionResult;
1303
1389
  private collect;
1304
1390
  private flatExit;
1305
1391
  /**
1306
1392
  * BACKTEST-сборка сигнала: каскад по свечам ПОСЛЕ входа (forward squeezePressure),
1307
- * допустимо только на истории. Делегирует в общее ядро с mode="backtest".
1393
+ * допустимо только на истории. Возвращает BacktestSignal с реализованным result
1394
+ * (replay exit-плана вперёд) — главное отличие backtest от plan. entryFrom/entryTo
1395
+ * — зона входа для replay (из parser-item); без свечей result.entered=false.
1308
1396
  */
1309
1397
  private buildSignal;
1310
1398
  /**
@@ -1312,6 +1400,12 @@ declare class PumpMatrix {
1312
1400
  * БЕЗ look-ahead. Делегирует в общее ядро с mode="live".
1313
1401
  */
1314
1402
  private buildSignalLive;
1403
+ /**
1404
+ * Реализованный результат для backtest: replay ИТОГОВОГО (с учётом инверсии)
1405
+ * направления и exit-плана сигнала по свечам после входа. Зона входа из parser-item;
1406
+ * если не задана — точка = open первой свечи (как в обучении). Нет свечей → не вошли.
1407
+ */
1408
+ private replayResult;
1315
1409
  /**
1316
1410
  * Строит ЕДИНЫЙ TradeSignal из вердикта. Возвращает null, если исполнять нечего:
1317
1411
  * каскад дал veto ИЛИ получившийся action не в allow-списке. Инверсия здесь же
@@ -1343,4 +1437,4 @@ declare class PumpMatrix {
1343
1437
  declare function predict(parserItems: ParserItem[], config?: Partial<DetectorConfig>): PredictionResult;
1344
1438
 
1345
1439
  export { CASCADE_AGGRESSION, DEFAULT_CONFIG, DEFAULT_GRID, DEFAULT_META_POLICY, DEFAULT_POLICY, DEFAULT_RELIABILITY, DEFAULT_SELECTION, DEFAULT_VIABILITY, MAX_CANDLES_PER_CHUNK, PumpMatrix, STEP_MS, alignTs, assessViability, buildTable, buildWindowedTable, canRefit, cascadeAggressionOf, certifyStrategy, clusterAuthors, computeReliability, conservatismKey, deflatedSharpe, earlyWarning, effectiveTrials, emptyLedger, entryStartTs, enumerateBursts, enumeratePosts, exitKey, expectedMaxSharpe, fetchCandlesChunked, fitAttemptCount, intersectPolicy, isMoreConservative, jaccardPair, jaccardScreen, kurtosis, labelBurst, lagXCorr, loadPredict, mean, minTrackRecordLength, mulberry32, normalCdf, normalInv, oneStandardErrorSelect, percentile, pnlStats, predict, probabilityOfBacktestOverfitting, realityCheckPValue, recordAttempt, replayExit, resolveExit, resolveExitNoRegime, riskRewardStats, selfTuneLag, sharpe, shrinkageExpectancy, silentProgress, singleChannelSignals, skewness, squeezePressure, standardError, stationaryBootstrapResample, stdev, stdoutProgress, train, variance, volRegimeOf, volumeFeatures, volumeZScore, windowEvents, winrate };
1346
- export type { AuthorMap, CandleInterval, Certification, CertificationInput, DetectorConfig, DetectorMode, Direction, ExitParams, ExitPlan, ExitReason, ExitTensor, FitAttempt, GetCandles, ICandleData, LabeledBurst, MetaLedgerState, MetaPolicy, ParserItem, PnlStats, PredictionResult, ProgressEvent, ProgressFn, PumpVerdict, Reliability, ReliabilityConfig, ReliabilityInput, ReplayResult, ResolveSource, ResolvedExit, RiskRewardStats, SelectionConfig, SignalAction, SignalEvent, SignalOrigin, SignalPolicy, SignalRecord, TradeSignal, TrainGrid, TrainOptions, TrainResult, TrainedParams, ViabilityConfig, ViabilityReport, VolRegime, VolumeFeatures };
1440
+ export type { AuthorMap, BacktestResult, BacktestSignal, CandleInterval, Certification, CertificationInput, DetectorConfig, DetectorMode, Direction, ExitParams, ExitPlan, ExitReason, ExitTensor, FitAttempt, GetCandles, ICandleData, LabeledBurst, MetaLedgerState, MetaPolicy, ParserItem, PnlStats, PredictionResult, ProgressEvent, ProgressFn, PumpVerdict, Reliability, ReliabilityConfig, ReliabilityInput, ReplayResult, ResolveSource, ResolvedExit, RiskRewardStats, SelectionConfig, SignalAction, SignalEvent, SignalOrigin, SignalPolicy, SignalRecord, TradeSignal, TrainGrid, TrainOptions, TrainResult, TrainedParams, ViabilityConfig, ViabilityReport, VolRegime, VolumeFeatures };