neuroalpha 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1099,6 +1099,18 @@ async function loadForecast(force, silent = false) {
1099
1099
  if (!label) label = '⛔ новости';
1100
1100
  }
1101
1101
  }
1102
+ if (r.blockedByQuarantine) {
1103
+ reasons.push(r.blockedByQuarantine);
1104
+ if (!label) label = '⛔ карантин';
1105
+ }
1106
+ if (r.blockedByCooldown) {
1107
+ reasons.push(r.blockedByCooldown);
1108
+ if (!label) label = '⛔ пауза';
1109
+ }
1110
+ if (r.blockedByCorridor) {
1111
+ reasons.push(r.blockedByCorridor);
1112
+ if (!label) label = '⛔ коридор';
1113
+ }
1102
1114
  if (r.liquid === false) {
1103
1115
  reasons.push('Ставки запрещены из-за низкой ликвидности бумаги');
1104
1116
  if (!label) label = '⛔ неликвид';
@@ -3008,6 +3020,7 @@ function renderBrokers() {
3008
3020
  })() : ''}
3009
3021
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
3010
3022
  <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · плечо <b>${fmtNum(a.maxLeverage || 1, 1)}×</b> · стоп по общей просадке <b>${a.maxDrawdownPct > 0 ? `−${fmtNum(a.maxDrawdownPct, 0)}%` : 'выкл.'}</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
