neuroalpha 0.5.22 → 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.22",
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,
@@ -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,
@@ -580,14 +581,16 @@ export function deleteBrokerAccount(id) {
580
581
 
581
582
  // ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
582
583
  // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
583
- 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 });
584
585
 
585
586
  function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
586
587
  return new Promise((resolve, reject) => {
587
588
  let settled = false;
589
+ let wallTimer = null;
588
590
  const done = (fn, val) => {
589
591
  if (settled) return;
590
592
  settled = true;
593
+ if (wallTimer) clearTimeout(wallTimer);
591
594
  fn(val);
592
595
  };
593
596
  const u = new URL(url);
@@ -622,6 +625,12 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
622
625
  res.on('error', (err) => done(reject, err));
623
626
  res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
624
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?.();
625
634
  req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
626
635
  req.on('error', (err) => done(reject, err));
627
636
  req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
@@ -710,8 +719,9 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
710
719
  // Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
711
720
  export async function fetchTbankRealtimePrices() {
712
721
  try {
713
- // только токен владельца текущих данных — токены других пользователей не используются никогда
714
- const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
722
+ // только токен владельца текущих данных — токены других пользователей не используются никогда (предпочитаем боевой контур песочнице)
723
+ const rawAccs = loadRawAccounts();
724
+ const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
715
725
  if (!tbAcc?.token) return null;
716
726
  await ensureTbankShortableMap(tbAcc);
717
727
  const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
@@ -744,7 +754,8 @@ export async function fetchTbankRealtimePrices() {
744
754
  // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
745
755
  export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
746
756
  try {
747
- const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
757
+ const rawAccs = loadRawAccounts();
758
+ const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
748
759
  if (!tbAcc?.token) return null;
749
760
  await ensureTbankShortableMap(tbAcc);
750
761
  const shortMap = kvGet('tbankShortableMap');
@@ -756,17 +767,18 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
756
767
  // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
757
768
  const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
758
769
  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,
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',
769
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
+ }
770
782
  if (!r.ok) return null;
771
783
  const j = await r.json();
772
784
  const out = [];
@@ -1771,7 +1783,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1771
1783
  if (aQty > 0) {
1772
1784
  const lots = Math.floor(aQty / sp.lotSize);
1773
1785
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1774
- const hitHardStopLoss = curGainPct <= -5.0;
1786
+ const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1775
1787
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1776
1788
  const dg = divGuardOf(sym, curPx);
1777
1789
  const divExit = Boolean(dg?.exitLong);
@@ -1796,7 +1808,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1796
1808
  } else if (aQty < 0) {
1797
1809
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1798
1810
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1799
- const hitHardStopLoss = curGainPct <= -4.5;
1811
+ const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
1800
1812
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1801
1813
  const dg = divGuardOf(sym, curPx);
1802
1814
  const divExit = Boolean(dg?.exitShort);
@@ -1899,7 +1911,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1899
1911
  }
1900
1912
  }
1901
1913
  const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
1902
- const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
1914
+ const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
1915
+ const openShortRub = [...heldMap.values()]
1916
+ .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
1917
+ .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
1918
+ const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
1903
1919
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
1904
1920
 
1905
1921
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
@@ -2111,9 +2127,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2111
2127
  return brokersState();
2112
2128
  }
2113
2129
 
2130
+ const batchNowTs = Date.now();
2131
+ if (!onlyEntries) {
2132
+ acc.lastFullStepWallTs = batchNowTs;
2133
+ }
2114
2134
  acc.pendingBatch = {
2115
- id: `batch_${Date.now()}`,
2116
- createdAt: Date.now(),
2135
+ id: `batch_${batchNowTs}`,
2136
+ createdAt: batchNowTs,
2117
2137
  bar: fc.bar,
2118
2138
  budgetRub: budget,
2119
2139
  availRub: round(availRub, 0),
@@ -2190,19 +2210,35 @@ async function sendOrderToBroker(acc, ord) {
2190
2210
  if (bestBid > 0 && bestAsk >= bestBid) {
2191
2211
  const midPx = (bestBid + bestAsk) / 2;
2192
2212
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
2213
+ const step = sp.minStep || 0.01;
2193
2214
  const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
2194
2215
  if (isEntry && spreadPct > maxSpreadPct) {
2195
2216
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2196
2217
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2197
2218
  }
2198
- if (isEntry && ord.symbol !== 'LQDT') {
2199
- // Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
2200
- const passivePx = isBuy
2201
- ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
2202
- : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
2203
- if (passivePx > 0) finalLimitPx = passivePx;
2204
- } else if (ord.symbol === 'LQDT') {
2219
+ if (ord.symbol === 'LQDT') {
2205
2220
  finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2221
+ } else if (!isEntry) {
2222
+ // Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
2223
+ // чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
2224
+ if (spreadPct <= 0.80) {
2225
+ const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2226
+ if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2227
+ }
2228
+ } else {
2229
+ // Входящие заявки (buy / short):
2230
+ // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
2231
+ // • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
2232
+ const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
2233
+ if (tightSpread) {
2234
+ const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2235
+ if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
2236
+ } else {
2237
+ const passivePx = isBuy
2238
+ ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
2239
+ : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
2240
+ if (passivePx > 0) finalLimitPx = passivePx;
2241
+ }
2206
2242
  }
2207
2243
  }
2208
2244
  }
@@ -2722,15 +2758,17 @@ registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrder
2722
2758
  // ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
2723
2759
  let lastSeenBar = null;
2724
2760
  let schedulerBusy = false;
2761
+ let schedulerBusySince = 0;
2725
2762
 
2726
2763
  async function tickBrokerSchedule() {
2727
- if (schedulerBusy) return;
2764
+ if (schedulerBusy && Date.now() - schedulerBusySince < 90000) return;
2728
2765
  if (!tradingGate().allowNew) return;
2729
2766
  const nowMsk = new Date(Date.now() + MSK);
2730
2767
  const dow = nowMsk.getUTCDay();
2731
2768
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2732
2769
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
2733
2770
  schedulerBusy = true;
2771
+ schedulerBusySince = Date.now();
2734
2772
  try {
2735
2773
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
2736
2774
  const isNewBar = Boolean(latest && latest !== lastSeenBar);
@@ -2751,14 +2789,21 @@ async function tickBrokerSchedule() {
2751
2789
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
2752
2790
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
2753
2791
  const minStepMs = stepBars * BAR_MS;
2754
- const lastRunWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2792
+ const lastFullWallTs = acc.lastFullStepWallTs || 0;
2793
+ const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2794
+ 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);
2795
+ const nowTs = Date.now();
2796
+ const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
2755
2797
  const dueFullStep = Boolean(
2756
- latest &&
2757
- (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2758
- !(lastRunWallTs > 0 && Date.now() - lastRunWallTs < minStepMs - 5 * 60000),
2798
+ nightExitDue || (
2799
+ latest &&
2800
+ (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2801
+ !(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
2802
+ ),
2759
2803
  );
2760
2804
  if (dueFullStep) {
2761
- acc.lastScheduledBarTs = latest;
2805
+ acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
2806
+ acc.lastFullStepWallTs = nowTs;
2762
2807
  saveRawAccounts(list);
2763
2808
  if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2764
2809
  await prepareBrokerOrders(acc.id).catch(() => {});
@@ -2769,7 +2814,7 @@ async function tickBrokerSchedule() {
2769
2814
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2770
2815
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2771
2816
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2772
- if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && Date.now() - lastRunWallTs >= 20000) {
2817
+ if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2773
2818
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2774
2819
  }
2775
2820
  }
@@ -3056,6 +3101,9 @@ async function checkIntradayProfitLock(accId) {
3056
3101
  for (const ord of lockOrders) {
3057
3102
  logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3058
3103
  try {
3104
+ if (acc.brokerStopBySymbol?.[ord.symbol]) {
3105
+ await closeBrokerStopBeforeExit(acc, ord.symbol);
3106
+ }
3059
3107
  const r = await sendOrderToBroker(acc, ord);
3060
3108
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
3061
3109
  delete acc.entryTsBySymbol[ord.symbol];
@@ -3068,10 +3116,10 @@ async function checkIntradayProfitLock(accId) {
3068
3116
  }
3069
3117
  acc.pendingBatch = {
3070
3118
  ...(acc.pendingBatch || {}),
3071
- id: `batch_tp_${execNowTs}`,
3119
+ id: `batch_stop_${execNowTs}`,
3072
3120
  createdAt: execNowTs,
3073
3121
  executedAt: execNowTs,
3074
- bar: `${fmtBarMsk(execNowTs)} (Take-Profit)`,
3122
+ bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
3075
3123
  orders: lockOrders,
3076
3124
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3077
3125
  results,
@@ -3084,9 +3132,12 @@ async function checkIntradayProfitLock(accId) {
3084
3132
 
3085
3133
  // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
3086
3134
  let autoRefreshBusy = false;
3135
+ let autoRefreshBusySince = 0;
3087
3136
  async function tickBrokerAutoRefresh() {
3088
- if (autoRefreshBusy || schedulerBusy) return;
3137
+ const now = Date.now();
3138
+ if ((autoRefreshBusy && now - autoRefreshBusySince < 90000) || (schedulerBusy && now - schedulerBusySince < 45000)) return;
3089
3139
  autoRefreshBusy = true;
3140
+ autoRefreshBusySince = now;
3090
3141
  try {
3091
3142
  const curYear = new Date(Date.now() + MSK).getUTCFullYear();
3092
3143
  const users = db.prepare('SELECT * FROM users').all();
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import { 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
5
  let realtimeCandleFetcher = null;
@@ -80,9 +80,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
80
80
  const now = Date.now();
81
81
  const pKey = `candProbe:${symbol}:${interval}`;
82
82
  const probe = kvGet(pKey);
83
- // Защита от ложных меток candProbe v1 (где из-за старого условия first.ts <= range.min записывалось fromTs без скачивания):
84
- // валидной считаем только метку v2 (или если range.min реально доходит до fromTs)
85
- 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;
86
88
 
87
89
  if (!range.n) {
88
90
  // В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
@@ -90,28 +92,35 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
90
92
  signal,
91
93
  onBatch: (page) => saveCandles(symbol, interval, page),
92
94
  });
93
- kvSet(pKey, { v: 2, fromTs });
95
+ kvSet(pKey, { v: 3, fromTs });
94
96
  lastTailCheckAt.set(`${symbol}:${interval}`, now);
95
97
  return loadCandles(symbol, interval, fromTs, tillTs);
96
98
  }
97
99
 
98
- // 1) ЛЕВЫЙ КРАЙ:
99
- // Если в локальной базе SQLite уже накоплена история (range.n >= 500 свечей), НИКОГДА не лезем на биржу докачивать левый край
100
- // при смене «Окна обучения» или «Периода исследования» — работаем мгновенно по уже сохранённым в SQLite историческим свечам!
101
- 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) {
102
105
  const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
103
106
  if (!first || first.ts >= range.min - DAY) {
104
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
107
+ probeFrom = fromTs; // раньше имеющихся свечей на бирже истории нет
105
108
  } else {
106
- const leftTill = tsToDate(range.min + DAY);
107
- await fetchCandles(symbol, interval, tsToDate(fromTs), leftTill, {
108
- signal,
109
- onBatch: (page) => saveCandles(symbol, interval, page),
110
- });
111
- 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 });
112
111
  }
113
- } else if (!probe || probe.v !== 2 || probe.fromTs > fromTs) {
114
- 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 });
115
124
  }
116
125
 
117
126
  // 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
@@ -137,7 +146,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
137
146
  // В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
138
147
  let filledByTbank = false;
139
148
  if (isTbankEligible) {
140
- const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 2 * DAY), Math.min(tillTs, now)).catch(() => null);
149
+ const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 3 * DAY + 120000), Math.min(tillTs, now)).catch(() => null);
141
150
  if (tbRows?.length) {
142
151
  saveCandles(symbol, interval, tbRows);
143
152
  filledByTbank = true;
@@ -149,7 +158,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
149
158
  onBatch: (page) => saveCandles(symbol, interval, page),
150
159
  });
151
160
  }
152
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
161
+ kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: now });
153
162
  }
154
163
 
155
164
  return loadCandles(symbol, interval, fromTs, tillTs);
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);