neuroalpha 0.5.22 → 0.5.27

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.
@@ -227,7 +227,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
227
227
  // Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
228
228
  const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
229
229
  for (const v of datasetCache.values()) {
230
- if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
230
+ if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
231
231
  return v.ds;
232
232
  }
233
233
  }
@@ -255,19 +255,30 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
255
255
  if (signal?.aborted) throw new Error('Отменено');
256
256
  const symCtrl = new AbortController();
257
257
  const effSignal = signal ? AbortSignal.any([signal, symCtrl.signal]) : symCtrl.signal;
258
- const killer = setTimeout(() => symCtrl.abort(), WITHIN);
258
+ let killer = null;
259
259
  try {
260
- // акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
261
- const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
262
- const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
263
- // объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
264
- raw[s] = compact(rows);
265
- if (divs?.length) {
266
- // DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
267
- const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
268
- divStat.applied += st.applied;
269
- divStat.skipped += st.skipped;
270
- }
260
+ await Promise.race([
261
+ (async () => {
262
+ // акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
263
+ const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
264
+ const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
265
+ // объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
266
+ raw[s] = compact(rows);
267
+ if (divs?.length) {
268
+ // DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
269
+ const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
270
+ divStat.applied += st.applied;
271
+ divStat.skipped += st.skipped;
272
+ }
273
+ })(),
274
+ new Promise((_, rej) => {
275
+ killer = setTimeout(() => {
276
+ symCtrl.abort();
277
+ rej(new Error('Превышено время ожидания загрузки'));
278
+ }, WITHIN);
279
+ killer.unref?.();
280
+ }),
281
+ ]);
271
282
  } catch (e) {
272
283
  if (signal?.aborted) throw new Error('Отменено');
273
284
  raw[s] = EMPTY();
@@ -419,18 +430,47 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
419
430
  }
420
431
  // макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
421
432
  const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
422
- const days = new Set(timeline.map(dayKey)).size;
423
- // ликвидность: в скольких из последних 20 свечей были сделки
433
+ const modernTimeline = timeline.filter((t) => t >= 1705104000000);
434
+ const effTl = modernTimeline.length >= 1000 ? modernTimeline : timeline;
435
+ const days = new Set(effTl.map(dayKey)).size;
436
+ // ликвидность: в скольких из последних 20 свечей ОСНОВНОЙ сессии (10:00–18:50 МСК) были сделки.
437
+ // 1) Утренняя (до 10:00 МСК) и вечерняя (с 19:00 МСК) дополнительные сессии не списывают очки ликвидности за пустые 10-минутки
438
+ // (если на доп. сессии сделки есть — засчитываем 1 в окно, если нет — окно основной сессии не сдвигается и не штрафуется).
439
+ // 2) На самых свежих 4 свечах правого края шкалы (последние 40 минут), где возможен рассинхрон между мгновенными свечами T-Invest API
440
+ // и 20–30-минутной задержкой бесплатного MOEX ISS (или локального кэша SQLite), не штрафуем бумаги, у которых свеча ещё не подтянулась с биржи.
441
+ const isMainSessionBar = new Uint8Array(T);
442
+ for (let i = 0; i < T; i++) {
443
+ const h = hourMsk[i];
444
+ isMainSessionBar[i] = (h >= 10 && h < 19) ? 1 : 0;
445
+ }
446
+ const tailGraceStart = Math.max(0, T - 4);
424
447
  for (const tk of tokens) {
425
448
  tk.liq = new Uint8Array(T);
426
- let n = 0;
449
+ let lastRealIdx = -1;
450
+ for (let i = T - 1; i >= 0; i--) {
451
+ if (tk.has[i]) { lastRealIdx = i; break; }
452
+ }
453
+ // Если бумага торговалась сегодня/вчера и отстаёт от правого края шкалы не более чем на 12 свечей (2 часа рассинхрона кэша/MOEX ISS),
454
+ // считаем хвостовые свечи после её последнего полученного бара ожидающими догрузки, а не отсутствием ликвидности:
455
+ const tailPendingFrom = (lastRealIdx >= 0 && T - 1 - lastRealIdx <= 12) ? Math.max(lastRealIdx + 1, tailGraceStart) : T;
456
+ const win = new Uint8Array(20);
457
+ let wLen = 0, wPtr = 0, n = 0;
427
458
  for (let i = 0; i < T; i++) {
428
- n += tk.has[i];
429
- if (i >= 20) n -= tk.has[i - 20];
459
+ const h = tk.has[i] || (i >= tailPendingFrom ? 1 : 0);
460
+ if (isMainSessionBar[i] || h) {
461
+ if (wLen < 20) {
462
+ win[wLen++] = h;
463
+ n += h;
464
+ } else {
465
+ n += h - win[wPtr];
466
+ win[wPtr] = h;
467
+ wPtr = (wPtr + 1) % 20;
468
+ }
469
+ }
430
470
  tk.liq[i] = n;
431
471
  }
432
472
  }