3023
+ ${a.quarantinedStocks?.length ? `<span class="k">Карантин бумаг:</span><span class="v" title="Временное ограничение после убыточных сделок (с затуханием ~15 торговых дней): обычные сигналы пропускаются, допускаются только усиленные сигналы ≥110% от порога">${a.quarantinedStocks.map((q) => `<b>${esc(q.symbol)}</b> <span class="neg">(${fmtNum(q.pnlPct, 2)}%, до ${esc(q.untilText)})</span>`).join(' · ')} <span class="muted small">— пропуск сигналов &lt;110% порога</span></span>` : ''}
3011
3024
  ${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
3012
3025
  </div>
3013
3026
  ${hasPortfolioSnapshot ? `<div class="kpis">
@@ -3978,8 +3991,13 @@ const helpCache = new Map();
3978
3991
  // ---------- журнал исполнения по счёту ----------
3979
3992
  const EVENT_LABELS = {
3980
3993
  order_sent: ['заявка отправлена', ''],
3981
- order_expired: ['заявка снята (>12 мин)', 'muted'],
3994
+ order_expired: ['заявка снята', 'muted'],
3982
3995
  spread_skip: ['пропуск входа (широкий спред)', 'mid'],
3996
+ signal_skip: ['сигнал пропущен фильтром', 'mid'],
3997
+ order_skip: ['вход не отправлен (устарел/вне окна)', 'muted'],
3998
+ entry_day_block: ['блок входа до конца дня (коридор)', 'neg'],
3999
+ profit_lock: ['фиксация прибыли (Take-Profit)', 'pos'],
4000
+ news_exit: ['выход по важной новости', 'mid'],
3983
4001
  order_error: ['ошибка заявки', 'neg'],
3984
4002
  protective_stop: ['защитный стоп', 'neg'],
3985
4003
  protective_stop_error: ['ошибка защитного стопа', 'neg'],
@@ -11,7 +11,7 @@ import { getSettings, normalizeParams } from '../settings.js';
11
11
  import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
12
12
  import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
13
13
  import { forecastNow } from '../model/forecast.js';
14
- import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
14
+ import { TIER1_LIQUID_SYMBOLS, QUARANTINE_OVERRIDE_RATIO, symbolEdgeMult, isStockQuarantined, computeAccountQuarantine, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
15
15
  import { liveDividendGuard } from '../model/dividends.js';
16
16
  import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
17
17
  import { MODELS } from '../model/models.js';
@@ -489,10 +489,42 @@ function publicAccount(a) {
489
489
  };
490
490
  }
491
491
  const executionAudit = computeExecutionAudit(a);
492
+ const heldAlgoSet = new Set((a.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(a, p.symbol, p.qty) !== 0).map((p) => p.symbol));
493
+ const quarantinedStocks = [...computeAccountQuarantine(a.lastHistory?.operations, heldAlgoSet).values()];
492
494
  // Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
493
495
  const { token: rawToken, ...rest } = a;
494
496
  const tok = String(rawToken || '').trim();
495
- return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
497
+ return { ...rest, lastSync, executionAudit, quarantinedStocks, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
498
+ }
499
+
500
+ export function getActiveAccountSignalFilters(nowTs = Date.now()) {
501
+ try {
502
+ const list = loadRawAccounts();
503
+ const acc = list.find((a) => a.mode === 'auto' || a.mode === 'confirm') || list[0];
504
+ if (!acc) return null;
505
+ const heldSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(acc, p.symbol, p.qty) !== 0).map((p) => p.symbol));
506
+ const quarantinedMap = computeAccountQuarantine(acc.lastHistory?.operations, heldSet, nowTs);
507
+ const cooldownMap = new Map();
508
+ for (const [sym, ts] of Object.entries(acc.exitCooldownBySymbol || {})) {
509
+ const remMin = Math.ceil((60 * 60000 - (nowTs - ts)) / 60000);
510
+ if (remMin > 0) cooldownMap.set(sym, `Пауза после недавнего выхода (${remMin} мин)`);
511
+ }
512
+ for (const [sym, ts] of Object.entries(acc.entryFailCooldownBySymbol || {})) {
513
+ const remMin = Math.ceil((5 * 60000 - (nowTs - ts)) / 60000);
514
+ if (remMin > 0 && !cooldownMap.has(sym)) {
515
+ const why = acc.entryFailReasonBySymbol?.[sym] || 'отказ шлюза брокера';
516
+ cooldownMap.set(sym, `Пауза ${remMin} мин после отказа (${why})`);
517
+ }
518
+ }
519
+ const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
520
+ const dayBlockMap = new Map();
521
+ for (const [sym, b] of Object.entries(acc.entryDayBlockBySymbol || {})) {
522
+ if (b?.day === todayMsk) dayBlockMap.set(sym, b);
523
+ }
524
+ return { accountId: acc.id, accountName: acc.name, quarantinedMap, cooldownMap, dayBlockMap };
525
+ } catch {
526
+ return null;
527
+ }
496
528
  }
497
529
 
498
530
  export function brokersState() {
@@ -1802,6 +1834,29 @@ const isExitOrder = (o) => o.action === 'sell' || o.action === 'cover';
1802
1834
  const ACCOUNT_LOCK_TTL_MS = 75000;
1803
1835
  const accountBusyLocks = new Map(); // id -> { since, token }
1804
1836
  const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstTs, lastTs, lastPx, extremePx }
1837
+ const loggedSignalSkips = new Map(); // `${accId}:${barKey}:${symbol}:${side}` -> ts
1838
+
1839
+ function recordSignalSkipOnce(acc, fc, r, side, reason) {
1840
+ if (!acc || !r?.symbol) return;
1841
+ const barKey = String(fc?.barTs || String(fc?.bar || '').slice(0, 16) || Math.floor(Date.now() / (10 * 60000)));
1842
+ const k = `${acc.id}:${barKey}:${r.symbol}:${side}`;
1843
+ if (loggedSignalSkips.has(k)) return;
1844
+ loggedSignalSkips.set(k, Date.now());
1845
+ if (loggedSignalSkips.size > 600) {
1846
+ const cutoff = Date.now() - 6 * 3600000;
1847
+ for (const [ek, ets] of loggedSignalSkips) if (ets < cutoff) loggedSignalSkips.delete(ek);
1848
+ }
1849
+ const thr = side === 'short' ? r.shortThresholdPct : r.buyThresholdPct;
1850
+ const ratioPct = thr > 0 ? Math.round((Math.abs(Number(r.expectedPct) || 0) / thr) * 100) : 0;
1851
+ logBrokerEvent(acc, 'signal_skip', `${side === 'short' ? 'short' : 'buy'} ${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%, порог ${thr}%, ${ratioPct}%): ${reason}`);
1852
+ logTradeDecision(acc, {
1853
+ symbol: r.symbol,
1854
+ action: 'skip',
1855
+ refPrice: r.price,
1856
+ reason,
1857
+ decision: { bar: fc?.bar || '', side, expectedPct: r.expectedPct, thresholdPct: thr, ratioPct },
1858
+ });
1859
+ }
1805
1860
 
1806
1861
  // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1807
1862
  // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
@@ -1940,7 +1995,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1940
1995
  hub.emit({ type: 'trading:pulse', kind: 'broker_calc', ts: Date.now(), accountId: acc.id, name: acc.name, text: `Пересчёт заявок по счёту «${acc.name}»…` });
1941
1996
  // При плановом шаге снимаем старые неисполненные заявки со стакана, а при быстром доборе (onlyEntries) не трогаем уже стоящие свежие заявки!
1942
1997
  if (!onlyEntries && acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
1943
- await cancelBrokerOrder(id, 'all').catch(() => {});
1998
+ await cancelBrokerOrder(id, 'all', 'не исполнена к плановому шагу').catch(() => {});
1944
1999
  list = loadRawAccounts();
1945
2000
  acc = list.find((x) => x.id === id) || acc;
1946
2001
  } else if (acc.token && (!acc.lastSync || Date.now() - (acc.lastSync.at || 0) > 45000)) {
@@ -2000,8 +2055,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2000
2055
  const nowTs = Date.now();
2001
2056
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
2002
2057
  const H = barsFromHours(effHorizon);
2003
- const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
2004
- const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
2058
+ const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
2059
+ const stepBars = autoAdapt ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
2060
+ const timeOk = !autoAdapt || canEnterIntraday(nowTs, holdBars, BAR_INTERVAL);
2005
2061
  const entryTsMap = acc.entryTsBySymbol || {};
2006
2062
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
2007
2063
  const entryFailMap = acc.entryFailCooldownBySymbol || {};
@@ -2022,33 +2078,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2022
2078
  const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
2023
2079
  const entryDayBlockMap = acc.entryDayBlockBySymbol || {};
2024
2080
  const isDayBlocked = (sym, side) => entryDayBlockMap[sym]?.day === todayMsk && entryDayBlockMap[sym]?.side === side;
2025
- // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
2026
- // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
2027
- // временно исключаем такую бумагу из новых входов!
2028
- const quarantined = new Set();
2029
- if (autoAdapt && Array.isArray(acc.lastHistory?.operations)) {
2030
- const cutoffTs = nowTs - 21 * DAY;
2031
- const recentOps = acc.lastHistory.operations
2032
- .filter((op) => op.date >= cutoffTs && op.symbol && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee'))
2033
- .sort((a, b) => a.date - b.date);
2034
- const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
2035
- const statBySym = {};
2036
- for (const op of recentOps) {
2037
- if (heldNowSet.has(op.symbol)) continue; // считаем только полностью закрытые цепочки сделок
2038
- const st = statBySym[op.symbol] || (statBySym[op.symbol] = { buys: 0, sells: 0, buyRub: 0, netRub: 0 });
2039
- const pay = Number(op.paymentRub) || 0;
2040
- if (op.kind === 'buy') { st.buys++; st.buyRub += Math.abs(pay); st.netRub += pay; }
2041
- else if (op.kind === 'sell') { st.sells++; st.netRub += pay; }
2042
- else if (op.kind === 'fee') { st.netRub += pay; }
2043
- }
2044
- for (const [sym, st] of Object.entries(statBySym)) {
2045
- const closedRounds = Math.min(st.buys, st.sells);
2046
- const netPct = st.buyRub > 10000 ? (st.netRub / st.buyRub) : 0;
2047
- if (st.buyRub > 10000 && isStockQuarantined(closedRounds, netPct)) {
2048
- quarantined.add(sym);
2049
- }
2050
- }
2051
- }
2081
+ // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с simulator.js / research.js):
2082
+ // если по закрытым FIFO-кругам накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%, с затуханием QUARANTINE_DECAY),
2083
+ // бумага пропускает обычные сигналы, но пропускает усиленные сигналы >= 110% индивидуального порога (QUARANTINE_OVERRIDE_RATIO).
2084
+ const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(acc, p.symbol, p.qty) !== 0).map((p) => p.symbol));
2085
+ const quarantinedMap = autoAdapt
2086
+ ? computeAccountQuarantine(acc.lastHistory?.operations, heldNowSet, nowTs, Number(params.commissionPct) || 0.04)
2087
+ : new Map();
2088
+ const isQuarantinedFor = (r, side) => {
2089
+ const q = quarantinedMap.get(r.symbol);
2090
+ if (!q) return false;
2091
+ const reqEdge = side === 'short' ? reqShortEdgeOf(r.symbol) : reqBuyEdgeOf(r.symbol);
2092
+ const edgeRatio = reqEdge > 0 ? Math.abs(Number(r.expectedPct) || 0) / reqEdge : 0;
2093
+ return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
2094
+ };
2052
2095
  const slots = dir === 'long' ? K : 2 * K;
2053
2096
  // Фильтр мощности слота по тарифу:
2054
2097
  // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
@@ -2084,6 +2127,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2084
2127
  const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
2085
2128
  const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
2086
2129
  const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
2130
+ const quarantineSkipped = fc.rows.filter((r) => {
2131
+ if (!isRawSignalRow(r) || isInCooldown(r.symbol)) return false;
2132
+ const side = r.expectedPct < 0 ? 'short' : 'buy';
2133
+ return !isDayBlocked(r.symbol, side) && isQuarantinedFor(r, side);
2134
+ });
2087
2135
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
2088
2136
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
2089
2137
  const divBlocked = [];
@@ -2093,10 +2141,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2093
2141
  || (isOnImpulseWatch(acc.id, r.symbol, 'buy', nowTs) && r.liquid && r.inIndex !== false && !r.buyBlockedByDip && !r.buyBlockedByOverheat && !r.buyBlockedByDiv && !r.buyBlockedByNews && r.expectedPct >= reqBuyEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
2094
2142
  const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
2095
2143
  || (isOnImpulseWatch(acc.id, r.symbol, 'short', nowTs) && r.liquid && r.inIndex !== false && r.shortAllowed !== false && !r.shortBlockedBySqueeze && !r.shortBlockedByDiv && !r.shortBlockedByNews && r.expectedPct <= -reqShortEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
2096
- const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
2144
+ const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !isQuarantinedFor(r, 'buy') && !nb?.get(r.symbol)?.bad)
2097
2145
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
2098
2146
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
2099
- const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!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)
2147
+ const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!autoAdapt || canEnterIntraday(nowTs, holdBars, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !isQuarantinedFor(r, 'short') && !nb?.get(r.symbol)?.good)
2100
2148
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
2101
2149
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
2102
2150
  const nonShortableSkipped = [];
@@ -2146,8 +2194,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2146
2194
  const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
2147
2195
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
2148
2196
  const heldMin = heldMs / 60000;
2149
- const horizonMin = Math.max(90, effHorizon * 60);
2150
- // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к динамическому плотному (0.95 × clamp(realizedBarPct / sigmaBarPct, 0.75, 1.25) к концу горизонта 2.5 ч)
2197
+ const horizonMin = Math.max(90, holdBars * BAR_INTERVAL);
2198
+ // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к динамическому плотному (0.95 × clamp(realizedBarPct / sigmaBarPct, 0.75, 1.25) к концу глубины сопровождения)
2151
2199
  const symMult = r.edgeMult || symbolEdgeMult(sym);
2152
2200
  const holdRatio = exitHoldRatio(heldMin, horizonMin, {
2153
2201
  sigmaBarPct: Number(r.day?.sigmaBarPct || r.sigmaPct) || 0,
@@ -2537,7 +2585,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2537
2585
  } else if (portfolioStale) {
2538
2586
  diagNote = `Сервис портфеля брокера временно отвечает с задержкой (${acc.lastSync?.staleReason || 'таймаут'}) — открытие новых позиций отложено до подтверждения остатков, текущие позиции и заявки под контролем.`;
2539
2587
  } else if (!timeOk) {
2540
- diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2588
+ diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше глубины сопровождения (${round((holdBars * BAR_INTERVAL) / 60, 2)} ч) — внутридневные сделки не переносятся через ночь.`;
2541
2589
  } else if (edgeWarn) {
2542
2590
  diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
2543
2591
  } else if ((newBuys.length || newShorts.length) && availRub < 1500) {
@@ -2559,7 +2607,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2559
2607
  : '';
2560
2608
  const dayBlocked = fc.rows.filter((r) => (r.signal && isDayBlocked(r.symbol, 'buy')) || (r.shortSignal && isDayBlocked(r.symbol, 'short')));
2561
2609
  const dayBlockNote = dayBlocked.length ? ` Сигнал ${dayBlocked.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} пропущен до конца дня: брокер отклонил вход из-за ценового коридора — бумага у планки Мосбиржи.` : '';
2562
- const cdNote = dayBlockNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
2610
+ const qNote = quarantineSkipped.length
2611
+ ? ` Сигнал ${quarantineSkipped.map((r) => { const q = quarantinedMap.get(r.symbol); const thr = r.expectedPct < 0 ? reqShortEdgeOf(r.symbol) : reqBuyEdgeOf(r.symbol); const pct = thr > 0 ? Math.round((Math.abs(r.expectedPct) / thr) * 100) : 0; return `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%, ${pct}% порога, карантин ${q?.pnlPct ?? ''}% до ${q?.untilText || '—'})`; }).join(', ')} пропущен: по бумаге в карантине принимаются только усиленные сигналы ≥${Math.round(QUARANTINE_OVERRIDE_RATIO * 100)}% порога.`
2612
+ : '';
2613
+ const cdNote = dayBlockNote + qNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
2563
2614
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2564
2615
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2565
2616
  const thrRangeStr = dir !== 'long'
@@ -2570,6 +2621,22 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2570
2621
  : `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
2571
2622
  }
2572
2623
  }
2624
+ if (entryTimeOk) {
2625
+ for (const r of quarantineSkipped) {
2626
+ const q = quarantinedMap.get(r.symbol);
2627
+ recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', q?.reasonText || 'карантин счёта после убыточных сделок');
2628
+ }
2629
+ for (const r of cooldownSkipped) {
2630
+ recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', 'пауза после недавнего выхода из позиции');
2631
+ }
2632
+ for (const r of entryFailSkipped) {
2633
+ recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', `пауза 5 мин после ошибки приёма у брокера (${entryFailReasonMap[r.symbol] || 'отказ шлюза'})`);
2634
+ }
2635
+ for (const r of fc.rows) {
2636
+ if (r.signal && isDayBlocked(r.symbol, 'buy')) recordSignalSkipOnce(acc, fc, r, 'buy', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
2637
+ if (r.shortSignal && isDayBlocked(r.symbol, 'short')) recordSignalSkipOnce(acc, fc, r, 'short', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
2638
+ }
2639
+ }
2573
2640
  if (divBlocked.length) {
2574
2641
  diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
2575
2642
  }
@@ -3158,7 +3225,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
3158
3225
  }
3159
3226
  }
3160
3227
 
3161
- export async function cancelBrokerOrder(id, orderId = null) {
3228
+ export async function cancelBrokerOrder(id, orderId = null, reason = null) {
3162
3229
  const list = loadRawAccounts();
3163
3230
  const acc = list.find((x) => x.id === id);
3164
3231
  if (!acc) throw new Error('Счёт не найден');
@@ -3168,22 +3235,44 @@ export async function cancelBrokerOrder(id, orderId = null) {
3168
3235
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
3169
3236
  const accountId = acc.accountId || acc.lastSync?.accountId;
3170
3237
  if (!accountId) throw new Error('Не определён ID счёта');
3238
+ const activeByOrdId = new Map((acc.lastSync?.activeOrders || []).map((o) => [String(o.orderId), o]));
3171
3239
  if (orderId && orderId !== 'all') {
3172
3240
  const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
3173
3241
  method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
3174
3242
  });
3175
3243
  if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
3244
+ const ao = activeByOrdId.get(String(orderId));
3245
+ const remLots = ao ? Math.max(0, (Number(ao.lotsRequested) || 0) - (Number(ao.lotsExecuted) || 0)) : 0;
3246
+ logBrokerEvent(acc, 'order_expired', `${ao?.symbol || 'заявка'}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л. по ${ao.price} ₽` : ''}) · ${orderId}`);
3176
3247
  } else {
3177
3248
  const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
3178
3249
  method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
3179
3250
  });
