neuroalpha 0.5.33 → 0.6.0

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.
@@ -1,6 +1,9 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
3
4
  import https from 'node:https';
5
+ import path from 'node:path';
6
+ import { ROOT } from '../config.js';
4
7
  import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
5
8
  import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
6
9
  import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
@@ -10,6 +13,7 @@ import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
10
13
  import { forecastNow } from '../model/forecast.js';
11
14
  import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
12
15
  import { liveDividendGuard } from '../model/dividends.js';
16
+ import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
13
17
  import { MODELS } from '../model/models.js';
14
18
  import { blockingNews } from '../news/index.js';
15
19
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
@@ -193,6 +197,45 @@ function logBrokerEvent(acc, kind, text) {
193
197
  } catch { /* журнал не должен ломать торговлю */ }
194
198
  }
195
199
 
200
+ // ---------- журнал торговых решений: почему открыта или закрыта каждая позиция (для разбора дня и сверки с симулятором) ----------
201
+ db.exec(`CREATE TABLE IF NOT EXISTS trade_decisions (
202
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
203
+ ts INTEGER NOT NULL,
204
+ user_id INTEGER NOT NULL,
205
+ account_id TEXT NOT NULL,
206
+ symbol TEXT NOT NULL,
207
+ action TEXT NOT NULL,
208
+ version TEXT,
209
+ data TEXT NOT NULL
210
+ );
211
+ CREATE INDEX IF NOT EXISTS trade_decisions_acc ON trade_decisions(user_id, account_id, ts);`);
212
+
213
+ const APP_VERSION = (() => {
214
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || ''; } catch { return ''; }
215
+ })();
216
+
217
+ function logTradeDecision(acc, ord, result = {}) {
218
+ try {
219
+ const data = {
220
+ reason: ord.reason || '',
221
+ lots: result.lots ?? ord.lots,
222
+ limitPrice: result.limitPrice ?? ord.limitPrice,
223
+ refPrice: ord.refPrice,
224
+ amountRub: ord.amountRub,
225
+ isStopLoss: Boolean(ord.isStopLoss),
226
+ ...(ord.decision || {}),
227
+ };
228
+ db.prepare('INSERT INTO trade_decisions (ts, user_id, account_id, symbol, action, version, data) VALUES (?, ?, ?, ?, ?, ?, ?)')
229
+ .run(Date.now(), currentUserId(), String(acc?.id || ''), String(ord.symbol), String(ord.action), APP_VERSION, JSON.stringify(data));
230
+ } catch { /* журнал не должен ломать торговлю */ }
231
+ }
232
+
233
+ export function listTradeDecisions(accountId, { fromTs = 0, limit = 500 } = {}) {
234
+ return db.prepare('SELECT ts, symbol, action, version, data FROM trade_decisions WHERE user_id = ? AND account_id = ? AND ts >= ? ORDER BY id DESC LIMIT ?')
235
+ .all(currentUserId(), String(accountId), Number(fromTs) || 0, Math.max(1, Math.min(5000, Number(limit) || 500)))
236
+ .map((r) => { let d = {}; try { d = JSON.parse(r.data); } catch { /* пусто */ } return { ts: r.ts, symbol: r.symbol, action: r.action, version: r.version, ...d }; });
237
+ }
238
+
196
239
  export function listBrokerEvents(accountId, limit = 200) {
197
240
  return db.prepare('SELECT ts, kind, text FROM broker_events WHERE user_id = ? AND account_id = ? ORDER BY id DESC LIMIT ?')
198
241
  .all(currentUserId(), String(accountId), Math.max(1, Math.min(1000, Number(limit) || 200)));
@@ -500,7 +543,11 @@ export function saveBrokerAccount(input = {}) {
500
543
  const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
501
544
  const nextBaseRunId = input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null);
502
545
  const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.02)));
546
+ const nextLeverage = Math.max(1, Math.min(planMaxLeverage(plan), Math.round((Number(input.maxLeverage ?? prev.maxLeverage ?? 1) || 1) * 2) / 2));
547
+ const rawTotalDd = Number(input.maxDrawdownPct ?? prev.maxDrawdownPct ?? 0) || 0;
548
+ const nextTotalDd = rawTotalDd > 0 ? Math.max(3, Math.min(60, rawTotalDd)) : 0;
503
549
  const strategyOrCapChanged = idx < 0