433
- const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(T / days) : 54 };
473
+ const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(effTl.length / days) : 54 };
434
474
  datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
435
475
  if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
436
476
  return ds;
@@ -5,7 +5,7 @@
5
5
  // По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
6
6
  // • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
7
7
  // • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
8
- export const ENTRY_EDGE_MULT = 1.65;
8
+ export const ENTRY_EDGE_MULT = 1.60;
9
9
  export const TIER2_EDGE_MULT = 1.20;
10
10
 
11
11
  // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
@@ -20,13 +20,45 @@ export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub =
20
20
  const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
21
21
  const base = isT1 ? 1.0 : TIER2_EDGE_MULT;
22
22
  if (!(sigma > 0 || avgBarValRub > 0)) return base;
23
- const refVal = isT1 ? 15e6 : 1.5e6;
24
- const liqAdj = avgBarValRub > 0
25
- ? Math.max(-0.07, Math.min(0.10, -0.05 * Math.log10(Math.max(5e4, avgBarValRub) / refVal)))
23
+ if (isT1) {
24
+ const refVal = 15e6;
25
+ const liqAdj = avgBarValRub > 0
26
+ ? Math.max(-0.07, Math.min(0.10, -0.05 * Math.log10(Math.max(5e4, avgBarValRub) / refVal)))
27
+ : 0;
28
+ const sigRatio = (sigma > 0 && avgSigma > 0) ? (sigma / avgSigma) : 1;
29
+ const volAdj = Math.max(-0.05, Math.min(0.10, 0.12 * (sigRatio - 1)));
30
+ return Math.max(0.88, Math.min(1.45, base * (1 + liqAdj + volAdj)));
31
+ }
32
+ const liqAdj2 = avgBarValRub > 0
33
+ ? Math.max(-0.015, Math.min(0.025, -0.012 * Math.log10(Math.max(1e5, avgBarValRub) / 1.5e6)))
26
34
  : 0;
