neuroalpha 0.5.21 → 0.5.24

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.
@@ -121,35 +121,37 @@ const MACRO = [
121
121
 
122
122
  const MCODES = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z']; // месяцы в кодах фьючерсов
123
123
 
124
- // Список существующих контрактов BR с их периодами (кэш на сутки, чтобы не пробовать все коды каждый раз)
125
- async function brentContracts(fromTs, tillTs, signal) {
124
+ // Список существующих контрактов BR с их периодами (кэш на 7 суток, чтобы не пробовать все коды каждый раз)
125
+ async function brentContracts(fromTs, tillTs, signal, { localOnly = false } = {}) {
126
126
  const cached = kvGet('brContracts');
127
127
  let list = cached?.list;
128
- if (!cached || Date.now() - cached.at > DAY) {
128
+ if ((!cached || Date.now() - cached.at > 7 * DAY) && !(localOnly && list?.length)) {
129
129
  list = [];
130
130
  const codes = [];
131
- const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs + 100 * DAY).getUTCFullYear();
131
+ const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs + 50 * DAY).getUTCFullYear();
132
132
  for (let y = y0; y <= y1; y++) for (const m of MCODES) codes.push(`BR${m}${y % 10}`);
133
- await pool(codes, 6, async (code) => {
133
+ await pool(codes, 4, async (code) => {
134
134
  try {
135
- const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal });
135
+ const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal, localOnly });
136
136
  if (rows.length) list.push({ code, first: rows[0].ts, last: rows[rows.length - 1].ts });
137
137
  } catch { /* контракта нет */ }
138
138
  });
139
139
  list.sort((a, b) => a.first - b.first);
140
- kvSet('brContracts', { at: Date.now(), list });
140
+ if (list.length) kvSet('brContracts', { at: Date.now(), list });
141
141
  }
142
- return list.filter((c) => c.last >= fromTs && c.first <= tillTs);
142
+ return (list || []).filter((c) => c.last >= fromTs && c.first <= tillTs);
143
143
  }
144
144
 
145
145
  // Непрерывный ряд цены нефти: в каждый день — контракт с наибольшим оборотом дня,
146
146
  // на стыке контрактов цена продолжается без скачка (доходность стыка = 0)