3180
3251
  if (rOrd.ok) {
3181
3252
  const jOrd = await rOrd.json();
3182
- await Promise.all((jOrd.orders || []).map((o) => (
3183
- o.orderId ? brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
3253
+ const rawOrders = jOrd.orders || [];
3254
+ const isScheduledCleanup = reason === 'не исполнена к плановому шагу';
3255
+ const nowMs = Date.now();
3256
+ const toCancel = rawOrders.filter((o) => {
3257
+ if (!o?.orderId) return false;
3258
+ // Свежую заявку (< 90 с), только что выставленную быстрым добором перед границей шага, не сбиваем на первой же минуте
3259
+ if (isScheduledCleanup && o.orderDate && nowMs - Date.parse(o.orderDate) < 90000) return false;
3260
+ return true;
3261
+ });
3262
+ const metaCache = kvGet('tbankInstMeta') || {};
3263
+ await Promise.all(toCancel.map((o) => (
3264
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
3184
3265
  method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
3185
- }).catch(() => null) : null
3266
+ }).catch(() => null)
3186
3267
  )));
3268
+ for (const o of toCancel) {
3269
+ const ao = activeByOrdId.get(String(o.orderId));
3270
+ const m = metaCache[o.ticker] || metaCache[o.figi] || metaCache[o.instrumentUid] || {};
3271
+ const sym = ao?.symbol || m.ticker || o.ticker || o.figi || 'заявка';
3272
+ const remLots = Math.max(0, (Number(o.lotsRequested) || 0) - (Number(o.lotsExecuted) || 0));
3273
+ const px = ao?.price || round(qVal(o.initialSecurityPrice), 4);
3274
+ logBrokerEvent(acc, 'order_expired', `${sym}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л.${px ? ` по ${px} ₽` : ''}` : ''}) · ${o.orderId}`);
3275
+ }
3187
3276
  }
3188
3277
  }
3189
3278
  return syncBrokerAccount(id);
@@ -3395,8 +3484,9 @@ async function tickBrokerSchedule() {
3395
3484
  const autoAdapt = params.modelAutoAdapt !== false;
3396
3485
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
3397
3486
  const H = barsFromHours(effHorizon);
3398
- // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
3399
- const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
3487
+ const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
3488
+ // Внутричасовой перехват holdBars/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному окну holdBars
3489
+ const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
3400
3490
  const minStepMs = stepBars * BAR_MS;
3401
3491
  const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
3402
3492
  const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
package/server/index.js CHANGED
@@ -146,10 +146,23 @@ function lanUrls() {
146
146
  .map((a) => `http://${a.address}:${PORT}`);
147
147
  }
148
148
 
149
+ const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
150
+ || (process.env.OPEN_BROWSER !== '0'
151
+ && process.env.NO_BROWSER !== '1'
152
+ && !process.argv.includes('--no-browser')
153
+ && !process.execArgv.includes('--watch')
154
+ && !IS_CENTRAL
155
+ && (!IS_INSTANCE || IS_DESKTOP)
156
+ && (process.platform === 'win32' || process.platform === 'darwin' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)));
157
+
149
158
  wss.on('error', () => {});
150
159
  server.on('error', (e) => {
151
- if (e.code === 'EADDRINUSE') console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: http://localhost:${PORT}`);
152
- else console.error(e);
160
+ if (e.code === 'EADDRINUSE') {
161
+ console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: http://localhost:${PORT}`);
162
+ if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
163
+ } else {
164
+ console.error(e);
165
+ }
153
166
  process.exit(1);
154
167
  });
155
168
 
@@ -160,7 +173,7 @@ server.listen(PORT, LISTEN_HOST, () => {
160
173
  console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
161
174
  if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
162
175
  console.log(`Клиент: http://localhost:${PORT}`);
163
- if (process.env.OPEN_BROWSER === '1') openBrowser(`http://localhost:${PORT}`);
176
+ if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
164
177
  });
165
178
 
166
179
  function openBrowser(url) {
@@ -239,13 +239,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
239
239
  }
240
240
  return lastClosedBar >= latestClosedSlotTs;
241
241
  };
242
+ const cacheTtlMs = isTradingNow ? 4 * 60000 : 30 * 60000;
242
243
  const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
243
244
  const hit = datasetCache.get(key);
244
- if (hit && now - hit.at < 4 * 60000 && isCacheFreshForSlot(hit)) return hit.ds;
245
+ if (hit && now - hit.at < cacheTtlMs && isCacheFreshForSlot(hit)) return hit.ds;
245
246
  // Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
246
247
  const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
247
248
  for (const v of datasetCache.values()) {
248
- if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
249
+ if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < cacheTtlMs && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
249
250
  return v.ds;
250
251
  }
251
252
  }
@@ -263,7 +264,6 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
263
264
  const symbols = vocab.map((v) => v.symbol);
264
265
  const raw = {};
265
266
  let done = 0;
266
- const divStat = { applied: 0, skipped: 0 };
267
267
  const macroIds = useMacro ? MACRO.filter((m) => m.id !== 'BR').map((m) => m.id) : [];
268
268
  const all = [...symbols, 'IMOEX', ...macroIds];
269
269
  // акция дольше 2 минут — пропускаем и останавливаем её загрузку (скачанное останется в кэше)
@@ -284,9 +284,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
284
284
  raw[s] = compact(rows);
285
285
  if (divs?.length) {
286
286
  // DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
287
- const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
288
- divStat.applied += st.applied;
289
- divStat.skipped += st.skipped;
287
+ adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
290
288
  }
291
289
  })(),
292
290
  new Promise((_, rej) => {
@@ -306,7 +304,6 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
306
304
  onProgress({ symbol: s, done: ++done, total: all.length });
307
305
  }
308
306
  });