550
+ || nextLeverage !== (prev.maxLeverage || 1)
504
551
  || acctChanged
505
552
  || nextBaseRunId !== (prev.baseRunId || null)
506
553
  || maxCapitalRub !== (prev.maxCapitalRub || 100000)
@@ -524,11 +571,18 @@ export function saveBrokerAccount(input = {}) {
524
571
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
525
572
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
526
573
  entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
574
+ entryFailReasonBySymbol: acctChanged ? {} : (prev.entryFailReasonBySymbol || {}),
575
+ entryDayBlockBySymbol: acctChanged ? {} : (prev.entryDayBlockBySymbol || {}),
527
576
  peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
528
577
  baseRunId: nextBaseRunId,
529
578
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
530
579
  maxCapitalRub,
531
580
  maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
581
+ // плечо: множитель размера позиций 1-го эшелона и предел суммарной позиции (× капитал); 1 — без плеча
582
+ maxLeverage: nextLeverage,
583
+ // стоп по общей просадке от максимума капитала (0 — выключен): запрещает новые входы до ручного снятия
584
+ maxDrawdownPct: nextTotalDd,
585
+ equityPeakRub: acctChanged ? 0 : (prev.equityPeakRub || 0),
532
586
  limitOffsetPct: nextLimitOffset,
533
587
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
534
588
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
@@ -574,6 +628,16 @@ export function resetBrokerDailyStop(id) {
574
628
  const acc = list.find((x) => x.id === id);
575
629
  if (!acc) throw new Error('Счёт не найден');
576
630
  if (!acc.haltedReason) throw new Error('Дневной стоп на этом счёте не активен');
631
+ if (acc.haltedReason.startsWith(TOTAL_DD_PREFIX)) {
632
+ // снятие стопа по общей просадке: отсчёт просадки начинается заново от текущей оценки счёта
633
+ logBrokerEvent(acc, 'total_dd_reset', `снят вручную (${acc.haltedReason})`);
634
+ acc.haltedReason = null;
635
+ acc.equityPeakRub = Number(acc.lastSync?.totalRub) || 0;
636
+ saveRawAccounts(list);
637
+ const st0 = brokersState();
638
+ hub.emit({ type: 'brokers:update', state: st0 });
639
+ return st0;
640
+ }
577
641
  if (!acc.haltedReason.startsWith('Сработал дневной стоп')) throw new Error('Эту блокировку снять нельзя: ' + acc.haltedReason);
578
642
  const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
579
643
  logBrokerEvent(acc, 'daily_stop_reset', `снят вручную до конца дня (${acc.haltedReason})`);
@@ -1526,6 +1590,17 @@ export async function syncBrokerAccount(id) {
1526
1590
  } else {
1527
1591
  acc.ddBreachCount = 0;
1528
1592
  }
1593
+ // Стоп по общей просадке от максимума оценки счёта (настройка maxDrawdownPct). Вывод денег со счёта уменьшает оценку —
1594
+ // после вывода стоп снимается вручную («Снять стоп»), и отсчёт начинается от новой оценки.
1595
+ if (!isGlitchZeroPortfolio && data.totalRub > 0) {
1596
+ const peak = Math.max(Number(acc.equityPeakRub) || 0, data.totalRub);
1597
+ acc.equityPeakRub = round(peak, 2);
1598
+ const totalDd = peak > 0 ? round(((peak - data.totalRub) / peak) * 100, 2) : 0;
1599
+ if (acc.maxDrawdownPct > 0 && totalDd >= acc.maxDrawdownPct && !acc.haltedReason) {
1600
+ acc.haltedReason = `${TOTAL_DD_PREFIX}: −${totalDd}% от максимума ${fmtRubSimple(peak)} ₽ (порог ${acc.maxDrawdownPct}%)`;
1601
+ logBrokerEvent(acc, 'total_dd_stop', acc.haltedReason);
1602
+ }
1603
+ }
1529
1604
  // История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
1530
1605
  const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
1531
1606
  const nowTs = Date.now();
@@ -1611,46 +1686,17 @@ export async function freezeBrokerBaseline(id) {
1611
1686
  return st;
1612
1687
  }
1613
1688
 
1614
- // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1615
- function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 999) {
1616
- if (!(avgPx > 0 && curPx > 0)) return null;
1617
- const isLong = side === 'long';
1618
- const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
1619
- const effPeakPx = peakPx > 0
1620
- ? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
1621
- : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1622
- const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1623
- const tierMult = symbolEdgeMult(sym);
1624
- // Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
1625
- const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
1626
- // 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
1627
- // фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
1628
- const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
1629
- if (peakGainPct >= tpTriggerPct) {
1630
- const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
1631
- if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
1632
- return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1633
- }
1634
- }
1635
- // 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
1636
- // не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
1637
- if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
1638
- const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
1639
- if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
1640
- return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1641
- }
1642
- }
1643
- return null;
1644
- }
1645
- function sessionEdgeWarning(now = Date.now()) {
1646
- const d = new Date(now + MSK);
1647
- const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
1648
- // открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1649
- if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 + 25) || (mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
1650
- return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
1651
- }
1652
- return null;
1689
+ // Дневной стоп по просадке запрещает только НОВЫЕ входы: выходы по сигналу, фиксация прибыли, стопы и закрытие перед ночью продолжают работать
1690
+ // Стоп по общей просадке от максимума капитала действует так же (только выходы), но не снимается сам с началом нового дня
1691
+ const TOTAL_DD_PREFIX = 'Сработал стоп по общей просадке';
1692
+ const isDailyStopHalt = (acc) => Boolean(acc?.haltedReason && (acc.haltedReason.startsWith('Сработал дневной стоп') || acc.haltedReason.startsWith(TOTAL_DD_PREFIX)));
1693
+ // Предел плеча по тарифу: «Инвестор» — без плеча (только покупки), «Профи» — до 3, «VIP» и «Хозяин» — до MAX_LEVERAGE
1694
+ function planMaxLeverage(plan) {
1695
+ if (plan?.isOwner || plan?.id === 'owner' || plan?.allowVipBoost || plan?.id === 'vip') return MAX_LEVERAGE;
1696
+ if (plan?.id === 'pro') return 3;
1697
+ return 1;
1653
1698
  }
