neuroalpha 0.5.32 → 0.5.34

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.5.32",
3
+ "version": "0.5.34",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -524,6 +524,7 @@ export function saveBrokerAccount(input = {}) {
524
524
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
525
525
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
526
526
  entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
527
+ entryFailReasonBySymbol: acctChanged ? {} : (prev.entryFailReasonBySymbol || {}),
527
528
  peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
528
529
  baseRunId: nextBaseRunId,
529
530
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
@@ -1612,7 +1613,7 @@ export async function freezeBrokerBaseline(id) {
1612
1613
  }
1613
1614
 
1614
1615
  // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1615
- function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
1616
+ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 999) {
1616
1617
  if (!(avgPx > 0 && curPx > 0)) return null;
1617
1618
  const isLong = side === 'long';
1618
1619
  const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
@@ -1621,19 +1622,22 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
1621
1622
  : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1622
1623
  const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1623
1624
  const tierMult = symbolEdgeMult(sym);
1624
- // 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
1625
+ // Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
1626
+ const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
1627
+ // 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
1625
1628
  // фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
1626
- if (peakGainPct >= 1.20 * tierMult) {
1627
- const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
1628
- if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
1629
+ const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
1630
+ if (peakGainPct >= tpTriggerPct) {
1631
+ const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
1632
+ if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
1629
1633
  return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1630
1634
  }
1631
1635
  }
1632
- // 2) Защита заработанного плюса / безубытка (от +0.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
1633
- // не позволяем плюсовой сделке развернуться обратно в убыток
1634
- if (peakGainPct >= 0.75 * tierMult) {
1635
- const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
1636
- if (curGainPct <= breakevenFloorPct) {
1636
+ // 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
1637
+ // не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
1638
+ if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
1639
+ const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
1640
+ if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
1637
1641
  return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1638
1642
  }
1639
1643
  }
@@ -1834,6 +1838,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1834
1838
  const entryTsMap = acc.entryTsBySymbol || {};
1835
1839
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1836
1840
  const entryFailMap = acc.entryFailCooldownBySymbol || {};
1841
+ const entryFailReasonMap = acc.entryFailReasonBySymbol || {};
1837
1842
  // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
1838
1843
  // Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
1839
1844
  // что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
@@ -1843,10 +1848,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1843
1848
  const minShortEdgePct = round(baseShortNeedPct, 3);
1844
1849
  const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1845
1850
  const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1846
- const isInCooldown = (sym) => Boolean(
1847
- (autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
1848
- (entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
1849
- );
1851
+ const isExitCooldown = (sym) => Boolean(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000));
1852
+ const isEntryFailCooldown = (sym) => Boolean(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000));
1853
+ const isInCooldown = (sym) => isExitCooldown(sym) || isEntryFailCooldown(sym);
1850
1854
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1851
1855
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1852
1856
  // временно исключаем такую бумагу из новых входов!
@@ -1906,7 +1910,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1906
1910
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
1907
1911
  const nb = params.newsFilter ? blockingNews(6) : null;
1908
1912
 
1909
- const cooldownSkipped = fc.rows.filter((r) => ((r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))) && isInCooldown(r.symbol));
1913
+ const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
1914
+ const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
1915
+ const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
1910
1916
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1911
1917
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1912
1918
  const divBlocked = [];
@@ -1977,7 +1983,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1977
1983
  if (aQty > 0) {
1978
1984
  const lots = Math.floor(aQty / sp.lotSize);
1979
1985
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1980
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1986
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
1981
1987
  const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1982
1988
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1983
1989
  const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
@@ -2007,7 +2013,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2007
2013
  } else if (aQty < 0) {
2008
2014
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2009
2015
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
2010
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
2016
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
2011
2017
  const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
2012
2018
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
2013
2019
  const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
@@ -2331,14 +2337,17 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2331
2337
  diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
2332
2338
  } else if (!newBuys.length && !newShorts.length) {
2333
2339
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2334
- const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
2340
+ const failNote = entryFailSkipped.length
2341
+ ? ` Заявка по ${entryFailSkipped.map((r) => `${r.symbol} (${entryFailReasonMap[r.symbol] || 'отказ шлюза брокера'})`).join('; ')} временно отложена на 5 мин после ошибки приёма у брокера.`
2342
+ : '';
2343
+ const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
2335
2344
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2336
2345
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2337
2346
  const thrRangeStr = dir !== 'long'
2338
2347
  ? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
2339
2348
  : `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
2340
- diagNote = cdNote
2341
- ? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
2349
+ diagNote = (failNote || cdNote)
2350
+ ? `На свече ${fc.bar} новых входов нет.${failNote}${cdNote}${noShortNote}`
2342
2351
  : `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
2343
2352
  }