27
- const sigRatio = (sigma > 0 && avgSigma > 0) ? (sigma / avgSigma) : 1;
28
- const volAdj = Math.max(-0.05, Math.min(0.10, 0.12 * (sigRatio - 1)));
29
- return Math.max(0.88, Math.min(1.45, base * (1 + liqAdj + volAdj)));
35
+ return Math.round(base * (1 + liqAdj2) * 1000) / 1000;
36
+ }
37
+
38
+ // Пороги защиты от структурного слома режима (взрывного шорт-сквиза ≥20% за торговый день или отвесного обвала ≥9,5% за 30 минут):
39
+ export const REGIME_SHOCK_1D = 0.20;
40
+ export const REGIME_CRASH_30M = 0.095;
41
+
42
+ export function isRegimeShocked(ds, i, t, side = 'long') {
43
+ const tk = ds?.tokens?.[i];
44
+ if (!tk || t < 1) return false;
45
+ const bpd = Math.max(1, Math.round(ds.barsPerDay || 54));
46
+ const t1d = Math.max(0, t - bpd);
47
+ const p0 = tk.close[t1d], p1 = tk.close[t];
48
+ const m0 = ds.mclose?.[t1d], m1 = ds.mclose?.[t];
49
+ if (p0 > 0 && p1 > 0 && m0 > 0 && m1 > 0) {
50
+ const ex1d = (p1 / p0 - 1) - (m1 / m0 - 1);
51
+ if (side === 'short' ? ex1d > REGIME_SHOCK_1D : ex1d < -REGIME_SHOCK_1D) return true;
52
+ }
53
+ if (side === 'long' && t >= 3) {
54
+ const t3 = t - 3;
55
+ const pa = tk.close[t3], ma = ds.mclose?.[t3];
56
+ if (pa > 0 && p1 > 0 && ma > 0 && m1 > 0) {
57
+ const ex30m = (p1 / pa - 1) - (m1 / ma - 1);
58
+ if (ex30m < -REGIME_CRASH_30M) return true;
59
+ }
60
+ }
61
+ return false;
30
62
  }
31
63
 
32
64
  export function computeTokenEdgeMults(tokens, sigma, fromIdx = 0, tillIdx = 0) {
@@ -5,7 +5,7 @@ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
5
  import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
6
6
  import { loadProfiles } from './profiles.js';
7
7
  import { isMarketDown } from './research.js';
8
- import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults } from './edge.js';
8
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M } from './edge.js';
9
9
  export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
10
10
  import { newsForSymbols, blockingNews } from '../news/index.js';
11
11
  import { liveDividendGuard } from './dividends.js';
@@ -16,10 +16,38 @@ import { hub } from '../hub.js';
16
16
  const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
17
17
  let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
18
18
  const inflight = new Map();
19
+ const inflightStartedAt = new Map();
20
+ let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
21
+
22
+ // Синхронная живая доходность индекса относительно закрытия свечи idx-1:
23
+ // если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
24
+ // считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
25
+ // с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
26
+ function computeLiveMarketReturn(ds, idx, liveSnap) {
27
+ if (idx < 1 || idx >= ds.T) return 0;
28
+ if (liveSnap?.prices?.size > 0) {
29
+ let wSum = 0, rSum = 0, cnt = 0;
30
+ for (const tk of ds.tokens) {
31
+ if (tk.inIndex && !tk.inIndex[idx]) continue;
32
+ const prevPx = tk.close[idx - 1];
33
+ const livePx = liveSnap.prices.get(tk.symbol);
34
+ if (livePx > 0 && prevPx > 0) {
35
+ const w = tk.weight > 0 ? tk.weight : 1;
36
+ rSum += w * Math.log(livePx / prevPx);
37
+ wSum += w;
38
+ cnt++;
39
+ }
40
+ }
41
+ if (cnt >= 15 && wSum > 0) return rSum / wSum;
42
+ const mPrev = ds.mclose?.[idx - 1] || 0;
43
+ if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
44
+ }
45
+ return ds.mret[idx] || 0;
46
+ }
19
47
 
20
48
  // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
21
49
  function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
22
- const { tokens, mret, mclose } = ds;
50
+ const { tokens, mret } = ds;
23
51
  const N = fTrain.N;
24
52
  const { beta, sigma, cos } = fTrain;
25
53
  const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
@@ -31,11 +59,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
31
59
  const barTs = ds.timeline[idx] || 0;
32
60
  const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
33
61
  const liveScale = 1 / Math.sqrt(elapsedBars);
