neuroalpha 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1026,12 +1026,16 @@ async function loadForecast(force, silent = false) {
1026
1026
  </tr>`;
1027
1027
  const buyThr = f.buyThresholdPct ?? f.thresholdPct;
1028
1028
  const shortThr = f.shortThresholdPct ?? f.thresholdPct;
1029
+ // бумаги, выбывшие из индекса, остаются в модели как контекст, но в таблицах не показываются
1030
+ const shownRows = f.rows.filter((r) => r.inIndex !== false);
1029
1031
  const shortRows = [
1030
- ...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1031
- ...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1032
+ ...shownRows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1033
+ ...shownRows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1032
1034
  ].slice(0, 4);
1033
- box.innerHTML = `<div style="overflow-x:auto"><table><thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
1034
- <table style="margin-top:14px"><thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${shortRows.map(row).join('')}</tbody></table></div>
1035
+ // общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
1036
+ const fcTable = (title, rows, extraStyle = '') => `<table class="fc-table" style="table-layout:fixed;width:100%${extraStyle}">${colgroup}<thead><tr><th>${title}</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table>`;
1037
+ box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6))}
1038
+ ${fcTable('Слабее рынка', shortRows, ';margin-top:14px')}</div>
1035
1039
  <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
1036
1040
  if (state.brokers?.accounts?.length) renderBrokers();