1699
+ const isExitOrder = (o) => o.action === 'sell' || o.action === 'cover';
1654
1700
 
1655
1701
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1656
1702
  const ACCOUNT_LOCK_TTL_MS = 75000;
@@ -1767,9 +1813,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1767
1813
  let list = loadRawAccounts();
1768
1814
  let acc = list.find((x) => x.id === id);
1769
1815
  if (!acc) throw new Error('Счёт не найден');
1770
- if (acc.haltedReason) throw new Error(acc.haltedReason);
1816
+ const exitsOnly = isDailyStopHalt(acc);
1817
+ if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1771
1818
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1772
- if (onlyEntries && edgeWarn) {
1819
+ if (onlyEntries && (edgeWarn || exitsOnly)) {
1773
1820
  return brokersState();
1774
1821
  }
1775
1822
 
@@ -1825,6 +1872,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1825
1872
  const vipBoost = Boolean(plan.allowVipBoost);
1826
1873
  const rawDir = params.modelDirection || 'long';
1827
1874
  const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
1875
+ // Плечо счёта (только при включённой маржинальной торговле и в пределах тарифа) и режим «капитал в LQDT как залог»:
1876
+ // внутридневные позиции открываются под обеспечение, LQDT под входы не продаётся; предел заявки проверяет брокер (GetMaxLots)
1877
+ const leverage = marginAllowed ? Math.max(1, Math.min(planMaxLeverage(plan), Number(acc.maxLeverage) || 1)) : 1;
1878
+ const collateral = Boolean(STRATEGY_POLICY.cashYield && acc.cashSweep && acc.broker === 'tbank' && marginAllowed);
1879
+ const marginSizing = marginAllowed && (collateral || leverage > 1);
1828
1880
  // Единые пороги удержания с эталонным симулятором (research.js): первые 65 минут — мягкий порог 0.35, затем плотный порог 0.95 (с учётом эшелона)
1829
1881
  const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
1830
1882
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
@@ -1837,6 +1889,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1837
1889
  const entryTsMap = acc.entryTsBySymbol || {};
1838
1890
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1839
1891
  const entryFailMap = acc.entryFailCooldownBySymbol || {};
1892
+ const entryFailReasonMap = acc.entryFailReasonBySymbol || {};
1840
1893
  // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
1841
1894
  // Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
1842
1895
  // что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
@@ -1846,10 +1899,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1846
1899
  const minShortEdgePct = round(baseShortNeedPct, 3);
1847
1900
  const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1848
1901
  const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1849
- const isInCooldown = (sym) => Boolean(
1850
- (autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
1851
- (entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
1852
- );
1902
+ const isExitCooldown = (sym) => Boolean(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000));
1903
+ const isEntryFailCooldown = (sym) => Boolean(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000));
1904
+ const isInCooldown = (sym) => isExitCooldown(sym) || isEntryFailCooldown(sym);
1905
+ // вход, отклонённый брокером из-за ценового коридора (код 30099), заблокирован в эту сторону до конца дня
1906
+ const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
1907
+ const entryDayBlockMap = acc.entryDayBlockBySymbol || {};
1908
+ const isDayBlocked = (sym, side) => entryDayBlockMap[sym]?.day === todayMsk && entryDayBlockMap[sym]?.side === side;
1853
1909
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1854
1910
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1855
1911
  // временно исключаем такую бумагу из новых входов!
@@ -1909,15 +1965,17 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1909
1965
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
1910
1966
  const nb = params.newsFilter ? blockingNews(6) : null;
1911
1967
 
1912
- const cooldownSkipped = fc.rows.filter((r) => ((r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))) && isInCooldown(r.symbol));
1968
+ const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
1969
+ const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
1970
+ const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
1913
1971
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1914
1972
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1915
1973
  const divBlocked = [];