62
+ const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
34
63
  for (let uk = 0; uk < W; uk++) {
35
64
  const t = lo + uk;
36
65
  const isLast = uk === u && liveSnap?.prices?.size > 0;
37
- const mPrev = mclose?.[t - 1] || 0;
38
- const mt = isLast && liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : mret[t];
66
+ const mt = isLast ? mLive : mret[t];
39
67
  for (let i = 0; i < N; i++) {
40
68
  const tk = tokens[i];
41
69
  let rt = tk.ret[t];
@@ -180,8 +208,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
180
208
  const idx = f.lo + u;
181
209
  if (idx < 1 || idx >= ds.T) return;
182
210
  const N = f.N;
183
- const mPrev = ds.mclose?.[idx - 1] || 0;
184
- const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
211
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
185
212
  const barTs = ds.timeline[idx] || 0;
186
213
  const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
187
214
  const liveScale = 1 / Math.sqrt(elapsedBars);
@@ -280,7 +307,7 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
280
307
  emb: f.emb,
281
308
  embN: f.embN,
282
309
  eigenvalues: f.eigenvalues,
283
- macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
310
+ macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b, msig: m.msig })) } : null,
284
311
  };
285
312
  const lightFitted = {
286
313
  ...fitted,
@@ -289,9 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
289
316
  const trainedAt = Date.now();
290
317
  trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
291
318
 
292
- if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
319
+ const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
293
320
  return {
294
- ...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
321
+ ...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
295
322
  snap,
296
323
  trainedAt,
297
324
  };
@@ -316,19 +343,31 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
316
343
  const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
317
344
  const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
318
345
  const bpd = ds.barsPerDay || 54;
346
+ const idx30m = Math.max(0, idx - 3);
319
347
  const idx1d = Math.max(0, idx - bpd);
320
348
  const idx3d = Math.max(0, idx - 3 * bpd);
321
- const mNow = liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0);
349
+ const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
350
+ const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
351
+ const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
352
+ ? mPrevBar * Math.exp(mLiveRet)
353
+ : (liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0));
354
+ const m30m = ds.mclose?.[idx30m] > 0 && mNow > 0 ? (mNow / ds.mclose[idx30m] - 1) : 0;
322
355
  const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
323
356
  const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
