neuroalpha 0.6.7 → 0.6.8

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.8",
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)) {
@@ -2022,33 +2077,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2022
2077
  const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
2023
2078
  const entryDayBlockMap = acc.entryDayBlockBySymbol || {};
2024
2079
  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
- }
2080
+ // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с simulator.js / research.js):
2081
+ // если по закрытым FIFO-кругам накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%, с затуханием QUARANTINE_DECAY),
2082
+ // бумага пропускает обычные сигналы, но пропускает усиленные сигналы >= 110% индивидуального порога (QUARANTINE_OVERRIDE_RATIO).
2083
+ const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(acc, p.symbol, p.qty) !== 0).map((p) => p.symbol));
2084
+ const quarantinedMap = autoAdapt
2085
+ ? computeAccountQuarantine(acc.lastHistory?.operations, heldNowSet, nowTs, Number(params.commissionPct) || 0.04)
2086
+ : new Map();
2087
+ const isQuarantinedFor = (r, side) => {
2088
+ const q = quarantinedMap.get(r.symbol);
2089
+ if (!q) return false;
2090
+ const reqEdge = side === 'short' ? reqShortEdgeOf(r.symbol) : reqBuyEdgeOf(r.symbol);
2091
+ const edgeRatio = reqEdge > 0 ? Math.abs(Number(r.expectedPct) || 0) / reqEdge : 0;
2092
+ return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
2093
+ };
2052
2094
  const slots = dir === 'long' ? K : 2 * K;
2053
2095
  // Фильтр мощности слота по тарифу:
2054
2096
  // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
@@ -2084,6 +2126,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2084
2126
  const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
2085
2127
  const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
2086
2128
  const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
2129
+ const quarantineSkipped = fc.rows.filter((r) => {
2130
+ if (!isRawSignalRow(r) || isInCooldown(r.symbol)) return false;
2131
+ const side = r.expectedPct < 0 ? 'short' : 'buy';
2132
+ return !isDayBlocked(r.symbol, side) && isQuarantinedFor(r, side);
2133
+ });
2087
2134
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
2088
2135
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
2089
2136
  const divBlocked = [];
@@ -2093,10 +2140,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2093
2140
  || (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
2141
  const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
2095
2142
  || (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)
2143
+ 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
2144
  .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
2145
  .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)
2146
+ 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') && !isQuarantinedFor(r, 'short') && !nb?.get(r.symbol)?.good)
2100
2147
  .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
2148
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
2102
2149
  const nonShortableSkipped = [];
@@ -2559,7 +2606,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2559
2606
  : '';
2560
2607
  const dayBlocked = fc.rows.filter((r) => (r.signal && isDayBlocked(r.symbol, 'buy')) || (r.shortSignal && isDayBlocked(r.symbol, 'short')));
2561
2608
  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 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
2609
+ const qNote = quarantineSkipped.length
2610
+ ? ` Сигнал ${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)}% порога.`
2611
+ : '';
2612
+ const cdNote = dayBlockNote + qNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
2563
2613
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2564
2614
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2565
2615
  const thrRangeStr = dir !== 'long'