309
- if (useDividends) console.log(`[dataset] дивиденды: учтено ${divStat.applied}, пропущено (нет подтверждающего падения цены) ${divStat.skipped}`);
310
307
  // нефть Brent: склейка фьючерсов (много контрактов — отдельно от общего пула)
311
308
  if (useMacro) {
312
309
  try {
@@ -101,9 +101,125 @@ export function computeTokenEdgeMults(tokens, sigma, fromIdx = 0, tillIdx = 0) {
101
101
  return out;
102
102
  }
103
103
 
104
- // Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48)
104
+ // Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48).
105
+ // Сильный сигнал (≥110% от индивидуального порога входа) пробивает карантин: проверка 11.11.2026 в лаборатории правил (06.2024–11.2026)
106
+ // показала рост CAGR на обеих половинах периода (при плече 3×: 61,9%/54,6% по половинам против 58,6%/52,1%, просадка 6,3% против 6,4%).
105
107
  export const QUARANTINE_DECAY = 0.9985;
106
- export const isStockQuarantined = (cnt, pnl) => (cnt >= 2 && pnl < -0.012) || pnl < -0.025;
108
+ export const QUARANTINE_OVERRIDE_RATIO = 1.10;
109
+ export const isStockQuarantined = (cnt, pnl, edgeRatio = 0) => {
110
+ if (edgeRatio >= QUARANTINE_OVERRIDE_RATIO) return false;
111
+ return (cnt >= 2 && pnl < -0.012) || pnl < -0.025;
112
+ };
113
+
114
+ // Расчёт карантина по истории реальных сделок счёта (FIFO-круги с затуханием QUARANTINE_DECAY и коэффициентом 0,90 на сделку —
115
+ // единая формула с simulator.js, research.js и live.js, без искажения от частичных исполнений и продаж старого портфеля).
116
+ const NON_STOCK_TICKERS = new Set(['LQDT', 'TMON@', 'TMON', 'SBMM']);
117
+ export function computeAccountQuarantine(operations, heldSymbols = new Set(), nowTs = Date.now(), feeSidePct = 0.04) {
118
+ const out = new Map();
119
+ if (!Array.isArray(operations) || !operations.length) return out;
120
+ const cutoffTs = nowTs - 45 * DAY;
121
+ const recent = operations
122
+ .filter((o) => o && o.date >= cutoffTs && o.symbol && !NON_STOCK_TICKERS.has(o.symbol) && (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0)
123
+ .sort((a, b) => a.date - b.date);
124
+ if (!recent.length) return out;
125
+ const roundTripFee = 2 * ((Number(feeSidePct) || 0.04) / 100);
126
+ const queues = new Map();
127
+ const rawTrips = [];
128
+ for (const op of recent) {
129
+ const sym = op.symbol;
130
+ const dir = op.kind === 'buy' ? 'long' : 'short';
131
+ let q = queues.get(sym);
132
+ // Несопоставленные остатки старше 18 часов — это односторонние продажи/покупки начального портфеля, а не внутридневные сделки
133
+ if (q && q.lots.length) {
134
+ q.lots = q.lots.filter((l) => op.date - l.ts <= 18 * 3600000);
135
+ if (!q.lots.length) q = null;
136
+ }
137
+ if (!q || !q.lots.length || q.side === dir) {
138
+ if (!q || !q.lots.length) q = { side: dir, lots: [] };
139
+ q.lots.push({ qty: op.qty, px: op.price, ts: op.date });
140
+ queues.set(sym, q);
141
+ } else {
142
+ let rem = op.qty;
143
+ while (rem > 0 && q.lots.length) {
144
+ const e = q.lots[0];
145
+ const take = Math.min(rem, e.qty);
146
+ const gross = q.side === 'long' ? (op.price / e.px - 1) : (1 - op.price / e.px);
147
+ const net = gross - roundTripFee;
148
+ rawTrips.push({ symbol: sym, side: q.side, alloc: take * e.px, entryTs: e.ts, exitTs: op.date, net });
149
+ e.qty -= take;
150
+ rem -= take;
151
+ if (e.qty <= 0) q.lots.shift();
152
+ }
153
+ if (rem > 0) {
154
+ q = { side: dir, lots: [{ qty: rem, px: op.price, ts: op.date }] };
155
+ queues.set(sym, q);
156
+ }
157
+ }
158
+ }
159
+ // Объединяем частичные исполнения одной и той же заявки (вход или выход в пределах 5 минут) в один круг сделки
160
+ const mergedBySym = new Map();
161
+ for (const t of rawTrips) {
162
+ if (t.alloc < 2000) continue;
163
+ const list = mergedBySym.get(t.symbol) || [];
164
+ const prev = list.length ? list[list.length - 1] : null;
165
+ if (prev && prev.side === t.side && (Math.abs(t.exitTs - prev.exitTs) <= 5 * 60000 || Math.abs(t.entryTs - prev.entryTs) <= 5 * 60000)) {
166
+ const totAlloc = prev.alloc + t.alloc;
167
+ prev.net = (prev.net * prev.alloc + t.net * t.alloc) / totAlloc;
168
+ prev.alloc = totAlloc;
169
+ prev.exitTs = Math.max(prev.exitTs, t.exitTs);
170
+ } else {
171
+ list.push({ ...t });
172
+ mergedBySym.set(t.symbol, list);
173
+ }
174
+ }
175
+ const STEP_MS = 30 * 60000;
176
+ const TRADING_SHARE = 15.5 / 24;
177
+ for (const [sym, trips] of mergedBySym) {
178
+ if (heldSymbols && heldSymbols.has(sym)) continue;
179
+ let pnl = 0, cnt = 0, prevExitTs = 0;
180
+ for (const t of trips) {
181
+ if (prevExitTs > 0) {
182
+ const dtSteps = Math.max(0, (t.exitTs - prevExitTs) / STEP_MS) * TRADING_SHARE;
183
+ const d = Math.pow(QUARANTINE_DECAY, dtSteps);
184
+ pnl *= d;
185
+ cnt *= d;
186
+ }
187
+ pnl = pnl * 0.90 + t.net;
188
+ cnt += 1;
189
+ prevExitTs = t.exitTs;
190
+ }
191
+ if (prevExitTs > 0) {
192
+ const tailSteps = Math.max(0, (nowTs - prevExitTs) / STEP_MS) * TRADING_SHARE;
193
+ const dTail = Math.pow(QUARANTINE_DECAY, tailSteps);
194
+ pnl *= dTail;
195
+ cnt *= dTail;
196
+ }
197
+ if (isStockQuarantined(cnt, pnl, 0)) {
198
+ let expSteps = 0;
199
+ for (let s = 1; s <= 2500; s++) {
200
+ const d = Math.pow(QUARANTINE_DECAY, s);
201
+ if (!isStockQuarantined(cnt * d, pnl * d, 0)) { expSteps = s; break; }
202
+ }
203
+ const untilTs = Math.round(nowTs + expSteps * STEP_MS / TRADING_SHARE);
204
+ const untilIso = new Date(untilTs + 3 * 3600000).toISOString();
205
+ const untilText = `${untilIso.slice(8, 10)}.${untilIso.slice(5, 7)}`;
206
+ const pnlPct = Math.round(pnl * 10000) / 100;
207
+ const lastTrip = trips[trips.length - 1];
208
+ out.set(sym, {
209
+ symbol: sym,
210
+ cnt: Math.round(cnt * 100) / 100,
211
+ pnlPct,
212
+ tradesCount: trips.length,
213
+ lastExitTs: prevExitTs,
214
+ lastSide: lastTrip?.side || 'long',
215
+ untilTs,
216
+ untilText,
217
+ reasonText: `Карантин счёта после убытка ${pnlPct}% (до ${untilText} пропускаются сигналы слабее ${Math.round(QUARANTINE_OVERRIDE_RATIO * 100)}% порога)`,
218
+ });
219
+ }
220
+ }
221
+ return out;
222
+ }
107
223
 
108
224
  // Граница вечерней сессии Мосбиржи (последняя 10-минутная свеча 23:40–23:50 МСК):
109
225
  // не открываем новые внутридневные позиции, когда до конца торгов остаётся меньше горизонта H, и закрываем позиции перед ночью
@@ -4,12 +4,12 @@ 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 { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday, isMarketDown } from './edge.js';
7
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, isStockQuarantined, canEnterIntraday, isMarketDown } from './edge.js';
8
8
  export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
9
9
  import { newsForSymbols, blockingNews } from '../news/index.js';
10
- import { dayContext } from './policy.js';
10
+ import { dayContext, STRATEGY_POLICY } from './policy.js';
11
11
  import { liveDividendGuard } from './dividends.js';
12
- import { fetchTbankRealtimePrices } from '../brokers/index.js';
12
+ import { fetchTbankRealtimePrices, getActiveAccountSignalFilters } from '../brokers/index.js';
13
13
  import { kvGet, kvSet } from '../db.js';
14
14
  import { hub } from '../hub.js';
15
15
 
@@ -447,7 +447,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
447
447
  const isLimitUpLocked = isFlatLimitBar && excess1d > 0.05;
448
448
  const isLimitDownLocked = isFlatLimitBar && excess1d < -0.05;
449
449
  const barDecisionTs = ds.timeline[idx] || Date.now();
450
- const isMorningOpenShortBlocked = autoAdapt && !canEnterIntraday(barDecisionTs, H, 10, 'short', tk.symbol);
450
+ const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
451
+ const isMorningOpenShortBlocked = autoAdapt && !canEnterIntraday(barDecisionTs, holdBars, 10, 'short', tk.symbol);
451
452
  const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
452
453
  const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
453
454
  const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
@@ -504,12 +505,29 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
504
505
  return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
505
506
  }
506
507
 
507
- // Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр) — в прогнозе «сейчас»
508
+ // Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр, карантин/пауза счёта) — в прогнозе «сейчас»
509
+ function formatNearSignalsList(episodesMap) {
510
+ return [...episodesMap.values()]
511
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
512
+ .slice(0, 120)
513
+ .map((ep) => {
514
+ const cleanReasons = ep.status === 'signal' ? [] : (ep.reasons || []);
515
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
516
+ const reasonText = ep.status === 'signal'
517
+ ? 'Порог пройден — сигнал сформирован'
518
+ : ep.status === 'blocked'
519
+ ? `Порог пройден, но вход закрыт: ${cleanReasons.join(' · ') || 'защитный фильтр'}`
520
+ : `Ниже порога на ${gapPct}%${cleanReasons.length ? ` (также активен фильтр: ${cleanReasons.join(' · ')})` : ''}`;
521
+ return { ...ep, reasons: cleanReasons, gapPct, reasonText };
522
+ });
523
+ }
524
+
508
525
  function withNews(result, p) {
509
526
  const nm = p.newsEnabled ? newsForSymbols(24) : null;
510
527
  const nb = p.newsFilter ? blockingNews(6) : null;
511
528
  const shortableMap = kvGet('tbankShortableMap')?.map || null;
512
529
  const nowTs = Date.now();
530
+ const accFilters = getActiveAccountSignalFilters(nowTs);
513
531
  const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
514
532
  const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
515
533
  if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
@@ -582,29 +600,124 @@ function withNews(result, p) {
582
600
  const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
583
601
  r.thresholdRatioPct = Math.round(ratio * 100);
584
602
  r.crossedThreshold = ratio >= 1;
603
+
604
+ // Учёт фильтров живого счёта (карантин после убыточных сделок, пауза после выхода, дневной блок 30099):
605
+ const qInfo = accFilters?.quarantinedMap?.get(r.symbol);
606
+ if (qInfo) {
607
+ r.quarantineInfo = qInfo;
608
+ if (isStockQuarantined(qInfo.cnt, qInfo.pnlPct / 100, ratio)) {
609
+ r.blockedByQuarantine = qInfo.reasonText;
610
+ r.signal = false;
611
+ r.shortSignal = false;
612
+ } else {
613
+ delete r.blockedByQuarantine;
614
+ }
615
+ } else {
616
+ delete r.quarantineInfo;
617
+ delete r.blockedByQuarantine;
618
+ }
619
+ const cdWhy = accFilters?.cooldownMap?.get(r.symbol);
620
+ if (cdWhy) {
621
+ r.blockedByCooldown = cdWhy;
622
+ r.signal = false;
623
+ r.shortSignal = false;
624
+ } else {
625
+ delete r.blockedByCooldown;
626
+ }
627
+ const wantSide = r.expectedPct < 0 ? 'short' : 'buy';
628
+ const dayBlk = accFilters?.dayBlockMap?.get(r.symbol);
629
+ if (dayBlk && dayBlk.side === wantSide) {
630
+ r.blockedByCorridor = 'Вход заблокирован до конца дня: цена у границы ценового коридора Мосбиржи (код 30099)';
631
+ if (wantSide === 'buy') r.signal = false;
632
+ else r.shortSignal = false;
633
+ } else {
634
+ delete r.blockedByCorridor;
635
+ }
585
636
  }
586
- // Если в сохранённом кэше список был обрезан прежним лимитом (40), пересобираем полный список из накопленных записей
637
+ // Если в сохранённом кэше список ещё не загружен или был обрезан, восстанавливаем его
587
638
  const saved = !nearSignalsCache.episodesMap.size ? kvGet('nearSignalsLog') : null;
588
- if (saved?.entries?.length && (saved.list?.length || 0) < Math.min(120, saved.entries.length)) {
639
+ if (saved?.entries?.length && !nearSignalsCache.episodesMap.size) {
589
640
  nearSignalsCache = { key: saved.key, scannedBarTs: saved.scannedBarTs || 0, episodesMap: new Map(saved.entries), list: [] };
590
641
  }
591
- if (!Array.isArray(result.nearSignals) || !result.nearSignals.length || (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length)) {
592
- if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
593
- nearSignalsCache.list = [...nearSignalsCache.episodesMap.values()]
594
- .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
595
- .slice(0, 120)
596
- .map((ep) => {
597
- const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
598
- const reasonText = ep.status === 'signal'
599
- ? 'Порог пройден — сигнал сформирован'
600
- : ep.status === 'blocked'
601
- ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
602
- : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
603
- return { ...ep, gapPct, reasonText };
642
+ // Живое пополнение журнала сегодняшними внутрисвечными сигналами и почти-сигналами (чтобы входы внутри 10-мин свечи сразу попадали в журнал)
643
+ if (nearSignalsCache.episodesMap.size > 0 && Array.isArray(result.rows)) {
644
+ const liveTs = result.liveTs || nowTs;
645
+ const slotBarTs = Math.floor(liveTs / BAR_MS) * BAR_MS;
646
+ const barMskIso = new Date(slotBarTs + MSK).toISOString();
647
+ const dayStr = barMskIso.slice(0, 10);
648
+ const timeStr = barMskIso.slice(11, 16);
649
+ const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
650
+ let liveUpdated = false;
651
+ for (const row of result.rows) {
652
+ if (row.inIndex === false || (row.thresholdRatioPct || 0) < 75) continue;
653
+ const side = row.expectedPct < 0 ? 'short' : 'long';
654
+ const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
655
+ const crossed = Boolean(row.crossedThreshold);
656
+ const reasons = [];
657
+ if (side === 'short') {
658
+ if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
659
+ if (row.shortBlockedBySqueeze) reasons.push('защита от сквиза');
660
+ if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
661
+ if (row.shortBlockedByBroker) reasons.push('запрет шорта у брокера');
662
+ if (row.shortBlockedByDiv) reasons.push(`отсечка дивидендов (${row.shortBlockedByDiv})`);
663
+ if (row.shortBlockedByNews) reasons.push(`фильтр новостей (${row.shortBlockedByNews})`);
664
+ } else {
665
+ if (row.buyBlockedByDip) reasons.push('защита от обвала свечи');
666
+ if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
667
+ if (row.buyBlockedByDiv) reasons.push(`отсечка дивидендов (${row.buyBlockedByDiv})`);
668
+ if (row.buyBlockedByNews) reasons.push(`фильтр новостей (${row.buyBlockedByNews})`);
669
+ }
670
+ if (row.blockedByQuarantine) reasons.push(`карантин счёта (${row.quarantineInfo?.pnlPct ?? ''}%)`);
671
+ if (row.blockedByCooldown) reasons.push(row.blockedByCooldown);
672
+ if (row.blockedByCorridor) reasons.push('ценовой коридор 30099');
673
+ if (row.liquid === false) reasons.push('низкая ликвидность');
674
+ const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
675
+ const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
676
+ const pctNow = row.thresholdRatioPct || 0;
677
+ const epKey = `${dayStr}:${row.symbol}:${side}`;
678
+ const prev = nearSignalsCache.episodesMap.get(epKey);
679
+ if (!prev) {
680
+ nearSignalsCache.episodesMap.set(epKey, {
681
+ day: dayStr, firstBar: timeStr, lastBar: timeStr, firstBarTs: slotBarTs, lastBarTs: slotBarTs,
682
+ peakBar: timeStr, barsCount: 1, symbol: row.symbol, name: row.name, side, price: row.price,
683
+ peakExpectedPct: row.expectedPct, thresholdPct: thr, peakRatioPct: pctNow,
684
+ status, reasons: status === 'signal' ? [] : reasons,
604
685
  });
686
+ liveUpdated = true;
687
+ } else {
688
+ if (slotBarTs > prev.lastBarTs) {
689
+ prev.lastBar = timeStr;
690
+ prev.lastBarTs = slotBarTs;
691
+ prev.barsCount += 1;
692
+ liveUpdated = true;
693
+ }
694
+ if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow > prev.peakRatioPct)) {
695
+ prev.peakExpectedPct = row.expectedPct;
696
+ prev.thresholdPct = thr;
697
+ prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
698
+ prev.peakBar = timeStr;
699
+ prev.price = row.price;
700
+ prev.status = status;
701
+ prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
702
+ liveUpdated = true;
703
+ }
704
+ }
605
705
  }
606
- const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
607
- result.nearSignals = savedNear;
706
+ if (liveUpdated) {
707
+ nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
708
+ kvSet('nearSignalsLog', {
709
+ key: nearSignalsCache.key,
710
+ scannedBarTs: nearSignalsCache.scannedBarTs,
711
+ entries: [...nearSignalsCache.episodesMap.entries()],
712
+ list: nearSignalsCache.list,
713
+ });
714
+ }
715
+ }
716
+ if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
717
+ nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
718
+ }
719
+ if (nearSignalsCache.list.length || !Array.isArray(result.nearSignals) || !result.nearSignals.length) {
720
+ result.nearSignals = nearSignalsCache.list.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
608
721
  }
609
722
  return result;
610
723
  }
@@ -624,7 +737,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
624
737
  key,
625
738
  scannedBarTs: saved.scannedBarTs,
626
739
  episodesMap: new Map(saved.entries),
627
- list: saved.list || [],
740
+ list: [],
628
741
  };
629
742
  } else {
630
743
  nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
@@ -695,7 +808,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
695
808
  thresholdPct: thr,
696
809
  peakRatioPct: Math.round(ratio * 100),
697
810
  status,
698
- reasons,
811
+ reasons: status === 'signal' ? [] : reasons,
699
812
  });