1916
- const entryTimeOk = timeOk && !edgeWarn;
1917
- const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1974
+ const entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
1975
+ const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1918
1976
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1919
1977
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1920
- const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1978
+ const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1921
1979
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1922
1980
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1923
1981
  const nonShortableSkipped = [];
@@ -1980,8 +2038,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1980
2038
  if (aQty > 0) {
1981
2039
  const lots = Math.floor(aQty / sp.lotSize);
1982
2040
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1983
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
1984
- const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
2041
+ const profitLockReason = autoAdapt && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
2042
+ const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT || Boolean(autoAdapt && r.regimeCrash);
1985
2043
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1986
2044
  const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1987
2045
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
@@ -2004,14 +2062,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2004
2062
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2005
2063
  reason: exitReason,
2006
2064
  isStopLoss: hitHardStopLoss,
2065
+ decision: { bar: fc.bar, expectedPct: r.expectedPct, exitThresholdPct: curExitThr, heldMin: round(heldMin, 1), gainPct: round(curGainPct, 2), avgPrice: avgPx, day: r.day || null },
2007
2066
  });
2008
2067
  closing.add(sym);
2009
2068
  }
2010
2069
  } else if (aQty < 0) {
2011
2070
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2012
2071
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
2013
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
2014
- const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
2072
+ const profitLockReason = autoAdapt && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
2073
+ const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT || Boolean(autoAdapt && r.regimeSqueeze);
2015
2074
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
2016
2075
  const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
2017
2076
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
@@ -2034,6 +2093,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2034
2093
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2035
2094
  reason: exitReason,
2036
2095
  isStopLoss: hitHardStopLoss,
2096
+ decision: { bar: fc.bar, expectedPct: r.expectedPct, exitThresholdPct: -curExitThr, heldMin: round(heldMin, 1), gainPct: round(curGainPct, 2), avgPrice: avgPx, day: r.day || null },
2037
2097
  });