@@ -2570,6 +2620,22 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2570
2620
  : `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
2571
2621
  }
2572
2622
  }
2623
+ if (entryTimeOk) {
2624
+ for (const r of quarantineSkipped) {
2625
+ const q = quarantinedMap.get(r.symbol);
2626
+ recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', q?.reasonText || 'карантин счёта после убыточных сделок');
2627
+ }
2628
+ for (const r of cooldownSkipped) {
2629
+ recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', 'пауза после недавнего выхода из позиции');
2630
+ }
2631
+ for (const r of entryFailSkipped) {
2632
+ recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', `пауза 5 мин после ошибки приёма у брокера (${entryFailReasonMap[r.symbol] || 'отказ шлюза'})`);
2633
+ }
2634
+ for (const r of fc.rows) {
2635
+ if (r.signal && isDayBlocked(r.symbol, 'buy')) recordSignalSkipOnce(acc, fc, r, 'buy', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
2636
+ if (r.shortSignal && isDayBlocked(r.symbol, 'short')) recordSignalSkipOnce(acc, fc, r, 'short', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
2637
+ }
2638
+ }
2573
2639
  if (divBlocked.length) {
2574
2640
  diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
2575
2641
  }
@@ -3158,7 +3224,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
3158
3224
  }
3159
3225
  }
3160
3226
 
3161
- export async function cancelBrokerOrder(id, orderId = null) {
3227
+ export async function cancelBrokerOrder(id, orderId = null, reason = null) {
3162
3228
  const list = loadRawAccounts();
3163
3229
  const acc = list.find((x) => x.id === id);
3164
3230
  if (!acc) throw new Error('Счёт не найден');
@@ -3168,22 +3234,44 @@ export async function cancelBrokerOrder(id, orderId = null) {
3168
3234
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
3169
3235
  const accountId = acc.accountId || acc.lastSync?.accountId;
3170
3236
  if (!accountId) throw new Error('Не определён ID счёта');
3237
+ const activeByOrdId = new Map((acc.lastSync?.activeOrders || []).map((o) => [String(o.orderId), o]));
3171
3238
  if (orderId && orderId !== 'all') {
3172
3239
  const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
3173
3240
  method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
3174
3241
  });
3175
3242
  if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
3243
+ const ao = activeByOrdId.get(String(orderId));
3244
+ const remLots = ao ? Math.max(0, (Number(ao.lotsRequested) || 0) - (Number(ao.lotsExecuted) || 0)) : 0;
3245
+ logBrokerEvent(acc, 'order_expired', `${ao?.symbol || 'заявка'}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л. по ${ao.price} ₽` : ''}) · ${orderId}`);
3176
3246
  } else {
3177
3247
  const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
3178
3248
  method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
3179
3249
  });
3180
3250
  if (rOrd.ok) {
3181
3251
  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`, {
3252
+ const rawOrders = jOrd.orders || [];
3253
+ const isScheduledCleanup = reason === 'не исполнена к плановому шагу';
3254
+ const nowMs = Date.now();
3255
+ const toCancel = rawOrders.filter((o) => {
3256
+ if (!o?.orderId) return false;
3257
+ // Свежую заявку (< 90 с), только что выставленную быстрым добором перед границей шага, не сбиваем на первой же минуте
3258
+ if (isScheduledCleanup && o.orderDate && nowMs - Date.parse(o.orderDate) < 90000) return false;
3259
+ return true;
3260
+ });
3261
+ const metaCache = kvGet('tbankInstMeta') || {};
3262
+ await Promise.all(toCancel.map((o) => (
3263
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
3184
3264
  method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
3185
- }).catch(() => null) : null
3265
+ }).catch(() => null)
3186
3266
  )));
3267
+ for (const o of toCancel) {
3268
+ const ao = activeByOrdId.get(String(o.orderId));
3269
+ const m = metaCache[o.ticker] || metaCache[o.figi] || metaCache[o.instrumentUid] || {};
3270
+ const sym = ao?.symbol || m.ticker || o.ticker || o.figi || 'заявка';
3271
+ const remLots = Math.max(0, (Number(o.lotsRequested) || 0) - (Number(o.lotsExecuted) || 0));
3272
+ const px = ao?.price || round(qVal(o.initialSecurityPrice), 4);
3273
+ logBrokerEvent(acc, 'order_expired', `${sym}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л.${px ? ` по ${px} ₽` : ''}` : ''}) · ${o.orderId}`);
3274
+ }
3187
3275
  }
3188
3276
  }
3189
3277
  return syncBrokerAccount(id);
@@ -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
10
  import { dayContext } 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
 
@@ -504,12 +504,29 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
504
504
  return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
505
505
  }
506
506
 