700
813
  } else {
701
814
  prev.lastBar = timeStr;
@@ -709,14 +822,14 @@ async function updateNearSignalsJournal(ds, p, last, key) {
709
822
  prev.peakBar = timeStr;
710
823
  prev.price = row.price;
711
824
  prev.status = status;
712
- if (reasons.length) prev.reasons = reasons;
825
+ prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
713
826
  } else if (pctNow > prev.peakRatioPct) {
714
827
  prev.peakExpectedPct = row.expectedPct;
715
828
  prev.thresholdPct = thr;
716
829
  prev.peakRatioPct = pctNow;
717
830
  prev.peakBar = timeStr;
718
831
  }
719
- if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
832
+ if (prev.status !== 'signal' && reasons.length && !prev.reasons?.length) prev.reasons = reasons;
720
833
  }
721
834
  }
722
835
  nearSignalsCache.scannedBarTs = barTs;
@@ -724,18 +837,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
724
837
  }
725
838
 
726
839
  if (updated || !nearSignalsCache.list.length) {
727
- const list = [...nearSignalsCache.episodesMap.values()]
728
- .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
729
- .slice(0, 120)
730
- .map((ep) => {
731
- const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
732
- const reasonText = ep.status === 'signal'
733
- ? 'Порог пройден — сигнал сформирован'
734
- : ep.status === 'blocked'
735
- ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
736
- : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
737
- return { ...ep, gapPct, reasonText };
738
- });
840
+ const list = formatNearSignalsList(nearSignalsCache.episodesMap);
739
841
  nearSignalsCache.list = list;
740
842
  kvSet('nearSignalsLog', {
741
843
  key,
@@ -838,10 +940,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
838
940
  inflight.delete(key);
839
941
  inflightStartedAt.delete(key);
840
942
  }
943
+ const mskDate = new Date(now + MSK);
944
+ const mskDow = mskDate.getUTCDay();
945
+ const mskMins = mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes();
946
+ const isTradingWindow = mskDow >= 1 && mskDow <= 5 && mskMins >= 7 * 60 && mskMins <= 23 * 60 + 55;
841
947
  const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
842
948
  const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
843
- const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
844
- if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
949
+ const barStale = isTradingWindow && curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
950
+ const dsTtlMs = isTradingWindow ? 4 * 60000 : 30 * 60000;
951
+ if ((now - baseState.dsLoadedAt >= dsTtlMs || barStale) && !inflight.has(key)) {
845
952
  setImmediate(() => {
846
953
  refreshDatasetStateInBackground(p, key).catch(() => {});
847
954
  });
@@ -7,6 +7,7 @@ import { latestBarTs } from '../data/moex.js';
7
7
  import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
8
8
  import { predictAt, lastClosedIndex } from './forecast.js';
9
9
  import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
10
+ import { STRATEGY_POLICY } from './policy.js';
10
11
  import { computeReport, downsample } from './metrics.js';
11
12
  import { MODELS } from './models.js';
12
13
  import { blockingNews } from '../news/index.js';
@@ -395,7 +396,8 @@ class LiveEngine {
395
396
  const autoAdapt = p.modelAutoAdapt !== false;
396
397
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
397
398
  const H = barsFromHours(effHorizon);
398
- const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
399
+ const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
400
+ const stepBars = autoAdapt ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
399
401
  if (s.barsSinceRebalance >= stepBars) {
400
402
  await new Promise((r) => setImmediate(r));
401
403
  const r = await predictAt(ds, p, idx);
@@ -415,9 +417,8 @@ class LiveEngine {
415
417
  };
416
418
  const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
417
419
  const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
418
- const quarantined = new Set();
420
+ const stMap = new Map();
419
421
  if (autoAdapt && s.trades.length >= 1) {
420
- const stMap = new Map();
421
422
  for (const t of s.trades) {
422
423
  // затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
423
424
  const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
@@ -427,7 +428,6 @@ class LiveEngine {
427
428
  cur.cnt += wDecay;
428
429
  stMap.set(t.symbol, cur);
429
430
  }
430
- for (const [sym, st] of stMap) if (isStockQuarantined(st.cnt, st.pnl)) quarantined.add(sym);
431
431
  }
432
432
  for (const [sym, h] of s.held) {
433
433
  if (autoAdapt && shouldExitBeforeNight(ts, h.entryTs, stepBars, BAR_INTERVAL)) {
@@ -444,7 +444,7 @@ class LiveEngine {
444
444
  const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * (x?.edgeMult || symbolEdgeMult(sym));
445
445
  if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
446
446
  }
447
- const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
447
+ const timeOk = !autoAdapt || canEnterIntraday(ts, holdBars, BAR_INTERVAL);
448
448
  const sideHeld = (side) => [...s.held].filter(([sym, h]) => h.side === side && !closing.has(sym));
449
449
  const nb = p.newsFilter ? blockingNews(6) : null;
450
450
  const take = (list, side, n) => {
@@ -452,7 +452,10 @@ class LiveEngine {
452
452
  const out = [];
453
453
  for (const x of list) {
454
454
  if (out.length >= lim) break;
455
- if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
455
+ const qSt = stMap.get(x.symbol);
456
+ const thrPct = side === 'buy' ? (x.buyThresholdPct || r.buyThresholdPct || r.thresholdPct) : (x.shortThresholdPct || r.shortThresholdPct || r.thresholdPct);
457
+ const edgeRatio = thrPct > 0 ? Math.abs(x.expectedPct || 0) / thrPct : 0;
458
+ if (qSt && isStockQuarantined(qSt.cnt, qSt.pnl, edgeRatio)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
456
459
  const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
457
460
  if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
458
461
  out.push(x);
@@ -461,7 +464,7 @@ class LiveEngine {
461
464
  };
462
465
  const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
463
466
  const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
464
- const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x) && (!autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL, 'short', x.symbol))).sort((a, b) => a.expected - b.expected);
467
+ const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x) && (!autoAdapt || canEnterIntraday(ts, holdBars, BAR_INTERVAL, 'short', x.symbol))).sort((a, b) => a.expected - b.expected);
465
468
  const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
466
469
  if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
467
470
  const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
@@ -38,6 +38,12 @@ export const STRATEGY_POLICY = Object.freeze({
38
38
  // в спокойный день (множитель до 0.75×) не вытряхивает позицию раньше времени, в турбулентный день (до 1.25×) быстрее фиксирует выход при затухании прогноза.
39
39
  // Проверка 09.11.2026 (06.2024–11.2026, плечо 3×): CAGR 58,6%/52,1% по половинам (весь 55,1%, PF 1,97, DD 6,4%) против 57,5%/48,1% (весь 52,6%, PF 1,89, DD 6,8%).
40
40
  exitVolClamp: Object.freeze([0.75, 1.25]),
41
+ // глубина сопровождения позиции и вечерней отсечки входов в 10-минутных свечах (13 свечей = 130 минут = 2 ч 10 м),
42
+ // при сохранении базового мульти-горизонта обучения модели (H = 15 свечей = 2,5 ч) и калибровки порога входа √H.
43
+ // Проверка 12.11.2026 (06.2024–11.2026, 59 051 свеча): улучшает итог и снижает риск на обеих половинах истории при любом плече
44
+ // (при плече 2×: CAGR 54,5%/40,6% по половинам, весь 47,2%, PF 1,87, DD 3,4%, худшая сделка −3,54% против 51,9%/40,3%, весь 45,8%, PF 1,82, DD 3,6%, худшая −4,39%;
45
+ // при плече 3×: CAGR 65,9%/55,2% по половинам, весь 60,3%, PF 2,03, DD 6,0% против 61,9%/54,6%, весь 58,0%, PF 1,97, DD 6,3%).
46
+ holdBars: 13,
41
47
  });
42
48
 
43
49
  // ---------- окна начала и конца сессий (широкий спред, гэпы, аукционы) ----------
@@ -393,7 +393,7 @@ async function run(runId, p, state) {
393
393
  const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
394
394
  // Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
395
395
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
396
- const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
396
+ const stockAllowed = (i, edgeRatio = 0) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i], edgeRatio);
397
397
  let curExitRatio = autoAdapt ? 0.95 : exitRatio;
398
398
  const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
399
399
  const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
@@ -459,7 +459,7 @@ async function run(runId, p, state) {
459
459
  }
460
460
  }
461
461
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
462
- const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
462
+ const canOpenAt = (i, edgeRatio = 0) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i, edgeRatio) && canEnter(i, t);
463
463
  const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
464
464
  const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
465
465
  let order, entries;
@@ -480,8 +480,8 @@ async function run(runId, p, state) {
480
480
  }
481
481
  const needL = autoAdapt && bear ? need * 1.25 : need;
482
482
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
483
- const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
484
- const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
483
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
484
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i, (-edge(i)) / (needS * symMult[i])) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
485
485
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
486
486
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
487
487
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -522,7 +522,7 @@ async function run(runId, p, state) {
522
522
  if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn, false, shocked ? 'Защита от экстремального обвала' : '');
523
523
  }
524
524
  const needL = autoAdapt && bear ? need * 1.25 : need;
525
- const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
525
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
526
526
  const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
527
527
  if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
528
528
  const bestNewEdge = edge(rawCand[0]);
@@ -1118,7 +1118,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1118
1118
  const borrowFee = (h, t) => h.alloc * (p.modelShortFeePct / 100) * ((tsOf(t) - tsOf(h.entryT)) / 3600000 / 24 / 365);
1119
1119
  const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
1120
1120
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
1121
- const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
1121
+ const stockAllowed = (i, edgeRatio = 0) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i], edgeRatio);
1122
1122
  const curExitRatio = autoAdapt ? 0.95 : exitRatio;