2038
2098
  closing.add(sym);
2039
2099
  }
@@ -2070,7 +2130,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2070
2130
  // Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
2071
2131
  // если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
2072
2132
  // освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
2073
- if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 && (buyCands.length > 0 || shortCands.length > 0)) {
2133
+ if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 * (marginSizing ? leverage : 1) && (buyCands.length > 0 || shortCands.length > 0)) {
2074
2134
  const bestNewPct = Math.max(
2075
2135
  buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
2076
2136
  shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
@@ -2123,7 +2183,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2123
2183
  .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
2124
2184
  .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
2125
2185
  const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
2126
- let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
2186
+ let availRub = marginSizing
2187
+ ? Math.max(0, budget * 0.99 * leverage - usedRub)
2188
+ : Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
2127
2189
 
2128
2190
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
2129
2191
  const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)) && !(onlyEntries && activeOrderSymbols.has(r.symbol)));
@@ -2202,7 +2264,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2202
2264
  ];
2203
2265
  // Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
2204
2266
  // сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
2205
- if (acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
2267
+ if (!collateral && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
2206
2268
  const lqdtPos = heldMap.get('LQDT');
2207
2269
  const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
2208
2270
  if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
@@ -2251,16 +2313,18 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2251
2313
  remWeightSum = Math.max(0, remWeightSum - wCur);
2252
2314
  remNew--;
2253
2315
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2254
- const rawSideCapRub = side === 'short'
2316
+ // множитель размера по эшелону: 1-й эшелон — плечо счёта, 2–3-й — половинный размер (policy.js)
2317
+ const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage) : 1;
2318
+ const rawSideCapRub = tierMult * (side === 'short'
2255
2319
  ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
2256
- : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
2320
+ : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub));
2257
2321
  // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
2258
2322
  // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2259
2323
  const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2260
2324
  const sideCapRub = (!isTier1 && barTurnoverRub > 0)
2261
2325
  ? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
2262
2326
  : rawSideCapRub;
2263
- const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2327
+ const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult * convOf(r));
2264
2328
  const sum = Math.min(rawSum, propShare, availRub);
2265
2329
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
2266
2330
  const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