507
- // Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр) — в прогнозе «сейчас»
507
+ // Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр, карантин/пауза счёта) — в прогнозе «сейчас»
508
+ function formatNearSignalsList(episodesMap) {
509
+ return [...episodesMap.values()]
510
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
511
+ .slice(0, 120)
512
+ .map((ep) => {
513
+ const cleanReasons = ep.status === 'signal' ? [] : (ep.reasons || []);
514
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
515
+ const reasonText = ep.status === 'signal'
516
+ ? 'Порог пройден — сигнал сформирован'
517
+ : ep.status === 'blocked'
518
+ ? `Порог пройден, но вход закрыт: ${cleanReasons.join(' · ') || 'защитный фильтр'}`
519
+ : `Ниже порога на ${gapPct}%${cleanReasons.length ? ` (также активен фильтр: ${cleanReasons.join(' · ')})` : ''}`;
520
+ return { ...ep, reasons: cleanReasons, gapPct, reasonText };
521
+ });
522
+ }
523
+
508
524
  function withNews(result, p) {
509
525
  const nm = p.newsEnabled ? newsForSymbols(24) : null;
510
526
  const nb = p.newsFilter ? blockingNews(6) : null;
511
527
  const shortableMap = kvGet('tbankShortableMap')?.map || null;
512
528
  const nowTs = Date.now();
529
+ const accFilters = getActiveAccountSignalFilters(nowTs);
513
530
  const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
514
531
  const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
515
532
  if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
@@ -582,29 +599,124 @@ function withNews(result, p) {
582
599
  const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
583
600
  r.thresholdRatioPct = Math.round(ratio * 100);
584
601
  r.crossedThreshold = ratio >= 1;
602
+
603
+ // Учёт фильтров живого счёта (карантин после убыточных сделок, пауза после выхода, дневной блок 30099):
604
+ const qInfo = accFilters?.quarantinedMap?.get(r.symbol);
605
+ if (qInfo) {
606
+ r.quarantineInfo = qInfo;
607
+ if (isStockQuarantined(qInfo.cnt, qInfo.pnlPct / 100, ratio)) {
608
+ r.blockedByQuarantine = qInfo.reasonText;
609
+ r.signal = false;
610
+ r.shortSignal = false;
611
+ } else {
612
+ delete r.blockedByQuarantine;
613
+ }
614
+ } else {
615
+ delete r.quarantineInfo;
616
+ delete r.blockedByQuarantine;
617
+ }
618
+ const cdWhy = accFilters?.cooldownMap?.get(r.symbol);
619
+ if (cdWhy) {
620
+ r.blockedByCooldown = cdWhy;
621
+ r.signal = false;
622
+ r.shortSignal = false;
623
+ } else {
624
+ delete r.blockedByCooldown;
625
+ }
626
+ const wantSide = r.expectedPct < 0 ? 'short' : 'buy';
627
+ const dayBlk = accFilters?.dayBlockMap?.get(r.symbol);
628
+ if (dayBlk && dayBlk.side === wantSide) {
629
+ r.blockedByCorridor = 'Вход заблокирован до конца дня: цена у границы ценового коридора Мосбиржи (код 30099)';
630
+ if (wantSide === 'buy') r.signal = false;
631
+ else r.shortSignal = false;
632
+ } else {
633
+ delete r.blockedByCorridor;
634
+ }
585
635
  }
586
- // Если в сохранённом кэше список был обрезан прежним лимитом (40), пересобираем полный список из накопленных записей
636
+ // Если в сохранённом кэше список ещё не загружен или был обрезан, восстанавливаем его
587
637
  const saved = !nearSignalsCache.episodesMap.size ? kvGet('nearSignalsLog') : null;
588
- if (saved?.entries?.length && (saved.list?.length || 0) < Math.min(120, saved.entries.length)) {
638
+ if (saved?.entries?.length && !nearSignalsCache.episodesMap.size) {
589
639
  nearSignalsCache = { key: saved.key, scannedBarTs: saved.scannedBarTs || 0, episodesMap: new Map(saved.entries), list: [] };
590
640
  }
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 };
641
+ // Живое пополнение журнала сегодняшними внутрисвечными сигналами и почти-сигналами (чтобы входы внутри 10-мин свечи сразу попадали в журнал)
642
+ if (nearSignalsCache.episodesMap.size > 0 && Array.isArray(result.rows)) {
643
+ const liveTs = result.liveTs || nowTs;
644
+ const slotBarTs = Math.floor(liveTs / BAR_MS) * BAR_MS;
645
+ const barMskIso = new Date(slotBarTs + MSK).toISOString();
646
+ const dayStr = barMskIso.slice(0, 10);
647
+ const timeStr = barMskIso.slice(11, 16);
648
+ const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
649
+ let liveUpdated = false;
650
+ for (const row of result.rows) {
651
+ if (row.inIndex === false || (row.thresholdRatioPct || 0) < 75) continue;
652
+ const side = row.expectedPct < 0 ? 'short' : 'long';
653
+ const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
654
+ const crossed = Boolean(row.crossedThreshold);
655
+ const reasons = [];
656
+ if (side === 'short') {
657
+ if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
658
+ if (row.shortBlockedBySqueeze) reasons.push('защита от сквиза');
659
+ if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
660
+ if (row.shortBlockedByBroker) reasons.push('запрет шорта у брокера');
661
+ if (row.shortBlockedByDiv) reasons.push(`отсечка дивидендов (${row.shortBlockedByDiv})`);
662
+ if (row.shortBlockedByNews) reasons.push(`фильтр новостей (${row.shortBlockedByNews})`);
663
+ } else {
664
+ if (row.buyBlockedByDip) reasons.push('защита от обвала свечи');
665
+ if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
666
+ if (row.buyBlockedByDiv) reasons.push(`отсечка дивидендов (${row.buyBlockedByDiv})`);
667
+ if (row.buyBlockedByNews) reasons.push(`фильтр новостей (${row.buyBlockedByNews})`);
668
+ }
669
+ if (row.blockedByQuarantine) reasons.push(`карантин счёта (${row.quarantineInfo?.pnlPct ?? ''}%)`);
670
+ if (row.blockedByCooldown) reasons.push(row.blockedByCooldown);
671
+ if (row.blockedByCorridor) reasons.push('ценовой коридор 30099');
672
+ if (row.liquid === false) reasons.push('низкая ликвидность');
673
+ const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
674
+ const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
675
+ const pctNow = row.thresholdRatioPct || 0;
676
+ const epKey = `${dayStr}:${row.symbol}:${side}`;
677
+ const prev = nearSignalsCache.episodesMap.get(epKey);
678
+ if (!prev) {
679
+ nearSignalsCache.episodesMap.set(epKey, {
680
+ day: dayStr, firstBar: timeStr, lastBar: timeStr, firstBarTs: slotBarTs, lastBarTs: slotBarTs,
681
+ peakBar: timeStr, barsCount: 1, symbol: row.symbol, name: row.name, side, price: row.price,
682
+ peakExpectedPct: row.expectedPct, thresholdPct: thr, peakRatioPct: pctNow,
683
+ status, reasons: status === 'signal' ? [] : reasons,
604
684
  });
685
+ liveUpdated = true;
686
+ } else {
687
+ if (slotBarTs > prev.lastBarTs) {
688
+ prev.lastBar = timeStr;
689
+ prev.lastBarTs = slotBarTs;
690
+ prev.barsCount += 1;
691
+ liveUpdated = true;
692
+ }
693
+ if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow > prev.peakRatioPct)) {
694
+ prev.peakExpectedPct = row.expectedPct;
695
+ prev.thresholdPct = thr;
696
+ prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
697
+ prev.peakBar = timeStr;
698
+ prev.price = row.price;
699
+ prev.status = status;
700
+ prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
701
+ liveUpdated = true;
702
+ }
703
+ }
605
704
  }