324
357
  const rows = ds.tokens.map((tk, i) => {
325
358
  let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
326
359
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
360
+ const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
327
361
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
328
- const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
362
+ const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
363
+ const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
364
+ const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
365
+ const lastMovePct = round(rawResPct, 3);
329
366
  // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
367
+ const p30m = tk.close[idx30m] || 0;
330
368
  const p1d = tk.close[idx1d] || 0;
331
369
  const p3d = tk.close[idx3d] || 0;
370
+ const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
332
371
  const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
333
372
  const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
334
373
  const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
@@ -336,8 +375,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
336
375
  let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
337
376
  if (isOverheated && selfRaw > 0) {
338
377
  // Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
339
- // ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — обнуляем ложный отскок вверх:
340
- exp -= selfRaw;
378
+ // ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — гасим ложный отскок вверх, не переворачивая в искусственный шорт:
379
+ if (exp > 0) exp = Math.max(0, exp - selfRaw);
341
380
  selfRaw = 0;
342
381
  }
343
382
  const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
@@ -347,18 +386,22 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
347
386
  const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
348
387
  const symNeedL = Math.max(needL * tierMult, 0.0030);
349
388
  const symNeedS = Math.max(needS * tierMult, 0.0031);
350
- // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
389
+ // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
351
390
  // обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
352
391
  const EXTREME_BAR_MOVE_PCT = 4.5;
353
- const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT) && !buyBlockedByOverheat;
354
- const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
392
+ const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M;
393
+ const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D;
394
+ const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
395
+ const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
396
+ const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
397
+ const liveShortOk = !isExtremeSpike;
355
398
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
356
399
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
357
400
  const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
358
401
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
359
402
  const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
360
- const shortBlockedBySqueeze = Boolean(liveSnap && exp < 0 && lastMovePct > EXTREME_BAR_MOVE_PCT);
361
- const buyBlockedByDip = Boolean(liveSnap && exp > 0 && lastMovePct < -EXTREME_BAR_MOVE_PCT);
403
+ const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
404
+ const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
362
405
  const row = {
363
406
  i, symbol: tk.symbol, name: tk.name,
364
407
  tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
@@ -374,6 +417,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
374
417
  weight: round(weight, 3),
375
418
  signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
376
419
  ...(shortAllowed !== null ? { shortAllowed } : {}),
420
+ ...(isRegimeCrash ? { regimeCrash: true } : {}),
421
+ ...(isRegimeSqueeze ? { regimeSqueeze: true } : {}),
377
422
  ...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
378
423
  ...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
379
424
  ...(inIdx ? {} : { inIndex: false }),
@@ -398,8 +443,8 @@ function withNews(result, p) {
398
443
  const nb = p.newsFilter ? blockingNews(6) : null;
399
444
  const shortableMap = kvGet('tbankShortableMap')?.map || null;
400
445
  const nowTs = Date.now();
401
- const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.413;
402
- const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.516;
446
+ const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
447
+ const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
403
448
  if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
404
449
  if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
405
450
  for (const r of result.rows) {
@@ -420,13 +465,13 @@ function withNews(result, p) {
420
465
  } else if (r.expectedPct >= 0) {
421
466
  delete r.shortBlockedByBroker;
422
467
  }
423
- if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
468
+ if (r.expectedPct < 0 && (r.shortBlockedBySqueeze || r.regimeSqueeze || (r.lastMovePct ?? 0) > 4.5)) {
424
469
  r.shortBlockedBySqueeze = true;
425
470
  r.shortSignal = false;
426
471
  } else {
427
472
  delete r.shortBlockedBySqueeze;
428
473
  }
429
- if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
474
+ if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
430
475
  r.buyBlockedByDip = true;
431
476
  r.signal = false;
432
477
  } else {
@@ -460,10 +505,156 @@ function withNews(result, p) {
460
505
  } else {
461
506
  delete r.shortBlockedByNews;
462
507
  }
508
+
509
+ const effThr = r.expectedPct < 0 ? (r.shortThresholdPct || baseShortThr) : (r.buyThresholdPct || baseBuyThr);
510
+ const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
511
+ r.thresholdRatioPct = Math.round(ratio * 100);
512
+ r.crossedThreshold = ratio >= 1;
513
+ }
514
+ if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
515
+ const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
516
+ result.nearSignals = savedNear;
463
517
  }
464
518
  return result;
465
519
  }
466
520
 
521
+ // Формирует и инкрементально обновляет 5-дневный журнал сигналов, заблокированных входов (≥100% порога) и почти-сигналов (75–99% порога)
522
+ async function updateNearSignalsJournal(ds, p, last, key) {
523
+ if (!ds?.timeline?.length || last < 0) return [];
524
+ const lastBarTs = ds.timeline[last] || Date.now();
525
+ const minTs = lastBarTs - 7 * DAY;
526
+ let startIdx = ds.timeline.findIndex((t) => t >= minTs);
527
+ if (startIdx < 0) startIdx = Math.max(0, last - 480);
528
+
529
+ if (nearSignalsCache.key !== key || nearSignalsCache.scannedBarTs < ds.timeline[startIdx]) {
530
+ const saved = kvGet('nearSignalsLog');
531
+ if (saved?.key === key && saved.scannedBarTs >= ds.timeline[startIdx] && Array.isArray(saved.entries)) {
532
+ nearSignalsCache = {
533
+ key,
534
+ scannedBarTs: saved.scannedBarTs,
535
+ episodesMap: new Map(saved.entries),
536
+ list: saved.list || [],
537
+ };
538
+ } else {
539
+ nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
540
+ }
541
+ }
542
+
543
+ for (const [epKey, ep] of nearSignalsCache.episodesMap) {
544
+ if (ep.lastBarTs < minTs) nearSignalsCache.episodesMap.delete(epKey);
545
+ }
546
+
547
+ let updated = false;
548
+ for (let idx = startIdx; idx <= last; idx++) {
549
+ const barTs = ds.timeline[idx];
550
+ if (barTs <= nearSignalsCache.scannedBarTs) continue;
551
+ const r = await predictAt(ds, p, idx, { explain: false, liveSnap: null });
552
+ const nb = p.newsFilter ? blockingNews(6, barTs) : null;
553
+ const barMskIso = new Date(barTs + MSK).toISOString();
554
+ const dayStr = barMskIso.slice(0, 10);
555
+ const timeStr = barMskIso.slice(11, 16);
556
+
557
+ for (const row of r.rows) {
558
+ if (row.inIndex === false) continue;
559
+ const side = row.expectedPct < 0 ? 'short' : 'long';
560
+ const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
561
+ const absExp = Math.abs(row.expectedPct || 0);
562
+ const ratio = thr > 0 ? absExp / thr : 0;
563
+ if (ratio < 0.75) continue;
564
+
565
+ const crossed = absExp >= thr;
566
+ const dg = p.modelUseDividends !== false ? liveDividendGuard(row.symbol, row.price, barTs) : null;
567
+ const bNews = nb?.get(row.symbol);
568
+ const reasons = [];
569
+ if (side === 'short') {
570
+ if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
571
+ if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
572
+ if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
573
+ if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
574
+ if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
575
+ } else {
576
+ if (row.buyBlockedByDip || row.regimeCrash || (row.lastMovePct ?? 0) < -4.5) reasons.push('защита от обвала свечи');
577
+ if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
578
+ if (dg?.blockLong) reasons.push(`отсечка дивидендов ${dg.exDate}`);
579
+ if (bNews?.bad) reasons.push(`фильтр новостей (${bNews.bad.gist})`);
580
+ }
581
+ if (row.liquid === false) reasons.push('низкая ликвидность');
582
+
583
+ const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
584
+ const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
585
+ const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
586
+
587
+ const epKey = `${dayStr}:${row.symbol}:${side}`;
588
+ const prev = nearSignalsCache.episodesMap.get(epKey);
589
+ if (!prev) {
590
+ nearSignalsCache.episodesMap.set(epKey, {
591
+ day: dayStr,
592
+ firstBar: timeStr,
593
+ lastBar: timeStr,
594
+ firstBarTs: barTs,
595
+ lastBarTs: barTs,
596
+ peakBar: timeStr,
597
+ barsCount: 1,
598
+ symbol: row.symbol,
599
+ name: row.name,
600
+ side,
601
+ price: row.price,
602
+ peakExpectedPct: row.expectedPct,
603
+ thresholdPct: thr,
604
+ peakRatioPct: Math.round(ratio * 100),
605
+ status,
606
+ reasons,
607
+ });
608
+ } else {
609
+ prev.lastBar = timeStr;
610
+ prev.lastBarTs = barTs;
611
+ prev.barsCount += 1;
612
+ const pctNow = Math.round(ratio * 100);
613
+ if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow >= prev.peakRatioPct)) {
614
+ prev.peakExpectedPct = row.expectedPct;
615
+ prev.thresholdPct = thr;
616
+ prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
617
+ prev.peakBar = timeStr;
618
+ prev.price = row.price;
619
+ prev.status = status;
620
+ if (reasons.length) prev.reasons = reasons;
621
+ } else if (pctNow > prev.peakRatioPct) {
622
+ prev.peakExpectedPct = row.expectedPct;
623
+ prev.thresholdPct = thr;
624
+ prev.peakRatioPct = pctNow;
625
+ prev.peakBar = timeStr;
626
+ }
627
+ if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
628
+ }
629
+ }
630
+ nearSignalsCache.scannedBarTs = barTs;
631
+ updated = true;
632
+ }
633
+
634
+ if (updated || !nearSignalsCache.list.length) {
635
+ const list = [...nearSignalsCache.episodesMap.values()]
636
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
637
+ .slice(0, 40)
638
+ .map((ep) => {
639
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
640
+ const reasonText = ep.status === 'signal'
641
+ ? 'Порог пройден — сигнал сформирован'
642
+ : ep.status === 'blocked'
643
+ ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
644
+ : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
645
+ return { ...ep, gapPct, reasonText };
646
+ });
647
+ nearSignalsCache.list = list;
648
+ kvSet('nearSignalsLog', {
649
+ key,
650
+ scannedBarTs: nearSignalsCache.scannedBarTs,
651
+ entries: [...nearSignalsCache.episodesMap.entries()],
652
+ list,
653
+ });
654
+ }
655
+ return nearSignalsCache.list;
656
+ }
657
+
467
658
  // Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