@@ -2270,6 +2334,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2270
2334
  orders.push({
2271
2335
  ...ord,
2272
2336
  expectedPct: r.expectedPct,
2337
+ useMargin: marginSizing,
2338
+ decision: {
2339
+ bar: fc.bar, expectedPct: r.expectedPct,
2340
+ thresholdPct: side === 'buy' ? reqBuyEdgeOf(r.symbol) : reqShortEdgeOf(r.symbol),
2341
+ edgeMult: r.edgeMult, sigmaBarPct: r.sigmaPct, lastMovePct: r.lastMovePct, selfPct: r.selfPct,
2342
+ marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(rawSum, 0), leverage, tierMult, collateral,
2343
+ stopPct: protectiveStopPct(side === 'buy' ? 'long' : 'short'), day: r.day || null, onlyEntries: Boolean(onlyEntries),
2344
+ },
2273
2345
  reason: side === 'buy'
2274
2346
  ? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
2275
2347
  : `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
@@ -2280,16 +2352,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2280
2352
  // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
2281
2353
  // учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
2282
2354
  // Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
2283
- if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
2355
+ if (!onlyEntries && !exitsOnly && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
2284
2356
  const mskNow = new Date(nowTs + MSK);
2285
2357
  const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
2286
2358
  const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
2287
2359
  const hadStockTrade24h = histOpsAll.some((op) => (op.kind === 'buy' || op.kind === 'sell') && op.symbol && op.symbol !== 'LQDT' && op.symbol !== 'TMON@' && (nowTs - (op.date || 0) < DAY));
2288
- if (isFridayNight || !hadStockTrade24h) {
2360
+ // В режиме залога LQDT под входы не продаётся, поэтому свободные рубли размещаются сразу, как только у стратегии нет открытых позиций
2361
+ // (пока позиции открыты, рубли от шортов нужны для их закрытия — иначе к ночи образуется заём с платой за перенос)
2362
+ const flatNow = ![...heldMap.values()].some((q) => q.symbol !== 'LQDT' && rowMap.has(q.symbol) && algoQtyOf(q.symbol, q.qty) !== 0 && !closing.has(q.symbol));
2363
+ if (collateral ? flatNow : (isFridayNight || !hadStockTrade24h)) {
2289
2364
  const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
2290
2365
  const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
2291
2366
  const reserveRub = Math.max(5000, round(budget * 0.03, 0));
2292
- const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
2367
+ const cashNowRub = acc.lastSync?.cashRub || 0;
2368
+ const sweepRub = collateral ? cashNowRub - reserveRub : Math.min(availRub - reserveRub, cashNowRub - reserveRub);
2293
2369
  if (lqdtPx > 0 && sweepRub >= 20000) {
2294
2370
  const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
2295
2371
  if (ord) {
@@ -2316,7 +2392,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2316
2392
  planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
2317
2393
  }
2318
2394
  if (!orders.length) {
2319
- if (!timeOk) {
2395
+ if (exitsOnly) {
2396
+ diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
2397
+ } else if (!timeOk) {
2320
2398
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2321
2399
  } else if (edgeWarn) {
2322
2400
  diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
@@ -2334,14 +2412,19 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2334
2412
  diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
2335
2413
  } else if (!newBuys.length && !newShorts.length) {
2336
2414
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2337
- const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
2415
+ const failNote = entryFailSkipped.length
2416
+ ? ` Заявка по ${entryFailSkipped.map((r) => `${r.symbol} (${entryFailReasonMap[r.symbol] || 'отказ шлюза брокера'})`).join('; ')} временно отложена на 5 мин после ошибки приёма у брокера.`
2417
+ : '';
2418
+ const dayBlocked = fc.rows.filter((r) => (r.signal && isDayBlocked(r.symbol, 'buy')) || (r.shortSignal && isDayBlocked(r.symbol, 'short')));
2419
+ const dayBlockNote = dayBlocked.length ? ` Сигнал ${dayBlocked.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} пропущен до конца дня: брокер отклонил вход из-за ценового коридора — бумага у планки Мосбиржи.` : '';
2420
+ const cdNote = dayBlockNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
2338
2421
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2339
2422
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2340
2423
  const thrRangeStr = dir !== 'long'
2341
2424
  ? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
2342
2425
  : `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
2343
- diagNote = cdNote
2344
- ? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
2426
+ diagNote = (failNote || cdNote)
2427
+ ? `На свече ${fc.bar} новых входов нет.${failNote}${cdNote}${noShortNote}`
2345
2428
  : `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
2346
2429
  }
2347
2430
  }
@@ -2422,6 +2505,9 @@ function formatTbankOrderError(status, rawText = '', ord = {}) {
2422
2505
  if (code.includes('30052') || /short.*disabled|short.*not/i.test(code)) {
2423
2506
  return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
2424
2507
  }
2508
+ if (code.includes('30099') || /outside the limits/i.test(code)) {
2509
+ return 'Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)';
2510
+ }
2425
2511
  return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
2426
2512
  }
2427
2513
 
@@ -2543,8 +2629,15 @@ async function sendOrderToBroker(acc, ord) {
2543
2629
  }).catch(() => null);
2544
2630
  if (rMax?.ok) {
2545
2631
  const jMax = await rMax.json().catch(() => null);
2632
+ // покупка под залог (плечо / LQDT в обеспечении) ограничивается маржинальным лимитом брокера, а не свободными рублями
2633
+ // (закрытие шорта тоже: при залоге в LQDT свободных рублей может не хватать, а закрыть позицию нужно целиком)
2634
+ const cashBuyLots = Number(jMax?.buyLimits?.buyMaxLots);
2635
+ const marginBuyLots = Number(jMax?.buyMarginLimits?.buyMaxLots);
2636
+ const bestBuyLots = Math.max(Number.isFinite(cashBuyLots) ? cashBuyLots : -1, Number.isFinite(marginBuyLots) ? marginBuyLots : -1);
2546
2637
  const maxLots = isBuy
2547
- ? Number(jMax?.buyLimits?.buyMaxLots ?? jMax?.buyMarginLimits?.buyMaxLots ?? lotsToOrder)
2638
+ ? ((ord.useMargin || ord.action === 'cover') && bestBuyLots >= 0
2639
+ ? bestBuyLots
2640
+ : Number(jMax?.buyLimits?.buyMaxLots ?? jMax?.buyMarginLimits?.buyMaxLots ?? lotsToOrder))
2548
2641
  : Number((ord.action === 'short' ? jMax?.sellMarginLimits?.sellMaxLots : null) ?? jMax?.sellLimits?.sellMaxLots ?? lotsToOrder);
2549
2642
  if (Number.isFinite(maxLots)) {
2550
2643
  if (maxLots < 1) {
@@ -2580,7 +2673,8 @@ async function sendOrderToBroker(acc, ord) {
2580
2673
  throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
2581
2674
  }
2582
2675
  const j = await r.json();
2583
- logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
2676
+ logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}${ord.reason ? ` · ${ord.reason}` : ''}`);
2677
+ logTradeDecision(acc, ord, { lots: lotsToOrder, limitPrice: finalLimitPx });
2584
2678
  return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
2585
2679
  }
