neuroalpha 0.5.24 → 0.5.28

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.24",
3
+ "version": "0.5.28",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1079,20 +1079,36 @@ async function loadForecast(force, silent = false) {
1079
1079
  }
1080
1080
  return reasons.length ? { blocked: true, label, title: reasons.join(' · ') } : { blocked: false, label: '', title: '' };
1081
1081
  };
1082
- const sigBadge = (r, blk) => {
1082
+ const sigBadge = (r, blk, crossed, pctOfThr, thrText, thrVal) => {
1083
1083
  if (r.signal) return '<span class="tag ok">купить</span>';
1084
1084
  if (r.shortSignal) return '<span class="tag no">шорт</span>';
1085
- if (blk?.blocked) return `<span class="tag blocked" title="${esc(blk.title)}">${esc(blk.label)}</span>`;
1086
- return '<span class="muted small">—</span>';
1085
+ if (blk?.blocked && crossed) {
1086
+ return `<span class="tag blocked" title="${esc(`Порог ${thrText} пройден (ожидание ${fmtPct(r.expectedPct, 2)}, ${pctOfThr}% порога), но вход заблокирован: ${blk.title}`)}">${esc(blk.label)}</span>`;
1087
+ }
1088
+ if (blk?.blocked && !crossed) {
1089
+ const shortTag = blk.label.replace(/^⛔\s*/, '');
1090
+ return `<span class="muted small" title="${esc(`Ожидание ${fmtPct(r.expectedPct, 2)} ниже порога ${thrText} (${pctOfThr}% от порога) — сигнала нет. Дополнительно по бумаге активен фильтр: ${blk.title}`)}">ниже порога · ${esc(shortTag)}</span>`;
1091
+ }
1092
+ if (pctOfThr >= 75) {
1093
+ const gap = Math.max(0, (Number(thrVal) || 0) - Math.abs(Number(r.expectedPct) || 0));
1094
+ return `<span class="tag mid" title="${esc(`Почти сигнал: ожидание ${fmtPct(r.expectedPct, 2)} достигло ${pctOfThr}% от порога ${thrText} (не хватает ${fmtNum(gap, 2)}%)`)}">почти (${pctOfThr}%)</span>`;
1095
+ }
1096
+ return `<span class="muted small" title="${esc(`Ожидание ${fmtPct(r.expectedPct, 2)} ниже порога ${thrText} (${pctOfThr}% от порога)`)}">—</span>`;
1087
1097
  };
1088
1098
  const row = (r, side) => {
1089
1099
  const blk = !r.signal && !r.shortSignal ? blockInfoOf(r, side) : { blocked: false };
1090
- const trCls = (r.signal || r.shortSignal) ? ' class="best"' : blk.blocked ? ` class="fc-blocked" title="${esc(blk.title)}"` : '';
1091
1100
  const isShort = side === 'short' || (side !== 'long' && r.expectedPct < 0);
1092
1101
  const isT1 = r.tier ? r.tier === 1 : TIER1_SET.has(r.symbol);
1093
1102
  const thrVal = isShort
1094
1103
  ? (r.shortThresholdPct ?? (isT1 ? shortThr : shortThrT2))
1095
1104
  : (r.buyThresholdPct ?? (isT1 ? buyThr : buyThrT2));
1105
+ const pctOfThr = thrVal > 0 ? Math.round((Math.abs(Number(r.expectedPct) || 0) / thrVal) * 100) : 0;
1106
+ const crossed = thrVal > 0 && Math.abs(Number(r.expectedPct) || 0) >= thrVal;
1107
+ const trCls = (r.signal || r.shortSignal)
1108
+ ? ' class="best"'
1109
+ : (blk.blocked && crossed)
1110
+ ? ` class="fc-blocked" title="${esc(`Порог пройден, но вход заблокирован: ${blk.title}`)}"`
1111
+ : '';
1096
1112
  const thrFmt = Number(thrVal || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1097
1113
  const thrText = isShort ? `−${thrFmt}%` : `+${thrFmt}%`;
1098
1114
  const multStr = r.edgeMult ? `×${fmtNum(r.edgeMult, 2)}` : (isT1 ? '×1,00' : '×1,20');
@@ -1106,7 +1122,7 @@ async function loadForecast(force, silent = false) {
1106
1122
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
1107
1123
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
1108
1124
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
1109
- <td style="text-align:center;white-space:nowrap">${sigBadge(r, blk)}</td>
1125
+ <td style="text-align:center;white-space:nowrap">${sigBadge(r, blk, crossed, pctOfThr, thrText, thrVal)}</td>
1110
1126
  <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => `<span class="sym-link" data-sym="${esc(x.symbol)}" title="Сосед по карте рынка: вклад ${x.contribPct > 0 ? '+' : ''}${x.contribPct}%, близость ${x.closeness}">${esc(x.symbol)}</span>`).join(', ')}</td>
1111
1127
  <td class="num muted small" style="white-space:nowrap" title="${esc(thrTitle)}">${thrText}</td>
1112
1128
  </tr>`;
@@ -1119,9 +1135,41 @@ async function loadForecast(force, silent = false) {
1119
1135
  ].slice(0, 4);
1120
1136
  // общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
1121
1137
  const fcTable = (title, rows, extraStyle = '', side = 'long') => `<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><th class="num" title="Индивидуальный порог входа с учётом ликвидности бумаги (1-й или 2–3-й эшелон) и тренда индекса">Порог</th></tr></thead><tbody>${rows.map((r) => row(r, side)).join('')}</tbody></table>`;
1138
+ const nearList = Array.isArray(f.nearSignals) ? f.nearSignals : [];
1139
+ const prevNearEl = $('#fc-near-box');
1140
+ const nearOpenAttr = (!prevNearEl || prevNearEl.open) ? ' open' : '';
1141
+ const nearHtml = nearList.length ? `<details id="fc-near-box"${nearOpenAttr} style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
1142
+ <summary style="font-weight:500">Журнал сигналов и почти-сигналов за неделю (≥75% порога) · ${nearList.filter((x) => x.status === 'signal').length} сигн., ${nearList.filter((x) => x.status === 'blocked').length} блок., ${nearList.filter((x) => x.status === 'near').length} почти</summary>
1143
+ <div style="overflow-x:auto;margin-top:8px">
1144
+ <table class="fc-table" style="width:100%">
1145
+ <thead><tr><th>Дата · время МСК</th><th>Бумага</th><th>Направление</th><th class="num">Пик ожидания</th><th class="num">Порог</th><th class="num">К порогу</th><th>Статус и причина</th></tr></thead>
1146
+ <tbody>${nearList.slice(0, 18).map((ep) => {
1147
+ const dStr = ep.day ? `${ep.day.slice(8, 10)}.${ep.day.slice(5, 7)}` : '';
1148
+ const tRange = ep.firstBar && ep.lastBar && ep.firstBar !== ep.lastBar ? `${ep.firstBar}–${ep.lastBar} (${ep.barsCount} св.)` : `${ep.peakBar || ep.lastBar || ''}`;
1149
+ const isSh = ep.side === 'short';
1150
+ const thrStr = `${isSh ? '−' : '+'}${fmtNum(ep.thresholdPct, 2)}%`;
1151
+ const stBadge = ep.status === 'signal'
1152
+ ? `<span class="tag ok">${isSh ? '✓ сигнал шорт' : '✓ сигнал купить'}</span>`
1153
+ : ep.status === 'blocked'
1154
+ ? '<span class="tag blocked">⛔ блок входа</span>'
1155
+ : `<span class="tag mid">почти (${ep.peakRatioPct}%)</span>`;
1156
+ const rowCls = ep.status === 'signal' ? ' class="best"' : ep.status === 'blocked' ? ' class="fc-blocked"' : '';
1157
+ return `<tr${rowCls}>
1158
+ <td class="muted small" style="white-space:nowrap">${esc(dStr)} ${esc(tRange)}</td>
1159
+ <td style="white-space:nowrap"><span class="sym-link" data-sym="${esc(ep.symbol)}" title="Открыть досье акции ${esc(ep.symbol)}"><b>${esc(ep.symbol)}</b> <span class="muted small">${esc(ep.name || '')}</span></span></td>
1160
+ <td class="${isSh ? 'neg' : 'pos'} small" style="white-space:nowrap">${isSh ? 'В шорт' : 'В покупку'}</td>
1161
+ <td class="num ${cls(ep.peakExpectedPct)}" style="white-space:nowrap"><b>${fmtPct(ep.peakExpectedPct, 2)}</b></td>
1162
+ <td class="num muted small" style="white-space:nowrap">${thrStr}</td>
1163
+ <td class="num small" style="white-space:nowrap"><b>${ep.peakRatioPct}%</b></td>
1164
+ <td class="small" style="white-space:normal">${stBadge} <span class="muted">${esc(ep.reasonText || '')}</span></td>
1165
+ </tr>`;
1166
+ }).join('')}</tbody>
1167
+ </table>
1168
+ </div>
1169
+ </details>` : '';
1122
1170
  box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6), '', 'long')}
1123
1171
  ${fcTable('Слабее рынка', shortRows, ';margin-top:14px', 'short')}</div>
1124
- <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Точный индивидуальный порог каждой акции рассчитывается в колонке <b>«Порог»</b> с учётом её ликвидности (оборота торгов), волатильности свечи и тренда индекса. <span style="color:var(--blocked-fg)">Розовым</span> отмечены строки, по которым вход запрещён (запрет шорта у брокера, отсечка дивидендов, фильтр новостей или экстремальная свеча ±4,5%).</p>`;
1172
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Точный индивидуальный порог каждой акции рассчитывается в колонке <b>«Порог»</b> с учётом её ликвидности (оборота торгов), волатильности свечи и тренда индекса. <span style="color:var(--blocked-fg)">Розовым</span> выделяются только те бумаги, где ожидание <b>уже пробило порог</b>, но сделка остановлена защитным фильтром (если ожидание ещё ниже порога, фильтр указывается справочно рядом с «ниже порога»).</p>${nearHtml}`;
1125
1173
  if (state.brokers?.accounts?.length) renderBrokers();
1126
1174
  } catch (e) {
1127
1175
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -2527,8 +2575,10 @@ function renderBrokers() {
2527
2575
  const baseMap = a.baselineQty || {};
2528
2576
  const curSubTab = brokerTabById.get(a.id) || 'assets';
2529
2577
 
2578
+ const isSweepLqdt = (p) => Boolean(a.cashSweep && a.broker === 'tbank' && p.symbol === 'LQDT');
2579
+
2530
2580
  const algoQtyOf = (p) => {
2531
- if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
2581
+ if (!isSweepLqdt(p) && ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol)))) return 0;
2532
2582
  if (lMode === 'takeover') return p.qty;
2533
2583
  const bq = Number(baseMap[p.symbol] || 0);
2534
2584
  if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
@@ -2542,6 +2592,16 @@ function renderBrokers() {
2542
2592
  if (isNonTradablePos(p)) {
2543
2593
  return '<span class="tag" title="Бумага снята с торгов, проходит редомициляцию или заблокирована (рыночная оценка 0 ₽). Стратегия её не затрагивает">не торгуется (0 ₽)</span>';
2544
2594
  }
2595
+ if (isSweepLqdt(p)) {
2596
+ const aq = algoQtyOf(p);
2597
+ const bq = Number(baseMap[p.symbol] || 0);
2598
+ if (aq > 0 && bq > 0 && lMode !== 'takeover') {
2599
+ return `<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически продаётся под новые сигналы">старый: ${fmtNum(bq, 0)} шт. · резерв стратегии: ${fmtNum(aq, 0)} шт.</span>`;
2600
+ }
2601
+ if (aq > 0) {
2602
+ return '<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически высвобождается под новые сигналы покупки и шорта">резерв стратегии (кэш в LQDT)</span>';
2603
+ }
2604
+ }
2545
2605
  if (p.instrumentType && p.instrumentType !== 'share') {
2546
2606
  const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
2547
2607
  return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
@@ -783,8 +783,8 @@ export function createRouter() {
783
783
  tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
784
784
  edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
785
785
  sigmaPct: fcRow?.sigmaPct ?? null,
786
- buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.413) * tierMult, 0.30), 3),
787
- shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.516) * tierMult, 0.31), 3),
786
+ buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.40) * tierMult, 0.30), 3),
787
+ shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.50) * tierMult, 0.31), 3),
788
788
  shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