147
- async function loadBrent(fromTs, tillTs, signal) {
148
- const list = await brentContracts(fromTs, tillTs, signal);
147
+ async function loadBrent(fromTs, tillTs, signal, { localOnly = false } = {}) {
148
+ const list = await brentContracts(fromTs, tillTs, signal, { localOnly });
149
149
  const raw = [];
150
+ const now = Date.now();
150
151
  await pool(list, 4, async (c) => {
151
152
  try {
152
- const rows = await ensureCandles(c.code, BAR_INTERVAL, fromTs, tillTs, { signal });
153
+ const isExpired = c.last < now - 10 * DAY;
154
+ const rows = await ensureCandles(c.code, BAR_INTERVAL, fromTs, tillTs, { signal, localOnly: localOnly || isExpired });
153
155
  if (rows.length) raw.push(compact(rows));
154
156
  } catch { /* пропускаем */ }
155
157
  });
@@ -280,7 +282,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
280
282
  if (useMacro) {
281
283
  try {
282
284
  onProgress({ symbol: 'BR', done: done, total: all.length });
283
- raw.BR = await loadBrent(fromTs, tillTs, signal);
285
+ raw.BR = await loadBrent(fromTs, tillTs, signal, { localOnly });
284
286
  } catch (e) {
285
287
  if (signal?.aborted) throw new Error('Отменено');
286
288
  raw.BR = EMPTY();
@@ -397,6 +399,24 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
397
399
  }
398
400
  }
399
401
  const mret = lret(mk.close);
402
+ // На самых свежих барах текущего дня (последние 2 часа), когда 10-минутные свечи акций уже получены в реальном времени из Т-Банка,
403
+ // а индекс IMOEX в бесплатном MOEX ISS ещё отстаёт на 20 минут, достраиваем mret и mclose по средневзвешенной доходности акций индекса:
404
+ for (let i = Math.max(1, T - 12); i < T; i++) {
405
+ if (!mk.has[i] && mk.close[i - 1] > 0) {
406
+ let wSum = 0, rSum = 0;
407
+ for (const tk of tokens) {
408
+ if (tk.has[i] && (!tk.inIndex || tk.inIndex[i])) {
409
+ const w = tk.weight > 0 ? tk.weight : 1;
410
+ rSum += w * tk.ret[i];
411
+ wSum += w;
412
+ }
413
+ }
414
+ if (wSum > 0) {
415
+ mret[i] = rSum / wSum;
416
+ mk.close[i] = mk.close[i - 1] * Math.exp(mret[i]);
417
+ }
418
+ }
419
+ }
400
420
  // макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
401
421
  const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
402
422
  const days = new Set(timeline.map(dayKey)).size;
@@ -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';
@@ -17,9 +17,35 @@ const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, f
17
17
  let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
18
18
  const inflight = new Map();
19
19
 
20
+ // Синхронная живая доходность индекса относительно закрытия свечи idx-1:
21
+ // если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
22
+ // считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
23
+ // с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
24
+ function computeLiveMarketReturn(ds, idx, liveSnap) {
25
+ if (idx < 1 || idx >= ds.T) return 0;
26
+ if (liveSnap?.prices?.size > 0) {
27
+ let wSum = 0, rSum = 0, cnt = 0;
28
+ for (const tk of ds.tokens) {
29
+ if (tk.inIndex && !tk.inIndex[idx]) continue;
30
+ const prevPx = tk.close[idx - 1];
31
+ const livePx = liveSnap.prices.get(tk.symbol);
32
+ if (livePx > 0 && prevPx > 0) {
33
+ const w = tk.weight > 0 ? tk.weight : 1;
34
+ rSum += w * Math.log(livePx / prevPx);
35
+ wSum += w;
36
+ cnt++;
37
+ }
38
+ }
39
+ if (cnt >= 15 && wSum > 0) return rSum / wSum;
40
+ const mPrev = ds.mclose?.[idx - 1] || 0;
41
+ if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
42
+ }
43
+ return ds.mret[idx] || 0;
44
+ }
45
+
20
46
  // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
21
47
  function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
22
- const { tokens, mret, mclose } = ds;
48
+ const { tokens, mret } = ds;
23
49
  const N = fTrain.N;
24
50
  const { beta, sigma, cos } = fTrain;
25
51
  const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
@@ -31,11 +57,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
31
57
  const barTs = ds.timeline[idx] || 0;
32
58
  const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
33
59
  const liveScale = 1 / Math.sqrt(elapsedBars);
60
+ const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
34
61
  for (let uk = 0; uk < W; uk++) {
35
62
  const t = lo + uk;
36
63
  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];
64
+ const mt = isLast ? mLive : mret[t];
39
65
  for (let i = 0; i < N; i++) {
40
66
  const tk = tokens[i];
41
67
  let rt = tk.ret[t];
@@ -180,8 +206,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
180
206
  const idx = f.lo + u;
181
207
  if (idx < 1 || idx >= ds.T) return;
182
208
  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];
209
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
185
210
  const barTs = ds.timeline[idx] || 0;
186
211
  const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
187
212
  const liveScale = 1 / Math.sqrt(elapsedBars);
@@ -280,7 +305,7 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
280
305
  emb: f.emb,
281
306
  embN: f.embN,
282
307
  eigenvalues: f.eigenvalues,
283
- macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
308
+ macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b, msig: m.msig })) } : null,
284
309
  };