1123
1123
  const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
1124
1124
  const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
@@ -1159,7 +1159,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1159
1159
  }
1160
1160
  }
1161
1161
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
1162
- const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
1162
+ const canOpenAt = (i, edgeRatio = 0) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i, edgeRatio) && canEnter(i, t);
1163
1163
  const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
1164
1164
  const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
1165
1165
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
@@ -1174,8 +1174,8 @@ async function runStabilityLoop(targetId, runRow, st) {
1174
1174
  }
1175
1175
  const needL = autoAdapt && bear ? need * 1.25 : need;
1176
1176
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
1177
- const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1178
- const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
1177
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1178
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i, (-edge(i)) / (needS * symMult[i])) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
1179
1179
  const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1180
1180
  if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
1181
1181
  const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
@@ -1201,7 +1201,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1201
1201
  if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1202
1202
  }
1203
1203
  const needL = autoAdapt && bear ? need * 1.25 : need;
1204
- const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1204
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1205
1205
  const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1206
1206
  if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
1207
1207
  const bestNewEdge = edge(rawCand[0]);
@@ -36,8 +36,9 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
36
36
  const N = tokens.length;
37
37
  const H = wf.H;
38
38
  const sqrtH = Math.sqrt(H);
39
+ const holdBars = policy.holdBars ?? H;
39
40
  const bpd = Math.max(1, Math.round(ds.barsPerDay || 94));