2344
2353
  }
@@ -2419,6 +2428,9 @@ function formatTbankOrderError(status, rawText = '', ord = {}) {
2419
2428
  if (code.includes('30052') || /short.*disabled|short.*not/i.test(code)) {
2420
2429
  return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
2421
2430
  }
2431
+ if (code.includes('30099') || /outside the limits/i.test(code)) {
2432
+ return 'Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)';
2433
+ }
2422
2434
  return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
2423
2435
  }
2424
2436
 
@@ -2770,6 +2782,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2770
2782
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2771
2783
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2772
2784
  acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
2785
+ acc.entryFailReasonBySymbol = acc.entryFailReasonBySymbol || {};
2773
2786
  acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2774
2787
  const execNowTs = Date.now();
2775
2788
  const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
@@ -2812,11 +2825,14 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2812
2825
  acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2813
2826
  delete acc.exitCooldownBySymbol[ord.symbol];
2814
2827
  delete acc.entryFailCooldownBySymbol[ord.symbol];
2828
+ delete acc.entryFailReasonBySymbol[ord.symbol];
2815
2829
  } else if (ord.action === 'sell' || ord.action === 'cover') {
2816
2830
  hadExits = true;
2817
2831
  delete acc.entryTsBySymbol[ord.symbol];
2818
2832
  delete acc.peakPriceBySymbol[ord.symbol];
2819
- if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2833
+ if (ord.symbol !== 'LQDT') {
2834
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
2835
+ }
2820
2836
  }
