neuroalpha 0.5.34 → 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)
@@ -525,11 +572,17 @@ export function saveBrokerAccount(input = {}) {
525
572
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
526
573
  entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
527
574
  entryFailReasonBySymbol: acctChanged ? {} : (prev.entryFailReasonBySymbol || {}),
575
+ entryDayBlockBySymbol: acctChanged ? {} : (prev.entryDayBlockBySymbol || {}),
528
576
  peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
529
577
  baseRunId: nextBaseRunId,
530
578
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
531
579
  maxCapitalRub,
532
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),
533
586
  limitOffsetPct: nextLimitOffset,
534
587
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
535
588
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
@@ -575,6 +628,16 @@ export function resetBrokerDailyStop(id) {
575
628
  const acc = list.find((x) => x.id === id);
576
629
  if (!acc) throw new Error('Счёт не найден');
577
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
+ }
578
641
  if (!acc.haltedReason.startsWith('Сработал дневной стоп')) throw new Error('Эту блокировку снять нельзя: ' + acc.haltedReason);
579
642
  const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
580
643
  logBrokerEvent(acc, 'daily_stop_reset', `снят вручную до конца дня (${acc.haltedReason})`);
@@ -1527,6 +1590,17 @@ export async function syncBrokerAccount(id) {
1527
1590
  } else {
1528
1591
  acc.ddBreachCount = 0;
1529
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
+ }
1530
1604
  // История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
1531
1605
  const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
1532
1606
  const nowTs = Date.now();
@@ -1612,46 +1686,17 @@ export async function freezeBrokerBaseline(id) {
1612
1686
  return st;
1613
1687
  }
1614
1688
 
1615
- // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1616
- function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 999) {
1617
- if (!(avgPx > 0 && curPx > 0)) return null;
1618
- const isLong = side === 'long';
1619
- const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
1620
- const effPeakPx = peakPx > 0
1621
- ? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
1622
- : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1623
- const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1624
- const tierMult = symbolEdgeMult(sym);
1625
- // Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
1626
- const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
1627
- // 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
1628
- // фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
1629
- const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
1630
- if (peakGainPct >= tpTriggerPct) {
1631
- const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
1632
- if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
1633
- return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1634
- }
1635
- }
1636
- // 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
1637
- // не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
1638
- if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
1639
- const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
1640
- if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
1641
- return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1642
- }
1643
- }
1644
- return null;
1645
- }
1646
- function sessionEdgeWarning(now = Date.now()) {
1647
- const d = new Date(now + MSK);
1648
- const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
1649
- // открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1650
- 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)) {
1651
- return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
1652
- }
1653
- 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;
1654
1698
  }
1699
+ const isExitOrder = (o) => o.action === 'sell' || o.action === 'cover';
1655
1700
 
1656
1701
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1657
1702
  const ACCOUNT_LOCK_TTL_MS = 75000;
@@ -1768,9 +1813,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1768
1813
  let list = loadRawAccounts();
1769
1814
  let acc = list.find((x) => x.id === id);
1770
1815
  if (!acc) throw new Error('Счёт не найден');
1771
- if (acc.haltedReason) throw new Error(acc.haltedReason);
1816
+ const exitsOnly = isDailyStopHalt(acc);
1817
+ if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1772
1818
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1773
- if (onlyEntries && edgeWarn) {
1819
+ if (onlyEntries && (edgeWarn || exitsOnly)) {
1774
1820
  return brokersState();
1775
1821
  }
1776
1822
 
@@ -1826,6 +1872,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1826
1872
  const vipBoost = Boolean(plan.allowVipBoost);
1827
1873
  const rawDir = params.modelDirection || 'long';
1828
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);
1829
1880
  // Единые пороги удержания с эталонным симулятором (research.js): первые 65 минут — мягкий порог 0.35, затем плотный порог 0.95 (с учётом эшелона)
1830
1881
  const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
1831
1882
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
@@ -1851,6 +1902,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1851
1902
  const isExitCooldown = (sym) => Boolean(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000));
1852
1903
  const isEntryFailCooldown = (sym) => Boolean(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000));
1853
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;
1854
1909
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1855
1910
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1856
1911
  // временно исключаем такую бумагу из новых входов!
@@ -1916,11 +1971,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1916
1971
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1917
1972
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1918
1973
  const divBlocked = [];
1919
- const entryTimeOk = timeOk && !edgeWarn;
1920
- 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)
1921
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; })
1922
1977
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1923
- 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)
1924
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; })
1925
1980
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1926
1981
  const nonShortableSkipped = [];
