neuroalpha 0.5.21 → 0.5.24

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.24",
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';
@@ -118,15 +118,15 @@ export function roundToStep(price, minStep = 0.01, decimals = 2) {
118
118
  return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
119
119
  }
120
120
 
121
- // Снайперский расчёт лимитной цены (1-tick Limit):
122
- // отталкивается от живой секундной цены basePx и сдвигает её ровно на 1 минимальный шаг цены (minStep),
123
- // но не более заданного offsetPct, чтобы не переплачивать маркетмейкерам за пересечение спреда!
121
+ // Снайперский расчёт лимитной цены:
122
+ // отталкивается от живой секундной цены basePx и задаёт защитный предел исполнения по offsetPct (но не менее 1 минимального шага цены minStep),
123
+ // а в момент фактической отправки в стакан sendOrderToBroker дополнительно подстраивает цену под лучший bid/ask живого стакана 1×1!
124
124
  export function sniperLimitPrice(basePx, dir, offsetPct = 0.02, sp = { minStep: 0.01, decimals: 2 }) {
125
125
  const step = sp.minStep > 0 ? sp.minStep : 0.01;
126
- const maxOffRatio = Math.max(0, Math.min(0.0005, (Number(offsetPct) || 0.02) / 100));
126
+ const maxOffRatio = Math.max(0, Math.min(0.001, (Number(offsetPct) || 0.02) / 100));
127
127
  const byPct = basePx * (1 + dir * maxOffRatio);
128
128
  const byOneTick = basePx + dir * step;
129
- const rawLimit = dir > 0 ? Math.min(byPct, byOneTick) : Math.max(byPct, byOneTick);
129
+ const rawLimit = dir > 0 ? Math.max(byPct, byOneTick) : Math.min(byPct, byOneTick);
130
130
  return roundToStep(rawLimit, step, sp.decimals);
131
131
  }
132
132
 