606
- const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
607
- result.nearSignals = savedNear;
705
+ if (liveUpdated) {
706
+ nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
707
+ kvSet('nearSignalsLog', {
708
+ key: nearSignalsCache.key,
709
+ scannedBarTs: nearSignalsCache.scannedBarTs,
710
+ entries: [...nearSignalsCache.episodesMap.entries()],
711
+ list: nearSignalsCache.list,
712
+ });
713
+ }
714
+ }
715
+ if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
716
+ nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
717
+ }
718
+ if (nearSignalsCache.list.length || !Array.isArray(result.nearSignals) || !result.nearSignals.length) {
719
+ result.nearSignals = nearSignalsCache.list.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
608
720
  }
609
721
  return result;
610
722
  }
@@ -624,7 +736,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
624
736
  key,
625
737
  scannedBarTs: saved.scannedBarTs,
626
738
  episodesMap: new Map(saved.entries),
627
- list: saved.list || [],
739
+ list: [],
628
740
  };
629
741
  } else {
630
742
  nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
@@ -695,7 +807,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
695
807
  thresholdPct: thr,
696
808
  peakRatioPct: Math.round(ratio * 100),
697
809
  status,
698
- reasons,
810
+ reasons: status === 'signal' ? [] : reasons,
699
811
  });