2821
2837
  } catch (e) {
2822
2838
  // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
@@ -2826,6 +2842,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2826
2842
  else ord.uncertainSend = true;
2827
2843
  if (ord.action === 'buy' || ord.action === 'short') {
2828
2844
  acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
2845
+ acc.entryFailReasonBySymbol[ord.symbol] = e.message;
2829
2846
  }
2830
2847
  results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2831
2848
  logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
@@ -3232,19 +3249,24 @@ async function reconcileBrokerStopsLocked(list, acc) {
3232
3249
  const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
3233
3250
  if (!aQty) continue;
3234
3251
  const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
3252
+ const wantOpenDir = aQty > 0 ? 'buy' : 'sell';
3235
3253
  if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
3254
+ const activeOpenOrd = activeOrders.find((o) => o.symbol === sym && o.direction === wantOpenDir);
3255
+ const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
3256
+ const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
3236
3257
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
3237
3258
  const avgPx = Number(pos.avgPrice) || 0;
3238
3259
  if (lots < 1 || avgPx <= 0) continue;
3239
3260
  const side = aQty > 0 ? 'long' : 'short';
3240
3261
  const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
3241
- want.set(sym, { lots, side, stopPx, sp });
3262
+ want.set(sym, { lots, side, stopPx, sp, filling: isFillingNow });
3242
3263
  }
3243
3264
  let changed = false;
3244
3265
  // 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
3245
3266
  for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
3246
3267
  const w = want.get(sym);
3247
3268
  const alive = t?.stopOrderId && active.has(t.stopOrderId);
3269
+ if (alive && w?.filling && w.side === t.side) continue;
3248
3270
  const same = alive && w && w.lots === t.lots && w.side === t.side && Math.abs(w.stopPx - t.stopPx) < (w.sp.minStep || 0.01) / 2;
3249
3271
  if (same) continue;
3250
3272
  if (alive) {
@@ -3265,7 +3287,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
3265
3287
  }
3266
3288
  // 2) выставить недостающие
3267
3289
  for (const [sym, w] of want) {
3268
- if (acc.brokerStopBySymbol[sym]) continue;
3290
+ if (acc.brokerStopBySymbol[sym] || w.filling) continue;
3269
3291
  const cd = acc.brokerStopCooldown[sym];
3270
3292
  if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
3271
3293
  if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
@@ -3349,12 +3371,14 @@ async function checkIntradayProfitLock(accId) {
3349
3371
  const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
3350
3372
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
3351
3373
  if (avgPx <= 0) continue;
3374
+ const effEntryTs = Math.max(Number(acc.entryTsBySymbol?.[sym]) || 0, Number(lastHistEntryOp?.date) || 0);
3375
+ const heldMin = effEntryTs > 0 ? Math.max(0, (Date.now() - effEntryTs) / 60000) : 999;
3352
3376
 
3353
3377
  if (aQty > 0) {
3354
3378
  const lots = Math.floor(aQty / sp.lotSize);
3355
3379
  if (lots < 1) continue;
3356
3380
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3357
- const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3381
+ const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
3358
3382
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3359
3383
  if (hitHardStopLoss || profitLockReason) {
3360
3384
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
@@ -3369,7 +3393,7 @@ async function checkIntradayProfitLock(accId) {
3369
3393
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3370
3394
  if (lots < 1) continue;
3371
3395
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3372
- const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3396
+ const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
3373
3397
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3374
3398
  if (hitHardStopLoss || profitLockReason) {
3375
3399
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
@@ -3414,7 +3438,9 @@ async function checkIntradayProfitLock(accId) {
3414
3438
  sentLockOrders.push(ord);
3415
3439
  delete acc.entryTsBySymbol[ord.symbol];
3416
3440
  delete acc.peakPriceBySymbol[ord.symbol];
3417
- if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3441
+ if (ord.symbol !== 'LQDT') {
3442
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3443
+ }
3418
3444
  } catch (e) {
3419
3445
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
3420
3446
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
@@ -281,8 +281,9 @@ export function newsForSymbols(hours = 24) {
281
281
  // Для живой проверки с newsFilter: самые важные свежие новости (важность ≥ 0.6, |тон| ≥ 0.4)
282
282
  // по тикерам: symbol → { bad: { gist, imp, tone, ts }, good: {…} }
283
283
  export function blockingNews(hours = 6, atTs = Date.now()) {
284
+ const sinceTs = atTs - hours * 3600000;
284
285
  const rows = db.prepare(`SELECT ts, title, gist, imp, tags FROM news
285
- WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(atTs - hours * 3600000, atTs);
286
+ WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(sinceTs, atTs);
286
287
  const m = new Map();
287
288
  for (const r of rows) {
288
289
  for (const [sym, tone] of parseTags(r)) {
@@ -293,6 +294,29 @@ export function blockingNews(hours = 6, atTs = Date.now()) {
293
294
  m.set(sym, e);
294
295
  }
295
296
  }
297
+ // Официальные сообщения Мосбиржи о расширении верхней/нижней границы ценового коридора акции (планки) —
298
+ // блокируют вход против пробоя планки даже до разметки ИИ (и защищают от отказа шлюза брокера с кодом 30099)
299
+ const corridorRows = db.prepare(`SELECT ts, title, tags FROM news
300
+ WHERE ts >= ? AND ts <= ? AND title LIKE '%границы ценового коридора%ценной бумаги%' AND title NOT LIKE '%коридора РЕПО%'`).all(sinceTs, atTs);
301
+ for (const r of corridorRows) {
302
+ const t = String(r.title || '');
303
+ if (/коридора\s+репо/i.test(t)) continue;
304
+ const isUpper = /верхней границы ценового коридора/i.test(t);
305
+ const isLower = /нижней границы ценового коридора/i.test(t);
306
+ if (!isUpper && !isLower) continue;
307
+ const syms = new Set(parseTags(r).map(([s]) => s).filter(Boolean));
308
+ const matchSym = /ценной бумаги\s+([A-Z0-9]{2,8})\b/i.exec(t);
309
+ if (matchSym?.[1]) syms.add(matchSym[1].toUpperCase());
310
+ for (const sym of syms) {
311
+ const e = m.get(sym) || {};
312
+ const side = isUpper ? 'good' : 'bad';
313
+ const tone = isUpper ? 0.9 : -0.9;
314
+ const imp = 0.95;
315
+ const gist = isUpper ? `Мосбиржа расширила верхнюю планку коридора ${sym}` : `Мосбиржа расширила нижнюю планку коридора ${sym}`;
316
+ if (!e[side] || imp >= e[side].imp) e[side] = { tone, imp, gist, ts: r.ts };
317
+ m.set(sym, e);
318
+ }
319
+ }
296
320
  return m;
297
321
  }
298
322