neuroalpha 0.5.21 → 0.5.22

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.21",
3
+ "version": "0.5.22",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1060,6 +1060,10 @@ async function loadForecast(force, silent = false) {
1060
1060
  reasons.push(`Аварийная защита от обвала свечи: бумага падает на текущей свече (${fmtNum(r.lastMovePct, 2)}% < −4,5%), покупка запрещена`);
1061
1061
  label = '⛔ провал';
1062
1062
  }
1063
+ if (r.buyBlockedByOverheat) {
1064
+ reasons.push(`Защита от покупки на вершине пампа: бумага уже перегрета (+${fmtNum(r.buyBlockedByOverheat, 1)}% сверх рынка) — высок риск отката`);
1065
+ if (!label) label = '⛔ перегрев';
1066
+ }
1063
1067
  if (r.buyBlockedByDiv) {
1064
1068
  reasons.push(`Защита от дивидендного гэпа: ${r.buyBlockedByDiv}`);
1065
1069
  if (!label) label = '⛔ отсечка';
@@ -4088,7 +4092,9 @@ async function openStockModal(symbol) {
4088
4092
  ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4089
4093
  : fc?.buyBlockedByDip
4090
4094
  ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4091
- : '<span class="tag">нейтрально (вне порога)</span>';
4095
+ : fc?.buyBlockedByOverheat
4096
+ ? `<span class="tag blocked" title="Бумага уже перегрета (+${fmtNum(fc.buyBlockedByOverheat, 1)}% сверх рынка) — покупка на вершине пампа запрещена">⛔ перегрев (+${fmtNum(fc.buyBlockedByOverheat, 1)}%)</span>`
4097
+ : '<span class="tag">нейтрально (вне порога)</span>';
4092
4098
  const shortBadge = d.shortAllowedByBroker === true
4093
4099
  ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
4094
4100
  : d.shortAllowedByBroker === false
@@ -5,7 +5,7 @@ import express from 'express';
5
5
  import { ROOT, strategyDefaults } from '../config.js';
6
6
  import { activeModelInfo } from '../llm/client.js';
7
7
  import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
8
- import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
8
+ import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles, saveCandles } from '../db.js';
9
9
  import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
10
  import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
11
11
  import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
@@ -14,9 +14,9 @@ import { getSettings, updateSettings, resetSettings, revertSettingsChange } from
14
14
  import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
15
15
  import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
16
16
  import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
17
- import { ensureDividends } from '../data/moex.js';
17
+ import { ensureDividends, ensureCandles } from '../data/moex.js';
18
18
  import { live } from '../model/live.js';
19
- import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading } from '../brokers/index.js';
19
+ import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading, fetchTbankCandles } from '../brokers/index.js';
20
20
  import { listNews } from '../news/index.js';
21
21
  import * as strategist from '../strategist/index.js';
22
22
  import { round, MSK, DAY } from '../util.js';