@@ -498,6 +498,7 @@ export function saveBrokerAccount(input = {}) {
498
498
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
499
499
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
500
500
  lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
501
+ lastFullStepWallTs: strategyOrCapChanged ? 0 : (prev.lastFullStepWallTs || 0),
501
502
  // дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
502
503
  haltedReason: prev.haltedReason || null,
503
504
  ddOverrideDay: prev.ddOverrideDay || null,
@@ -579,9 +580,17 @@ export function deleteBrokerAccount(id) {
579
580
  }
580
581
 
581
582
  // ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
582
- // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.)
583
+ // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
584
+ const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
585
+
583
586
  function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
584
587
  return new Promise((resolve, reject) => {
588
+ let settled = false;
589
+ const done = (fn, val) => {
590
+ if (settled) return;
591
+ settled = true;
592
+ fn(val);
593
+ };
585
594
  const u = new URL(url);
586
595
  const payload = body === null || body === undefined ? null : (typeof body === 'string' ? body : body.toString());
587
596
  const reqHeaders = { ...headers };
@@ -594,6 +603,7 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
594
603
  path: u.pathname + u.search,
595
604
  method,
596
605
  headers: reqHeaders,
606
+ agent: BROKER_HTTPS_AGENT,
597
607
  ca: TRUSTED_CA,
598
608
  rejectUnauthorized: true,
599
609
  timeout: timeoutMs,
@@ -603,16 +613,19 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
603
613
  res.on('end', () => {
604
614
  const text = Buffer.concat(chunks).toString('utf8');
605
615
  const status = res.statusCode || 0;
606
- resolve({
616
+ done(resolve, {
607
617
  ok: status >= 200 && status < 300,
608
618
  status,
609
619
  text: async () => text,
610
620
  json: async () => (text ? JSON.parse(text) : {}),
611
621
  });
612
622
  });
623
+ res.on('error', (err) => done(reject, err));
624
+ res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
613
625
  });
614
626
  req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
615
- req.on('error', reject);
627
+ req.on('error', (err) => done(reject, err));
628
+ req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
616
629
  if (payload !== null) req.write(payload);
617
630
  req.end();
618
631
  });
@@ -698,8 +711,9 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
698
711
  // Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
699
712
  export async function fetchTbankRealtimePrices() {
700
713
  try {
701
- // только токен владельца текущих данных — токены других пользователей не используются никогда
702
- const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
714
+ // только токен владельца текущих данных — токены других пользователей не используются никогда (предпочитаем боевой контур песочнице)
715
+ const rawAccs = loadRawAccounts();
716
+ const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
703
717
  if (!tbAcc?.token) return null;
704
718
  await ensureTbankShortableMap(tbAcc);
705
719
  const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
@@ -729,6 +743,64 @@ export async function fetchTbankRealtimePrices() {
729
743
  }
730
744
  }
731
745
 
746
+ // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
747
+ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
748
+ try {
749
+ const rawAccs = loadRawAccounts();
750
+ const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
751
+ if (!tbAcc?.token) return null;
752
+ await ensureTbankShortableMap(tbAcc);
753
+ const shortMap = kvGet('tbankShortableMap');
754
+ const uid = shortMap?.uidMap?.[symbol];
755
+ if (!uid) return null;
756
+ const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
757
+ const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
758
+ const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
759
+ // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
760
+ const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
761
+ 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,
772
+ });
773
+ if (!r.ok) return null;
774
+ const j = await r.json();
775
+ const out = [];
776
+ for (const c of j.candles || []) {
777
+ if (!includeIncomplete && c.isComplete === false) continue;
778
+ const ts = Date.parse(c.time);
779
+ const open = qVal(c.open);
780
+ const high = qVal(c.high);
781
+ const low = qVal(c.low);
782
+ const close = qVal(c.close);
783
+ if (!(ts > 0 && close > 0)) continue;
784
+ const volShares = (Number(c.volume) || 0) * lotSize;
785
+ const avgPx = (open + high + low + close) / 4;
786
+ out.push({
787
+ ts,
788
+ open,
789
+ high,
790
+ low,
791
+ close,
792
+ volume: volShares,
793
+ value: Math.round(volShares * avgPx),
794
+ isComplete: c.isComplete !== false,
795
+ });
796
+ }
797
+ return out.length ? out : null;
798
+ } catch {
799
+ return null;
800
+ }
801
+ }
802
+ registerRealtimeCandleFetcher(fetchTbankCandles);
803
+
732
804
  // Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
733
805
  async function ensureTbankShortableMap(acc) {
734
806
  if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
@@ -1702,7 +1774,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1702
1774
  if (aQty > 0) {
1703
1775
  const lots = Math.floor(aQty / sp.lotSize);
1704
1776
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1705
- const hitHardStopLoss = curGainPct <= -5.0;
1777
+ const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.buyBlockedByDip);
1706
1778
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1707
1779
  const dg = divGuardOf(sym, curPx);
1708
1780
  const divExit = Boolean(dg?.exitLong);
@@ -1727,7 +1799,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1727
1799
  } else if (aQty < 0) {
1728
1800
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1729
1801
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1730
- const hitHardStopLoss = curGainPct <= -4.5;
1802
+ const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.shortBlockedBySqueeze);
1731
1803
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1732
1804
  const dg = divGuardOf(sym, curPx);
1733
1805
  const divExit = Boolean(dg?.exitShort);
@@ -1830,7 +1902,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1830
1902
  }
1831
1903
  }
1832
1904
  const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
1833
- const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
1905
+ const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
1906
+ const openShortRub = [...heldMap.values()]
1907
+ .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
1908
+ .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
1909
+ const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
1834
1910
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
1835
1911
 
1836
1912
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
@@ -2042,9 +2118,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2042
2118
  return brokersState();
2043
2119
  }
2044
2120
 
