neuroalpha 0.5.7 → 0.5.9

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.7",
3
+ "version": "0.5.9",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -72,7 +72,9 @@ const md = (s) => {
72
72
  ? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
73
73
  : m
74
74
  ));
75
- return DOMPurify.sanitize(withSyms, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
75
+ // широкие таблицы листаются внутри своей обёртки, а не растягивают ленту чата
76
+ const withWraps = withSyms.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
77
+ return DOMPurify.sanitize(withWraps, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
76
78
  };
77
79
  const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
78
80
  const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
@@ -1026,12 +1028,16 @@ async function loadForecast(force, silent = false) {
1026
1028
  </tr>`;
1027
1029
  const buyThr = f.buyThresholdPct ?? f.thresholdPct;
1028
1030
  const shortThr = f.shortThresholdPct ?? f.thresholdPct;
1031
+ // бумаги, выбывшие из индекса, остаются в модели как контекст, но в таблицах не показываются
1032
+ const shownRows = f.rows.filter((r) => r.inIndex !== false);
1029
1033
  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),
1034
+ ...shownRows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1035
+ ...shownRows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1032
1036
  ].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>
1037
+ // общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
1038
+ 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>`;
1039
+ box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6))}
1040
+ ${fcTable('Слабее рынка', shortRows, ';margin-top:14px')}</div>
1035
1041
  <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
1036
1042
  if (state.brokers?.accounts?.length) renderBrokers();
1037
1043
  } catch (e) {
@@ -1111,18 +1117,58 @@ const TOOL_LABELS = {
1111
1117
  connect_broker_account: 'Подключение брокерского счёта',
1112
1118
  };
1113
1119
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1120
+ // Автопрокрутка ленты чата («прилипание к низу»):
1121
+ // • пока пользователь внизу, новые сообщения и потоковый ответ прокручивают ленту вниз;
1122
+ // • как только пользователь сам прокрутил вверх (колесо, тач, клавиши, полоса прокрутки) — лента его не дёргает;
1123
+ // • прилипание возвращается, когда пользователь сам дошёл до низа, нажал «↓» или отправил новый запрос.
1114
1124
  const feed = $('#feed');
1115
- const nearBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
1116
- const scrollDown = (force) => { if (force || nearBottom()) requestAnimationFrame(() => { feed.scrollTop = feed.scrollHeight; }); };
1117
1125
  const feedDown = $('#feed-down');
1126
+ const BOTTOM_EPS = 24;
1127
+ const distFromBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight;
1128
+ let stickToBottom = true;
1129
+ let lastFeedTop = 0;
1130
+ let feedScrollRaf = 0;
1118
1131
  let unseen = 0;
1119
1132
  const updateDown = () => {
1120
- feedDown.hidden = !unseen || nearBottom();
1121
- feedDown.textContent = unseen ? `↓ ${unseen}` : '↓';
1133
+ const show = !stickToBottom && distFromBottom() > BOTTOM_EPS;
1134
+ feedDown.hidden = !show;
1135
+ feedDown.textContent = unseen ? `↓ ${unseen} нов.` : '↓';
1122
1136
  };
1123
- const toBottom = () => { unseen = 0; scrollDown(true); updateDown(); };
1124
- feed.addEventListener('scroll', () => { if (nearBottom()) unseen = 0; updateDown(); });
1125
- feedDown.addEventListener('click', toBottom);
1137
+ const scrollDown = (force = false, smooth = false) => {
1138
+ if (force) stickToBottom = true;
1139
+ if (!stickToBottom) { updateDown(); return; }
1140
+ cancelAnimationFrame(feedScrollRaf);
1141
+ feedScrollRaf = requestAnimationFrame(() => {
1142
+ if (!stickToBottom) return;
1143
+ if (smooth) feed.scrollTo({ top: feed.scrollHeight, behavior: 'smooth' });
1144
+ else feed.scrollTop = feed.scrollHeight;
1145
+ lastFeedTop = feed.scrollTop;
1146
+ unseen = 0;
1147
+ updateDown();
1148
+ });
1149
+ };
1150
+ const toBottom = (smooth = false) => { unseen = 0; scrollDown(true, smooth); };
1151
+ const releaseStick = () => { if (distFromBottom() > 2) { stickToBottom = false; updateDown(); } };
1152
+ feed.addEventListener('wheel', (e) => {
1153
+ if (e.deltaY >= 0) return;
1154
+ // прокрутка внутри блока размышлений или таблицы, которые сами ещё листаются вверх, ленту не трогает
1155
+ const inner = e.target.closest?.('.think-body, .md-c pre, .md-c .tbl-wrap');
1156
+ if (inner && inner.scrollTop > 0) return;
1157
+ stickToBottom = false;
1158
+ updateDown();
1159
+ }, { passive: true });
1160
+ feed.addEventListener('touchmove', releaseStick, { passive: true });
1161
+ feed.addEventListener('keydown', (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) { stickToBottom = false; updateDown(); } });
1162
+ feed.addEventListener('scroll', () => {
1163
+ const top = feed.scrollTop;
1164
+ // прокрутка вверх (в том числе мышью за полосу прокрутки) — пользователь читает историю
1165
+ if (top < lastFeedTop - 4 && distFromBottom() > BOTTOM_EPS) stickToBottom = false;
1166
+ // пользователь сам вернулся вниз — снова следим за новыми сообщениями
1167
+ if (distFromBottom() <= BOTTOM_EPS) { stickToBottom = true; unseen = 0; }
1168
+ lastFeedTop = top;
1169
+ updateDown();
1170
+ }, { passive: true });
1171
+ feedDown.addEventListener('click', () => toBottom(true));
1126
1172
  const msgEls = new Map();
