neuroalpha 0.5.24 → 0.5.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.24",
3
+ "version": "0.5.27",
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>`;
@@ -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,
@@ -581,14 +581,16 @@ export function deleteBrokerAccount(id) {
581
581
 
582
582
  // ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
583
583
  // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
584
- const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
584
+ const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, keepAliveInitialDelay: 10000, maxSockets: 6, timeout: 20000, ca: TRUSTED_CA });
585
585
 
586
586
  function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
587
587
  return new Promise((resolve, reject) => {
588
588
  let settled = false;
589
+ let wallTimer = null;
589
590
  const done = (fn, val) => {
590
591
  if (settled) return;
591
592
  settled = true;
593
+ if (wallTimer) clearTimeout(wallTimer);
592
594
  fn(val);
593
595
  };
594
596
  const u = new URL(url);
@@ -623,6 +625,12 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
623
625
  res.on('error', (err) => done(reject, err));
624
626
  res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
625
627
  });
628
+ wallTimer = setTimeout(() => {
629
+ const err = new Error('Превышено время ожидания ответа от брокера');
630
+ req.destroy(err);
631
+ done(reject, err);
632
+ }, Math.max(1500, timeoutMs));
633
+ wallTimer.unref?.();
626
634
  req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
627
635
  req.on('error', (err) => done(reject, err));
628
636
  req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
@@ -759,17 +767,18 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
759
767
  // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
760
768
  const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
761
769
  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,
770
+ const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
771
+ const body = JSON.stringify({
772
+ instrumentId: uid,
773
+ from: new Date(safeFrom).toISOString(),
774
+ to: new Date(tillTs).toISOString(),
775
+ interval: 'CANDLE_INTERVAL_10_MIN',
772
776
  });
777
+ let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
778
+ if (r.status === 429 || r.status >= 500) {
779
+ await new Promise((res) => setTimeout(res, 350 + Math.floor(Math.random() * 400)));
780
+ r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
781
+ }
773
782
  if (!r.ok) return null;
774
783
  const j = await r.json();
775
784
  const out = [];
@@ -1774,7 +1783,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1774
1783
  if (aQty > 0) {
1775
1784
  const lots = Math.floor(aQty / sp.lotSize);
1776
1785
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1777
- const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.buyBlockedByDip);
1786
+ const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1778
1787
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1779
1788
  const dg = divGuardOf(sym, curPx);
1780
1789
  const divExit = Boolean(dg?.exitLong);
@@ -1799,7 +1808,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1799
1808
  } else if (aQty < 0) {
1800
1809
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1801
1810
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1802
- const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.shortBlockedBySqueeze);
1811
+ const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
1803
1812
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1804
1813
  const dg = divGuardOf(sym, curPx);
1805
1814
  const divExit = Boolean(dg?.exitShort);
@@ -2749,15 +2758,17 @@ registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrder
2749
2758
  // ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
2750
2759
  let lastSeenBar = null;
2751
2760
  let schedulerBusy = false;
2761
+ let schedulerBusySince = 0;
2752
2762
 
2753
2763
  async function tickBrokerSchedule() {
2754
- if (schedulerBusy) return;
2764
+ if (schedulerBusy && Date.now() - schedulerBusySince < 90000) return;
2755
2765
  if (!tradingGate().allowNew) return;
2756
2766
  const nowMsk = new Date(Date.now() + MSK);
2757
2767
  const dow = nowMsk.getUTCDay();
2758
2768
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2759
2769
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
2760
2770
  schedulerBusy = true;
2771
+ schedulerBusySince = Date.now();
2761
2772
  try {
2762
2773
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
2763
2774
  const isNewBar = Boolean(latest && latest !== lastSeenBar);
@@ -3121,9 +3132,12 @@ async function checkIntradayProfitLock(accId) {
3121
3132
 
3122
3133
  // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
3123
3134
  let autoRefreshBusy = false;
3135
+ let autoRefreshBusySince = 0;
3124
3136
  async function tickBrokerAutoRefresh() {
3125
- if (autoRefreshBusy || schedulerBusy) return;
3137
+ const now = Date.now();
3138
+ if ((autoRefreshBusy && now - autoRefreshBusySince < 90000) || (schedulerBusy && now - schedulerBusySince < 45000)) return;
3126
3139
  autoRefreshBusy = true;
3140
+ autoRefreshBusySince = now;
3127
3141
  try {
3128
3142
  const curYear = new Date(Date.now() + MSK).getUTCFullYear();
3129
3143
  const users = db.prepare('SELECT * FROM users').all();
@@ -227,7 +227,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
227
227
  // Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
228
228
  const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
229
229
  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) {
230
+ if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
231
231
  return v.ds;
232
232
  }
233
233
  }
@@ -255,19 +255,30 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
255
255
  if (signal?.aborted) throw new Error('Отменено');
256
256
  const symCtrl = new AbortController();
257
257
  const effSignal = signal ? AbortSignal.any([signal, symCtrl.signal]) : symCtrl.signal;
258
- const killer = setTimeout(() => symCtrl.abort(), WITHIN);
258
+ let killer = null;
259
259
  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
- }
260
+ await Promise.race([
261
+ (async () => {
262
+ // акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
263
+ const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
264
+ const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
265
+ // объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
266
+ raw[s] = compact(rows);
267
+ if (divs?.length) {
268
+ // DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
269
+ const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
270
+ divStat.applied += st.applied;
271
+ divStat.skipped += st.skipped;
272
+ }
273
+ })(),
274
+ new Promise((_, rej) => {
275
+ killer = setTimeout(() => {
276
+ symCtrl.abort();
277
+ rej(new Error('Превышено время ожидания загрузки'));
278
+ }, WITHIN);
279
+ killer.unref?.();
280
+ }),
281
+ ]);
271
282
  } catch (e) {
272
283
  if (signal?.aborted) throw new Error('Отменено');
273
284
  raw[s] = EMPTY();
@@ -419,18 +430,47 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
419
430
  }
420
431
  // макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
421
432
  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 свечей были сделки
433
+ const modernTimeline = timeline.filter((t) => t >= 1705104000000);
434
+ const effTl = modernTimeline.length >= 1000 ? modernTimeline : timeline;
435
+ const days = new Set(effTl.map(dayKey)).size;
436
+ // ликвидность: в скольких из последних 20 свечей ОСНОВНОЙ сессии (10:00–18:50 МСК) были сделки.
437
+ // 1) Утренняя (до 10:00 МСК) и вечерняя (с 19:00 МСК) дополнительные сессии не списывают очки ликвидности за пустые 10-минутки
438
+ // (если на доп. сессии сделки есть — засчитываем 1 в окно, если нет — окно основной сессии не сдвигается и не штрафуется).
439
+ // 2) На самых свежих 4 свечах правого края шкалы (последние 40 минут), где возможен рассинхрон между мгновенными свечами T-Invest API
440
+ // и 20–30-минутной задержкой бесплатного MOEX ISS (или локального кэша SQLite), не штрафуем бумаги, у которых свеча ещё не подтянулась с биржи.
441
+ const isMainSessionBar = new Uint8Array(T);
442
+ for (let i = 0; i < T; i++) {
443
+ const h = hourMsk[i];
444
+ isMainSessionBar[i] = (h >= 10 && h < 19) ? 1 : 0;
445
+ }
446
+ const tailGraceStart = Math.max(0, T - 4);
424
447
  for (const tk of tokens) {
425
448
  tk.liq = new Uint8Array(T);
426
- let n = 0;
449
+ let lastRealIdx = -1;
450
+ for (let i = T - 1; i >= 0; i--) {
451
+ if (tk.has[i]) { lastRealIdx = i; break; }
452
+ }
453
+ // Если бумага торговалась сегодня/вчера и отстаёт от правого края шкалы не более чем на 12 свечей (2 часа рассинхрона кэша/MOEX ISS),
454
+ // считаем хвостовые свечи после её последнего полученного бара ожидающими догрузки, а не отсутствием ликвидности:
455
+ const tailPendingFrom = (lastRealIdx >= 0 && T - 1 - lastRealIdx <= 12) ? Math.max(lastRealIdx + 1, tailGraceStart) : T;
456
+ const win = new Uint8Array(20);
457
+ let wLen = 0, wPtr = 0, n = 0;
427
458
  for (let i = 0; i < T; i++) {
428
- n += tk.has[i];
429
- if (i >= 20) n -= tk.has[i - 20];
459
+ const h = tk.has[i] || (i >= tailPendingFrom ? 1 : 0);
460
+ if (isMainSessionBar[i] || h) {
461
+ if (wLen < 20) {
462
+ win[wLen++] = h;
463
+ n += h;
464
+ } else {
465
+ n += h - win[wPtr];
466
+ win[wPtr] = h;
467
+ wPtr = (wPtr + 1) % 20;
468
+ }
469
+ }
430
470
  tk.liq[i] = n;
431
471
  }
432
472
  }
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 };
473
+ 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
474
  datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
435
475
  if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
436
476
  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),
@@ -415,6 +417,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
415
417
  weight: round(weight, 3),
416
418
  signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
417
419
  ...(shortAllowed !== null ? { shortAllowed } : {}),
420
+ ...(isRegimeCrash ? { regimeCrash: true } : {}),
421
+ ...(isRegimeSqueeze ? { regimeSqueeze: true } : {}),
418
422
  ...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
419
423
  ...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
420
424
  ...(inIdx ? {} : { inIndex: false }),
@@ -439,8 +443,8 @@ function withNews(result, p) {
439
443
  const nb = p.newsFilter ? blockingNews(6) : null;
440
444
  const shortableMap = kvGet('tbankShortableMap')?.map || null;
441
445
  const nowTs = Date.now();
442
- const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.413;
443
- const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.516;
446
+ const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
447
+ const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
444
448
  if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
445
449
  if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
446
450
  for (const r of result.rows) {
@@ -461,13 +465,13 @@ function withNews(result, p) {
461
465
  } else if (r.expectedPct >= 0) {
462
466
  delete r.shortBlockedByBroker;
463
467
  }
464
- if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
468
+ if (r.expectedPct < 0 && (r.shortBlockedBySqueeze || r.regimeSqueeze || (r.lastMovePct ?? 0) > 4.5)) {
465
469
  r.shortBlockedBySqueeze = true;
466
470
  r.shortSignal = false;
467
471
  } else {
468
472
  delete r.shortBlockedBySqueeze;
469
473
  }
470
- if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
474
+ if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
471
475
  r.buyBlockedByDip = true;
472
476
  r.signal = false;
473
477
  } else {
@@ -501,10 +505,156 @@ function withNews(result, p) {
501
505
  } else {
502
506
  delete r.shortBlockedByNews;
503
507
  }
508
+
509
+ const effThr = r.expectedPct < 0 ? (r.shortThresholdPct || baseShortThr) : (r.buyThresholdPct || baseBuyThr);
510
+ const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
511
+ r.thresholdRatioPct = Math.round(ratio * 100);
512
+ r.crossedThreshold = ratio >= 1;
513
+ }
514
+ if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
515
+ const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
516
+ result.nearSignals = savedNear;
504
517
  }
505
518
  return result;
506
519
  }
507
520
 
521
+ // Формирует и инкрементально обновляет 5-дневный журнал сигналов, заблокированных входов (≥100% порога) и почти-сигналов (75–99% порога)
522
+ async function updateNearSignalsJournal(ds, p, last, key) {
523
+ if (!ds?.timeline?.length || last < 0) return [];
524
+ const lastBarTs = ds.timeline[last] || Date.now();
525
+ const minTs = lastBarTs - 7 * DAY;
526
+ let startIdx = ds.timeline.findIndex((t) => t >= minTs);
527
+ if (startIdx < 0) startIdx = Math.max(0, last - 480);
528
+
529
+ if (nearSignalsCache.key !== key || nearSignalsCache.scannedBarTs < ds.timeline[startIdx]) {
530
+ const saved = kvGet('nearSignalsLog');
531
+ if (saved?.key === key && saved.scannedBarTs >= ds.timeline[startIdx] && Array.isArray(saved.entries)) {
532
+ nearSignalsCache = {
533
+ key,
534
+ scannedBarTs: saved.scannedBarTs,
535
+ episodesMap: new Map(saved.entries),
536
+ list: saved.list || [],
537
+ };
538
+ } else {
539
+ nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
540
+ }
541
+ }
542
+
543
+ for (const [epKey, ep] of nearSignalsCache.episodesMap) {
544
+ if (ep.lastBarTs < minTs) nearSignalsCache.episodesMap.delete(epKey);
545
+ }
546
+
547
+ let updated = false;
548
+ for (let idx = startIdx; idx <= last; idx++) {
549
+ const barTs = ds.timeline[idx];
550
+ if (barTs <= nearSignalsCache.scannedBarTs) continue;
551
+ const r = await predictAt(ds, p, idx, { explain: false, liveSnap: null });
552
+ const nb = p.newsFilter ? blockingNews(6, barTs) : null;
553
+ const barMskIso = new Date(barTs + MSK).toISOString();
554
+ const dayStr = barMskIso.slice(0, 10);
555
+ const timeStr = barMskIso.slice(11, 16);
556
+
557
+ for (const row of r.rows) {
558
+ if (row.inIndex === false) continue;
559
+ const side = row.expectedPct < 0 ? 'short' : 'long';
560
+ const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
561
+ const absExp = Math.abs(row.expectedPct || 0);
562
+ const ratio = thr > 0 ? absExp / thr : 0;
563
+ if (ratio < 0.75) continue;
564
+
565
+ const crossed = absExp >= thr;
566
+ const dg = p.modelUseDividends !== false ? liveDividendGuard(row.symbol, row.price, barTs) : null;
567
+ const bNews = nb?.get(row.symbol);
568
+ const reasons = [];
569
+ if (side === 'short') {
570
+ if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
571
+ if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
572
+ if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
573
+ if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
574
+ if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
575
+ } else {
576
+ if (row.buyBlockedByDip || row.regimeCrash || (row.lastMovePct ?? 0) < -4.5) reasons.push('защита от обвала свечи');
577
+ if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
578
+ if (dg?.blockLong) reasons.push(`отсечка дивидендов ${dg.exDate}`);
579
+ if (bNews?.bad) reasons.push(`фильтр новостей (${bNews.bad.gist})`);
580
+ }
581
+ if (row.liquid === false) reasons.push('низкая ликвидность');
582
+
583
+ const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
584
+ const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
585
+ const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
586
+
587
+ const epKey = `${dayStr}:${row.symbol}:${side}`;
588
+ const prev = nearSignalsCache.episodesMap.get(epKey);
589
+ if (!prev) {
590
+ nearSignalsCache.episodesMap.set(epKey, {
591
+ day: dayStr,
592
+ firstBar: timeStr,
593
+ lastBar: timeStr,
594
+ firstBarTs: barTs,
595
+ lastBarTs: barTs,
596
+ peakBar: timeStr,
597
+ barsCount: 1,
598
+ symbol: row.symbol,
599
+ name: row.name,
600
+ side,
601
+ price: row.price,
602
+ peakExpectedPct: row.expectedPct,
603
+ thresholdPct: thr,
604
+ peakRatioPct: Math.round(ratio * 100),
605
+ status,
606
+ reasons,
607
+ });
608
+ } else {
609
+ prev.lastBar = timeStr;
610
+ prev.lastBarTs = barTs;
611
+ prev.barsCount += 1;
612
+ const pctNow = Math.round(ratio * 100);
613
+ if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow >= prev.peakRatioPct)) {
614
+ prev.peakExpectedPct = row.expectedPct;
615
+ prev.thresholdPct = thr;
616
+ prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
617
+ prev.peakBar = timeStr;
618
+ prev.price = row.price;
619
+ prev.status = status;
620
+ if (reasons.length) prev.reasons = reasons;
621
+ } else if (pctNow > prev.peakRatioPct) {
622
+ prev.peakExpectedPct = row.expectedPct;
623
+ prev.thresholdPct = thr;
624
+ prev.peakRatioPct = pctNow;
625
+ prev.peakBar = timeStr;
626
+ }
627
+ if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
628
+ }
629
+ }
630
+ nearSignalsCache.scannedBarTs = barTs;
631
+ updated = true;
632
+ }
633
+
634
+ if (updated || !nearSignalsCache.list.length) {
635
+ const list = [...nearSignalsCache.episodesMap.values()]
636
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
637
+ .slice(0, 40)
638
+ .map((ep) => {
639
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
640
+ const reasonText = ep.status === 'signal'
641
+ ? 'Порог пройден — сигнал сформирован'
642
+ : ep.status === 'blocked'
643
+ ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
644
+ : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
645
+ return { ...ep, gapPct, reasonText };
646
+ });
647
+ nearSignalsCache.list = list;
648
+ kvSet('nearSignalsLog', {
649
+ key,
650
+ scannedBarTs: nearSignalsCache.scannedBarTs,
651
+ entries: [...nearSignalsCache.episodesMap.entries()],
652
+ list,
653
+ });
654
+ }
655
+ return nearSignalsCache.list;
656
+ }
657
+
508
658
  // Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
509
659
  function attachDeltas(rows, stateObj) {
510
660
  const prevExp = stateObj.prevExpMap;
@@ -590,7 +740,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
590
740
  // ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
591
741
  // а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
592
742
  if (!force && baseState && baseState.key === key) {
593
- if (now - baseState.dsLoadedAt >= 4 * 60000 && !inflight.has(key)) {
743
+ const inflightAge = now - (inflightStartedAt.get(key) || 0);
744
+ if (inflight.has(key) && inflightAge > 90000) {
745
+ inflight.delete(key);
746
+ inflightStartedAt.delete(key);
747
+ }
748
+ const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
749
+ const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
750
+ const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
751
+ if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
594
752
  setImmediate(() => {
595
753
  refreshDatasetStateInBackground(p, key).catch(() => {});
596
754
  });
@@ -615,8 +773,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
615
773
  }
616
774
  const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
617
775
  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;
776
+ const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.40;
777
+ const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.50;
620
778
  const rows = (saved.result.rows || []).map((r) => {
621
779
  const px = liveSnap?.prices?.get(r.symbol);
622
780
  const mult = symbolEdgeMult(r.symbol);
@@ -646,11 +804,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
646
804
 
647
805
  // Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
648
806
  function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
649
- if (!force && inflight.has(key)) return inflight.get(key);
807
+ const now = Date.now();
808
+ if (!force && inflight.has(key)) {
809
+ if (now - (inflightStartedAt.get(key) || 0) < 90000) return inflight.get(key);
810
+ inflight.delete(key);
811
+ inflightStartedAt.delete(key);
812
+ }
650
813
  const autoAdapt = p.modelAutoAdapt !== false;
651
814
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
652
815
  const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
653
- const now = Date.now();
654
816
  const task = (async () => {
655
817
  hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
656
818
  // Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
@@ -666,6 +828,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
666
828
  ]);
667
829
  const last = lastClosedIndex(ds, now);
668
830
  const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
831
+ const nearSignals = await updateNearSignalsJournal(ds, p, last, key).catch(() => []);
669
832
  const snap = r.snap || mapSnapshot(r.f);
670
833
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
671
834
  const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
@@ -686,6 +849,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
686
849
  shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
687
850
  marketDown: r.marketDown,
688
851
  rows: sortedRows,
852
+ nearSignals,
689
853
  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
854
  heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
691
855
  };
@@ -708,8 +872,21 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
708
872
  };
709
873
  kvSet('lastForecastSnap', { key, result });
710
874
  hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
875
+ if (coldLocalFirst) {
876
+ setTimeout(() => {
877
+ if (!inflight.has(key)) {
878
+ refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
879
+ }
880
+ }, 2000);
881
+ }
711
882
  return result;
712
- })().finally(() => inflight.delete(key));
883
+ })().finally(() => {
884
+ if (inflight.get(key) === task) {
885
+ inflight.delete(key);
886
+ inflightStartedAt.delete(key);
887
+ }
888
+ });
713
889
  inflight.set(key, task);
890
+ inflightStartedAt.set(key, now);
714
891
  return task;
715
892
  }
@@ -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
  };