@@ -1983,8 +2038,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1983
2038
  if (aQty > 0) {
1984
2039
  const lots = Math.floor(aQty / sp.lotSize);
1985
2040
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1986
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
1987
- 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);
1988
2043
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1989
2044
  const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1990
2045
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
@@ -2007,14 +2062,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2007
2062
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2008
2063
  reason: exitReason,
2009
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 },
2010
2066
  });
2011
2067
  closing.add(sym);
2012
2068
  }
2013
2069
  } else if (aQty < 0) {
2014
2070
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2015
2071
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
2016
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
2017
- 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);
2018
2074
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
2019
2075
  const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
2020
2076
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
@@ -2037,6 +2093,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2037
2093
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2038
2094
  reason: exitReason,
2039
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 },
2040
2097
  });
2041
2098
  closing.add(sym);
2042
2099
  }
@@ -2073,7 +2130,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2073
2130
  // Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
2074
2131
  // если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
2075
2132
  // освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
2076
- 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)) {
2077
2134
  const bestNewPct = Math.max(
2078
2135
  buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
2079
2136
  shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
@@ -2126,7 +2183,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2126
2183
  .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
2127
2184
  .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
2128
2185
  const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
2129
- 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));
2130
2189
 
2131
2190
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
2132
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)));
@@ -2205,7 +2264,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2205
2264
  ];
2206
2265
  // Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
2207
2266
  // сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
2208
- 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')) {
2209
2268
  const lqdtPos = heldMap.get('LQDT');
2210
2269
  const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
2211
2270
  if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
@@ -2254,16 +2313,18 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2254
2313
  remWeightSum = Math.max(0, remWeightSum - wCur);
2255
2314
  remNew--;
2256
2315
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2257
- 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'
2258
2319
  ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
2259
- : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
2320
+ : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub));
2260
2321
  // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
2261
2322
  // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2262
2323
  const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2263
2324
  const sideCapRub = (!isTier1 && barTurnoverRub > 0)
2264
2325
  ? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
2265
2326
  : rawSideCapRub;
2266
- 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));
2267
2328
  const sum = Math.min(rawSum, propShare, availRub);
2268
2329
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
2269
2330
  const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