40
- const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(H / 5)) : H;
41
+ const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
41
42
  const K = policy.K ?? 4;
42
43
  const dir = p.modelDirection === 'long' ? 'long' : 'longshort';
43
44
  const realCost = (p.commissionPct + p.slippagePct) / 100;
@@ -48,7 +49,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
48
49
  const commission = p.commissionPct / 100;
49
50
  const cooldownBars = Math.round((policy.cooldownMin ?? 30) / BAR_INTERVAL);
50
51
  const stopCooldownBars = Math.round((policy.stopCooldownMin ?? 60) / BAR_INTERVAL);
51
- const horizonMin = Math.max(90, (H * BAR_INTERVAL));
52
+ const horizonMin = Math.max(90, holdBars * BAR_INTERVAL);
52
53
  const slotW = policy.slotPct ?? (plan === 'owner' ? 0.45 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
53
54
  const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
54
55
  const convK = plan === 'owner' ? 0.30 : 0.25;
@@ -275,22 +276,24 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
275
276
  dayDdBreachCount = 0;
276
277
  }
277
278
  }
278
- const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
279
+ const timeOk = !dayHalted && canEnterIntraday(decTs, holdBars, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
279
280
  if (timeOk) {
280
- const baseOk = (i) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i]) && (policy.tier2 !== false || isT1(i));
281
+ const baseOk = (i, edgeRatio = 0) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i], edgeRatio) && (policy.tier2 !== false || isT1(i));
281
282
  const candL = [];
282
283
  const candS = [];
283
284
  for (let i = 0; i < N; i++) {
284
285
  const e = edge(i);
285
- if (e > Math.max(needL * mult[i] * tierScale(i), minEdgeL)) {
286
- if (!baseOk(i)) continue;
286
+ const thrL = Math.max(needL * mult[i] * tierScale(i), minEdgeL);
287
+ const thrS = Math.max(needS * mult[i] * tierScale(i), minEdgeS);
288
+ if (e > thrL) {
289
+ if (!baseOk(i, e / thrL)) continue;
287
290
  const fl = flags(i);
288
291
  if (fl.dipBlock || fl.overheat || divRule(i, t)?.blockLong) continue;
289
292
  candL.push(i);
290
- } else if (dir !== 'long' && e < -Math.max(needS * mult[i] * tierScale(i), minEdgeS)) {
291
- if (!baseOk(i)) continue;
293
+ } else if (dir !== 'long' && e < -thrS) {
294
+ if (!baseOk(i, (-e) / thrS)) continue;
292
295
  if (shortable && !shortable.has(tokens[i].symbol)) continue;
293
- if (!canEnterIntraday(decTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)) continue;
296
+ if (!canEnterIntraday(decTs, holdBars, BAR_INTERVAL, 'short', tokens[i].symbol)) continue;
294
297
  const fl = flags(i);
295
298
  if (fl.spikeBlock || divRule(i, t)?.blockShort) continue;
296
299
  candS.push(i);
@@ -333,7 +336,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
333
336
  const sigmaBarPct = sg[i] * 100;
334
337
  const ctx = dayContext(ds, i, t, sigmaBarPct);
335
338
  if (counterTrendBlocked(side, ctx, policy)) { stats.blockedCT++; continue; }
336
- const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: H }, policy);
339
+ const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: holdBars }, policy);
337
340
  const conv = Math.min(convMax, Math.max(0.85, 0.80 + convK * (Math.abs(e) / need)));
338
341
  const tm = isT1(i) ? (policy.sizeMultT1 ?? lev) : (policy.sizeMultT2 ?? 1);
339
342
  if (!(tm > 0)) continue;
@@ -23,9 +23,11 @@ const MAX_STEPS = 20;
23
23
  const MSK = 3 * 3600000;
24
24
  const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
25
25
  const fmtClockMsk = (ts) => new Date(ts + MSK).toISOString().slice(11, 16);
26
- // Общие вопросы о состоянии торгов («как дела», «что происходит») требуют проверки фактических данных, а не ответа по календарю
26
+ // Общие вопросы о состоянии торгов («как дела», «что происходит») и вопросы по акциям/сигналам требуют вызова свежих инструментов, а не чтения старых реплик диалога
27
27
  const STATUS_QUESTION_RE = /^(?:ну\s+)?(?:как\s+(?:дела|делишки|торговля|идут\s+торги|сч[её]т|успехи)|что\s+(?:происходит|нового))(?:[\s!?.,]|$)/i;
28
+ const STOCK_OR_SIGNAL_QUESTION_RE = /(?:почему\s+не\s+(?:покупа|бер|взял|вошл|шорт|прода|откры)|почему\s+пропустил|что\s+(?:по|с)\s+\S+|сигнал\s+по|прогноз\s+по|как\s+вчера|вчерашн)/i;
28
29
  const isStatusQuestion = (text = '') => STATUS_QUESTION_RE.test(String(text || '').trim());
30
+ const isStockOrSignalQuestion = (text = '') => STOCK_OR_SIGNAL_QUESTION_RE.test(String(text || '').trim());
29
31
  const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