2586
2680
  if (acc.broker === 'finam') {
@@ -2754,12 +2848,33 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2754
2848
  const acc = list.find((x) => x.id === id);
2755
2849
  if (!acc) throw new Error('Счёт не найден');
2756
2850
  if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2757
- if (acc.haltedReason) throw new Error(acc.haltedReason);
2758
2851
  const gate = tradingGate();
2759
2852
  if (!gate.allowNew) throw new Error(gate.reason);
2760
2853
  const batch = acc.pendingBatch;
2761
2854
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2762
2855
  if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
2856
+ if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2857
+ // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов или в окне начала сессии не отправляем
2858
+ const execCheckTs = Date.now();
2859
+ const staleWhy = (() => {
2860
+ if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
2861
+ if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
2862
+ if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
2863
+ if (isDailyStopHalt(acc)) return acc.haltedReason;
2864
+ return null;
2865
+ })();
2866
+ if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
2867
+ const dropped = batch.orders.filter((o) => !isExitOrder(o) && o.symbol !== 'LQDT');
2868
+ batch.orders = batch.orders.filter((o) => isExitOrder(o) || o.symbol === 'LQDT');
2869
+ logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
2870
+ if (!batch.orders.length) {
2871
+ batch.status = 'expired';
2872
+ batch.diagNote = `Входы не отправлены: ${staleWhy}.`;
2873
+ saveRawAccounts(list);
2874
+ hub.emit({ type: 'brokers:update', state: brokersState() });
2875
+ throw new Error(`Входы не отправлены: ${staleWhy}`);
2876
+ }
2877
+ }
2763
2878
  // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2764
2879
  // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2765