468
659
  function attachDeltas(rows, stateObj) {
469
660
  const prevExp = stateObj.prevExpMap;
@@ -549,7 +740,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
549
740
  // ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
550
741
  // а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
551
742
  if (!force && baseState && baseState.key === key) {
552
- if (now - baseState.dsLoadedAt >= 4 * 60000 && !inflight.has(key)) {
743
+ const inflightAge = now - (inflightStartedAt.get(key) || 0);
744
+ if (inflight.has(key) && inflightAge > 90000) {
745
+ inflight.delete(key);
746
+ inflightStartedAt.delete(key);
747
+ }
748
+ const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
749
+ const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
750
+ const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
751
+ if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
553
752
  setImmediate(() => {
554
753
  refreshDatasetStateInBackground(p, key).catch(() => {});
555
754
  });
@@ -574,8 +773,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
574
773
  }
575
774
  const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
576
775
  const liveTs = liveSnap?.ts || now;
577
- const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.413;
578
- const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.516;
776
+ const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.40;
777
+ const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.50;
579
778
  const rows = (saved.result.rows || []).map((r) => {
580
779
  const px = liveSnap?.prices?.get(r.symbol);
581
780
  const mult = symbolEdgeMult(r.symbol);
@@ -605,11 +804,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
605
804
 
606
805
  // Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
607
806
  function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
608
- if (!force && inflight.has(key)) return inflight.get(key);
807
+ const now = Date.now();
808
+ if (!force && inflight.has(key)) {
809
+ if (now - (inflightStartedAt.get(key) || 0) < 90000) return inflight.get(key);
810
+ inflight.delete(key);
811
+ inflightStartedAt.delete(key);
812
+ }
609
813
  const autoAdapt = p.modelAutoAdapt !== false;
610
814
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
611
815
  const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
612
- const now = Date.now();
613
816
  const task = (async () => {
614
817
  hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
615
818
  // Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
@@ -625,6 +828,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
625
828
  ]);
626
829
  const last = lastClosedIndex(ds, now);
627
830
  const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
831
+ const nearSignals = await updateNearSignalsJournal(ds, p, last, key).catch(() => []);
628
832
  const snap = r.snap || mapSnapshot(r.f);
629
833
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
630
834
  const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
@@ -645,6 +849,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
645
849
  shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
646
850
  marketDown: r.marketDown,
647
851
  rows: sortedRows,
852
+ nearSignals,
648
853
  map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
649
854
  heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
650
855
  };
@@ -667,8 +872,21 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
667
872
  };
668
873
  kvSet('lastForecastSnap', { key, result });
669
874
  hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
875
+ if (coldLocalFirst) {
876
+ setTimeout(() => {
877
+ if (!inflight.has(key)) {
878
+ refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
879
+ }
880
+ }, 2000);
881
+ }
670
882
  return result;
671
- })().finally(() => inflight.delete(key));
883
+ })().finally(() => {
884
+ if (inflight.get(key) === task) {
885
+ inflight.delete(key);
886
+ inflightStartedAt.delete(key);
887
+ }
888
+ });
672
889
  inflight.set(key, task);
890
+ inflightStartedAt.set(key, now);
673
891
  return task;
674
892
  }