@@ -2273,6 +2334,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2273
2334
  orders.push({
2274
2335
  ...ord,
2275
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
+ },
2276
2345
  reason: side === 'buy'
2277
2346
  ? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
2278
2347
  : `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
@@ -2283,16 +2352,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2283
2352
  // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
2284
2353
  // учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
2285
2354
  // Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
2286
- 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) {
2287
2356
  const mskNow = new Date(nowTs + MSK);
2288
2357
  const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
2289
2358
  const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
2290
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));
2291
- 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)) {
2292
2364
  const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
2293
2365
  const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
2294
2366
  const reserveRub = Math.max(5000, round(budget * 0.03, 0));
2295
- 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);
2296
2369
  if (lqdtPx > 0 && sweepRub >= 20000) {
2297
2370
  const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
2298
2371
  if (ord) {
@@ -2319,7 +2392,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2319
2392
  planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
2320
2393
  }
2321
2394
  if (!orders.length) {
2322
- if (!timeOk) {
2395
+ if (exitsOnly) {
2396
+ diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
2397
+ } else if (!timeOk) {
2323
2398
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2324
2399
  } else if (edgeWarn) {
2325
2400
  diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
@@ -2340,7 +2415,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2340
2415
  const failNote = entryFailSkipped.length
2341
2416
  ? ` Заявка по ${entryFailSkipped.map((r) => `${r.symbol} (${entryFailReasonMap[r.symbol] || 'отказ шлюза брокера'})`).join('; ')} временно отложена на 5 мин после ошибки приёма у брокера.`
2342
2417
  : '';
2343
- const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
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 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
2344
2421
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2345
2422
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2346
2423
  const thrRangeStr = dir !== 'long'
@@ -2552,8 +2629,15 @@ async function sendOrderToBroker(acc, ord) {
2552
2629
  }).catch(() => null);
2553
2630
  if (rMax?.ok) {
2554
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);
2555
2637
  const maxLots = isBuy
2556
- ? 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))
2557
2641
  : Number((ord.action === 'short' ? jMax?.sellMarginLimits?.sellMaxLots : null) ?? jMax?.sellLimits?.sellMaxLots ?? lotsToOrder);
2558
2642
  if (Number.isFinite(maxLots)) {
2559
2643
  if (maxLots < 1) {
@@ -2589,7 +2673,8 @@ async function sendOrderToBroker(acc, ord) {
2589
2673
  throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
2590
2674
  }
2591
2675
  const j = await r.json();
2592
- 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 });
2593
2678
  return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
2594
2679
  }
2595
2680
  if (acc.broker === 'finam') {
@@ -2763,12 +2848,33 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2763
2848
  const acc = list.find((x) => x.id === id);
2764
2849
  if (!acc) throw new Error('Счёт не найден');
2765
2850
  if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2766
- if (acc.haltedReason) throw new Error(acc.haltedReason);
2767
2851
  const gate = tradingGate();
2768
2852
  if (!gate.allowNew) throw new Error(gate.reason);
2769
2853
  const batch = acc.pendingBatch;
2770
2854
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2771
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
+ }
2772
2878
  // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2773
2879
  // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2774
2880
  for (const ord of batch.orders) {
@@ -2843,6 +2949,14 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2843
2949
  if (ord.action === 'buy' || ord.action === 'short') {
2844
2950
  acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
2845
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
+ }
2846
2960
  }
2847
2961
  results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2848
2962
  logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
@@ -3081,7 +3195,7 @@ async function tickBrokerSchedule() {
3081
3195
  const plan = effectivePlan(u);
3082
3196
  if ((plan.maxBrokerAccounts ?? 0) < 1) return;
3083
3197
  for (const acc of list) {
3084
- if (acc.autoSchedule === false || acc.haltedReason) continue;
3198
+ if (acc.autoSchedule === false || (acc.haltedReason && !isDailyStopHalt(acc))) continue;
3085
3199
  const params = normalizeParams(getSettings());
3086
3200
  const autoAdapt = params.modelAutoAdapt !== false;
3087
3201
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
@@ -3117,7 +3231,7 @@ async function tickBrokerSchedule() {
3117
3231
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3118
3232
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3119
3233
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3120
- 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) {
3121
3235
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3122
3236
  }
3123
3237
  }
@@ -3155,8 +3269,8 @@ async function resumeBrokerAccountsOnBoot() {
3155
3269
  // • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
3156
3270
  // • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
3157
3271
  // • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
3158
- const BROKER_STOP_LONG_PCT = 5.0;
3159
- const BROKER_STOP_SHORT_PCT = 4.5;
3272
+ const BROKER_STOP_LONG_PCT = protectiveStopPct('long');
3273
+ const BROKER_STOP_SHORT_PCT = protectiveStopPct('short');
3160
3274
  const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
3161
3275
  const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
3162
3276
  const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
@@ -3325,13 +3439,14 @@ async function reconcileBrokerStopsLocked(list, acc) {
3325
3439
  async function checkIntradayProfitLock(accId) {
3326
3440
  const list = loadRawAccounts();
3327
3441
  const acc = list.find((x) => x.id === accId);
3328
- if (!acc || acc.mode !== 'auto' || !acc.token || acc.haltedReason) return;
3442
+ if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3329
3443
  if (!tradingGate().allowProtective) return;
3330
3444
  const nowMsk = new Date(Date.now() + MSK);
3331
3445
  const dow = nowMsk.getUTCDay();
3332
3446
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
3333
3447
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
3334
- if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
3448
+ // В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
3449
+ const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
3335
3450
 
3336
3451
  const positions = acc.lastSync?.positions || [];
3337
3452
  if (!positions.length) return;
@@ -3378,7 +3493,7 @@ async function checkIntradayProfitLock(accId) {
3378
3493
  const lots = Math.floor(aQty / sp.lotSize);
3379
3494
  if (lots < 1) continue;
3380
3495
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3381
- 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;
3382
3497
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3383
3498
  if (hitHardStopLoss || profitLockReason) {
3384
3499
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
@@ -3393,7 +3508,7 @@ async function checkIntradayProfitLock(accId) {
3393
3508
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3394
3509
  if (lots < 1) continue;
3395
3510
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3396
- 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;
3397
3512
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3398
3513
  if (hitHardStopLoss || profitLockReason) {
3399
3514
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
@@ -0,0 +1,28 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Дневная история цен фонда денежного рынка LQDT (MOEX ISS) — доход капитала, который стратегия держит в залоге.
3
+ // Фонд переводился между режимами торгов (TQTF → TQBR с 22.06.2026), поэтому история берётся по всем режимам рынка акций.
4
+ import { kvGet, kvSet } from '../db.js';
5
+
6
+ const DAY = 86400000;
7
+
8
+ export async function ensureLqdtHistory({ fromDate = '2024-01-01', signal } = {}) {
9
+ const cached = kvGet('lqdtDaily');
10
+ if (cached?.rows?.length && Date.now() - (cached.at || 0) < DAY) return cached.rows;
11
+ try {
12
+ const byDate = new Map();
13
+ for (let start = 0; start < 10000; start += 100) {
14
+ const r = await fetch(`https://iss.moex.com/iss/history/engines/stock/markets/shares/securities/LQDT.json?from=${fromDate}&iss.meta=off&history.columns=BOARDID,TRADEDATE,CLOSE&start=${start}`, { signal: signal || AbortSignal.timeout(20000) });
15
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
16
+ const j = await r.json();
17
+ const data = j.history?.data || [];
18
+ for (const [, d, c] of data) if (c > 0 && !byDate.has(d)) byDate.set(d, c);
19
+ if (data.length < 100) break;
20
+ }
21
+ // цена закрытия дня d известна к концу основной сессии
22
+ const rows = [...byDate].map(([d, c]) => ({ ts: Date.parse(`${d}T18:50:00+03:00`), close: c })).sort((a, b) => a.ts - b.ts);
23
+ if (rows.length) kvSet('lqdtDaily', { at: Date.now(), rows });
24
+ return rows.length ? rows : (cached?.rows || []);
25
+ } catch {
26
+ return cached?.rows || [];
27
+ }
28
+ }
@@ -35,6 +35,18 @@ export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub =
35
35
  return Math.round(base * (1 + liqAdj2) * 1000) / 1000;