285
310
  const lightFitted = {
286
311
  ...fitted,
@@ -289,9 +314,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
289
314
  const trainedAt = Date.now();
290
315
  trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
291
316
 
292
- if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
317
+ const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
293
318
  return {
294
- ...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
319
+ ...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
295
320
  snap,
296
321
  trainedAt,
297
322
  };
@@ -315,27 +340,66 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
315
340
  const avgInv = nInv ? sInv / nInv : 0;
316
341
  const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
317
342
  const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
343
+ const bpd = ds.barsPerDay || 54;
344
+ const idx30m = Math.max(0, idx - 3);
345
+ const idx1d = Math.max(0, idx - bpd);
346
+ const idx3d = Math.max(0, idx - 3 * bpd);
347
+ const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
348
+ const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
349
+ const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
350
+ ? mPrevBar * Math.exp(mLiveRet)
351
+ : (liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0));
352
+ const m30m = ds.mclose?.[idx30m] > 0 && mNow > 0 ? (mNow / ds.mclose[idx30m] - 1) : 0;
353
+ const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
354
+ const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
318
355
  const rows = ds.tokens.map((tk, i) => {
319
- const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
356
+ let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
320
357
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
358
+ const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
321
359
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
322
- const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
360
+ const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
361
+ const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
362
+ const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
363
+ const lastMovePct = round(rawResPct, 3);
364
+ // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
365
+ const p30m = tk.close[idx30m] || 0;
366
+ const p1d = tk.close[idx1d] || 0;
367
+ const p3d = tk.close[idx3d] || 0;
368
+ const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
369
+ const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
370
+ const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
371
+ const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
372
+ const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
373
+ let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
374
+ if (isOverheated && selfRaw > 0) {
375
+ // Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
376
+ // ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — гасим ложный отскок вверх, не переворачивая в искусственный шорт:
377
+ if (exp > 0) exp = Math.max(0, exp - selfRaw);
378
+ selfRaw = 0;
379
+ }
380
+ const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
381
+ ? round((excess1d >= 0.06 ? excess1d : excess3d) * 100, 1)
382
+ : 0;
323
383
  // Индивидуальный множитель порога каждой акции по её фактическому обороту в рублях и волатильности свечи:
324
384
  const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
325
385
  const symNeedL = Math.max(needL * tierMult, 0.0030);
326
386
  const symNeedS = Math.max(needS * tierMult, 0.0031);
327
- // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
387
+ // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
328
388
  // обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
329
389
  const EXTREME_BAR_MOVE_PCT = 4.5;
330
- const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT);
331
- const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
390
+ const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M;
391
+ const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D;
392
+ const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
393
+ const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
394
+ const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
395
+ const liveShortOk = !isExtremeSpike;
332
396
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
333
397
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
334
398
  const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
335
399
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
336
400
  const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
337
- const shortBlockedBySqueeze = Boolean(liveSnap && exp < 0 && lastMovePct > EXTREME_BAR_MOVE_PCT);
338
- const buyBlockedByDip = Boolean(liveSnap && exp > 0 && lastMovePct < -EXTREME_BAR_MOVE_PCT);
401
+ const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
402
+ const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
339
403
  const row = {
340
404
  i, symbol: tk.symbol, name: tk.name,
341
405
  tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
@@ -352,6 +416,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
352
416
  signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
353
417
  ...(shortAllowed !== null ? { shortAllowed } : {}),
354
418
  ...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
419
+ ...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
355
420
  ...(inIdx ? {} : { inIndex: false }),
356
421
  };
357
422
  if (dir !== 'long') {
@@ -359,10 +424,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
359
424
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
360
425
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
361
426
  }
362
- if (explain) {
363
- const ex = explainAttention(fitted.attention, f, u, i);
427
+ if (explain && ex) {
364
428
  row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
365
- row.selfPct = round(ex.self * f.sigma[i] * Math.sqrt(H) * 100, 3);
429
+ row.selfPct = round(selfRaw * 100, 3);
366
430
  }
367
431
  return row;
368
432
  });
@@ -6,7 +6,7 @@ import { hub } from '../hub.js';
6
6
  import { latestBarTs } from '../data/moex.js';
7
7
  import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
8
8
  import { predictAt, lastClosedIndex } from './forecast.js';
9
- import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
9
+ import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
10
10
  import { computeReport, downsample } from './metrics.js';
11
11
  import { MODELS } from './models.js';
12
12
  import { blockingNews } from '../news/index.js';
@@ -434,9 +434,14 @@ class LiveEngine {
434
434
  closePos(sym, 'Закрытие перед ночным клирингом');
435
435
  continue;
436
436
  }
437
+ const tkIdx = bySym.get(sym);
438
+ if (autoAdapt && tkIdx !== undefined && isRegimeShocked(ds, tkIdx, idx, h.side || 'long')) {
439
+ closePos(sym, 'Защита от экстремального сквиза/обвала');
440
+ continue;
441
+ }
437
442
  const x = row.get(sym);
438
443
  const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
439
- const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * symbolEdgeMult(sym);
444
+ const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * (x?.edgeMult || symbolEdgeMult(sym));
440
445
  if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
441
446
  }
442
447
  const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
@@ -454,16 +459,33 @@ class LiveEngine {
454
459
  }
455
460
  return out;
456
461
  };
