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.
@@ -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 { ENTRY_EDGE_MULT, 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';
@@ -336,7 +336,7 @@ class LiveEngine {
336
336
  const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
337
337
  const invVol = s.params.modelAllocMode === 'invvol';
338
338
  const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
339
- const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
339
+ const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct) * ENTRY_EDGE_MULT;
340
340
  const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
341
341
  if (autoAdapt && buyOrders.length > 1) {
342
342
  buyOrders.sort((a, b) => Math.abs(b.expectedPct || 0) - Math.abs(a.expectedPct || 0));
@@ -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
  }
@@ -9,7 +9,7 @@ import { ensureProfiles } from './profiles.js';
9
9
  import { spearman } from './linalg.js';
10
10
  import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
11
11
  import { strategyDefaults } from '../config.js';
12
- import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
12
+ import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
13
13
  import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
14
14
 
15
15
  const DAY = 86400000;
@@ -40,7 +40,10 @@ export function isMarketDown(ds, t) {
40
40
  // 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
41
41
  // 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
42
42
  // 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
43
- const DATA_VERSION = 11;
43
+ // 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
44
+ // 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
45
+ // 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
46
+ const DATA_VERSION = 14;
44
47
 
45
48
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
46
49
  function reuseParamsKey(params) {
@@ -158,13 +161,14 @@ async function run(runId, p, state) {
158
161
  // НИКОГДА не сдвигала fromTs и не вызывала повторную загрузку котировок!
159
162
  const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
160
163
  const rawResearchFrom = todayStartMsk - p.modelHistoryDays * DAY;
161
- // Стабильная привязка сетки 20-дневных окон переобучения к эпохе (1706165400000 = 25.01.2024):
162
- // начало первого фолда выравнивается по 20-дневной сетке, чтобы смена календарного дня запуска НЕ перекраивала всю прошлую 3-летнюю историю фолдов,
163
- // а новые торговые дни честно дописывались в правый край последнего фолда!
164
- const EPOCH_ANCHOR_TS = 1706165400000;
164
+ // Стабильная привязка к современному режиму торгов Мосбиржи (утренняя + основная + вечерняя сессии, 94 свечи/день с 18.01.2024)
165
+ // и якорю сетки 20-дневных окон после завершения волны редомициляции ключевых эмитентов (1718272800000 = 13.06.2024 13:00 МСК):
166
+ // смена календарного дня запуска или наличие в SQLite архивных свечей 2022–2023 гг. НИКОГДА не сдвигает сетку фолдов и не искажает barsPerDay!
167
+ const MODERN_REGIME_START_TS = 1705560600000;
168
+ const EPOCH_ANCHOR_TS = 1718272800000;
165
169
  const gridBlockMs = 20 * DAY;
166
- const researchFrom = EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs;
167
- const fromTs = EPOCH_ANCHOR_TS - 660 * DAY;
170
+ const researchFrom = Math.max(EPOCH_ANCHOR_TS, EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs);
171
+ const fromTs = MODERN_REGIME_START_TS;
168
172
 
169
173
  emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
170
174
  const ds = await loadDataset({
@@ -177,7 +181,7 @@ async function run(runId, p, state) {
177
181
  const bpd = ds.barsPerDay;
178
182
  const trainLen = Math.round(p.modelTrainDays * bpd);
179
183
  const testLen = Math.round(20 * bpd);
180
- let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom), trainLen + 10);
184
+ let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom), FEATURE_WARMUP + maxH + 100);
181
185
  if (testStart < 0 || testStart >= T - H - 10) throw new Error('Недостаточно истории: увеличьте период исследования или уменьшите окно обучения');
182
186
  const firstTest = testStart;
183
187
 
@@ -461,8 +465,8 @@ async function run(runId, p, state) {
461
465
  }
462
466
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
463
467
  const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
464
- const okLong = (i) => !divRule(i, t)?.blockLong;
465
- const okShort = (i) => !divRule(i, t)?.blockShort;
468
+ const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
469
+ const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
466
470
  let order, entries;
467
471
  if (dir !== 'long') {
468
472
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
@@ -474,12 +478,15 @@ async function run(runId, p, state) {
474
478
  const e = edge(i);
475
479
  const barsHeld = tIn - h.entryT;
476
480
  const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
477
- if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
481
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
482
+ if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) {
483
+ closePos(i, h, tIn, false, shocked ? 'Защита от экстремального сквиза/обвала' : 'Прогноз ослаб');
484
+ }
478
485
  }
479
486
  const needL = autoAdapt && bear ? need * 1.25 : need;
480
487
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
481
- const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
482
- const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
488
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
489
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
483
490
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
484
491
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
485
492
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -516,11 +523,27 @@ async function run(runId, p, state) {
516
523
  const e = edge(i);
517
524
  const barsHeld = tIn - h.entryT;
518
525
  const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
519
- if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn, false);
526
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
527
+ if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn, false, shocked ? 'Защита от экстремального обвала' : '');
520
528
  }
521
- const free = K - held.size;
522
529
  const needL = autoAdapt && bear ? need * 1.25 : need;
523
- const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
530
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
531
+ const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
532
+ if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
533
+ const bestNewEdge = edge(rawCand[0]);
534
+ if (bestNewEdge >= need * 2.2) {
535
+ let weakestIdx = -1, weakestE = Infinity;
536
+ for (const [i] of held) {
537
+ const curSignedE = edge(i);
538
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
539
+ weakestE = curSignedE;
540
+ weakestIdx = i;
541
+ }
542
+ }
543
+ if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn, false, 'Ротация в более сильного лидера');
544
+ }
545
+ }
546
+ const free = K - held.size;
524
547
  const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