1127
1173
  const liveEls = new Map();
1128
1174
 
@@ -1195,7 +1241,7 @@ function renderMsg(m) {
1195
1241
  function upsertMsg(m) {
1196
1242
  state.chat.msgs.set(m.id, m);
1197
1243
  const old = msgEls.get(m.id);
1198
- const stick = nearBottom();
1244
+ const stick = stickToBottom;
1199
1245
  const el = renderMsg(m);
1200
1246
  if (!el) { old?.remove(); msgEls.delete(m.id); return; }
1201
1247
  if (old) {
@@ -1228,7 +1274,7 @@ function onDelta(d) {
1228
1274
 
1229
1275
  function liveLine(key, html) {
1230
1276
  let el = liveEls.get(key);
1231
- const stick = nearBottom();
1277
+ const stick = stickToBottom;
1232
1278
  if (!el) { el = document.createElement('div'); el.className = 'live'; liveEls.set(key, el); feed.append(el); }
1233
1279
  el.innerHTML = html;
1234
1280
  if (stick) scrollDown(true);
@@ -1343,6 +1389,8 @@ async function sendChat(text) {
1343
1389
  if (speechRec && speechActive) stopVoiceInput(false);
1344
1390
  text = (text ?? $('#chat-input').value).trim();
1345
1391
  if (!text) return;
1392
+ // новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
1393
+ toBottom(true);
1346
1394
  try {
1347
1395
  await post('/api/chat/send', { text, mode: 'advisor' });
1348
1396
  $('#chat-input').value = '';
@@ -3309,10 +3357,16 @@ $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#he
3309
3357
  let stockModalChart = null;
3310
3358
  let stockChartRo = null;
3311
3359
  let currentStockData = null;
3312
- let currentStockRange = '1m';
3313
-
3314
- function renderStockChart(d, range = '1m') {
3360
+ // Выбранный период и масштаб графика в досье акции сохраняются в браузере (масштаб — отдельно для каждого периода)
3361
+ const STOCK_CHART_KEY = 'na_stock_chart_v1';
3362
+ const loadStockChartPrefs = () => { try { return JSON.parse(localStorage.getItem(STOCK_CHART_KEY) || '{}') || {}; } catch { return {}; } };
3363
+ const saveStockChartPrefs = (patch) => { try { localStorage.setItem(STOCK_CHART_KEY, JSON.stringify({ ...loadStockChartPrefs(), ...patch })); } catch { /* ignore */ } };
3364
+ const STOCK_RANGES = ['1d', '1w', '1m', '1y', 'all'];
3365
+ let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1m';
3366
+
3367
+ function renderStockChart(d, range = currentStockRange) {
3315
3368
  currentStockRange = range;
3369
+ saveStockChartPrefs({ range });
3316
3370
  const box = $('#stock-chart-box');
3317
3371
  if (!box) return;
3318
3372
  if (stockChartRo) {
@@ -3424,7 +3478,27 @@ function renderStockChart(d, range = '1m') {
3424
3478
  try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
3425
3479
  }
3426
3480
  }
3427
- stockModalChart.timeScale().fitContent();
3481
+ const ts = stockModalChart.timeScale();
3482
+ ts.fitContent();
3483
+ // Масштаб храним как «сколько свечей видно» и «отступ от правого края», чтобы он подходил к любой акции
3484
+ const lastIdx = seriesData.length - 1;
3485
+ const zoom = loadStockChartPrefs().zoom?.[range];
3486
+ if (zoom && lastIdx > 5 && zoom.span >= 5) {
3487
+ const to = lastIdx - Math.max(-20, Math.min(lastIdx, zoom.rightGap || 0));
3488
+ ts.setVisibleLogicalRange({ from: to - Math.min(zoom.span, lastIdx + 20), to });
3489
+ }
3490
+ const chartRef = stockModalChart;
3491
+ const renderedAt = Date.now();
3492
+ let saveTimer = null;
3493
+ ts.subscribeVisibleLogicalRangeChange((lr) => {
3494
+ // изменения при построении графика не считаются выбором пользователя
3495
+ if (!lr || stockModalChart !== chartRef || Date.now() - renderedAt < 400) return;
3496
+ clearTimeout(saveTimer);
3497
+ saveTimer = setTimeout(() => {
3498
+ const prefs = loadStockChartPrefs();
3499
+ saveStockChartPrefs({ zoom: { ...(prefs.zoom || {}), [range]: { span: Math.round((lr.to - lr.from) * 10) / 10, rightGap: Math.round((lastIdx - lr.to) * 10) / 10 } } });
3500
+ }, 400);
3501
+ });
3428
3502
  }
3429
3503
 
3430
3504
  async function openStockModal(symbol) {
@@ -3477,11 +3551,7 @@ async function openStockModal(symbol) {
3477
3551
  <div class="stitle bar" style="margin:0 0 8px">
3478
3552
  <span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
3479
3553
  <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>
3554
+ ${[['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
3555
  </div>
3486
3556
  </div>
3487
3557
  <div id="stock-chart-box" class="chart"></div>
@@ -3586,7 +3656,7 @@ async function openStockModal(symbol) {
3586
3656
  ${newsHtml}
3587
3657
  `;
3588
3658
 
3589
- renderStockChart(d, '1m');
3659
+ renderStockChart(d, currentStockRange);
3590
3660
  } catch (err) {
3591
3661
  bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
3592
3662
  }
package/public/styles.css CHANGED
@@ -96,17 +96,26 @@ body.dragging iframe, body.dragging canvas { pointer-events: none; }
96
96
  .feed, .tab, .think-body, .modal .md { scrollbar-width: thin; scrollbar-color: #2a3140 transparent; }
97
97
 
98
98
  /* ---------- чат ---------- */
99
- .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
99
+ .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
100
100
  .chat-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
101
101
  .chat-head select { font-size: 12.5px; padding: 3px 6px; }
102
102
  .chat-head label { font-size: 12px; color: var(--muted); }
103
103
  .chat-head label input { width: 46px; padding: 2px 4px; }
104
104
  .feed-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
105
- .feed { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
106
- .feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; }
107
- .msg { max-width: 92%; }
105
+ /* Лента чата: только вертикальная прокрутка; широкое содержимое (таблицы, код) листается внутри своего блока */
106
+ .feed { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; min-width: 0; }
107
+ .feed > * { min-width: 0; max-width: 100%; }
108
+ .feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; z-index: 2; }
109
+ .msg { max-width: 92%; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
108
110
  .msg.user { align-self: flex-end; background: #1d3359; border-radius: 12px 12px 3px 12px; padding: 7px 12px; white-space: pre-wrap; }
109
- .msg.ai { align-self: stretch; }
111
+ .msg.ai { align-self: stretch; max-width: 100%; }
112
+ .md-c { min-width: 0; max-width: 100%; }
113
+ .md-c pre { max-width: 100%; overflow-x: auto; white-space: pre; background: var(--panel); padding: 8px 10px; border-radius: 6px; }
114
+ .md-c pre code { background: none; padding: 0; }
115
+ .md-c .tbl-wrap { max-width: 100%; overflow-x: auto; margin: 6px 0; }
116
+ .md-c .tbl-wrap table { margin: 0; }
117
+ .md-c img { max-width: 100%; height: auto; }
118
+ .ev, .live, .act { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
110
119
  .md-c > :first-child { margin-top: 0; }
111
120
  .md-c > :last-child { margin-bottom: 0; }
112
121
  .md-c p { margin: 6px 0; }
@@ -518,3 +527,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
518
527
  background: var(--accent);
519
528
  border-radius: 4px;
520
529
  }
530
+
531
+ /* Прогноз: две таблицы с общей сеткой колонок */
532
+ .fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
533
+
@@ -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,
@@ -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;