1037
1041
  } catch (e) {
@@ -3309,10 +3313,16 @@ $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#he
3309
3313
  let stockModalChart = null;
3310
3314
  let stockChartRo = null;
3311
3315
  let currentStockData = null;
3312
- let currentStockRange = '1m';
3313
-
3314
- function renderStockChart(d, range = '1m') {
3316
+ // Выбранный период и масштаб графика в досье акции сохраняются в браузере (масштаб — отдельно для каждого периода)
3317
+ const STOCK_CHART_KEY = 'na_stock_chart_v1';
3318
+ const loadStockChartPrefs = () => { try { return JSON.parse(localStorage.getItem(STOCK_CHART_KEY) || '{}') || {}; } catch { return {}; } };
3319
+ const saveStockChartPrefs = (patch) => { try { localStorage.setItem(STOCK_CHART_KEY, JSON.stringify({ ...loadStockChartPrefs(), ...patch })); } catch { /* ignore */ } };
3320
+ const STOCK_RANGES = ['1d', '1w', '1m', '1y', 'all'];
3321
+ let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1m';
3322
+
3323
+ function renderStockChart(d, range = currentStockRange) {
3315
3324
  currentStockRange = range;
3325
+ saveStockChartPrefs({ range });
3316
3326
  const box = $('#stock-chart-box');
3317
3327
  if (!box) return;
3318
3328
  if (stockChartRo) {
@@ -3424,7 +3434,27 @@ function renderStockChart(d, range = '1m') {
3424
3434
  try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
3425
3435
  }
3426
3436
  }
3427
- stockModalChart.timeScale().fitContent();
3437
+ const ts = stockModalChart.timeScale();
3438
+ ts.fitContent();
3439
+ // Масштаб храним как «сколько свечей видно» и «отступ от правого края», чтобы он подходил к любой акции
3440
+ const lastIdx = seriesData.length - 1;
3441
+ const zoom = loadStockChartPrefs().zoom?.[range];
3442
+ if (zoom && lastIdx > 5 && zoom.span >= 5) {
3443
+ const to = lastIdx - Math.max(-20, Math.min(lastIdx, zoom.rightGap || 0));
3444
+ ts.setVisibleLogicalRange({ from: to - Math.min(zoom.span, lastIdx + 20), to });
3445
+ }
3446
+ const chartRef = stockModalChart;
3447
+ const renderedAt = Date.now();
3448
+ let saveTimer = null;
3449
+ ts.subscribeVisibleLogicalRangeChange((lr) => {
3450
+ // изменения при построении графика не считаются выбором пользователя
3451
+ if (!lr || stockModalChart !== chartRef || Date.now() - renderedAt < 400) return;
3452
+ clearTimeout(saveTimer);
3453
+ saveTimer = setTimeout(() => {
3454
+ const prefs = loadStockChartPrefs();
3455
+ saveStockChartPrefs({ zoom: { ...(prefs.zoom || {}), [range]: { span: Math.round((lr.to - lr.from) * 10) / 10, rightGap: Math.round((lastIdx - lr.to) * 10) / 10 } } });
3456
+ }, 400);
3457
+ });
3428
3458
  }
3429
3459
 
3430
3460
  async function openStockModal(symbol) {
@@ -3477,11 +3507,7 @@ async function openStockModal(symbol) {
3477
3507
  <div class="stitle bar" style="margin:0 0 8px">
3478
3508
  <span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
3479
3509
  <div class="seg" id="stock-chart-range" style="margin:0">
3480
- <button type="button" data-srange="1d">1Д</button>
3481
- <button type="button" data-srange="1w">1Н</button>
3482
- <button type="button" data-srange="1m" class="active">1М</button>
3483
- <button type="button" data-srange="1y">1Г</button>
3484
- <button type="button" data-srange="all">Все</button>
3510
+ ${[['1d', '1Д'], ['1w', '1Н'], ['1m', '1М'], ['1y', '1Г'], ['all', 'Все']].map(([k, l]) => `<button type="button" data-srange="${k}"${k === currentStockRange ? ' class="active"' : ''}>${l}</button>`).join('')}
3485
3511
  </div>
3486
3512
  </div>
3487
3513
  <div id="stock-chart-box" class="chart"></div>
@@ -3586,7 +3612,7 @@ async function openStockModal(symbol) {
3586
3612
  ${newsHtml}
3587
3613
  `;
3588
3614
 
3589
- renderStockChart(d, '1m');
3615
+ renderStockChart(d, currentStockRange);
3590
3616
  } catch (err) {
3591
3617
  bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
3592
3618
  }
package/public/styles.css CHANGED
@@ -518,3 +518,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
518
518
  background: var(--accent);
519
519
  border-radius: 4px;
520
520
  }
521
+
522
+ /* Прогноз: две таблицы с общей сеткой колонок */
523
+ .fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
524
+
@@ -354,7 +354,7 @@ function computeExecutionAudit(a) {
354
354
  slipTier1Pct,
355
355
  slipTier2Pct,
356
356
  totalCostPerSidePct,
357
- breakEvenTargetPct: 0.237,
357
+ breakEvenTargetPct: 0.214,
358
358
  grossRub,
359
359
  feesRub: round(feesRub, 0),
360
360
  netRub,
@@ -1517,17 +1517,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1517
1517
  const nowTs = Date.now();
1518
1518
  const entryTsMap = acc.entryTsBySymbol || {};
1519
1519
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1520
- const mskDate = new Date(nowTs + MSK);
1521
- const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
1522
- const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
1523
- // Спред-фильтр реального стакана по эшелонам ликвидности:
1524
- // для 1-го эшелона требуем минимум базовый порог (≥ 0.50%), а для 2–3 эшелона (широкий спред стакана) — в 1.35× выше (≥ 0.675%)
1520
+ // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
1521
+ // Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
1522
+ // что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
1525
1523
  const baseBuyNeedPct = Number(fc.buyThresholdPct ?? fc.thresholdPct ?? 0.50);
1526
1524
  const baseShortNeedPct = Number(fc.shortThresholdPct ?? fc.thresholdPct ?? 0.50);
1527
- const minBuyEdgePct = round(baseBuyNeedPct * eveningBoost, 3);
1528
- const minShortEdgePct = round(baseShortNeedPct * eveningBoost, 3);
1529
- const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30 * eveningBoost), 3);
1530
- const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31 * eveningBoost), 3);
1525
+ const minBuyEdgePct = round(baseBuyNeedPct, 3);
1526
+ const minShortEdgePct = round(baseShortNeedPct, 3);
1527
+ const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1528
+ const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1531
1529
  const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1532
1530
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, как в research.js):
1533
1531
  // если за последние 14 дней по бумаге было >= 3 закрытых сделок на счёте и их суммарный итог отрицательный (< -1.0%),
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
3
- import { ensureVocab } from './vocab.js';
3
+ import { ensureVocab, ensureIndexHistory } from './vocab.js';
4
4
  import { kvGet, kvSet } from '../db.js';
5
5
 
6
6
  const MSK = 3 * 3600000;
@@ -230,7 +230,17 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
230
230
  return v.ds;
231
231
  }
232
232
  }
233
- const vocab = await ensureVocab();
233
+ // Универсум = нынешний состав индекса + все бумаги, входившие в него за период (без ошибки выжившего).
234
+ // Для каждой свечи ниже отмечается, входила ли бумага в индекс на эту дату (tk.inIndex).
235
+ const curVocab = await ensureVocab();
236
+ const indexHistory = await ensureIndexHistory(fromTs, tillTs, { localOnly }).catch(() => []);
237
+ const vocab = [...curVocab];
238
+ const seenSym = new Set(vocab.map((v) => v.symbol));
239
+ for (const snap of indexHistory) {
240
+ for (const v of snap.list) {
241
+ if (!seenSym.has(v.symbol)) { seenSym.add(v.symbol); vocab.push({ symbol: v.symbol, name: v.name, weight: 0 }); }
242
+ }
243
+ }
234
244
  const symbols = vocab.map((v) => v.symbol);
235
245
  const raw = {};
236
246
  let done = 0;
@@ -281,12 +291,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
281
291
  if (!raw.IMOEX?.n) throw new Error('Нет данных индекса МосБиржи');
282
292
 
283
293
  // временная шкала: будни, где торговалось хотя бы 40% акций
294
+ // (считается по нынешнему составу, чтобы шкала не зависела от расширения универсума историческими бумагами)
284
295
  const count = new Map();
285
- for (const s of symbols) {
286
- const c = raw[s];
296
+ for (const v of curVocab) {
297
+ const c = raw[v.symbol];
298
+ if (!c) continue;
287
299
  for (let i = 0; i < c.n; i++) if (dow(c.ts[i]) % 6 !== 0) count.set(c.ts[i], (count.get(c.ts[i]) || 0) + 1);
288
300
  }
289
- const timeline = [...count.entries()].filter(([, n]) => n >= symbols.length * 0.4).map(([t]) => t).sort((a, b) => a - b);
301
+ const timeline = [...count.entries()].filter(([, n]) => n >= curVocab.length * 0.4).map(([t]) => t).sort((a, b) => a - b);
290
302
  const T = timeline.length;
291
303
  const index = new Map(timeline.map((t, i) => [t, i]));
292
304
 
@@ -322,9 +334,26 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
322
334
  const s = build(c);
323
335
  let cov = 0;
324
336
  for (let i = 0; i < T; i++) cov += s.has[i];
325
- if (cov < T * 0.6) continue; // слишком мало торгов (новые или неликвидные бумаги)
337
+ // Раньше бумаги с торгами меньше 60% периода отбрасывались — это само по себе ошибка выжившего
338
+ // (выпадали вышедшие на биржу позже и ушедшие с неё). Теперь отсекаем только почти неторгуемые (< 15%),
339
+ // а участие в сделках ограничивает tk.inIndex и фильтр ликвидности.
340
+ if (cov < T * 0.15) continue;
326
341
  tokens.push({ symbol: v.symbol, name: v.name, weight: v.weight, ...s });
327
342
  }
343
+ // Входила ли бумага в индекс на дату свечи (снимок месяца действует с 1-го числа до следующего снимка)
344
+ const snaps = indexHistory.map((sn) => ({ ts: sn.ts, set: new Set(sn.list.map((x) => x.symbol)) })).sort((a, b) => a.ts - b.ts);
345
+ for (const tk of tokens) {
346
+ const mask = new Uint8Array(T);
347
+ if (!snaps.length) mask.fill(1);
348
+ else {
349
+ let si = 0;
350
+ for (let i = 0; i < T; i++) {
351
+ while (si + 1 < snaps.length && snaps[si + 1].ts <= timeline[i]) si++;
352
+ mask[i] = snaps[si].set.has(tk.symbol) ? 1 : 0;
353
+ }
354
+ }
355
+ tk.inIndex = mask;
356
+ }
328
357
  const mk = build(raw.IMOEX);
329
358
  // макро-ряды: выровненные цены с переносом (доходности посчитаем после lret)
330
359
  const macroRaw = [];
@@ -321,7 +321,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
321
321
  // и не даём сигнал на шорт, если на текущей живой свече акция взлетает вверх сильнее чем на +0.35%!
322
322
  const liveBuyOk = !(liveSnap?.realtime && lastMovePct < -0.35);
323
323
  const liveShortOk = !(liveSnap?.realtime && lastMovePct > 0.35);
324
- const rawShortHit = dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
324
+ // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
325
+ const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
326
+ const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
325
327
  const shortBlockedByBroker = Boolean(rawShortHit && shortableMap && shortableMap[tk.symbol] === false);
326
328
  const row = {
327
329
  i, symbol: tk.symbol, name: tk.name,
@@ -331,7 +333,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
331
333
  expectedPct: round(exp * 100, 3),
332
334
  lastMovePct,
333
335
  weight: round(weight, 3),
334
- signal: tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
336
+ signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
337
+ ...(inIdx ? {} : { inIndex: false }),
335
338
  };
336
339
  if (dir !== 'long') {
337
340
  row.shortSignal = rawShortHit && !shortBlockedByBroker;
@@ -37,7 +37,8 @@ export function isMarketDown(ds, t) {
37
37
 
38
38
  // Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
39
39
  // 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
40
- const DATA_VERSION = 8;
40
+ // 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
41
+ const DATA_VERSION = 9;
41
42
 
42
43
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
43
44
  function reuseParamsKey(params) {
@@ -300,6 +301,8 @@ async function run(runId, p, state) {
300
301
  if (n < (T - firstTest) * 0.5) futOpen = null;
301
302
  } catch { futOpen = null; }
302
303
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
304
+ // новые позиции — только по бумагам, входившим в индекс на дату решения (без ошибки выжившего)
305
+ const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
303
306
  // только покупки: на позицию капитал / K; с продажами: капитал / 2K (покупки и продажи вместе не больше капитала)
304
307
  const slots = dir === 'long' ? K : 2 * K;
305
308
 
@@ -322,7 +325,7 @@ async function run(runId, p, state) {
322
325
  }
323
326
  const free = K - hMap.size;
324
327
  if (free <= 0) continue;
325
- const cand = [...pr.keys()].filter((i) => !hMap.has(i) && stockOk(i) && liquid(i, t) && edge(i) > need).sort((a, b) => pr[b] - pr[a]).slice(0, free);
328
+ const cand = [...pr.keys()].filter((i) => !hMap.has(i) && stockOk(i) && canEnter(i, t) && edge(i) > need).sort((a, b) => pr[b] - pr[a]).slice(0, free);
326
329
  for (const i of cand) hMap.set(i, tokens[i].open[tIn]);
327
330
  }
328
331
  return { net: w - l, pf: l > 0 ? w / l : (w > 0 ? 3 : 0), trades: n };
@@ -455,8 +458,8 @@ async function run(runId, p, state) {
455
458
  }
456
459
  const needL = autoAdapt && bear ? need * 1.25 : need;
457
460
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
458
- const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
459
- const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
461
+ const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
462
+ const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
460
463
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
461
464
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
462
465
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -497,11 +500,11 @@ async function run(runId, p, state) {
497
500
  }
498
501
  const free = K - held.size;
499
502
  const needL = autoAdapt && bear ? need * 1.25 : need;
500
- const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
503
+ const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
501
504
  const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
502
505
  order = [...held.keys(), ...cand];
503
506
  } else {
504
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
507
+ const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
505
508
  order = cand;
506
509
  const next = new Set(order);
507
510
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
@@ -974,6 +977,7 @@ async function runStabilityLoop(targetId, runRow, st) {
974
977
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
975
978
  const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
976
979
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
980
+ const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
977
981
  const tsOf = (t) => timeline[Math.min(t, T - 1)];
978
982
 
979
983
  const runs = [];
@@ -1061,8 +1065,8 @@ async function runStabilityLoop(targetId, runRow, st) {
1061
1065
  }
1062
1066
  const needL = autoAdapt && bear ? need * 1.25 : need;
1063
1067
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
1064
- const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
1065
- const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
1068
+ const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
1069
+ const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
1066
1070
  const nL = K - countSide('long'), nS = K - countSide('short');
1067
1071
  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)])];
1068
1072
  } else if (effMinEdge > 0) {
@@ -1072,10 +1076,10 @@ async function runStabilityLoop(targetId, runRow, st) {
1072
1076
  if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1073
1077
  }
1074
1078
  const needL = autoAdapt && bear ? need * 1.25 : need;
1075
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
1079
+ const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
1076
1080
  order = [...held.keys(), ...cand];
1077
1081
  } else {
1078
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
1082
+ const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
1079
1083
  order = cand;
1080
1084
  const next = new Set(order);
1081
1085
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);
@@ -5,6 +5,55 @@ import { kvGet, kvSet } from '../db.js';
5
5
  const URL = 'https://iss.moex.com/iss/statistics/engines/stock/markets/index/analytics/MOEXBMI.json?iss.meta=off&limit=100';
6
6
  const MAX_AGE = 30 * 86400000;
7
7
 
8
+ // Исторический состав индекса MOEXBMI: снимок на середину каждого месяца (кэшируется навсегда — прошлое не меняется).
9
+ // Нужен, чтобы бэктест не страдал ошибкой выжившего: торговать только бумаги, входившие в индекс на дату сделки.
10
+ async function fetchComposition(date) {
11
+ const list = [];
12
+ for (let start = 0; start < 500; start += 100) {
13
+ const res = await fetch(`${URL}&date=${date}&start=${start}`, { signal: AbortSignal.timeout(30000) });
14
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
15
+ const j = await res.json();
16
+ const cols = j.analytics.columns;
17
+ const ix = (c) => cols.indexOf(c);
18
+ for (const r of j.analytics.data) list.push({ symbol: r[ix('ticker')], name: r[ix('shortnames')], weight: r[ix('weight')] });
19
+ if (j.analytics.data.length < 100) break;
20
+ }
21
+ return list;
22
+ }
23
+
24
+ export async function ensureIndexHistory(fromTs, tillTs = Date.now(), { localOnly = false } = {}) {
25
+ const months = [];
26
+ const d = new Date(fromTs);
27
+ d.setUTCDate(1);
28
+ const end = new Date(tillTs);
29
+ while (d <= end) {
30
+ months.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`);
31
+ d.setUTCMonth(d.getUTCMonth() + 1);
32
+ }
33
+ const curMonth = new Date(Date.now()).toISOString().slice(0, 7);
34
+ const out = [];
35
+ for (const m of months) {
36
+ const key = `bmiComp:${m}`;
37
+ let snap = kvGet(key);
38
+ const stale = m === curMonth && snap && Date.now() - (snap.at || 0) > 7 * 86400000;
39
+ if ((!snap || stale) && !localOnly) {
40
+ try {
41
+ let list = [];
42
+ for (const day of ['15', '16', '17', '18', '14', '13']) {
43
+ list = await fetchComposition(`${m}-${day}`);
44
+ if (list.length >= 20) break;
45
+ }
46
+ if (list.length >= 20) {
47
+ snap = { at: Date.now(), list };
48
+ kvSet(key, snap);
49
+ }
50
+ } catch { /* нет сети — используем то, что есть */ }
51
+ }
52
+ if (snap?.list?.length) out.push({ ts: Date.parse(`${m}-01T00:00:00+03:00`), list: snap.list });
53
+ }
54
+ return out;
55
+ }
56
+
8
57
  export async function ensureVocab({ refresh = false } = {}) {
9
58
  const cached = kvGet('vocab');
10
59
  if (cached && !refresh && Date.now() - cached.ts < MAX_AGE) return cached.list;