@@ -653,11 +653,18 @@ export function createRouter() {
653
653
  const sym = String(req.params.symbol || '').toUpperCase().trim();
654
654
  if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
655
655
 
656
- const [fc, rawSpecs, divs] = await Promise.all([
656
+ const nowTs = Date.now();
657
+ const [fc, rawSpecs, divs, tbLiveBars] = await Promise.all([
657
658
  forecastNow(getSettings()).catch(() => null),
658
659
  ensureBoardSpecs().catch(() => ({})),
659
660
  ensureDividends(sym).catch(() => []),
661
+ fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
662
+ ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
660
663
  ]);
664
+ if (tbLiveBars?.length) {
665
+ const doneBars = tbLiveBars.filter((b) => b.isComplete);
666
+ if (doneBars.length) saveCandles(sym, 10, doneBars);
667
+ }
661
668
  const tbMap = kvGet('tbankShortableMap') || {};
662
669
  const tbSpecs = tbMap.specMap || {};
663
670
  const shortableMap = tbMap.map || {};
@@ -669,7 +676,6 @@ export function createRouter() {
669
676
  const tierMult = symbolEdgeMult(sym);
670
677
 
671
678
  // 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
672
- const nowTs = Date.now();
673
679
  const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
674
680
  const intraday = rawBars30d.slice(-1500).map((b) => ({
675
681
  ts: b.ts,
@@ -698,6 +704,23 @@ export function createRouter() {
698
704
  const daily = [...dailyMap.values()];
699
705
 
700
706
  const curPrice = fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
707
+ if (curPrice > 0 && intraday.length) {
708
+ const lastBar = intraday[intraday.length - 1];
709
+ const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
710
+ if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
711
+ intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
712
+ } else if (lastBar.c !== curPrice) {
713
+ lastBar.c = curPrice;
714
+ if (curPrice > lastBar.h) lastBar.h = curPrice;
715
+ if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
716
+ }
717
+ if (daily.length) {
718
+ const lastDay = daily[daily.length - 1];
719
+ lastDay.c = curPrice;
720
+ if (curPrice > lastDay.h) lastDay.h = curPrice;
721
+ if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
722
+ }
723
+ }
701
724
  const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
702
725
  const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
703
726
  const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
@@ -5,7 +5,7 @@ import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
5
5
  import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
6
6
  import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
7
7
  import { getSettings, normalizeParams } from '../settings.js';
8
- import { latestBarTs } from '../data/moex.js';
8
+ import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
9
9
  import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
10
10
  import { forecastNow } from '../model/forecast.js';
11
11
  import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
@@ -579,9 +579,17 @@ export function deleteBrokerAccount(id) {
579
579
  }
580
580
 
581
581
  // ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
582
- // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.)
582
+ // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
583
+ const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
584
+
583
585
  function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
584
586
  return new Promise((resolve, reject) => {
587
+ let settled = false;
588
+ const done = (fn, val) => {
589
+ if (settled) return;
590
+ settled = true;
591
+ fn(val);
592
+ };
585
593
  const u = new URL(url);
586
594
  const payload = body === null || body === undefined ? null : (typeof body === 'string' ? body : body.toString());
587
595
  const reqHeaders = { ...headers };
@@ -594,6 +602,7 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
594
602
  path: u.pathname + u.search,
595
603
  method,
596
604
  headers: reqHeaders,
605
+ agent: BROKER_HTTPS_AGENT,
597
606
  ca: TRUSTED_CA,
598
607
  rejectUnauthorized: true,
599
608
  timeout: timeoutMs,
@@ -603,16 +612,19 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
603
612
  res.on('end', () => {
604
613
  const text = Buffer.concat(chunks).toString('utf8');
605
614
  const status = res.statusCode || 0;
606
- resolve({
615
+ done(resolve, {
607
616
  ok: status >= 200 && status < 300,
608
617
  status,
609
618
  text: async () => text,
610
619
  json: async () => (text ? JSON.parse(text) : {}),
611
620
  });
612
621
  });
622
+ res.on('error', (err) => done(reject, err));
623
+ res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
613
624
  });
614
625
  req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
615
- req.on('error', reject);
626
+ req.on('error', (err) => done(reject, err));
627
+ req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
616
628
  if (payload !== null) req.write(payload);
617
629
  req.end();
618
630
  });
@@ -729,6 +741,63 @@ export async function fetchTbankRealtimePrices() {
729
741
  }
730
742
  }
731
743
 
744
+ // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
745
+ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
746
+ try {
747
+ const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
748
+ if (!tbAcc?.token) return null;
749
+ await ensureTbankShortableMap(tbAcc);
750
+ const shortMap = kvGet('tbankShortableMap');
751
+ const uid = shortMap?.uidMap?.[symbol];
752
+ if (!uid) return null;
753
+ const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
754
+ const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
755
+ const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
756
+ // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
757
+ const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
758
+ if (safeFrom >= tillTs) return null;
759
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`, {
760
+ method: 'POST',
761
+ headers,
762
+ body: JSON.stringify({
763
+ instrumentId: uid,
764
+ from: new Date(safeFrom).toISOString(),
765
+ to: new Date(tillTs).toISOString(),
766
+ interval: 'CANDLE_INTERVAL_10_MIN',
767
+ }),
768
+ timeoutMs: 5000,
769
+ });
770
+ if (!r.ok) return null;
771
+ const j = await r.json();
772
+ const out = [];
773
+ for (const c of j.candles || []) {
774
+ if (!includeIncomplete && c.isComplete === false) continue;
775
+ const ts = Date.parse(c.time);
776
+ const open = qVal(c.open);
777
+ const high = qVal(c.high);
778
+ const low = qVal(c.low);
779
+ const close = qVal(c.close);
780
+ if (!(ts > 0 && close > 0)) continue;
781
+ const volShares = (Number(c.volume) || 0) * lotSize;
782
+ const avgPx = (open + high + low + close) / 4;
783
+ out.push({
784
+ ts,
785
+ open,
786
+ high,
787
+ low,
788
+ close,
789
+ volume: volShares,
790
+ value: Math.round(volShares * avgPx),
791
+ isComplete: c.isComplete !== false,
792
+ });
793
+ }
794
+ return out.length ? out : null;
795
+ } catch {
796
+ return null;
797
+ }
798
+ }
799
+ registerRealtimeCandleFetcher(fetchTbankCandles);
800
+
732
801
  // Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
733
802
  async function ensureTbankShortableMap(acc) {
734
803
  if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
@@ -2,6 +2,11 @@
2
2
  import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
3
3
  import { DAY, MSK } from '../util.js';
4
4
 
5
+ let realtimeCandleFetcher = null;
6
+ export function registerRealtimeCandleFetcher(fn) {
7
+ realtimeCandleFetcher = fn;
8
+ }
9
+
5
10
  const ISS = 'https://iss.moex.com/iss';
6
11
  const INDEXES = new Set(['IMOEX', 'RGBI', 'MOEXBMI']);
7
12
  // макро-инструменты: валюта (CETS) и фьючерсы (FORTS/RFUD)
@@ -121,18 +126,30 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
121
126
  const mskHour = mskDate.getUTCHours();
122
127
  const mskMins = mskHour * 60 + mskDate.getUTCMinutes();
123
128
  const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskMins >= 6 * 60 + 50 && mskHour < 24;
124
- const checkCooldownMs = isTradingHours ? 4 * 60000 : 4 * 3600000;
125
- const dbIsAlreadyFresh = (now - range.max < 10 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
129
+ const isTbankEligible = Boolean(realtimeCandleFetcher && Number(interval) === 10 && now - range.max <= 3 * DAY && !INDEXES.has(symbol) && !CURRENCY.has(symbol) && !FUTURES.test(symbol));
130
+ const isExpiredFuture = FUTURES.test(symbol) && (now - range.max > 10 * DAY);
131
+ const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - barMs(interval)) / barMs(interval)) * barMs(interval);
132
+ const checkCooldownMs = isExpiredFuture ? DAY : isTradingHours ? (isTbankEligible ? 45000 : 4 * 60000) : 4 * 3600000;
133
+ const dbIsAlreadyFresh = isExpiredFuture || (range.max >= latestClosedSlotTs) || (!isTradingHours && now - range.max < 18 * 3600000);
126
134
 
127
- if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
128
- // Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
129
- // чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
135
+ if (!dbIsAlreadyFresh && range.max < latestClosedSlotTs && (now - lastCheck >= checkCooldownMs)) {
130
136
  lastTailCheckAt.set(tailKey, now);
137
+ // В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
138
+ let filledByTbank = false;
139
+ if (isTbankEligible) {
140
+ const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 2 * DAY), Math.min(tillTs, now)).catch(() => null);
141
+ if (tbRows?.length) {
142
+ saveCandles(symbol, interval, tbRows);
143
+ filledByTbank = true;
144
+ }
145
+ }
146
+ if (!filledByTbank) {
147
+ await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
148
+ signal,
149
+ onBatch: (page) => saveCandles(symbol, interval, page),
150
+ });
151
+ }
131
152
  kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
132
- await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
133
- signal,
134
- onBatch: (page) => saveCandles(symbol, interval, page),
135
- });
136
153
  }
137
154
 
138
155
  return loadCandles(symbol, interval, fromTs, tillTs);
@@ -140,6 +157,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
140
157
 
141
158
  // Последняя свеча инструмента прямо с биржи (без кэша) — чтобы быстро понять, закрылась ли новая свеча
142
159
  export async function latestBarTs(symbol, interval = 60) {
160
+ if (realtimeCandleFetcher && Number(interval) === 10) {
161
+ const tbSym = INDEXES.has(symbol) ? 'SBER' : symbol;
162
+ const tbRows = await realtimeCandleFetcher(tbSym, Date.now() - 6 * 3600000, Date.now()).catch(() => null);
163
+ if (tbRows?.length) return tbRows[tbRows.length - 1].ts;
164
+ }
143
165
  const from = tsToDate(Date.now() - 5 * DAY);
144
166
  const rows = await fetchCandles(symbol, interval, from, null);
145
167
  return rows.length ? rows[rows.length - 1].ts : null;
@@ -121,35 +121,37 @@ const MACRO = [
121
121
 
122
122
  const MCODES = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z']; // месяцы в кодах фьючерсов
123
123
 
124
- // Список существующих контрактов BR с их периодами (кэш на сутки, чтобы не пробовать все коды каждый раз)
125
- async function brentContracts(fromTs, tillTs, signal) {
124
+ // Список существующих контрактов BR с их периодами (кэш на 7 суток, чтобы не пробовать все коды каждый раз)
125
+ async function brentContracts(fromTs, tillTs, signal, { localOnly = false } = {}) {
126
126
  const cached = kvGet('brContracts');
127
127
  let list = cached?.list;
128
- if (!cached || Date.now() - cached.at > DAY) {
128
+ if ((!cached || Date.now() - cached.at > 7 * DAY) && !(localOnly && list?.length)) {
129
129
  list = [];
130
130
  const codes = [];
131
- const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs + 100 * DAY).getUTCFullYear();
131
+ const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs + 50 * DAY).getUTCFullYear();
132
132
  for (let y = y0; y <= y1; y++) for (const m of MCODES) codes.push(`BR${m}${y % 10}`);
133
- await pool(codes, 6, async (code) => {
133
+ await pool(codes, 4, async (code) => {
134
134
  try {
135
- const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal });
135
+ const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal, localOnly });
136
136
  if (rows.length) list.push({ code, first: rows[0].ts, last: rows[rows.length - 1].ts });
137
137
  } catch { /* контракта нет */ }
138
138
  });
139
139
  list.sort((a, b) => a.first - b.first);
140
- kvSet('brContracts', { at: Date.now(), list });
140
+ if (list.length) kvSet('brContracts', { at: Date.now(), list });
141
141
  }
142
- return list.filter((c) => c.last >= fromTs && c.first <= tillTs);
142
+ return (list || []).filter((c) => c.last >= fromTs && c.first <= tillTs);
143
143
  }
144
144
 
145
145
  // Непрерывный ряд цены нефти: в каждый день — контракт с наибольшим оборотом дня,
146
146
  // на стыке контрактов цена продолжается без скачка (доходность стыка = 0)
147
- async function loadBrent(fromTs, tillTs, signal) {
148
- const list = await brentContracts(fromTs, tillTs, signal);
147
+ async function loadBrent(fromTs, tillTs, signal, { localOnly = false } = {}) {
148
+ const list = await brentContracts(fromTs, tillTs, signal, { localOnly });
149
149
  const raw = [];
150
+ const now = Date.now();
150
151
  await pool(list, 4, async (c) => {
151
152
  try {
152
- const rows = await ensureCandles(c.code, BAR_INTERVAL, fromTs, tillTs, { signal });
153
+ const isExpired = c.last < now - 10 * DAY;
154
+ const rows = await ensureCandles(c.code, BAR_INTERVAL, fromTs, tillTs, { signal, localOnly: localOnly || isExpired });
153
155
  if (rows.length) raw.push(compact(rows));
154
156
  } catch { /* пропускаем */ }
155
157
  });
@@ -280,7 +282,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
280
282
  if (useMacro) {
281
283
  try {
282
284
  onProgress({ symbol: 'BR', done: done, total: all.length });
283
- raw.BR = await loadBrent(fromTs, tillTs, signal);
285
+ raw.BR = await loadBrent(fromTs, tillTs, signal, { localOnly });
284
286
  } catch (e) {
285
287
  if (signal?.aborted) throw new Error('Отменено');
286
288
  raw.BR = EMPTY();
@@ -397,6 +399,24 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
397
399
  }
398
400
  }
399
401
  const mret = lret(mk.close);
402
+ // На самых свежих барах текущего дня (последние 2 часа), когда 10-минутные свечи акций уже получены в реальном времени из Т-Банка,
403
+ // а индекс IMOEX в бесплатном MOEX ISS ещё отстаёт на 20 минут, достраиваем mret и mclose по средневзвешенной доходности акций индекса:
404
+ for (let i = Math.max(1, T - 12); i < T; i++) {
405
+ if (!mk.has[i] && mk.close[i - 1] > 0) {
406
+ let wSum = 0, rSum = 0;
407
+ for (const tk of tokens) {
408
+ if (tk.has[i] && (!tk.inIndex || tk.inIndex[i])) {
409
+ const w = tk.weight > 0 ? tk.weight : 1;
410
+ rSum += w * tk.ret[i];
411
+ wSum += w;
412
+ }
413
+ }
414
+ if (wSum > 0) {
415
+ mret[i] = rSum / wSum;
416
+ mk.close[i] = mk.close[i - 1] * Math.exp(mret[i]);
417
+ }
418
+ }
419
+ }
400
420
  // макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
401
421
  const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
402
422
  const days = new Set(timeline.map(dayKey)).size;
@@ -315,11 +315,34 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
315
315
  const avgInv = nInv ? sInv / nInv : 0;
316
316
  const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
317
317
  const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
318
+ const bpd = ds.barsPerDay || 54;
319
+ const idx1d = Math.max(0, idx - bpd);
320
+ const idx3d = Math.max(0, idx - 3 * bpd);
321
+ const mNow = liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0);
322
+ const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
323
+ const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
318
324
  const rows = ds.tokens.map((tk, i) => {
319
- const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
325
+ let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
320
326
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
321
327
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
322
328
  const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
329
+ // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
330
+ const p1d = tk.close[idx1d] || 0;
331
+ const p3d = tk.close[idx3d] || 0;
332
+ const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
333
+ const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
334
+ const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
335
+ const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
336
+ let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
337
+ if (isOverheated && selfRaw > 0) {
338
+ // Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
339
+ // ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — обнуляем ложный отскок вверх:
340
+ exp -= selfRaw;
341
+ selfRaw = 0;
342
+ }
343
+ const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
344
+ ? round((excess1d >= 0.06 ? excess1d : excess3d) * 100, 1)
345
+ : 0;
323
346
  // Индивидуальный множитель порога каждой акции по её фактическому обороту в рублях и волатильности свечи:
324
347
  const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
325
348
  const symNeedL = Math.max(needL * tierMult, 0.0030);
@@ -327,7 +350,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
327
350
  // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
328
351
  // обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
329
352
  const EXTREME_BAR_MOVE_PCT = 4.5;
330
- const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT);
353
+ const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT) && !buyBlockedByOverheat;
331
354
  const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
332
355
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
333
356
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
@@ -352,6 +375,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
352
375
  signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
353
376
  ...(shortAllowed !== null ? { shortAllowed } : {}),
354
377
  ...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
378
+ ...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
355
379
  ...(inIdx ? {} : { inIndex: false }),
356
380
  };
357
381
  if (dir !== 'long') {
@@ -359,10 +383,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
359
383
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
360
384
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
361
385
  }
362
- if (explain) {
363
- const ex = explainAttention(fitted.attention, f, u, i);
386
+ if (explain && ex) {
364
387
  row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
365
- row.selfPct = round(ex.self * f.sigma[i] * Math.sqrt(H) * 100, 3);
388
+ row.selfPct = round(selfRaw * 100, 3);
366
389
  }
367
390
  return row;
368
391
  });