neuroalpha 0.5.27 → 0.5.29

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.
@@ -79,13 +79,29 @@ export const BROKER_META = [
79
79
 
80
80
  // ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
81
81
  const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
82
+
83
+ function withWallTimeout(promise, ms, label = 'Превышено время ожидания') {
84
+ let timer = null;
85
+ return Promise.race([
86
+ promise,
87
+ new Promise((_, reject) => {
88
+ timer = setTimeout(() => reject(new Error(label)), ms);
89
+ timer.unref?.();
90
+ }),
91
+ ]).finally(() => {
92
+ if (timer) clearTimeout(timer);
93
+ });
94
+ }
95
+
82
96
  export async function ensureBoardSpecs() {
83
97
  const cached = kvGet('moexSpecs');
84
98
  if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
85
99
  try {
86
- const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
87
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
88
- const j = await r.json();
100
+ const j = await withWallTimeout((async () => {
101
+ const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
102
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
103
+ return r.json();
104
+ })(), 26000, 'Таймаут загрузки справочника TQBR');
89
105
  const sc = j.securities.columns, mc = j.marketdata.columns;
90
106
  const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
91
107
  const prices = new Map();
@@ -489,6 +505,7 @@ export function saveBrokerAccount(input = {}) {
489
505
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
490
506
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
491
507
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
508
+ entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
492
509
  peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
493
510
  baseRunId: nextBaseRunId,
494
511
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
@@ -751,8 +768,8 @@ export async function fetchTbankRealtimePrices() {
751
768
  }
752
769
  }
753
770
 
754
- // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
755
- export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
771
+ // Возвращает свежие свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
772
+ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false, intervalMin = 10 } = {}) {
756
773
  try {
757
774
  const rawAccs = loadRawAccounts();
758
775
  const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
@@ -764,15 +781,17 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
764
781
  const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
765
782
  const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
766
783
  const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
767
- // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
768
- const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
784
+ const is1m = Number(intervalMin) === 1;
785
+ // Для CANDLE_INTERVAL_1_MIN максимальный диапазон одного запроса в T-Invest API — 1 сутки, для CANDLE_INTERVAL_10_MIN — 3 суток
786
+ const maxSpanMs = is1m ? (86400000 - 60000) : (3 * 86400000 - 60000);
787
+ const safeFrom = Math.max(fromTs, tillTs - maxSpanMs);
769
788
  if (safeFrom >= tillTs) return null;
770
789
  const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
771
790
  const body = JSON.stringify({
772
791
  instrumentId: uid,
773
792
  from: new Date(safeFrom).toISOString(),
774
793
  to: new Date(tillTs).toISOString(),
775
- interval: 'CANDLE_INTERVAL_10_MIN',
794
+ interval: is1m ? 'CANDLE_INTERVAL_1_MIN' : 'CANDLE_INTERVAL_10_MIN',
776
795
  });
777
796
  let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
778
797
  if (r.status === 429 || r.status >= 500) {
@@ -965,10 +984,13 @@ async function syncTbank(acc) {
965
984
  }
966
985
  }
967
986
  }
968
- let marginEnabled = false;
969
- let marginInfo = null;
987
+ const p = await rPort.json();
988
+ const hasShortPos = (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
989
+ let marginEnabled = Boolean(acc.lastSync?.marginEnabled || hasShortPos);
990
+ let marginInfo = acc.lastSync?.marginInfo || null;
970
991
  if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
971
992
  marginEnabled = false;
993
+ marginInfo = null;
972
994
  } else if (rMargin?.ok) {
973
995
  marginEnabled = true;
974
996
  const jMar = await rMargin.json().catch(() => null);
@@ -990,9 +1012,11 @@ async function syncTbank(acc) {
990
1012
  }
991
1013
  } else if (rMargin) {
992
1014
  const mTxt = await rMargin.text().catch(() => '');
993
- marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
1015
+ if (mTxt.includes('30051') || /margin.*disabled/i.test(mTxt)) {
1016
+ marginEnabled = false;
1017
+ marginInfo = null;
1018
+ }
994
1019
  }
995
- const p = await rPort.json();
996
1020
  const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
997
1021
  let rawOrders = jOrd?.orders || [];
998
1022
 
@@ -1464,10 +1488,17 @@ export async function syncBrokerAccount(id) {
1464
1488
  }
1465
1489
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
1466
1490
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
1467
- const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1491
+ const rawDdPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1492
+ // Защита от ложного срабатывания стопа (−99%) при утреннем клиринге брокера, когда API временно отдаёт totalAmountPortfolio ≈ cashRub без оценки бумаг
1493
+ const prevTotalRub = Number(acc.lastSync?.totalRub || 0);
1494
+ const isGlitchZeroPortfolio = rawDdPct >= 50 && prevTotalRub > 50000 && data.totalRub < prevTotalRub * 0.5 && (data.positions || []).some((p) => p.qty !== 0);
1495
+ if (isGlitchZeroPortfolio) {
1496
+ data.totalRub = prevTotalRub;
1497
+ }
1498
+ const ddPct = isGlitchZeroPortfolio ? (Number(acc.lastSync?.dayDrawdownPct) || 0) : rawDdPct;
1468
1499
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
1469
1500
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
1470
- } else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1501
+ } else if (!isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1471
1502
  // Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
1472
1503
  acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
1473
1504
  if (acc.ddBreachCount >= 2) {
@@ -1562,7 +1593,34 @@ export async function freezeBrokerBaseline(id) {
1562
1593
  return st;
1563
1594
  }
1564
1595
 
1565
- // ---------- предохранитель времени сессии (первые и последние 10 минут торгов МСК) ----------
1596
+ // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1597
+ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
1598
+ if (!(avgPx > 0 && curPx > 0)) return null;
1599
+ const isLong = side === 'long';
1600
+ const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
1601
+ const effPeakPx = peakPx > 0
1602
+ ? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
1603
+ : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1604
+ const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1605
+ const tierMult = symbolEdgeMult(sym);
1606
+ // 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
1607
+ // фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
1608
+ if (peakGainPct >= 1.20 * tierMult) {
1609
+ const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
1610
+ if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
1611
+ return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1612
+ }
1613
+ }
1614
+ // 2) Защита заработанного плюса / безубытка (от +0.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
1615
+ // не позволяем плюсовой сделке развернуться обратно в убыток
1616
+ if (peakGainPct >= 0.75 * tierMult) {
1617
+ const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
1618
+ if (curGainPct <= breakevenFloorPct) {
1619
+ return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1620
+ }
1621
+ }
1622
+ return null;
1623
+ }
1566
1624
  function sessionEdgeWarning(now = Date.now()) {
1567
1625
  const d = new Date(now + MSK);
1568
1626
  const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
@@ -1574,17 +1632,45 @@ function sessionEdgeWarning(now = Date.now()) {
1574
1632
  }
1575
1633
 
1576
1634
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1577
- const accountBusyLocks = new Set();
1635
+ const ACCOUNT_LOCK_TTL_MS = 75000;
1636
+ const accountBusyLocks = new Map(); // id -> { since, token }
1637
+
1638
+ function isAccountBusy(id) {
1639
+ const cur = accountBusyLocks.get(id);
1640
+ if (!cur) return false;
1641
+ if (Date.now() - cur.since >= ACCOUNT_LOCK_TTL_MS) {
1642
+ accountBusyLocks.delete(id);
1643
+ return false;
1644
+ }
1645
+ return true;
1646
+ }
1647
+
1648
+ function acquireAccountLock(id) {
1649
+ if (isAccountBusy(id)) return null;
1650
+ const token = Symbol(id);
1651
+ accountBusyLocks.set(id, { since: Date.now(), token });
1652
+ return token;
1653
+ }
1578
1654
 
1579
- export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1580
- if (accountBusyLocks.has(id)) return brokersState();
1581
- accountBusyLocks.add(id);
1655
+ function releaseAccountLock(id, token) {
1656
+ const cur = accountBusyLocks.get(id);
1657
+ if (cur && (!token || cur.token === token)) {
1658
+ accountBusyLocks.delete(id);
1659
+ }
1660
+ }
1661
+
1662
+ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBarTs = 0 } = {}) {
1663
+ const lockToken = acquireAccountLock(id);
1664
+ if (!lockToken) return brokersState();
1582
1665
  try {
1583
1666
  let list = loadRawAccounts();
1584
1667
  let acc = list.find((x) => x.id === id);
1585
1668
  if (!acc) throw new Error('Счёт не найден');
1586
1669
  if (acc.haltedReason) throw new Error(acc.haltedReason);
1587
1670
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1671
+ if (onlyEntries && edgeWarn) {
1672
+ return brokersState();
1673
+ }
1588
1674
 
1589
1675
  const plan = effectivePlan(currentUser());
1590
1676
  if ((plan.maxBrokerAccounts ?? 0) < 1) {
@@ -1602,11 +1688,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1602
1688
  acc = list.find((x) => x.id === id) || acc;
1603
1689
  }
1604
1690
  const params = normalizeParams(getSettings());
1605
- const [fc, rawSpecs, shortableMap] = await Promise.all([
1691
+ const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
1606
1692
  forecastNow(params),
1607
1693
  ensureBoardSpecs(),
1608
1694
  ensureTbankShortableMap(acc),
1609
- ]);
1695
+ ]), 55000, 'Превышено время ожидания расчёта прогноза');
1610
1696
  const specs = { ...rawSpecs };