2121
+ const batchNowTs = Date.now();
2122
+ if (!onlyEntries) {
2123
+ acc.lastFullStepWallTs = batchNowTs;
2124
+ }
2045
2125
  acc.pendingBatch = {
2046
- id: `batch_${Date.now()}`,
2047
- createdAt: Date.now(),
2126
+ id: `batch_${batchNowTs}`,
2127
+ createdAt: batchNowTs,
2048
2128
  bar: fc.bar,
2049
2129
  budgetRub: budget,
2050
2130
  availRub: round(availRub, 0),
@@ -2121,19 +2201,35 @@ async function sendOrderToBroker(acc, ord) {
2121
2201
  if (bestBid > 0 && bestAsk >= bestBid) {
2122
2202
  const midPx = (bestBid + bestAsk) / 2;
2123
2203
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
2204
+ const step = sp.minStep || 0.01;
2124
2205
  const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
2125
2206
  if (isEntry && spreadPct > maxSpreadPct) {
2126
2207
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2127
2208
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2128
2209
  }
2129
- if (isEntry && ord.symbol !== 'LQDT') {
2130
- // Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
2131
- const passivePx = isBuy
2132
- ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
2133
- : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
2134
- if (passivePx > 0) finalLimitPx = passivePx;
2135
- } else if (ord.symbol === 'LQDT') {
2210
+ if (ord.symbol === 'LQDT') {
2136
2211
  finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2212
+ } else if (!isEntry) {
2213
+ // Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
2214
+ // чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
2215
+ if (spreadPct <= 0.80) {
2216
+ const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2217
+ if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2218
+ }
2219
+ } else {
2220
+ // Входящие заявки (buy / short):
2221
+ // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
2222
+ // • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
2223
+ const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
2224
+ if (tightSpread) {
2225
+ const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2226
+ if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
2227
+ } else {
2228
+ const passivePx = isBuy
2229
+ ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
2230
+ : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
2231
+ if (passivePx > 0) finalLimitPx = passivePx;
2232
+ }
2137
2233
  }
2138
2234
  }
2139
2235
  }
@@ -2682,14 +2778,21 @@ async function tickBrokerSchedule() {
2682
2778
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
2683
2779
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
2684
2780
  const minStepMs = stepBars * BAR_MS;
2685
- const lastRunWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2781
+ const lastFullWallTs = acc.lastFullStepWallTs || 0;
2782
+ const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2783
+ 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
+ const nowTs = Date.now();
2785
+ const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
2686
2786
  const dueFullStep = Boolean(
2687
- latest &&
2688
- (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2689
- !(lastRunWallTs > 0 && Date.now() - lastRunWallTs < minStepMs - 5 * 60000),
2787
+ nightExitDue || (
2788
+ latest &&
2789
+ (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2790
+ !(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
2791
+ ),
2690
2792
  );
2691
2793
  if (dueFullStep) {
2692
- acc.lastScheduledBarTs = latest;
2794
+ acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
2795
+ acc.lastFullStepWallTs = nowTs;
2693
2796
  saveRawAccounts(list);
2694
2797
  if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2695
2798
  await prepareBrokerOrders(acc.id).catch(() => {});
@@ -2700,7 +2803,7 @@ async function tickBrokerSchedule() {
2700
2803
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2701
2804
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2702
2805
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2703
- if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && Date.now() - lastRunWallTs >= 20000) {
2806
+ if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2704
2807
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2705
2808
  }
2706
2809
  }
@@ -2987,6 +3090,9 @@ async function checkIntradayProfitLock(accId) {
2987
3090
  for (const ord of lockOrders) {
2988
3091
  logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
2989
3092
  try {
3093
+ if (acc.brokerStopBySymbol?.[ord.symbol]) {
3094
+ await closeBrokerStopBeforeExit(acc, ord.symbol);
3095
+ }
2990
3096
  const r = await sendOrderToBroker(acc, ord);
2991
3097
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
2992
3098
  delete acc.entryTsBySymbol[ord.symbol];
@@ -2999,10 +3105,10 @@ async function checkIntradayProfitLock(accId) {
2999
3105
  }
3000
3106
  acc.pendingBatch = {
3001
3107
  ...(acc.pendingBatch || {}),
3002
- id: `batch_tp_${execNowTs}`,
3108
+ id: `batch_stop_${execNowTs}`,
3003
3109
  createdAt: execNowTs,
3004
3110
  executedAt: execNowTs,
3005
- bar: `${fmtBarMsk(execNowTs)} (Take-Profit)`,
3111
+ bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
3006
3112
  orders: lockOrders,
3007
3113
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3008
3114
  results,
@@ -1,7 +1,12 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
2
+ import { saveCandles, loadCandles, candleRange, candleMaxBetween, 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)
@@ -75,9 +80,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
75
80
  const now = Date.now();
76
81
  const pKey = `candProbe:${symbol}:${interval}`;
77
82
  const probe = kvGet(pKey);
78
- // Защита от ложных меток candProbe v1 (где из-за старого условия first.ts <= range.min записывалось fromTs без скачивания):
79
- // валидной считаем только метку v2 (или если range.min реально доходит до fromTs)
80
- const covered = probe && probe.v === 2 && probe.fromTs <= fromTs + 5 * DAY;
83
+ // Метка v3: fromTs — до какой даты левый край подтверждён (свечи есть в базе или на бирже раньше их нет).
84
+ // Метки v1/v2 не доверяем: старое условие range.n < 500 записывало fromTs без скачивания, и у бумаг с накопленной
85
+ // историей левый край (например, 2022 год) так и не догружался — датасет зависел от того, когда создана база.
86
+ const verifiedFrom = probe?.v === 3 ? probe.fromTs : Infinity;
87
+ let leftFill = probe?.v === 3 ? probe.leftFill || null : null;
81
88
 
82
89
  if (!range.n) {
83
90
  // В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
@@ -85,28 +92,35 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
85
92
  signal,
86
93
  onBatch: (page) => saveCandles(symbol, interval, page),
87
94
  });
88
- kvSet(pKey, { v: 2, fromTs });
95
+ kvSet(pKey, { v: 3, fromTs });
89
96
  lastTailCheckAt.set(`${symbol}:${interval}`, now);
90
97
  return loadCandles(symbol, interval, fromTs, tillTs);
91
98
  }
92
99
 
93
- // 1) ЛЕВЫЙ КРАЙ:
94
- // Если в локальной базе SQLite уже накоплена история (range.n >= 500 свечей), НИКОГДА не лезем на биржу докачивать левый край
95
- // при смене «Окна обучения» или «Периода исследования» — работаем мгновенно по уже сохранённым в SQLite историческим свечам!
96
- if (!covered && range.n < 500 && range.min > fromTs + 5 * DAY) {
100
+ // 1) ЛЕВЫЙ КРАЙ: история докачивается один раз, дальше работаем только по SQLite.
101
+ // Докачка идёт от ранних свечей к поздним; пока она не завершена, в метке хранится leftFill,
102
+ // чтобы после обрыва продолжить с последней сохранённой свечи, а не оставить дыру в середине истории.
103
+ let probeFrom = Math.min(verifiedFrom, range.min);
104
+ if (!leftFill && range.min > fromTs + 5 * DAY && verifiedFrom > fromTs + 5 * DAY) {
97
105
  const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
98
106
  if (!first || first.ts >= range.min - DAY) {
99
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
107
+ probeFrom = fromTs; // раньше имеющихся свечей на бирже истории нет
100
108
  } else {
101
- const leftTill = tsToDate(range.min + DAY);
102
- await fetchCandles(symbol, interval, tsToDate(fromTs), leftTill, {
103
- signal,
104
- onBatch: (page) => saveCandles(symbol, interval, page),
105
- });
106
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
109
+ leftFill = { from: fromTs, till: range.min };
110
+ kvSet(pKey, { v: 3, fromTs: probeFrom, leftFill, tailCheckedAt: probe?.tailCheckedAt });
107
111
  }
108
- } else if (!probe || probe.v !== 2 || probe.fromTs > fromTs) {
109
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, range.min) });
112
+ }
113
+ if (leftFill) {
114
+ const resumeTs = candleMaxBetween(symbol, interval, leftFill.from, leftFill.till - 1) ?? leftFill.from;
115
+ await fetchCandles(symbol, interval, tsToDate(resumeTs), tsToDate(leftFill.till + DAY), {
116
+ signal,
117
+ onBatch: (page) => saveCandles(symbol, interval, page),
118
+ });
119
+ probeFrom = Math.min(probeFrom, leftFill.from);
120
+ leftFill = null;
121
+ }
122
+ if (probe?.v !== 3 || probe.fromTs !== probeFrom || probe.leftFill) {
123
+ kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: probe?.tailCheckedAt });
110
124
  }
111
125
 
112
126
  // 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
@@ -121,18 +135,30 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
121
135
  const mskHour = mskDate.getUTCHours();
122
136
  const mskMins = mskHour * 60 + mskDate.getUTCMinutes();
123
137
  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);
138
+ const isTbankEligible = Boolean(realtimeCandleFetcher && Number(interval) === 10 && now - range.max <= 3 * DAY && !INDEXES.has(symbol) && !CURRENCY.has(symbol) && !FUTURES.test(symbol));
139
+ const isExpiredFuture = FUTURES.test(symbol) && (now - range.max > 10 * DAY);
140
+ const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - barMs(interval)) / barMs(interval)) * barMs(interval);
141
+ const checkCooldownMs = isExpiredFuture ? DAY : isTradingHours ? (isTbankEligible ? 45000 : 4 * 60000) : 4 * 3600000;
142
+ const dbIsAlreadyFresh = isExpiredFuture || (range.max >= latestClosedSlotTs) || (!isTradingHours && now - range.max < 18 * 3600000);
126
143
 
127
- if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
128
- // Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
129
- // чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
144
+ if (!dbIsAlreadyFresh && range.max < latestClosedSlotTs && (now - lastCheck >= checkCooldownMs)) {
130
145
  lastTailCheckAt.set(tailKey, now);
131
- 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
- });
146
+ // В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
147
+ let filledByTbank = false;
148
+ if (isTbankEligible) {
149
+ const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 3 * DAY + 120000), Math.min(tillTs, now)).catch(() => null);
150
+ if (tbRows?.length) {
151
+ saveCandles(symbol, interval, tbRows);
152
+ filledByTbank = true;
153
+ }
154
+ }
155
+ if (!filledByTbank) {
156
+ await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
157
+ signal,
158
+ onBatch: (page) => saveCandles(symbol, interval, page),
159
+ });
160
+ }
161
+ kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: now });
136
162
  }