2880
  for (const ord of batch.orders) {
@@ -2773,6 +2888,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2773
2888
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2774
2889
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2775
2890
  acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
2891
+ acc.entryFailReasonBySymbol = acc.entryFailReasonBySymbol || {};
2776
2892
  acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2777
2893
  const execNowTs = Date.now();
2778
2894
  const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
@@ -2815,6 +2931,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2815
2931
  acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2816
2932
  delete acc.exitCooldownBySymbol[ord.symbol];
2817
2933
  delete acc.entryFailCooldownBySymbol[ord.symbol];
2934
+ delete acc.entryFailReasonBySymbol[ord.symbol];
2818
2935
  } else if (ord.action === 'sell' || ord.action === 'cover') {
2819
2936
  hadExits = true;
2820
2937
  delete acc.entryTsBySymbol[ord.symbol];
@@ -2831,6 +2948,15 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2831
2948
  else ord.uncertainSend = true;
2832
2949
  if (ord.action === 'buy' || ord.action === 'short') {
2833
2950
  acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
2951
+ acc.entryFailReasonBySymbol[ord.symbol] = e.message;
2952
+ // Брокер отклонил вход из-за ценового коридора (код 30099): бумага упирается в планку Мосбиржи —
2953
+ // вход в эту сторону блокируется до конца торгового дня, а не на 5 минут (повторные попытки идут против планки)
2954
+ if (String(e.message).includes('30099')) {
2955
+ const dayMsk = new Date(execNowTs + MSK).toISOString().slice(0, 10);
2956
+ acc.entryDayBlockBySymbol = Object.fromEntries(Object.entries(acc.entryDayBlockBySymbol || {}).filter(([, b]) => b?.day === dayMsk));
2957
+ acc.entryDayBlockBySymbol[ord.symbol] = { day: dayMsk, side: ord.action, reason: 'цена у границы ценового коридора Мосбиржи (отказ брокера, код 30099)' };
2958
+ logBrokerEvent(acc, 'entry_day_block', `${ord.action} ${ord.symbol}: вход заблокирован до конца дня — цена у границы ценового коридора (код 30099)`);
2959
+ }
2834
2960
  }
2835
2961
  results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2836
2962
  logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
@@ -3069,7 +3195,7 @@ async function tickBrokerSchedule() {
3069
3195
  const plan = effectivePlan(u);
3070
3196
  if ((plan.maxBrokerAccounts ?? 0) < 1) return;
3071
3197
  for (const acc of list) {
3072
- if (acc.autoSchedule === false || acc.haltedReason) continue;
3198
+ if (acc.autoSchedule === false || (acc.haltedReason && !isDailyStopHalt(acc))) continue;
3073
3199
  const params = normalizeParams(getSettings());
3074
3200
  const autoAdapt = params.modelAutoAdapt !== false;
3075
3201
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
@@ -3105,7 +3231,7 @@ async function tickBrokerSchedule() {
3105
3231
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3106
3232
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3107
3233
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3108
- if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3234
+ if (acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3109
3235
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3110
3236
  }
3111
3237
  }
@@ -3143,8 +3269,8 @@ async function resumeBrokerAccountsOnBoot() {
3143
3269
  // • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
3144
3270
  // • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
3145
3271
  // • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
3146
- const BROKER_STOP_LONG_PCT = 5.0;
3147
- const BROKER_STOP_SHORT_PCT = 4.5;
3272
+ const BROKER_STOP_LONG_PCT = protectiveStopPct('long');
3273
+ const BROKER_STOP_SHORT_PCT = protectiveStopPct('short');
3148
3274
  const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
3149
3275
  const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
3150
3276
  const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
@@ -3313,13 +3439,14 @@ async function reconcileBrokerStopsLocked(list, acc) {
3313
3439
  async function checkIntradayProfitLock(accId) {
3314
3440
  const list = loadRawAccounts();
3315
3441
  const acc = list.find((x) => x.id === accId);
3316
- if (!acc || acc.mode !== 'auto' || !acc.token || acc.haltedReason) return;
3442
+ if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3317
3443
  if (!tradingGate().allowProtective) return;
3318
3444
  const nowMsk = new Date(Date.now() + MSK);
3319
3445
  const dow = nowMsk.getUTCDay();
3320
3446
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
3321
3447
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
3322
- if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
3448
+ // В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
3449
+ const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
3323
3450
 
3324
3451
  const positions = acc.lastSync?.positions || [];
3325
3452
  if (!positions.length) return;
@@ -3366,7 +3493,7 @@ async function checkIntradayProfitLock(accId) {
3366
3493
  const lots = Math.floor(aQty / sp.lotSize);
3367
3494
  if (lots < 1) continue;
3368
3495
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3369
- const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
3496
+ const profitLockReason = !edgeNow && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
3370
3497
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3371
3498
  if (hitHardStopLoss || profitLockReason) {
3372
3499
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
@@ -3381,7 +3508,7 @@ async function checkIntradayProfitLock(accId) {
3381
3508
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3382
3509
  if (lots < 1) continue;
3383
3510
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3384
- const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
3511
+ const profitLockReason = !edgeNow && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
3385
3512
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3386
3513
  if (hitHardStopLoss || profitLockReason) {
3387
3514
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);