525
548
  order = [...held.keys(), ...cand];
526
549
  } else {
@@ -912,7 +935,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
912
935
  return round(((lo + hi) / 2) * 100, 3);
913
936
  };
914
937
  // Подбор порога входа под реальные издержки: живое исполнение, издержки 0,09% на сторону (комиссия 0,04% + проскальзывание ~0,05%)
915
- const thresholdSweep = [1, 1.5, 1.65, 2, 2.5, 3].map((m) => {
938
+ const thresholdSweep = [1, 1.5, 1.6, 1.65, 2, 2.5, 3].map((m) => {
916
939
  const sim = simulate(chosen, 0.0009, p.modelShortFeePct, 0, { ...LIVE, needMult: m });
917
940
  const f = computeReport(sim.trades, sim.curve, p.capital, 0).full;
918
941
  return { needMult: m, current: m === ENTRY_EDGE_MULT, entryEdgePct: round(2.5 * 2 * base * m * 100, 3), returnPct: f.returnPct, cagrPct: cagr(f.returnPct, yearsOf(sim.curve)), profitFactor: f.profitFactor, maxDrawdownPct: f.maxDrawdownPct, trades: f.trades, avgTradePct: f.avgTradePct, last12Pct: segment(sim, lastTs - 365 * 86400000).returnPct, last6Pct: segment(sim, lastTs - 182 * 86400000).returnPct };
@@ -924,7 +947,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
924
947
  market: { full: mSeg(-Infinity), last12: mSeg(lastTs - 365 * 86400000), last6: mSeg(lastTs - 182 * 86400000) },
925
948
  shortableKnown: Boolean(shortable),
926
949
  caveats: [
927
- 'Состав рынка — сегодняшний индекс широкого рынка, бумаги отобраны по покрытию данных за весь период (ошибка выжившего не устранена).',
950
+ 'Состав рынка — исторический состав индекса MOEXBMI на каждую дату плюс текущий состав; отсекаются только бумаги с покрытием данных менее 15% периода.',
928
951
  'Константы стратегии подбирались на этой же истории, поэтому «последние 6 и 12 месяцев» не являются честно отложенной выборкой. Честная проверка — торговля после фиксации версии.',
929
952
  'Список бумаг для шорта — текущий, а не на каждую дату.',
930
953
  ],
@@ -974,10 +997,11 @@ async function runStabilityLoop(targetId, runRow, st) {
974
997
  await pause();
975
998
  const p = normalizeParams(runRow.params || {});
976
999
  const shifts = [-8, -4, 0, 4, 8];
977
- const H = barsFromHours(p.modelHorizon);
1000
+ const autoAdapt = p.modelAutoAdapt !== false;
1001
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
1002
+ const H = barsFromHours(effHorizon);
978
1003
  const tillTs = Date.now();
979
- const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
980
- const fromTs = todayStartMsk - 1650 * DAY;
1004
+ const fromTs = 1705560600000;
981
1005
  const periodTill = runRow.metrics?.period?.till || tillTs;
982
1006
  const emitStab = (m) => { if (!st.cancelled) hub.emit({ type: 'stability:progress', runId: targetId, ...m }); };
983
1007
  emitStab({ pct: 2, text: 'Проверка устойчивости: чтение локальных цен…' });
@@ -991,11 +1015,18 @@ async function runStabilityLoop(targetId, runRow, st) {
991
1015
  const trainLen = Math.round(p.modelTrainDays * bpd), testLen = Math.round(20 * bpd);
992
1016
  const chosen = p.modelType in MODELS ? p.modelType : 'attention';
993
1017
  const realCost = (p.commissionPct + p.slippagePct) / 100;
994
- const K = p.modelTopK, dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
995
- const autoAdapt = p.modelAutoAdapt !== false;
1018
+ const rawTopK = Number(p.modelTopK ?? 2);
1019
+ const K = rawTopK > 0 ? rawTopK : 4;
1020
+ const dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
996
1021
  const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
997
1022
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
998
1023
  const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
1024
+ const baseSymMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
1025
+ const divCal = buildDividendCalendar(ds);
1026
+ const divRule = (i, t) => {
1027
+ const nd = nextDividendAt(divCal, i, t);
1028
+ return nd ? dividendRules(nd.daysToEx, nd.y) : null;
1029
+ };
999
1030
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
1000
1031
  const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
1001
1032
  const tsOf = (t) => timeline[Math.min(t, T - 1)];
@@ -1019,7 +1050,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1019
1050
  const maxH = autoAdapt ? Math.round(H * 1.4) : H;
1020
1051
  const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
1021
1052
  let fi = 0;
1022
- const preds = new Map(), sigmaAt = new Map();
1053
+ const preds = new Map(), sigmaAt = new Map(), multAt = new Map();
1023
1054
  while (testStart < T - maxH - 2) {
1024
1055
  if (st.cancelled) throw new Error('Отменено');
1025
1056
  const testEnd = Math.min(testStart + testLen, T - maxH - 2);
@@ -1028,12 +1059,14 @@ async function runStabilityLoop(targetId, runRow, st) {
1028
1059
  emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
1029
1060
  await tick();
1030
1061
  const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
1062
+ const foldEdgeMults = computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart);
1031
1063
  const m = await MODELS[chosen].fit(f);
1032
1064
  for (let t = testStart; t < testEnd; t++) {
1033
1065
  const u = t - f.lo;
1034
1066
  if (!f.yok[u]) continue;
1035
1067
  preds.set(t, MODELS[chosen].predict(m, f, u));
1036
1068
  sigmaAt.set(t, f.sigma);
1069
+ multAt.set(t, foldEdgeMults);
1037
1070
  }
1038
1071
  testStart = testEnd;
1039
1072
  fi++;
@@ -1051,7 +1084,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1051
1084
  const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
1052
1085
  const curExitRatio = autoAdapt ? 0.95 : exitRatio;
1053
1086
  const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
1054
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
1087
+ const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
1055
1088
  let closedThisBar = new Set();
1056
1089
  const closePos = (i, h, t) => {
1057
1090
  const exitPrice = px(i, t);
@@ -1075,9 +1108,14 @@ async function runStabilityLoop(targetId, runRow, st) {
1075
1108
  for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
1076
1109
  }
1077
1110
  const pr = preds.get(t), sg = sigmaAt.get(t);
1111
+ const symMult = multAt.get(t) || baseSymMult;
1078
1112
  const bear = isMarketDown(ds, t);
1079
1113
  const mktDown = mktFilter && bear;
1080
1114
  const barTs = tsOf(t);
1115
+ for (const [i, h] of [...held]) {
1116
+ const r = divRule(i, t);
1117
+ if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) closePos(i, h, tIn);
1118
+ }
1081
1119
  if (autoAdapt) {
1082
1120
  for (const [i, h] of [...held]) {
1083
1121
  if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) closePos(i, h, tIn);
@@ -1085,29 +1123,64 @@ async function runStabilityLoop(targetId, runRow, st) {
1085
1123
  }
1086
1124
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
1087
1125
  const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
1126
+ const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
1127
+ const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
1088
1128
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
1089
1129
  const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
1090
1130
  let order, entries;
1091
1131
  if (dir !== 'long') {
1092
1132
  for (const [i, h] of [...held]) {
1093
1133
  const e = edge(i), barsHeld = tIn - h.entryT;
1094
- const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symbolEdgeMult(tokens[i].symbol);
1095
- if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
1134
+ const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
1135
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
1136
+ if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
1096
1137
  }
1097
1138
  const needL = autoAdapt && bear ? need * 1.25 : need;
1098
1139
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
1099
- const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
1100
- const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
1140
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1141
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
1142
+ const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1143
+ if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
1144
+ const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
1145
+ if (bestNewEdge >= need * 2.2) {
1146
+ let weakestIdx = -1, weakestE = Infinity;
1147
+ for (const [i, h] of held) {
1148
+ const curSignedE = h.side === 'long' ? edge(i) : -edge(i);
1149
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
1150
+ weakestE = curSignedE;
1151
+ weakestIdx = i;
1152
+ }
1153
+ }
1154
+ if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
1155
+ }
1156
+ }
1101
1157
  const nL = K - countSide('long'), nS = K - countSide('short');
1102
1158
  entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
1103
1159
  } else if (effMinEdge > 0) {
1104
1160
  for (const [i, h] of [...held]) {
1105
1161
  const e = edge(i), barsHeld = tIn - h.entryT;
1106
- const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
1107
- if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1162
+ const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
1163
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
1164
+ if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1108
1165
  }
1109
1166
  const needL = autoAdapt && bear ? need * 1.25 : need;
1110
- const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
1167
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1168
+ const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1169
+ if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
1170
+ const bestNewEdge = edge(rawCand[0]);
1171
+ if (bestNewEdge >= need * 2.2) {
1172
+ let weakestIdx = -1, weakestE = Infinity;
1173
+ for (const [i] of held) {
1174
+ const curSignedE = edge(i);
1175
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
1176
+ weakestE = curSignedE;
1177
+ weakestIdx = i;
1178
+ }
1179
+ }
1180
+ if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
1181
+ }
1182
+ }
1183
+ const cand = rawCand.slice(0, Math.max(0, K - held.size));
1111
1184
  order = [...held.keys(), ...cand];
1112
1185
  } else {
1113
1186
  const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => !closedThisBar.has(i) && stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
@@ -1125,11 +1198,25 @@ async function runStabilityLoop(targetId, runRow, st) {
1125
1198
  }
1126
1199
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1127
1200
  let avail = Math.max(0, equity * 0.99 - usedStart);
1201
+ const activeTotal = Math.max(2, held.size + newEntries.length);
1202
+ const dynSlotWeight = autoAdapt
1203
+ ? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
1204
+ : maxSlotWeight;
1205
+ if (autoAdapt && newEntries.length > 1) {
1206
+ newEntries.sort((a, b) => Math.abs(b[2] ?? 0) - Math.abs(a[2] ?? 0));
1207
+ }
1208
+ const edgeWeights = newEntries.map(([i, , edgeAt]) => {
1209
+ const effE = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
1210
+ return Math.max(0.0001, effE);
1211
+ });
1212
+ let remWeightSum = edgeWeights.reduce((a, b) => a + b, 0);
1128
1213
  for (let idx = 0; idx < newEntries.length; idx++) {
1129
- const [i, side, edgeAt] = newEntries[idx];
1130
- const effEdge = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
1131
- const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / need))) : 1;
1132
- const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
1214
+ const [i, side] = newEntries[idx];
1215
+ const effEdge = edgeWeights[idx];
1216
+ const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (effEdge / need))) : 1;
1217
+ const propShare = autoAdapt && remWeightSum > 0 ? avail * (effEdge / remWeightSum) : avail / Math.max(1, newEntries.length - idx);
1218
+ remWeightSum = Math.max(0, remWeightSum - effEdge);
1219
+ const baseAlloc = autoAdapt ? Math.min(equity * dynSlotWeight * conv, propShare, avail) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
1133
1220
  let alloc = baseAlloc;
1134
1221
  if (invVol && avgInv > 0 && sg[i] > 0) {
1135
1222
  const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
@@ -280,9 +280,9 @@ export function newsForSymbols(hours = 24) {
280
280
 
281
281
  // Для живой проверки с newsFilter: самые важные свежие новости (важность ≥ 0.6, |тон| ≥ 0.4)
282
282
  // по тикерам: symbol → { bad: { gist, imp, tone, ts }, good: {…} }
283
- export function blockingNews(hours = 6) {
283
+ export function blockingNews(hours = 6, atTs = Date.now()) {
284
284
  const rows = db.prepare(`SELECT ts, title, gist, imp, tags FROM news
285
- WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ?`).all(Date.now() - hours * 3600000);
285
+ WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(atTs - hours * 3600000, atTs);
286
286
  const m = new Map();
287
287
  for (const r of rows) {
288
288
  for (const [sym, tone] of parseTags(r)) {
@@ -205,9 +205,9 @@ function systemPrompt() {
205
205
  - **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
206
206
  - **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
207
207
  - **Пороги входа и удержания (индивидуальные по эшелонам ликвидности):**
208
- - Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,65 (запас над реальными издержками по стресс-тесту и фактическим сделкам). Для высоколиквидных голубых фишек 1-го эшелона (SBER, LKOH, GAZP, T, YDEX, GMKN, FLOT и др.) при издержках 0,05% за сделку это +0,413% (в таблице отображается как +0,41%).
209
- - Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до +0,495% (+0,50%).
210
- - Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от +0,41% (1-й эшелон) и +0,50% (2–3-й эшелон), а контртрендовые шорты ужесточаются в 1,25× (до −0,52% для 1-го эшелона и −0,62% для 2–3-го эшелона). На падающем рынке — наоборот. Всегда называй пользователю именно индивидуальный порог конкретной бумаги (\`порог_покупки_проц\` / \`порог_шорта_проц\`).
208
+ - Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,60 (запас над реальными издержками по стресс-тесту и фактическим сделкам). Для высоколиквидных голубых фишек 1-го эшелона (SBER, LKOH, GAZP, T, YDEX, GMKN, FLOT и др.) при издержках 0,05% за сделку это +0,40% (с индивидуальной подстройкой под оборот и волатильность бумаги).
209
+ - Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до ~+0,48% (с индивидуальной поправкой на рублёвую ликвидность стакана).
210
+ - Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от базового порога (+0,40% 1-й эшелон и +0,48% 2–3-й эшелон), а контртрендовые шорты ужесточаются в 1,25× (до −0,50% для 1-го эшелона и −0,60% для 2–3-го эшелона). На падающем рынке — наоборот. Всегда называй пользователю именно индивидуальный порог конкретной бумаги (\`порог_покупки_проц\` / \`порог_шорта_проц\`).
211
211
  - Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от порога входа), затем — плотный порог фиксации прибыли.
212
212
  - Реальные издержки на счёте Т-Банка (по фактическим сделкам): комиссия ~0,04% + отклонение цены исполнения от цены сигнала ~0,03–0,08% на сторону.
213
213
  - **Dynamic Top-K (modelTopK = 0 Авто) и Conviction Sizing:** фиксированного числа акций нет — робот держит от 0 до 4 лонгов и до 4 шортов в зависимости от силы сигналов и концентрирует слот в лучших лидерах.
@@ -279,10 +279,13 @@ export const TOOLS = {
279
279
  ожидание_проц: x.expectedPct,
280
280
  порог_покупки_проц: symBuyThr,
281
281
  ...(x.shortSignal !== undefined ? { порог_шорта_проц: -symShortThr } : {}),
282
+ доля_порога_проц: x.thresholdRatioPct ?? 0,
283
+ порог_пройден: Boolean(x.crossedThreshold),
282
284
  импульс_текущей_свечи_проц: x.lastMovePct ?? 0,
283
285
  динамика_проц: x.deltaPct || 0,
284
286
  сигнал_покупки: !!x.signal,
285
287
  ...(buyBlockedByDip ? { покупка_заблокирована: `сейчас бумага падает на текущей свече (${x.lastMovePct}%), сработала защита от ловли падающего ножа` } : {}),
288
+ ...(x.buyBlockedByOverheat ? { покупка_заблокирована_перегревом: `бумага уже перегрета (+${x.buyBlockedByOverheat}% сверх рынка за 1–3 дня), сработала защита от покупки на вершине пампа` } : {}),
286
289
  ...(x.buyBlockedByDiv ? { покупка_заблокирована_дивидендами: x.buyBlockedByDiv } : {}),
287
290
  ...(x.buyBlockedByNews ? { покупка_заблокирована_новостями: x.buyBlockedByNews } : {}),
288
291
  ...(x.shortSignal !== undefined ? { сигнал_шорта: !!x.shortSignal } : {}),
@@ -309,6 +312,7 @@ export const TOOLS = {
309
312
  ...(targetRow ? { выбранная_акция: pick(targetRow) } : {}),
310
313
  сильнее_рынка: r.rows.slice(0, top).map(pick),
311
314
  слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
315
+ журнал_почти_сигналов_за_неделю: (r.nearSignals || []).slice(0, 12),
312
316
  },
313
317
  summary: `Прогноз на свече ${r.bar}: сигналов покупки ${r.rows.filter((x) => x.signal).length}, шорта ${r.rows.filter((x) => x.shortSignal).length}`,
314
318
  };