36
36
  }
37
37
 
38
+ // Фильтр падающего рынка: индекс Мосбиржи в среднесрочном нисходящем тренде (ниже уровня 10 торговых дней назад)
39
+ // или в сильном локальном обвале (ниже уровня дня и 3 дней назад на >1%)
40
+ export function isMarketDown(ds, t) {
41
+ const bpd = Math.max(1, Math.round(ds.barsPerDay || 54));
42
+ const d10 = bpd * 10, d3 = bpd * 3;
43
+ if (t < d3) return false;
44
+ const cur = ds.mclose[t], p3 = ds.mclose[t - d3], pd = ds.mclose[t - bpd];
45
+ if (!(cur > 0 && p3 > 0 && pd > 0)) return false;
46
+ if (t >= d10 && ds.mclose[t - d10] > 0 && cur < ds.mclose[t - d10] * 0.99) return true;
47
+ return cur < pd * 0.99 && cur < p3 * 0.99;
48
+ }
49
+
38
50
  // Пороги защиты от структурного слома режима (взрывного шорт-сквиза ≥20% за торговый день или отвесного обвала ≥9,5% за 30 минут):
39
51
  export const REGIME_SHOCK_1D = 0.20;
40
52
  export const REGIME_CRASH_30M = 0.095;
@@ -4,10 +4,10 @@ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
4
4
  import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
5
  import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
6
6
  import { loadProfiles } from './profiles.js';
7
- import { isMarketDown } from './research.js';
8
- import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday } from './edge.js';
7
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday, isMarketDown } from './edge.js';
9
8
  export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
10
9
  import { newsForSymbols, blockingNews } from '../news/index.js';
10
+ import { dayContext } from './policy.js';
11
11
  import { liveDividendGuard } from './dividends.js';
12
12
  import { fetchTbankRealtimePrices } from '../brokers/index.js';
13
13
  import { kvGet, kvSet } from '../db.js';
@@ -450,6 +450,12 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
450
450
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
451
451
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
452
452
  }
453
+ // контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
454
+ const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
455
+ row.day = {
456
+ prevClose: dc.prevClose, dayHigh: dc.dayHigh, dayLow: dc.dayLow, barsSinceHigh: dc.barsSinceHigh, barsSinceLow: dc.barsSinceLow,
457
+ marketDayPct: round(dc.marketDayPct, 3), realizedBarPct: round(dc.realizedBarPct, 3), dayMovePct: round(dc.dayMovePct, 2), sigmaBarPct: round(dc.sigmaBarPct, 3),
458
+ };
453
459
  if (explain && ex) {
454
460
  row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
455
461
  row.selfPct = round(selfRaw * 100, 3);