30
32
 
31
33
  // Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
@@ -208,6 +210,15 @@ function systemPrompt() {
208
210
  if (!noTools && isStatusQuestion(lastUser?.content)) {
209
211
  statusQuestionHint = `\n## Указание для текущего запроса
210
212
  Пользователь задал общий вопрос о состоянии. Прежде чем отвечать, **ОБЯЗАТЕЛЬНО** вызови \`get_brokers_status\` и \`get_broker_operations\` с \`{"period": "today"}\` (если счета подключены). Ответь по фактическим данным: идут ли торги, какие позиции открыты, какие сделки уже были сегодня и с каким результатом. Не говори «торги ещё не начались», если в данных есть сделки.
213
+ `;
214
+ } else if (!noTools && isStockOrSignalQuestion(lastUser?.content)) {
215
+ statusQuestionHint = `\n## Указание для текущего запроса
216
+ Пользователь спрашивает о конкретной бумаге, сигнале, пропуске входа или итогах торгов. **КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО** отвечать по прошлым сообщениям или старым вызовам инструментов в истории чата!
217
+ Прежде чем отвечать, **ОБЯЗАТЕЛЬНО** вызови свежие инструменты прямо сейчас:
218
+ - \`model_forecast\` (передай \`symbol\` с тикером или названием компании, если речь о конкретной акции, например \`{"symbol": "SMLT"}\` или \`{"symbol": "Самолет"}\`),
219
+ - \`get_brokers_status\` (чтобы проверить активные фильтры счёта: \`карантин_бумаг_на_счёте\`, \`последние_события_исполнения\`, \`подготовленный_пакет_заявок\`),
220
+ - и при вопросе о вчерашних/сегодняшних сделках — \`get_broker_operations\`.
221
+ В ответе называй точный индивидуальный порог акции из поля \`порог_покупки_проц\` / \`порог_шорта_проц\` свежего вызова \`model_forecast\` и проверяй поля \`заблокирована_карантином_счёта\`, \`заблокирована_паузой_после_выхода\`, \`заблокирована_ценовым_коридором\` и \`эпизоды_выбранной_акции_в_журнале\`.
211
222
  `;
212
223
  }
213
224
  } catch { /* ignore */ }
@@ -355,7 +366,14 @@ function buildContext(convId) {
355
366
  }
356
367
  if (m.role === 'tool') {
357
368
  let content = m.content || '';
358
- if (!lastToolIdx.includes(idx) && content.length > 1500) content = `${content.slice(0, 1500)}… (сокращено)`;
369
+ const ageMs = m.ts ? (Date.now() - m.ts) : 0;
370
+ const isStaleLiveTool = ageMs > 15 * 60000;
371
+ if (isStaleLiveTool) {
372
+ const stamp = m.ts ? fmtTs(m.ts) : 'ранее';
373
+ content = `[УСТАРЕВШИЙ СНИМОК ИНСТРУМЕНТА от ${stamp} МСК — НЕ ИСПОЛЬЗУЙ КАК ТЕКУЩИЕ ДАННЫЕ, вызови инструмент заново!] ${content.slice(0, 380)}…`;
374
+ } else if (!lastToolIdx.includes(idx) && content.length > 1500) {
375
+ content = `${content.slice(0, 1500)}… (сокращено)`;
376
+ }
359
377
  out.push({ role: 'tool', tool_call_id: m.tool_call_id, content });
360
378
  openIds.delete(m.tool_call_id);
361
379
  if (!openIds.size) { out.push(...deferred); deferred.length = 0; }
@@ -5,7 +5,7 @@ import { getRun, listRuns } from '../db.js';
5
5
  import { analyzeRun, compareRuns, recentRuns } from './analytics.js';
6
6
  import { forecastNow } from '../model/forecast.js';
7
7
  import { listNews } from '../news/index.js';
8
- import { BROKER_META, brokersState, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders, fetchBrokerHistory } from '../brokers/index.js';
8
+ import { BROKER_META, brokersState, listBrokerEvents, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders, fetchBrokerHistory } from '../brokers/index.js';
9
9
  import { fmtBarMsk, fmtDayMsk, MSK, DAY, round } from '../util.js';
10
10
  import { tradingSessionStatus } from '../session.js';
11
11
 
@@ -68,6 +68,17 @@ export const TOOLS = {
68
68
  ...(a.entryTsBySymbol?.[p.symbol] ? { время_входа_робота_мск: fmtBarMsk(a.entryTsBySymbol[p.symbol]) } : {}),
69
69
  })),
70
70
  активные_заявки_в_стакане: a.lastSync?.activeOrders || [],
71
+ карантин_бумаг_на_счёте: (a.quarantinedStocks || []).map((q) => ({
72
+ тикер: q.symbol,
73
+ накопленный_результат_проц: q.pnlPct,
74
+ до_даты: q.untilText,
75
+ правило: q.reasonText,
76
+ })),
77
+ последние_события_исполнения: listBrokerEvents(a.id, 15).map((ev) => ({
78
+ время_мск: fmtBarMsk(ev.ts),
79
+ тип: ev.kind,
80
+ описание: ev.text,
81
+ })),
71
82
  подготовленный_пакет_заявок: a.pendingBatch ? {
72
83
  свеча: a.pendingBatch.bar,
73
84
  статус: a.pendingBatch.status,
@@ -239,6 +250,13 @@ export const TOOLS = {
239
250
  цена: op.price || 0,
240
251
  сумма_руб: op.paymentRub,
241
252
  })),
253
+ события_заявок_и_пропусков_за_период: listBrokerEvents(a.id, 120)
254
+ .filter((ev) => {
255
+ const dIso = new Date((ev.ts || 0) + MSK).toISOString().slice(0, 10);
256
+ return dIso >= startIso && dIso <= endIso;
257
+ })
258
+ .reverse()
259
+ .map((ev) => ({ время_мск: fmtBarMsk(ev.ts), тип: ev.kind, описание: ev.text })),
242
260
  ...(prevActiveDay ? { подсказка: `За период ${periodLabel} операций не было; ближайший предыдущий день со сделками: ${prevActiveDay}` } : {}),
243
261
  };
244
262
  });
@@ -299,12 +317,17 @@ export const TOOLS = {
299
317
  ...(x.shortBlockedByBroker ? { шорт_запрещён_брокером: true } : {}),
300
318
  ...(x.shortBlockedByDiv ? { шорт_заблокирован_дивидендами: x.shortBlockedByDiv } : {}),
301
319
  ...(x.shortBlockedByNews ? { шорт_заблокирован_новостями: x.shortBlockedByNews } : {}),
320
+ ...(x.blockedByQuarantine ? { заблокирована_карантином_счёта: x.blockedByQuarantine } : (x.quarantineInfo ? { карантин_пробит_сильным_сигналом: `${x.quarantineInfo.reasonText} — сигнал ≥110% порога допущен` } : {})),
321
+ ...(x.blockedByCooldown ? { заблокирована_паузой_после_выхода: x.blockedByCooldown } : {}),
322
+ ...(x.blockedByCorridor ? { заблокирована_ценовым_коридором: x.blockedByCorridor } : {}),
302
323
  ликвидна: x.liquid,
303
324
  влияют_соседи: (x.influences || []).map((n) => `${n.symbol} (${n.contribPct > 0 ? '+' : ''}${n.contribPct}%, близость ${n.closeness})`),
304
325
  };
305
326
  };
306
327
  const symUp = symbol ? String(symbol).toUpperCase().trim() : '';
307
- const targetRow = symUp ? r.rows.find((x) => x.symbol === symUp) : null;
328
+ const targetRow = symUp
329
+ ? (r.rows.find((x) => x.symbol === symUp) || r.rows.find((x) => String(x.name || '').toUpperCase().includes(symUp)))
330
+ : null;
308
331
  return {
309
332
  result: {
310
333
  текущее_время_мск: fmtBarMsk(Date.now()),
@@ -315,7 +338,10 @@ export const TOOLS = {
315
338
  порог_шорта_1_эшелон_проц: -shortThr,
316
339
  порог_шорта_2_эшелон_проц: -shortThrT2,
317
340
  тренд_индекса: r.marketDown ? 'падающий (облегчён шорт, ужесточён лонг)' : 'растущий (стандартный лонг, ужесточён шорт)',
318
- ...(targetRow ? { выбранная_акция: pick(targetRow) } : {}),
341
+ ...(targetRow ? {
342
+ выбранная_акция: pick(targetRow),
343
+ эпизоды_выбранной_акции_в_журнале: (r.nearSignals || []).filter((e) => e.symbol === targetRow.symbol),
344
+ } : {}),
319
345
  сильнее_рынка: r.rows.slice(0, top).map(pick),
320
346
  слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
321
347
  журнал_почти_сигналов_за_неделю: (r.nearSignals || []).slice(0, 12),