137
163
 
138
164
  return loadCandles(symbol, interval, fromTs, tillTs);
@@ -140,6 +166,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
140
166
 
141
167
  // Последняя свеча инструмента прямо с биржи (без кэша) — чтобы быстро понять, закрылась ли новая свеча
142
168
  export async function latestBarTs(symbol, interval = 60) {
169
+ if (realtimeCandleFetcher && Number(interval) === 10) {
170
+ const tbSym = INDEXES.has(symbol) ? 'SBER' : symbol;
171
+ const tbRows = await realtimeCandleFetcher(tbSym, Date.now() - 6 * 3600000, Date.now()).catch(() => null);
172
+ if (tbRows?.length) return tbRows[tbRows.length - 1].ts;
173
+ }
143
174
  const from = tsToDate(Date.now() - 5 * DAY);
144
175
  const rows = await fetchCandles(symbol, interval, from, null);
145
176
  return rows.length ? rows[rows.length - 1].ts : null;
package/server/db.js CHANGED
@@ -343,6 +343,10 @@ export function candleRange(symbol, interval) {
343
343
  return db.prepare('SELECT MIN(ts) AS min, MAX(ts) AS max, COUNT(*) AS n FROM candles WHERE symbol = ? AND interval = ?').get(symbol, interval);
344
344
  }
345
345
 
346
+ export function candleMaxBetween(symbol, interval, fromTs, tillTs) {
347
+ return db.prepare('SELECT MAX(ts) AS max FROM candles WHERE symbol = ? AND interval = ? AND ts >= ? AND ts <= ?').get(symbol, interval, fromTs, tillTs)?.max ?? null;
348
+ }
349
+
346
350
  export function createRun(mode, params, uid = getUid()) {
347
351
  const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
348
352
  return Number(r.lastInsertRowid);