462
+ const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
463
+ const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
464
+ const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
465
+ const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
466
+ if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
467
+ const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
468
+ const exitNeed = r.threshold * exitRatio;
469
+ if (bestNewEdge >= r.threshold * 2.2) {
470
+ let weakestSym = null, weakestE = Infinity;
471
+ for (const [sym, h] of s.held) {
472
+ if (closing.has(sym)) continue;
473
+ const xCur = row.get(sym);
474
+ const curSignedE = xCur ? (h.side === 'short' ? -xCur.expected : xCur.expected) : 0;
475
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(r.threshold, curSignedE) * 2.4) {
476
+ weakestE = curSignedE;
477
+ weakestSym = sym;
478
+ }
479
+ }
480
+ if (weakestSym) closePos(weakestSym, 'Ротация в более сильного лидера');
481
+ }
482
+ }
457
483
  let entries;
458
484
  if (dir === 'long') {
459
485
  const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
460
- const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol) && !closing.has(x.symbol)).sort((a, b) => b.expected - a.expected);
461
486
  if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
462
487
  entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
463
488
  } else {
464
- const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
465
- const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
466
- const candS = !timeOk ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
467
489
  if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
468
490
  const nL = K - sideHeld('long').length;
469
491
  const nS = K - sideHeld('short').length;
@@ -472,7 +494,7 @@ class LiveEngine {
472
494
  for (const [x, action] of entries) {
473
495
  const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
474
496
  const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
475
- const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(p.modelHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
497
+ const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(effHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
476
498
  s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
477
499
  step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
478
500
  }
@@ -262,7 +262,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
262
262
  out[u] = a >= 0 ? sum / Math.sqrt(sc.len) : 0;
263
263
  }
264
264
  });
265
- list.push({ id: m.id, label: m.label, b, zs });
265
+ list.push({ id: m.id, label: m.label, b, msig, zs });
266
266
  }
267
267
  if (list.length) macro = { list };
268
268
  }
@@ -505,15 +505,37 @@ export const modelNames = () => Object.keys(MODELS);
505
505
  // Объяснение прогноза «внимания»: вклад каждого соседа в прогноз акции j
506
506
  export function explainAttention(m, f, u, j, top = 3) {
507
507
  const N = f.N;
508
+ const k = u * N + j;
508
509
  const contrib = new Float64Array(N);
509
- const wAtt = m.inertiaCoef ? 0.50 : 1, wIn = m.inertiaCoef ? 0.50 : 0;
510
+ let sAtt = 0;
510
511
  HEADS.forEach((h, hi) => {
511
512
  const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
512
513
  const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
513
- for (let i = 0; i < N; i++) contrib[i] += wAtt * m.coef[hi] * A[j * N + i] * X[u * N + i];
514
+ for (let i = 0; i < N; i++) {
515
+ const c = m.coef[hi] * A[j * N + i] * X[u * N + i];
516
+ contrib[i] += c;
517
+ sAtt += c;
518
+ }
514
519
  });
515
- let self = 0;
516
- for (let q = 0; q < 3; q++) self += (wAtt * m.coef[HEADS.length + q] + wIn * (m.inertiaCoef?.[q] || 0)) * f.X[0][q][u * N + j];
520
+ let selfAtt = 0, sIn = 0;
521
+ for (let q = 0; q < 3; q++) {
522
+ const xq = f.X[0][q][k];
523
+ selfAtt += m.coef[HEADS.length + q] * xq;
524
+ if (m.inertiaCoef) sIn += (m.inertiaCoef[q] || 0) * xq;
525
+ }
526
+ sAtt += selfAtt;
527
+ const ML = f.macro ? f.macro.list : [];
528
+ for (let mi = 0; mi < ML.length; mi++) {
529
+ let zz = 0;
530
+ const zs = ML[mi].zs;
531
+ for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
532
+ sAtt += ML[mi].b[j] * zz;
533
+ }
534
+ const conflictScale = (m.inertiaCoef && sAtt * sIn < 0) ? 0.5 : 1;
535
+ const wAtt = (m.inertiaCoef ? 0.50 : 1) * conflictScale;
536
+ const wIn = (m.inertiaCoef ? 0.50 : 0) * conflictScale;
537
+ for (let i = 0; i < N; i++) contrib[i] *= wAtt;
538
+ const self = wAtt * selfAtt + wIn * sIn;
517
539
  const idx = [...contrib.keys()].filter((i) => i !== j).sort((a, b) => Math.abs(contrib[b]) - Math.abs(contrib[a])).slice(0, top);
518
540
  return { neighbors: idx.map((i) => ({ i, contrib: contrib[i] })), self };
519
541
  }