789
789
  specs: {
790
790
  lotSize: sp.lotSize,
@@ -79,13 +79,29 @@ export const BROKER_META = [
79
79
 
80
80
  // ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
81
81
  const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
82
+
83
+ function withWallTimeout(promise, ms, label = 'Превышено время ожидания') {
84
+ let timer = null;
85
+ return Promise.race([
86
+ promise,
87
+ new Promise((_, reject) => {
88
+ timer = setTimeout(() => reject(new Error(label)), ms);
89
+ timer.unref?.();
90
+ }),
91
+ ]).finally(() => {
92
+ if (timer) clearTimeout(timer);
93
+ });
94
+ }
95
+
82
96
  export async function ensureBoardSpecs() {
83
97
  const cached = kvGet('moexSpecs');
84
98
  if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
85
99
  try {
86
- const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
87
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
88
- const j = await r.json();
100
+ const j = await withWallTimeout((async () => {
101
+ const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
102
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
103
+ return r.json();
104
+ })(), 26000, 'Таймаут загрузки справочника TQBR');
89
105
  const sc = j.securities.columns, mc = j.marketdata.columns;
90
106
  const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
91
107
  const prices = new Map();
@@ -581,14 +597,16 @@ export function deleteBrokerAccount(id) {
581
597
 
582
598
  // ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
583
599
  // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
584
- const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
600
+ const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, keepAliveInitialDelay: 10000, maxSockets: 6, timeout: 20000, ca: TRUSTED_CA });
585
601
 
586
602
  function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
587
603
  return new Promise((resolve, reject) => {
588
604
  let settled = false;
605
+ let wallTimer = null;
589
606
  const done = (fn, val) => {
590
607
  if (settled) return;
591
608
  settled = true;
609
+ if (wallTimer) clearTimeout(wallTimer);
592
610
  fn(val);
593
611
  };
594
612
  const u = new URL(url);
@@ -623,6 +641,12 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
623
641
  res.on('error', (err) => done(reject, err));
624
642
  res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
625
643
  });
644
+ wallTimer = setTimeout(() => {
645
+ const err = new Error('Превышено время ожидания ответа от брокера');
646
+ req.destroy(err);
647
+ done(reject, err);
648
+ }, Math.max(1500, timeoutMs));
649
+ wallTimer.unref?.();
626
650
  req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
627
651
  req.on('error', (err) => done(reject, err));
628
652
  req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
@@ -759,17 +783,18 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
759
783
  // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
760
784
  const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
761
785
  if (safeFrom >= tillTs) return null;
762
- const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`, {
763
- method: 'POST',
764
- headers,
765
- body: JSON.stringify({
766
- instrumentId: uid,
767
- from: new Date(safeFrom).toISOString(),
768
- to: new Date(tillTs).toISOString(),
769
- interval: 'CANDLE_INTERVAL_10_MIN',
770
- }),
771
- timeoutMs: 5000,
786
+ const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
787
+ const body = JSON.stringify({
788
+ instrumentId: uid,
789
+ from: new Date(safeFrom).toISOString(),
790
+ to: new Date(tillTs).toISOString(),
791
+ interval: 'CANDLE_INTERVAL_10_MIN',
772
792
  });
793
+ let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
794
+ if (r.status === 429 || r.status >= 500) {
795
+ await new Promise((res) => setTimeout(res, 350 + Math.floor(Math.random() * 400)));
796
+ r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
797
+ }
773
798
  if (!r.ok) return null;
774
799
  const j = await r.json();
775
800
  const out = [];
@@ -1565,11 +1590,36 @@ function sessionEdgeWarning(now = Date.now()) {
1565
1590
  }
1566
1591
 
1567
1592
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1568
- const accountBusyLocks = new Set();
1593
+ const ACCOUNT_LOCK_TTL_MS = 75000;
1594
+ const accountBusyLocks = new Map(); // id -> { since, token }
1569
1595
 
1570
- export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1571
- if (accountBusyLocks.has(id)) return brokersState();
1572
- accountBusyLocks.add(id);
1596
+ function isAccountBusy(id) {
1597
+ const cur = accountBusyLocks.get(id);
1598
+ if (!cur) return false;
1599
+ if (Date.now() - cur.since >= ACCOUNT_LOCK_TTL_MS) {
1600
+ accountBusyLocks.delete(id);
1601
+ return false;
1602
+ }
1603
+ return true;
1604
+ }
1605
+
1606
+ function acquireAccountLock(id) {
1607
+ if (isAccountBusy(id)) return null;
1608
+ const token = Symbol(id);
1609
+ accountBusyLocks.set(id, { since: Date.now(), token });
1610
+ return token;
1611
+ }
1612
+
1613
+ function releaseAccountLock(id, token) {
1614
+ const cur = accountBusyLocks.get(id);
1615
+ if (cur && (!token || cur.token === token)) {
1616
+ accountBusyLocks.delete(id);
1617
+ }
1618
+ }
1619
+
1620
+ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBarTs = 0 } = {}) {
1621
+ const lockToken = acquireAccountLock(id);
1622
+ if (!lockToken) return brokersState();
1573
1623
  try {
1574
1624
  let list = loadRawAccounts();
1575
1625
  let acc = list.find((x) => x.id === id);
@@ -1593,11 +1643,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1593
1643
  acc = list.find((x) => x.id === id) || acc;
1594
1644
  }
1595
1645
  const params = normalizeParams(getSettings());
1596
- const [fc, rawSpecs, shortableMap] = await Promise.all([
1646
+ const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
1597
1647
  forecastNow(params),
1598
1648
  ensureBoardSpecs(),
1599
1649
  ensureTbankShortableMap(acc),
1600
- ]);
1650
+ ]), 55000, 'Превышено время ожидания расчёта прогноза');
1601
1651
  const specs = { ...rawSpecs };
1602
1652
  if (acc.broker === 'tbank') {
1603
1653
  const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
@@ -1774,7 +1824,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1774
1824
  if (aQty > 0) {
1775
1825
  const lots = Math.floor(aQty / sp.lotSize);
1776
1826
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1777
- const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.buyBlockedByDip);
1827
+ const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1778
1828
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1779
1829
  const dg = divGuardOf(sym, curPx);
1780
1830
  const divExit = Boolean(dg?.exitLong);
@@ -1799,7 +1849,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1799
1849
  } else if (aQty < 0) {
1800
1850
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1801
1851
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1802
- const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.shortBlockedBySqueeze);
1852
+ const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
1803
1853
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1804
1854
  const dg = divGuardOf(sym, curPx);
1805
1855
  const divExit = Boolean(dg?.exitShort);
@@ -2121,6 +2171,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2121
2171
  const batchNowTs = Date.now();
2122
2172
  if (!onlyEntries) {
2123
2173
  acc.lastFullStepWallTs = batchNowTs;
2174
+ if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
2124
2175
  }
2125
2176
  acc.pendingBatch = {
2126
2177
  id: `batch_${batchNowTs}`,
@@ -2149,7 +2200,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2149
2200
  hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
2150
2201
  return st;
2151
2202
  } finally {
2152
- accountBusyLocks.delete(id);
2203
+ releaseAccountLock(id, lockToken);
2153
2204
  }
2154
2205
  }
2155
2206
 
@@ -2465,12 +2516,13 @@ async function sendOrderToBroker(acc, ord) {
2465
2516
  }
2466
2517
 
2467
2518
  export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2519
+ let lockToken = null;
2468
2520
  if (!_lockHeld) {
2469
2521
  // Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
2470
2522
  const waitUntil = Date.now() + 15000;
2471
- while (accountBusyLocks.has(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2472
- if (accountBusyLocks.has(id)) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2473
- accountBusyLocks.add(id);
2523
+ while (isAccountBusy(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2524
+ lockToken = acquireAccountLock(id);
2525
+ if (!lockToken) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2474
2526
  }
2475
2527
  try {
2476
2528
  const list = loadRawAccounts();
@@ -2554,7 +2606,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2554
2606
  hub.emit({ type: 'brokers:update', state: st });
2555
2607
  return st;
2556
2608
  } finally {
2557
- if (!_lockHeld) accountBusyLocks.delete(id);
2609
+ if (!_lockHeld && lockToken) releaseAccountLock(id, lockToken);
2558
2610
  }
2559
2611
  }
2560
2612
 
@@ -2749,15 +2801,17 @@ registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrder
2749
2801
  // ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
2750
2802
  let lastSeenBar = null;
2751
2803
  let schedulerBusy = false;
2804
+ let schedulerBusySince = 0;
2752
2805
 
2753
2806
  async function tickBrokerSchedule() {
2754
- if (schedulerBusy) return;
2807
+ if (schedulerBusy && Date.now() - schedulerBusySince < 90000) return;
2755
2808
  if (!tradingGate().allowNew) return;
2756
2809
  const nowMsk = new Date(Date.now() + MSK);
2757
2810
  const dow = nowMsk.getUTCDay();
2758
2811
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2759
2812
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
2760
2813
  schedulerBusy = true;
2814
+ schedulerBusySince = Date.now();
2761
2815
  try {
2762
2816
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
2763
2817
  const isNewBar = Boolean(latest && latest !== lastSeenBar);
@@ -2778,24 +2832,27 @@ async function tickBrokerSchedule() {
2778
2832
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
2779
2833
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
2780
2834
  const minStepMs = stepBars * BAR_MS;
2781
- const lastFullWallTs = acc.lastFullStepWallTs || 0;
2782
- const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2835
+ const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
2836
+ const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
2837
+ const lastAnyWallTs = acc.pendingBatch?.executedAt || batchCreatedTs || 0;
2838
+ // Если отметка шага была записана без фактического обновления pendingBatch, не считаем такой шаг состоявшимся
2839
+ const lastFullWallTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? rawLastFullWallTs : batchCreatedTs;
2840
+ const lastSchedBarTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? (acc.lastScheduledBarTs || 0) : 0;
2783
2841
  const hasOpenAlgoPos = (acc.lastSync?.positions || []).some((p) => p.qty && p.symbol !== 'LQDT' && p.symbol !== 'TMON@' && p.symbol !== 'TMON' && p.symbol !== 'SBMM' && algoQtyFor(acc, p.symbol, p.qty) !== 0);
2784
2842
  const nowTs = Date.now();
2785
2843
  const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
2786
2844
  const dueFullStep = Boolean(
2787
2845
  nightExitDue || (
2788
2846
  latest &&
2789
- (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2847
+ (latest - lastSchedBarTs >= minStepMs) &&
2790
2848
  !(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
2791
2849
  ),
2792
2850
  );
2793
2851
  if (dueFullStep) {
2794
- acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
2795
- acc.lastFullStepWallTs = nowTs;
2796
- saveRawAccounts(list);
2852
+ if (isAccountBusy(acc.id)) continue;
2853
+ const stepBarTs = latest || acc.lastScheduledBarTs || nowTs;
2797
2854
  if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2798
- await prepareBrokerOrders(acc.id).catch(() => {});
2855
+ await prepareBrokerOrders(acc.id, { scheduledBarTs: stepBarTs }).catch(() => {});
2799
2856
  continue;
2800
2857
  }
2801
2858
  // Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
@@ -2909,12 +2966,12 @@ async function reconcileBrokerStops(accId) {
2909
2966
  return;
2910
2967
  }
2911
2968
  if (!acc.lastSync?.ok) return;
2912
- if (accountBusyLocks.has(accId)) return; // идёт исполнение пакета — сверимся в следующий раз
2913
- accountBusyLocks.add(accId);
2969
+ const lockToken = acquireAccountLock(accId);
2970
+ if (!lockToken) return; // идёт исполнение пакета — сверимся в следующий раз
2914
2971
  try {
2915
- await reconcileBrokerStopsLocked(list, acc);
2972
+ await withWallTimeout(reconcileBrokerStopsLocked(list, acc), 35000, 'Превышено время сверки стоп-заявок');
2916
2973
  } finally {
2917
- accountBusyLocks.delete(accId);
2974
+ releaseAccountLock(accId, lockToken);
2918
2975
  }
2919
2976
  }
2920
2977
 
@@ -3121,9 +3178,12 @@ async function checkIntradayProfitLock(accId) {
3121
3178
 
3122
3179
  // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
3123
3180
  let autoRefreshBusy = false;
3181
+ let autoRefreshBusySince = 0;
3124
3182
  async function tickBrokerAutoRefresh() {
3125
- if (autoRefreshBusy || schedulerBusy) return;
3183
+ const now = Date.now();
3184
+ if ((autoRefreshBusy && now - autoRefreshBusySince < 90000) || (schedulerBusy && now - schedulerBusySince < 45000)) return;
3126
3185
  autoRefreshBusy = true;
3186
+ autoRefreshBusySince = now;
3127
3187
  try {
3128
3188
  const curYear = new Date(Date.now() + MSK).getUTCFullYear();
3129
3189
  const users = db.prepare('SELECT * FROM users').all();
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
2
+ import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate, barMs } from '../data/moex.js';
3
3
  import { ensureVocab, ensureIndexHistory } from './vocab.js';
4
4
  import { kvGet, kvSet } from '../db.js';
5
5
 
@@ -221,13 +221,25 @@ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {
221
221
  // цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
222
222
  // Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
223
223
  export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, useMacro = false, useDividends = true, localOnly = false, onProgress = () => {}, signal }) {
224
+ const now = Date.now();
225
+ const mskDate = new Date(now + MSK);
226
+ const mskDow = mskDate.getUTCDay();
227
+ const mskMins = mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes();
228
+ const isTradingNow = !localOnly && mskDow >= 1 && mskDow <= 5 && mskMins >= 7 * 60 && mskMins <= 23 * 60 + 50 && tillTs >= now - 2 * 60000;
229
+ const bMs = barMs(interval);
230
+ const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
231
+ const isCacheFreshForSlot = (entry) => {
232
+ if (!isTradingNow || now - entry.at < 50000) return true;
233
+ const lastBar = entry.ds?.timeline?.[entry.ds.T - 1] || 0;
234
+ return lastBar >= latestClosedSlotTs;
235
+ };
224
236
  const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
225
237
  const hit = datasetCache.get(key);
226
- if (hit && Date.now() - hit.at < 4 * 60000) return hit.ds;
238
+ if (hit && now - hit.at < 4 * 60000 && isCacheFreshForSlot(hit)) return hit.ds;
227
239
  // Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
228
240
  const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
229
241
  for (const v of datasetCache.values()) {
230
- if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
242
+ if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
231
243
  return v.ds;
232
244
  }
233
245
  }
@@ -255,19 +267,30 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
255
267
  if (signal?.aborted) throw new Error('Отменено');
256
268
  const symCtrl = new AbortController();
257
269
  const effSignal = signal ? AbortSignal.any([signal, symCtrl.signal]) : symCtrl.signal;
258
- const killer = setTimeout(() => symCtrl.abort(), WITHIN);
270
+ let killer = null;
259
271
  try {
260
- // акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
261
- const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
262
- const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
263
- // объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
264
- raw[s] = compact(rows);
265
- if (divs?.length) {
266
- // DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
267
- const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
268
- divStat.applied += st.applied;
269
- divStat.skipped += st.skipped;
270
- }
272
+ await Promise.race([
273
+ (async () => {
274
+ // акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
275
+ const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
276
+ const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
277
+ // объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
278
+ raw[s] = compact(rows);
279
+ if (divs?.length) {
280
+ // DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
281
+ const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
282
+ divStat.applied += st.applied;
283
+ divStat.skipped += st.skipped;
284
+ }
285
+ })(),
286
+ new Promise((_, rej) => {
287
+ killer = setTimeout(() => {
288
+ symCtrl.abort();
289
+ rej(new Error('Превышено время ожидания загрузки'));
290
+ }, WITHIN);
291
+ killer.unref?.();
292
+ }),
293
+ ]);
271
294
  } catch (e) {
272
295
  if (signal?.aborted) throw new Error('Отменено');
273
296
  raw[s] = EMPTY();
@@ -419,18 +442,47 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
419
442
  }
420
443
  // макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
421
444
  const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
422
- const days = new Set(timeline.map(dayKey)).size;
423
- // ликвидность: в скольких из последних 20 свечей были сделки
445
+ const modernTimeline = timeline.filter((t) => t >= 1705104000000);
446
+ const effTl = modernTimeline.length >= 1000 ? modernTimeline : timeline;
447
+ const days = new Set(effTl.map(dayKey)).size;
448
+ // ликвидность: в скольких из последних 20 свечей ОСНОВНОЙ сессии (10:00–18:50 МСК) были сделки.
449
+ // 1) Утренняя (до 10:00 МСК) и вечерняя (с 19:00 МСК) дополнительные сессии не списывают очки ликвидности за пустые 10-минутки
450
+ // (если на доп. сессии сделки есть — засчитываем 1 в окно, если нет — окно основной сессии не сдвигается и не штрафуется).
451
+ // 2) На самых свежих 4 свечах правого края шкалы (последние 40 минут), где возможен рассинхрон между мгновенными свечами T-Invest API
452
+ // и 20–30-минутной задержкой бесплатного MOEX ISS (или локального кэша SQLite), не штрафуем бумаги, у которых свеча ещё не подтянулась с биржи.
453
+ const isMainSessionBar = new Uint8Array(T);
454
+ for (let i = 0; i < T; i++) {
455
+ const h = hourMsk[i];
456
+ isMainSessionBar[i] = (h >= 10 && h < 19) ? 1 : 0;
457
+ }
458
+ const tailGraceStart = Math.max(0, T - 4);
424
459
  for (const tk of tokens) {
425
460
  tk.liq = new Uint8Array(T);
426
- let n = 0;
461
+ let lastRealIdx = -1;
462
+ for (let i = T - 1; i >= 0; i--) {
463
+ if (tk.has[i]) { lastRealIdx = i; break; }
464
+ }
465
+ // Если бумага торговалась сегодня/вчера и отстаёт от правого края шкалы не более чем на 12 свечей (2 часа рассинхрона кэша/MOEX ISS),
466
+ // считаем хвостовые свечи после её последнего полученного бара ожидающими догрузки, а не отсутствием ликвидности:
467
+ const tailPendingFrom = (lastRealIdx >= 0 && T - 1 - lastRealIdx <= 12) ? Math.max(lastRealIdx + 1, tailGraceStart) : T;
468
+ const win = new Uint8Array(20);
469
+ let wLen = 0, wPtr = 0, n = 0;
427
470
  for (let i = 0; i < T; i++) {
428
- n += tk.has[i];
429
- if (i >= 20) n -= tk.has[i - 20];
471
+ const h = tk.has[i] || (i >= tailPendingFrom ? 1 : 0);
472
+ if (isMainSessionBar[i] || h) {
473
+ if (wLen < 20) {
474
+ win[wLen++] = h;
475
+ n += h;
476
+ } else {
477
+ n += h - win[wPtr];
478
+ win[wPtr] = h;
479
+ wPtr = (wPtr + 1) % 20;
480
+ }
481
+ }
430
482
  tk.liq[i] = n;
431
483
  }
432
484
  }
433
- const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(T / days) : 54 };
485
+ const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(effTl.length / days) : 54 };
434
486
  datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
435
487
  if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
436
488
  return ds;
@@ -16,6 +16,8 @@ import { hub } from '../hub.js';
16
16
  const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
17
17
  let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
18
18
  const inflight = new Map();
19
+ const inflightStartedAt = new Map();
20
+ let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
19
21
 
20
22
  // Синхронная живая доходность индекса относительно закрытия свечи idx-1:
21
23
  // если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
@@ -161,42 +163,53 @@ export const isModelTrainedToday = (p, now = Date.now()) => {
161
163
  // в первую очередь берёт секундные цены реального времени из T-Invest API (GetLastPrices, 0 секунд задержки!),
162
164
  // а недостающие тикеры и индекс IMOEX дополняет из MOEX ISS
163
165
  async function fetchLiveMoexSnapshot() {
166
+ let timer = null;
164
167
  try {
165
- const [tbPrices, rShares, rIdx] = await Promise.all([
166
- fetchTbankRealtimePrices().catch(() => null),
167
- fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }).catch(() => null),
168
- fetch('https://iss.moex.com/iss/engines/stock/markets/index/boards/SNDX/securities/IMOEX.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,CURRENTVALUE,LASTVALUE', { signal: AbortSignal.timeout(6000) }).catch(() => null),
168
+ return await Promise.race([
169
+ (async () => {
170
+ const [tbPrices, rShares, rIdx] = await Promise.all([
171
+ fetchTbankRealtimePrices().catch(() => null),
172
+ fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }).catch(() => null),
173
+ fetch('https://iss.moex.com/iss/engines/stock/markets/index/boards/SNDX/securities/IMOEX.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,CURRENTVALUE,LASTVALUE', { signal: AbortSignal.timeout(6000) }).catch(() => null),
174
+ ]);
175
+ const prices = new Map();
176
+ if (rShares?.ok) {
177
+ const j = await rShares.json().catch(() => null);
178
+ const cols = j?.marketdata?.columns || [];
179
+ const iSec = cols.indexOf('SECID');
180
+ const iLast = cols.indexOf('LAST');
181
+ const iMkt = cols.indexOf('MARKETPRICE');
182
+ const iCur = cols.indexOf('LCURRENTPRICE');
183
+ for (const row of j?.marketdata?.data || []) {
184
+ const sym = row[iSec];
185
+ const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
186
+ if (sym && px > 0) prices.set(sym, px);
187
+ }
188
+ }
189
+ if (tbPrices?.size) {
190
+ for (const [sym, px] of tbPrices) {
191
+ if (px > 0) prices.set(sym, px);
192
+ }
193
+ }
194
+ if (!prices.size) return null;
195
+ let imoexPrice = 0;
196
+ if (rIdx?.ok) {
197
+ const ji = await rIdx.json().catch(() => null);
198
+ const ic = ji?.marketdata?.columns || [];
199
+ const ir = ji?.marketdata?.data?.[0];
200
+ if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
201
+ }
202
+ return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
203
+ })(),
204
+ new Promise((resolve) => {
205
+ timer = setTimeout(() => resolve(null), 7000);
206
+ timer.unref?.();
207
+ }),
169
208
  ]);
170
- const prices = new Map();
171
- if (rShares?.ok) {
172
- const j = await rShares.json().catch(() => null);
173
- const cols = j?.marketdata?.columns || [];
174
- const iSec = cols.indexOf('SECID');
175
- const iLast = cols.indexOf('LAST');
176
- const iMkt = cols.indexOf('MARKETPRICE');
177
- const iCur = cols.indexOf('LCURRENTPRICE');
178
- for (const row of j?.marketdata?.data || []) {
179
- const sym = row[iSec];
180
- const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
181
- if (sym && px > 0) prices.set(sym, px);
182
- }
183
- }
184
- if (tbPrices?.size) {
185
- for (const [sym, px] of tbPrices) {
186
- if (px > 0) prices.set(sym, px);
187
- }
188
- }
189
- if (!prices.size) return null;
190
- let imoexPrice = 0;
191
- if (rIdx?.ok) {
192
- const ji = await rIdx.json().catch(() => null);
193
- const ic = ji?.marketdata?.columns || [];
194
- const ir = ji?.marketdata?.data?.[0];
195
- if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
196
- }
197
- return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
198
209
  } catch {
199
210
  return null;
211
+ } finally {
212
+ if (timer) clearTimeout(timer);
200
213
  }
201
214
  }
202
215
 
@@ -415,6 +428,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
415
428
  weight: round(weight, 3),
416
429
  signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
417
430
  ...(shortAllowed !== null ? { shortAllowed } : {}),
431
+ ...(isRegimeCrash ? { regimeCrash: true } : {}),
432
+ ...(isRegimeSqueeze ? { regimeSqueeze: true } : {}),
418
433
  ...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
419
434
  ...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
420
435
  ...(inIdx ? {} : { inIndex: false }),
@@ -439,8 +454,8 @@ function withNews(result, p) {
439
454
  const nb = p.newsFilter ? blockingNews(6) : null;
440
455
  const shortableMap = kvGet('tbankShortableMap')?.map || null;
441
456
  const nowTs = Date.now();
442
- const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.413;
443
- const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.516;
457
+ const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
458
+ const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
444
459
  if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
445
460
  if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
446
461
  for (const r of result.rows) {
@@ -461,13 +476,13 @@ function withNews(result, p) {
461
476
  } else if (r.expectedPct >= 0) {
462
477
  delete r.shortBlockedByBroker;
463
478
  }
464
- if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
479
+ if (r.expectedPct < 0 && (r.shortBlockedBySqueeze || r.regimeSqueeze || (r.lastMovePct ?? 0) > 4.5)) {
465
480
  r.shortBlockedBySqueeze = true;
466
481
  r.shortSignal = false;
467
482
  } else {
468
483
  delete r.shortBlockedBySqueeze;
469
484
  }
470
- if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
485
+ if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
471
486
  r.buyBlockedByDip = true;
472
487
  r.signal = false;
473
488
  } else {
@@ -501,10 +516,156 @@ function withNews(result, p) {
501
516
  } else {
502
517
  delete r.shortBlockedByNews;
503
518
  }
519
+
520
+ const effThr = r.expectedPct < 0 ? (r.shortThresholdPct || baseShortThr) : (r.buyThresholdPct || baseBuyThr);
521
+ const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
522
+ r.thresholdRatioPct = Math.round(ratio * 100);
523
+ r.crossedThreshold = ratio >= 1;
524
+ }
525
+ if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
526
+ const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
527
+ result.nearSignals = savedNear;
504
528
  }
505
529
  return result;
506
530
  }
507
531
 
532
+ // Формирует и инкрементально обновляет 5-дневный журнал сигналов, заблокированных входов (≥100% порога) и почти-сигналов (75–99% порога)
533
+ async function updateNearSignalsJournal(ds, p, last, key) {
534
+ if (!ds?.timeline?.length || last < 0) return [];
535
+ const lastBarTs = ds.timeline[last] || Date.now();
536
+ const minTs = lastBarTs - 7 * DAY;
537
+ let startIdx = ds.timeline.findIndex((t) => t >= minTs);
538
+ if (startIdx < 0) startIdx = Math.max(0, last - 480);
539
+
540
+ if (nearSignalsCache.key !== key || nearSignalsCache.scannedBarTs < ds.timeline[startIdx]) {
541
+ const saved = kvGet('nearSignalsLog');
542
+ if (saved?.key === key && saved.scannedBarTs >= ds.timeline[startIdx] && Array.isArray(saved.entries)) {
543
+ nearSignalsCache = {
544
+ key,
545
+ scannedBarTs: saved.scannedBarTs,
546
+ episodesMap: new Map(saved.entries),
547
+ list: saved.list || [],
548
+ };
549
+ } else {
550
+ nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
551
+ }
552
+ }
553
+
554
+ for (const [epKey, ep] of nearSignalsCache.episodesMap) {
555
+ if (ep.lastBarTs < minTs) nearSignalsCache.episodesMap.delete(epKey);
556
+ }
557
+
558
+ let updated = false;
559
+ for (let idx = startIdx; idx <= last; idx++) {
560
+ const barTs = ds.timeline[idx];
561
+ if (barTs <= nearSignalsCache.scannedBarTs) continue;
562
+ const r = await predictAt(ds, p, idx, { explain: false, liveSnap: null });
563
+ const nb = p.newsFilter ? blockingNews(6, barTs) : null;
564
+ const barMskIso = new Date(barTs + MSK).toISOString();
565
+ const dayStr = barMskIso.slice(0, 10);
566
+ const timeStr = barMskIso.slice(11, 16);
567
+
568
+ for (const row of r.rows) {
569
+ if (row.inIndex === false) continue;
570
+ const side = row.expectedPct < 0 ? 'short' : 'long';
571
+ const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
572
+ const absExp = Math.abs(row.expectedPct || 0);
573
+ const ratio = thr > 0 ? absExp / thr : 0;
574
+ if (ratio < 0.75) continue;
575
+
576
+ const crossed = absExp >= thr;
577
+ const dg = p.modelUseDividends !== false ? liveDividendGuard(row.symbol, row.price, barTs) : null;
578
+ const bNews = nb?.get(row.symbol);
579
+ const reasons = [];
580
+ if (side === 'short') {
581
+ if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
582
+ if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
583
+ if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
584
+ if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
585
+ if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
586
+ } else {
587
+ if (row.buyBlockedByDip || row.regimeCrash || (row.lastMovePct ?? 0) < -4.5) reasons.push('защита от обвала свечи');
588
+ if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
589
+ if (dg?.blockLong) reasons.push(`отсечка дивидендов ${dg.exDate}`);
590
+ if (bNews?.bad) reasons.push(`фильтр новостей (${bNews.bad.gist})`);
591
+ }
592
+ if (row.liquid === false) reasons.push('низкая ликвидность');
593
+
594
+ const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
595
+ const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
596
+ const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
597
+
598
+ const epKey = `${dayStr}:${row.symbol}:${side}`;
599
+ const prev = nearSignalsCache.episodesMap.get(epKey);
600
+ if (!prev) {
601
+ nearSignalsCache.episodesMap.set(epKey, {
602
+ day: dayStr,
603
+ firstBar: timeStr,
604
+ lastBar: timeStr,
605
+ firstBarTs: barTs,
606
+ lastBarTs: barTs,
607
+ peakBar: timeStr,
608
+ barsCount: 1,
609
+ symbol: row.symbol,
610
+ name: row.name,
611
+ side,
612
+ price: row.price,
613
+ peakExpectedPct: row.expectedPct,
614
+ thresholdPct: thr,
615
+ peakRatioPct: Math.round(ratio * 100),
616
+ status,
617
+ reasons,
618
+ });
619
+ } else {
620
+ prev.lastBar = timeStr;
621
+ prev.lastBarTs = barTs;
622
+ prev.barsCount += 1;
623
+ const pctNow = Math.round(ratio * 100);
624
+ if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow >= prev.peakRatioPct)) {
625
+ prev.peakExpectedPct = row.expectedPct;
626
+ prev.thresholdPct = thr;
627
+ prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
628
+ prev.peakBar = timeStr;
629
+ prev.price = row.price;
630
+ prev.status = status;
631
+ if (reasons.length) prev.reasons = reasons;
632
+ } else if (pctNow > prev.peakRatioPct) {
633
+ prev.peakExpectedPct = row.expectedPct;
634
+ prev.thresholdPct = thr;
635
+ prev.peakRatioPct = pctNow;
636
+ prev.peakBar = timeStr;
637
+ }
638
+ if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
639
+ }
640
+ }
641
+ nearSignalsCache.scannedBarTs = barTs;
642
+ updated = true;
643
+ }
644
+
645
+ if (updated || !nearSignalsCache.list.length) {
646
+ const list = [...nearSignalsCache.episodesMap.values()]
647
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
648
+ .slice(0, 40)
649
+ .map((ep) => {
650
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
651
+ const reasonText = ep.status === 'signal'
652
+ ? 'Порог пройден — сигнал сформирован'
653
+ : ep.status === 'blocked'
654
+ ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
655
+ : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
656
+ return { ...ep, gapPct, reasonText };
657
+ });
658
+ nearSignalsCache.list = list;
659
+ kvSet('nearSignalsLog', {
660
+ key,
661
+ scannedBarTs: nearSignalsCache.scannedBarTs,
662
+ entries: [...nearSignalsCache.episodesMap.entries()],
663
+ list,
664
+ });
665
+ }
666
+ return nearSignalsCache.list;
667
+ }
668
+
508
669
  // Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
509
670
  function attachDeltas(rows, stateObj) {
510
671
  const prevExp = stateObj.prevExpMap;
@@ -590,7 +751,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
590
751
  // ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
591
752
  // а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
592
753
  if (!force && baseState && baseState.key === key) {
593
- if (now - baseState.dsLoadedAt >= 4 * 60000 && !inflight.has(key)) {
754
+ const inflightAge = now - (inflightStartedAt.get(key) || 0);
755
+ if (inflight.has(key) && inflightAge > 90000) {
756
+ inflight.delete(key);
757
+ inflightStartedAt.delete(key);
758
+ }
759
+ const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
760
+ const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
761
+ const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
762
+ if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
594
763
  setImmediate(() => {
595
764
  refreshDatasetStateInBackground(p, key).catch(() => {});
596
765
  });
@@ -615,8 +784,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
615
784
  }
616
785
  const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
617
786
  const liveTs = liveSnap?.ts || now;
618
- const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.413;
619
- const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.516;
787
+ const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.40;
788
+ const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.50;
620
789
  const rows = (saved.result.rows || []).map((r) => {
621
790
  const px = liveSnap?.prices?.get(r.symbol);
622
791
  const mult = symbolEdgeMult(r.symbol);
@@ -646,11 +815,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
646
815
 
647
816
  // Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
648
817
  function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
649
- if (!force && inflight.has(key)) return inflight.get(key);
818
+ const now = Date.now();
819
+ if (!force && inflight.has(key)) {
820
+ if (now - (inflightStartedAt.get(key) || 0) < 90000) return inflight.get(key);
821
+ inflight.delete(key);
822
+ inflightStartedAt.delete(key);
823
+ }
650
824
  const autoAdapt = p.modelAutoAdapt !== false;
651
825
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
652
826
  const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
653
- const now = Date.now();
654
827
  const task = (async () => {
655
828
  hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
656
829
  // Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
@@ -666,6 +839,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
666
839
  ]);
667
840
  const last = lastClosedIndex(ds, now);
668
841
  const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
842
+ const nearSignals = await updateNearSignalsJournal(ds, p, last, key).catch(() => []);
669
843
  const snap = r.snap || mapSnapshot(r.f);
670
844
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
671
845
  const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
@@ -686,6 +860,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
686
860
  shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
687
861
  marketDown: r.marketDown,
688
862
  rows: sortedRows,
863
+ nearSignals,
689
864
  map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
690
865
  heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
691
866
  };
@@ -708,8 +883,21 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
708
883
  };
709
884
  kvSet('lastForecastSnap', { key, result });
710
885
  hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
886
+ if (coldLocalFirst) {
887
+ setTimeout(() => {
888
+ if (!inflight.has(key)) {
889
+ refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
890
+ }
891
+ }, 2000);
892
+ }
711
893
  return result;
712
- })().finally(() => inflight.delete(key));
894
+ })().finally(() => {
895
+ if (inflight.get(key) === task) {
896
+ inflight.delete(key);
897
+ inflightStartedAt.delete(key);
898
+ }
899
+ });
713
900
  inflight.set(key, task);
901
+ inflightStartedAt.set(key, now);
714
902
  return task;
715
903
  }
@@ -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, isRegimeShocked } 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));
@@ -42,7 +42,8 @@ export function isMarketDown(ds, t) {
42
42
  // 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
43
43
  // 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
44
44
  // 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
45
- const DATA_VERSION = 13;
45
+ // 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
46
+ const DATA_VERSION = 14;
46
47
 
47
48
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
48
49
  function reuseParamsKey(params) {
@@ -160,13 +161,14 @@ async function run(runId, p, state) {
160
161
  // НИКОГДА не сдвигала fromTs и не вызывала повторную загрузку котировок!
161
162
  const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
162
163
  const rawResearchFrom = todayStartMsk - p.modelHistoryDays * DAY;
163
- // Стабильная привязка сетки 20-дневных окон переобучения к эпохе (1706165400000 = 25.01.2024):
164
- // начало первого фолда выравнивается по 20-дневной сетке, чтобы смена календарного дня запуска НЕ перекраивала всю прошлую 3-летнюю историю фолдов,
165
- // а новые торговые дни честно дописывались в правый край последнего фолда!
166
- 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;
167
169
  const gridBlockMs = 20 * DAY;
168
- const researchFrom = EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs;
169
- 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;
170
172
 
171
173
  emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
172
174
  const ds = await loadDataset({
@@ -179,7 +181,7 @@ async function run(runId, p, state) {
179
181
  const bpd = ds.barsPerDay;
180
182
  const trainLen = Math.round(p.modelTrainDays * bpd);
181
183
  const testLen = Math.round(20 * bpd);
182
- 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);
183
185
  if (testStart < 0 || testStart >= T - H - 10) throw new Error('Недостаточно истории: увеличьте период исследования или уменьшите окно обучения');
184
186
  const firstTest = testStart;
185
187
 
@@ -999,8 +1001,7 @@ async function runStabilityLoop(targetId, runRow, st) {
999
1001
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
1000
1002
  const H = barsFromHours(effHorizon);
1001
1003
  const tillTs = Date.now();
1002
- const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
1003
- const fromTs = todayStartMsk - 1650 * DAY;
1004
+ const fromTs = 1705560600000;
1004
1005
  const periodTill = runRow.metrics?.period?.till || tillTs;
1005
1006
  const emitStab = (m) => { if (!st.cancelled) hub.emit({ type: 'stability:progress', runId: targetId, ...m }); };
1006
1007
  emitStab({ pct: 2, text: 'Проверка устойчивости: чтение локальных цен…' });
@@ -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,6 +279,8 @@ 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,
@@ -310,6 +312,7 @@ export const TOOLS = {
310
312
  ...(targetRow ? { выбранная_акция: pick(targetRow) } : {}),
311
313
  сильнее_рынка: r.rows.slice(0, top).map(pick),
312
314
  слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
315
+ журнал_почти_сигналов_за_неделю: (r.nearSignals || []).slice(0, 12),
313
316
  },
314
317
  summary: `Прогноз на свече ${r.bar}: сигналов покупки ${r.rows.filter((x) => x.signal).length}, шорта ${r.rows.filter((x) => x.shortSignal).length}`,
315
318
  };