700
812
  } else {
701
813
  prev.lastBar = timeStr;
@@ -709,14 +821,14 @@ async function updateNearSignalsJournal(ds, p, last, key) {
709
821
  prev.peakBar = timeStr;
710
822
  prev.price = row.price;
711
823
  prev.status = status;
712
- if (reasons.length) prev.reasons = reasons;
824
+ prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
713
825
  } else if (pctNow > prev.peakRatioPct) {
714
826
  prev.peakExpectedPct = row.expectedPct;
715
827
  prev.thresholdPct = thr;
716
828
  prev.peakRatioPct = pctNow;
717
829
  prev.peakBar = timeStr;
718
830
  }
719
- if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
831
+ if (prev.status !== 'signal' && reasons.length && !prev.reasons?.length) prev.reasons = reasons;
720
832
  }
721
833
  }
722
834
  nearSignalsCache.scannedBarTs = barTs;
@@ -724,18 +836,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
724
836
  }
725
837
 
726
838
  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
- });
839
+ const list = formatNearSignalsList(nearSignalsCache.episodesMap);
739
840
  nearSignalsCache.list = list;
740
841
  kvSet('nearSignalsLog', {
741
842
  key,
@@ -415,9 +415,8 @@ class LiveEngine {
415
415
  };
416
416
  const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
417
417
  const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
418
- const quarantined = new Set();
418
+ const stMap = new Map();
419
419
  if (autoAdapt && s.trades.length >= 1) {
420
- const stMap = new Map();
421
420
  for (const t of s.trades) {
422
421
  // затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
423
422
  const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
@@ -427,7 +426,6 @@ class LiveEngine {
427
426
  cur.cnt += wDecay;
428
427
  stMap.set(t.symbol, cur);
429
428
  }
430
- for (const [sym, st] of stMap) if (isStockQuarantined(st.cnt, st.pnl)) quarantined.add(sym);
431
429
  }
432
430
  for (const [sym, h] of s.held) {
433
431
  if (autoAdapt && shouldExitBeforeNight(ts, h.entryTs, stepBars, BAR_INTERVAL)) {
@@ -452,7 +450,10 @@ class LiveEngine {
452
450
  const out = [];
453
451
  for (const x of list) {
454
452
  if (out.length >= lim) break;
455
- if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
453
+ const qSt = stMap.get(x.symbol);
454
+ const thrPct = side === 'buy' ? (x.buyThresholdPct || r.buyThresholdPct || r.thresholdPct) : (x.shortThresholdPct || r.shortThresholdPct || r.thresholdPct);
455
+ const edgeRatio = thrPct > 0 ? Math.abs(x.expectedPct || 0) / thrPct : 0;
456
+ if (qSt && isStockQuarantined(qSt.cnt, qSt.pnl, edgeRatio)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
456
457
  const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
457
458
  if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
458
459
  out.push(x);
@@ -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]);
@@ -277,18 +277,20 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
277
277
  }
278
278
  const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
279
279
  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));
280
+ 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
281
  const candL = [];
282
282
  const candS = [];
283
283
  for (let i = 0; i < N; i++) {
284
284
  const e = edge(i);
285
- if (e > Math.max(needL * mult[i] * tierScale(i), minEdgeL)) {
286
- if (!baseOk(i)) continue;
285
+ const thrL = Math.max(needL * mult[i] * tierScale(i), minEdgeL);
286
+ const thrS = Math.max(needS * mult[i] * tierScale(i), minEdgeS);
287
+ if (e > thrL) {
288
+ if (!baseOk(i, e / thrL)) continue;
287
289
  const fl = flags(i);
288
290
  if (fl.dipBlock || fl.overheat || divRule(i, t)?.blockLong) continue;
289
291
  candL.push(i);
290
- } else if (dir !== 'long' && e < -Math.max(needS * mult[i] * tierScale(i), minEdgeS)) {
291
- if (!baseOk(i)) continue;
292
+ } else if (dir !== 'long' && e < -thrS) {
293
+ if (!baseOk(i, (-e) / thrS)) continue;
292
294
  if (shortable && !shortable.has(tokens[i].symbol)) continue;
293
295
  if (!canEnterIntraday(decTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)) continue;
294
296
  const fl = flags(i);
@@ -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),