1611
1697
  if (acc.broker === 'tbank') {
1612
1698
  const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
@@ -1649,6 +1735,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1649
1735
  const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
1650
1736
  const entryTsMap = acc.entryTsBySymbol || {};
1651
1737
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1738
+ const entryFailMap = acc.entryFailCooldownBySymbol || {};
1652
1739
  // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
1653
1740
  // Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
1654
1741
  // что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
@@ -1658,7 +1745,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1658
1745
  const minShortEdgePct = round(baseShortNeedPct, 3);
1659
1746
  const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1660
1747
  const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1661
- const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1748
+ const isInCooldown = (sym) => Boolean(
1749
+ (autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
1750
+ (entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
1751
+ );
1662
1752
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1663
1753
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1664
1754
  // временно исключаем такую бумагу из новых входов!
@@ -1756,11 +1846,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1756
1846
  const smoothSellPool = [];
1757
1847
  const peakMap = acc.peakPriceBySymbol || {};
1758
1848
  const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
1759
- // 1) закрытие бумаг стратегии (покупок и шортов):
1849
+ // 1) закрытие бумаг стратегии (покупок и шортов) на плановом шаге (!onlyEntries):
1760
1850
  // • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
1761
1851
  // • либо если прогноз ослаб ниже порога выхода curExitThr.
1852
+ // В быстром цикле добора (onlyEntries) закрывающие заявки не формируются — сопровождение и выходы идут строго по плановому шагу (и защитным стопам).
1762
1853
  // Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
1763
- for (const [sym, pos] of heldMap) {
1854
+ for (const [sym, pos] of (onlyEntries ? [] : heldMap)) {
1764
1855
  if (!pos.qty) continue;
1765
1856
  const r = rowMap.get(sym);
1766
1857
  if (!r) continue;
@@ -1773,30 +1864,39 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1773
1864
  const lastHistEntryTs = lastHistEntryOp?.date || 0;
1774
1865
  const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
1775
1866
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1776
- // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1777
- // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95) с учётом эшелона
1867
+ const heldMin = heldMs / 60000;
1868
+ const horizonMin = Math.max(90, effHorizon * 60);
1869
+ // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
1778
1870
  const symMult = r.edgeMult || symbolEdgeMult(sym);
1779
- const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symMult, 3);
1871
+ const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
1872
+ const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
1873
+ const curExitThr = round(rampedExitThr * symMult, 3);
1874
+ const softExitThr = round(earlyExitThresholdPct * symMult, 3);
1780
1875
  const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
1781
1876
  const curPx = Number(r.price || pos.price) || 0;
1782
1877
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1783
1878
  if (aQty > 0) {
1784
1879
  const lots = Math.floor(aQty / sp.lotSize);
1785
1880
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1881
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1786
1882
  const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1787
- const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1883
+ // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1884
+ const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1885
+ const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
1788
1886
  const dg = divGuardOf(sym, curPx);
1789
1887
  const divExit = Boolean(dg?.exitLong);
1790
- const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1888
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
1791
1889
  if (lots >= 1 && !keep) {
1792
1890
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1793
1891
  const exitReason = hitHardStopLoss
1794
1892
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1795
- : divExit
1796
- ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1797
- : nightExit
1798
- ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1799
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1893
+ : profitLockReason
1894
+ ? profitLockReason
1895
+ : divExit
1896
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1897
+ : nightExit
1898
+ ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1899
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1800
1900
  orders.push({
1801
1901
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1802
1902
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1808,20 +1908,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1808
1908
  } else if (aQty < 0) {
1809
1909
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1810
1910
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1911
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1811
1912
  const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
1812
- const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1913
+ // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
1914
+ const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1915
+ const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
1813
1916
  const dg = divGuardOf(sym, curPx);
1814
1917
  const divExit = Boolean(dg?.exitShort);
1815
- const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1918
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
1816
1919
  if (lots >= 1 && !keep) {
1817
1920
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1818
1921
  const exitReason = hitHardStopLoss
1819
1922
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1820
- : divExit
1821
- ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1822
- : nightExit
1823
- ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1824
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1923
+ : profitLockReason
1924
+ ? profitLockReason
1925
+ : divExit
1926
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1927
+ : nightExit
1928
+ ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1929
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1825
1930
  orders.push({
1826
1931
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1827
1932
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -2128,11 +2233,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2128
2233
  }
2129
2234
 
2130
2235
  const batchNowTs = Date.now();
2236
+ const batchOrderSig = (arr) => (Array.isArray(arr) ? arr.map((o) => `${o.action}:${o.symbol}:${o.lots}`).join('|') : '');
2237
+ const prevPending = acc.pendingBatch;
2238
+ const curBatchSig = batchOrderSig(orders);
2239
+ const isDuplicatePending = Boolean(
2240
+ curBatchSig &&
2241
+ prevPending?.status === 'pending' &&
2242
+ batchOrderSig(prevPending.orders) === curBatchSig &&
2243
+ batchNowTs - (prevPending.createdAt || 0) < 2 * 3600000,
2244
+ );
2131
2245
  if (!onlyEntries) {
2132
2246
  acc.lastFullStepWallTs = batchNowTs;
2247
+ if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
2133
2248
  }
2134
2249
  acc.pendingBatch = {
2135
- id: `batch_${batchNowTs}`,
2250
+ id: isDuplicatePending && prevPending?.id ? prevPending.id : `batch_${batchNowTs}`,
2136
2251
  createdAt: batchNowTs,
2137
2252
  bar: fc.bar,
2138
2253
  budgetRub: budget,
@@ -2149,16 +2264,18 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2149
2264
  results: [],
2150
2265
  };
2151
2266
  saveRawAccounts(list);
2152
- notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
2153
2267
  if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
2154
2268
  return await executeBrokerOrders(id, { _lockHeld: true });
2155
2269
  }
2270
+ if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
2271
+ notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
2272
+ }
2156
2273
  const st = brokersState();
2157
2274
  hub.emit({ type: 'brokers:update', state: st });
2158
2275
  hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
2159
2276
  return st;
2160
2277
  } finally {
2161
- accountBusyLocks.delete(id);
2278
+ releaseAccountLock(id, lockToken);
2162
2279
  }
2163
2280
  }
2164
2281
 
@@ -2258,7 +2375,7 @@ async function sendOrderToBroker(acc, ord) {
2258
2375
  for (const ao of jActive?.orders || []) {
2259
2376
  const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
2260
2377
  const sameDir = String(ao.direction || '').includes(wantDir);
2261
- if (sameInst && sameDir && ao.orderId) {
2378
+ if (sameInst && (sameDir || !isEntry) && ao.orderId) {
2262
2379
  await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
2263
2380
  method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
2264
2381
  }).catch(() => null);
@@ -2474,12 +2591,13 @@ async function sendOrderToBroker(acc, ord) {
2474
2591
  }
2475
2592
 
2476
2593
  export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2594
+ let lockToken = null;
2477
2595
  if (!_lockHeld) {
2478
2596
  // Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
2479
2597
  const waitUntil = Date.now() + 15000;
2480
- while (accountBusyLocks.has(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2481
- if (accountBusyLocks.has(id)) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2482
- accountBusyLocks.add(id);
2598
+ while (isAccountBusy(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2599
+ lockToken = acquireAccountLock(id);
2600
+ if (!lockToken) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2483
2601
  }
2484
2602
  try {
2485
2603
  const list = loadRawAccounts();
@@ -2501,8 +2619,10 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2501
2619
  saveRawAccounts(list);
2502
2620
  const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
2503
2621
  const results = [];
2622
+ const newlySentOrders = [];
2504
2623
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2505
2624
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2625
+ acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
2506
2626
  acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2507
2627
  const execNowTs = Date.now();
2508
2628
  const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
@@ -2530,11 +2650,21 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2530
2650
  }
2531
2651
  }
2532
2652
  const r = await sendOrderToBroker(acc, ord);
2653
+ if (r.lots > 0) {
2654
+ ord.lots = r.lots;
2655
+ ord.shares = r.lots * (ord.lotSize || 1);
2656
+ }
2657
+ if (r.limitPrice > 0) {
2658
+ ord.limitPrice = r.limitPrice;
2659
+ ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
2660
+ }
2533
2661
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2662
+ newlySentOrders.push(ord);
2534
2663
  if (ord.action === 'buy' || ord.action === 'short') {
2535
2664
  acc.entryTsBySymbol[ord.symbol] = execNowTs;
2536
2665
  acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2537
2666
  delete acc.exitCooldownBySymbol[ord.symbol];
2667
+ delete acc.entryFailCooldownBySymbol[ord.symbol];
2538
2668
  } else if (ord.action === 'sell' || ord.action === 'cover') {
2539
2669
  hadExits = true;
2540
2670
  delete acc.entryTsBySymbol[ord.symbol];
@@ -2547,6 +2677,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2547
2677
  ord.sendAttempts = (ord.sendAttempts || 0) + 1;
2548
2678
  if (e.definitive) ord.clientOrderId = crypto.randomUUID();
2549
2679
  else ord.uncertainSend = true;
2680
+ if (ord.action === 'buy' || ord.action === 'short') {
2681
+ acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
2682
+ }
2550
2683
  results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2551
2684
  logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
2552
2685
  }
@@ -2555,6 +2688,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2555
2688
  batch.results = results;
2556
2689
  batch.executedAt = Date.now();
2557
2690
  saveRawAccounts(list);
2691
+ if (acc.mode === 'auto' && newlySentOrders.length > 0) {
2692
+ notifyBrokerBatch(currentUserId(), acc, { ...batch, orders: newlySentOrders }).catch(() => {});
2693
+ }
2558
2694
  await syncBrokerAccount(id).catch(() => {});
2559
2695
  if (acc.broker === 'tbank' && acc.token) {
2560
2696
  await fetchBrokerHistory(id).catch(() => {});
@@ -2563,7 +2699,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2563
2699
  hub.emit({ type: 'brokers:update', state: st });
2564
2700
  return st;
2565
2701
  } finally {
2566
- if (!_lockHeld) accountBusyLocks.delete(id);
2702
+ if (!_lockHeld && lockToken) releaseAccountLock(id, lockToken);
2567
2703
  }
2568
2704
  }
2569
2705
 
@@ -2789,24 +2925,27 @@ async function tickBrokerSchedule() {
2789
2925
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
2790
2926
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
2791
2927
  const minStepMs = stepBars * BAR_MS;
2792
- const lastFullWallTs = acc.lastFullStepWallTs || 0;
2793
- const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2928
+ const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
2929
+ const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
2930
+ const lastAnyWallTs = acc.pendingBatch?.executedAt || batchCreatedTs || 0;
2931
+ // Если отметка шага была записана без фактического обновления pendingBatch, не считаем такой шаг состоявшимся
2932
+ const lastFullWallTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? rawLastFullWallTs : batchCreatedTs;
2933
+ const lastSchedBarTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? (acc.lastScheduledBarTs || 0) : 0;
2794
2934
  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
2935
  const nowTs = Date.now();
2796
2936
  const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
2797
2937
  const dueFullStep = Boolean(
2798
2938
  nightExitDue || (
2799
2939
  latest &&
2800
- (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2940
+ (latest - lastSchedBarTs >= minStepMs) &&
2801
2941
  !(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
2802
2942
  ),
2803
2943
  );
2804
2944
  if (dueFullStep) {
2805
- acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
2806
- acc.lastFullStepWallTs = nowTs;
2807
- saveRawAccounts(list);
2945
+ if (isAccountBusy(acc.id)) continue;
2946
+ const stepBarTs = latest || acc.lastScheduledBarTs || nowTs;
2808
2947
  if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2809
- await prepareBrokerOrders(acc.id).catch(() => {});
2948
+ await prepareBrokerOrders(acc.id, { scheduledBarTs: stepBarTs }).catch(() => {});
2810
2949
  continue;
2811
2950
  }
2812
2951
  // Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
@@ -2814,7 +2953,7 @@ async function tickBrokerSchedule() {
2814
2953
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2815
2954
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2816
2955
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2817
- if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2956
+ if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2818
2957
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2819
2958
  }
2820
2959
  }
@@ -2920,12 +3059,12 @@ async function reconcileBrokerStops(accId) {
2920
3059
  return;
2921
3060
  }
2922
3061
  if (!acc.lastSync?.ok) return;
2923
- if (accountBusyLocks.has(accId)) return; // идёт исполнение пакета — сверимся в следующий раз
2924
- accountBusyLocks.add(accId);
3062
+ const lockToken = acquireAccountLock(accId);
3063
+ if (!lockToken) return; // идёт исполнение пакета — сверимся в следующий раз
2925
3064
  try {
2926
- await reconcileBrokerStopsLocked(list, acc);
3065
+ await withWallTimeout(reconcileBrokerStopsLocked(list, acc), 35000, 'Превышено время сверки стоп-заявок');
2927
3066
  } finally {
2928
- accountBusyLocks.delete(accId);
3067
+ releaseAccountLock(accId, lockToken);
2929
3068
  }
2930
3069
  }
2931
3070
 
@@ -3044,8 +3183,7 @@ async function checkIntradayProfitLock(accId) {
3044
3183
  if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
3045
3184
  const aQty = algoQtyOf(sym, pos.qty);
3046
3185
  if (!aQty) continue;
3047
- // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
3048
- if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
3186
+ const hasBrokerHardStop = Boolean(acc.brokerStops && acc.brokerStopBySymbol?.[sym]);
3049
3187
  // Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
3050
3188
  const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
3051
3189
  if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
@@ -3062,26 +3200,30 @@ async function checkIntradayProfitLock(accId) {
3062
3200
  const lots = Math.floor(aQty / sp.lotSize);
3063
3201
  if (lots < 1) continue;
3064
3202
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3065
- const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT;
3066
- if (hitHardStopLoss) {
3203
+ const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3204
+ const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3205
+ if (hitHardStopLoss || profitLockReason) {
3067
3206
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
3068
3207
  lockOrders.push({
3069
3208
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
3070
3209
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
3071
- reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)`,
3210
+ reason: hitHardStopLoss ? `Авто-стоп позиции (${round(curGainPct, 2)}%)` : profitLockReason,
3211
+ isStopLoss: hitHardStopLoss,
3072
3212
  });
3073
3213
  }
3074
3214
  } else if (aQty < 0) {
3075
3215
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3076
3216
  if (lots < 1) continue;
3077
3217
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3078
- const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT;
3079
- if (hitHardStopLoss) {
3218
+ const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3219
+ const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3220
+ if (hitHardStopLoss || profitLockReason) {
3080
3221
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
3081
3222
  lockOrders.push({
3082
3223
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
3083
3224
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
3084
- reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)`,
3225
+ reason: hitHardStopLoss ? `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)` : profitLockReason,
3226
+ isStopLoss: hitHardStopLoss,
3085
3227
  });
3086
3228
  }
3087
3229
  }
@@ -3089,6 +3231,7 @@ async function checkIntradayProfitLock(accId) {
3089
3231
 
3090
3232
  if (!lockOrders.length) return;
3091
3233
  const results = [];
3234
+ const sentLockOrders = [];
3092
3235
  const execNowTs = Date.now();
3093
3236
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
3094
3237
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
@@ -3099,16 +3242,25 @@ async function checkIntradayProfitLock(accId) {
3099
3242
  await cancelBrokerOrder(accId, co.orderId).catch(() => {});
3100
3243
  }
3101
3244
  for (const ord of lockOrders) {
3102
- logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3245
+ logBrokerEvent(acc, ord.isStopLoss ? 'protective_stop' : 'profit_lock', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3103
3246
  try {
3104
3247
  if (acc.brokerStopBySymbol?.[ord.symbol]) {
3105
3248
  await closeBrokerStopBeforeExit(acc, ord.symbol);
3106
3249
  }
3107
3250
  const r = await sendOrderToBroker(acc, ord);
3251
+ if (r.lots > 0) {
3252
+ ord.lots = r.lots;
3253
+ ord.shares = r.lots * (ord.lotSize || 1);
3254
+ }
3255
+ if (r.limitPrice > 0) {
3256
+ ord.limitPrice = r.limitPrice;
3257
+ ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
3258
+ }
3108
3259
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
3260
+ sentLockOrders.push(ord);
3109
3261
  delete acc.entryTsBySymbol[ord.symbol];
3110
3262
  delete acc.peakPriceBySymbol[ord.symbol];
3111
- acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3263
+ if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3112
3264
  } catch (e) {
3113
3265
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
3114
3266
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
@@ -3119,12 +3271,15 @@ async function checkIntradayProfitLock(accId) {
3119
3271
  id: `batch_stop_${execNowTs}`,
3120
3272
  createdAt: execNowTs,
3121
3273
  executedAt: execNowTs,
3122
- bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
3274
+ bar: `${fmtBarMsk(execNowTs)} (Фиксация прибыли / Защита)`,
3123
3275
  orders: lockOrders,
3124
3276
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3125
3277
  results,
3126
3278
  };
3127
3279
  saveRawAccounts(list);
3280
+ if (sentLockOrders.length > 0) {
3281
+ notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
3282
+ }
3128
3283
  await syncBrokerAccount(accId).catch(() => {});
3129
3284
  const st = brokersState();
3130
3285
  hub.emit({ type: 'brokers:update', state: st });
@@ -3151,7 +3306,9 @@ async function tickBrokerAutoRefresh() {
3151
3306
  if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
3152
3307
  await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
3153
3308
  }
3154
- if (acc.broker === 'tbank' && (!acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 120000)) {
3309
+ const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3310
+ const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
3311
+ if (acc.broker === 'tbank' && historyNeedSync) {
3155
3312
  await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
3156
3313
  }
3157
3314
  // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)