neuroalpha 0.5.31 → 0.5.33

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.31",
3
+ "version": "0.5.33",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -1612,7 +1612,7 @@ export async function freezeBrokerBaseline(id) {
1612
1612
  }
1613
1613
 
1614
1614
  // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1615
- function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
1615
+ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 999) {
1616
1616
  if (!(avgPx > 0 && curPx > 0)) return null;
1617
1617
  const isLong = side === 'long';
1618
1618
  const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
@@ -1621,19 +1621,22 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
1621
1621
  : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1622
1622
  const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1623
1623
  const tierMult = symbolEdgeMult(sym);
1624
- // 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
1624
+ // Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
1625
+ const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
1626
+ // 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
1625
1627
  // фиксируем не менее 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) {
1628
+ const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
1629
+ if (peakGainPct >= tpTriggerPct) {
1630
+ const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
1631
+ if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
1629
1632
  return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1630
1633
  }
1631
1634
  }
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) {
1635
+ // 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
1636
+ // не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
1637
+ if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
1638
+ const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
1639
+ if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
1637
1640
  return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1638
1641
  }
1639
1642
  }
@@ -1642,9 +1645,9 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
1642
1645
  function sessionEdgeWarning(now = Date.now()) {
1643
1646
  const d = new Date(now + MSK);
1644
1647
  const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
1645
- // открытие утренней сессии 06:50–07:05, открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1646
- if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 + 5) || (mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
1647
- return 'Первые или последние 10 минут сессии (широкий спред в стакане)';
1648
+ // открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1649
+ if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 + 25) || (mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
1650
+ return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
1648
1651
  }
1649
1652
  return null;
1650
1653
  }
@@ -1656,7 +1659,7 @@ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstT
1656
1659
 
1657
1660
  // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1658
1661
  // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
1659
- // дожидаясь остановки разгона и первого микро-отката от пика/дна (с шагом проверки 20 секунд)
1662
+ // дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
1660
1663
  async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1661
1664
  if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1662
1665
  for (const [k, v] of entryImpulseWatch) {
@@ -1674,7 +1677,7 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1674
1677
  ready.push(r);
1675
1678
  continue;
1676
1679
  }
1677
- const bars1m = await fetchTbankCandles(sym, nowTs - 8 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
1680
+ const bars1m = await fetchTbankCandles(sym, nowTs - 15 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
1678
1681
  if (!bars1m?.length) {
1679
1682
  ready.push(r);
1680
1683
  continue;
@@ -1686,16 +1689,25 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1686
1689
  const step = sp.minStep > 0 ? sp.minStep : 0.01;
1687
1690
  const wKey = `${acc.id}:${sym}:${side}`;
1688
1691
  const prevW = entryImpulseWatch.get(wKey) || null;
1692
+ let max1m = curPx, min1m = curPx > 0 ? curPx : Infinity;
1693
+ for (const b of bars1m) {
1694
+ if (b.high > max1m) max1m = b.high;
1695
+ if (b.low > 0 && b.low < min1m) min1m = b.low;
1696
+ }
1697
+ const windowRangePct = (Number.isFinite(min1m) && min1m > 0 && max1m > min1m) ? ((max1m - min1m) / min1m) * 100 : 0;
1698
+ const impulseMagPct = Math.max(Math.abs(Number(r.lastMovePct) || 0), windowRangePct);
1699
+ const minPullbackPct = Math.max(0.15, Math.min(0.55, impulseMagPct * 0.15));
1700
+ const isStrongSurge = impulseMagPct >= 1.5;
1689
1701
 
1690
1702
  if (isShort) {
1691
- const recentHigh = Math.max(prevW?.extremePx || 0, curPx, last1m?.high || 0, prev1m?.high || 0);
1703
+ const recentHigh = Math.max(prevW?.extremePx || 0, curPx, max1m);
1692
1704
  const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
1693
1705
  const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
1694
1706
  const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
1695
- const peakedAndPulledBack = pullbackPct >= 0.12 && pullbackTicks >= 2;
1696
- const tickedDownFromPrev = Boolean(prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 1.5);
1697
- const waitTimeout = Boolean(prevW && nowTs - prevW.firstTs >= 180000);
1698
- if (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev || waitTimeout) {
1707
+ const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
1708
+ const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
1709
+ const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
1710
+ if (readyShort) {
1699
1711
  entryImpulseWatch.delete(wKey);
1700
1712
  r.price = curPx;
1701
1713
  ready.push(r);
@@ -1704,14 +1716,14 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1704
1716
  deferred.push({ symbol: sym, side, curPx, extremePx: recentHigh });
1705
1717
  }
1706
1718
  } else {
1707
- const recentLow = Math.min(prevW?.extremePx || Infinity, curPx > 0 ? curPx : Infinity, last1m?.low > 0 ? last1m.low : Infinity, prev1m?.low > 0 ? prev1m.low : Infinity);
1719
+ const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
1708
1720
  const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
1709
1721
  const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
1710
1722
  const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
1711
- const bottomedAndBounced = bouncePct >= 0.12 && bounceTicks >= 2;
1712
- const tickedUpFromPrev = Boolean(prevW && curPx >= prevW.lastPx + step && bounceTicks >= 1.5);
1713
- const waitTimeout = Boolean(prevW && nowTs - prevW.firstTs >= 180000);
1714
- if (bottomedAndBounced || barTurnedUp || tickedUpFromPrev || waitTimeout) {
1723
+ const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
1724
+ const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
1725
+ const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
1726
+ if (readyLong) {
1715
1727
  entryImpulseWatch.delete(wKey);
1716
1728
  r.price = curPx;
1717
1729
  ready.push(r);
@@ -1901,10 +1913,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1901
1913
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1902
1914
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1903
1915
  const divBlocked = [];
1904
- const buyCands = !timeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1916
+ const entryTimeOk = timeOk && !edgeWarn;
1917
+ const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1905
1918
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1906
1919
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1907
- const allShortSignals = (rawDir !== 'long' && timeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1920
+ const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1908
1921
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1909
1922
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1910
1923
  const nonShortableSkipped = [];
@@ -1967,7 +1980,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1967
1980
  if (aQty > 0) {
1968
1981
  const lots = Math.floor(aQty / sp.lotSize);
1969
1982
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1970
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1983
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
1971
1984
  const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1972
1985
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1973
1986
  const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
@@ -1997,7 +2010,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1997
2010
  } else if (aQty < 0) {
1998
2011
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1999
2012
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
2000
- const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
2013
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
2001
2014
  const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
2002
2015
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
2003
2016
  const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
@@ -2305,6 +2318,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2305
2318
  if (!orders.length) {
2306
2319
  if (!timeOk) {
2307
2320
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2321
+ } else if (edgeWarn) {
2322
+ diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
2308
2323
  } else if ((newBuys.length || newShorts.length) && availRub < 1500) {
2309
2324
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
2310
2325
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
@@ -2319,7 +2334,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2319
2334
  diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
2320
2335
  } else if (!newBuys.length && !newShorts.length) {
2321
2336
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2322
- const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
2337
+ const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
2323
2338
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2324
2339
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2325
2340
  const thrRangeStr = dir !== 'long'
@@ -2370,7 +2385,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2370
2385
  results: [],
2371
2386
  };
2372
2387
  saveRawAccounts(list);
2373
- if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
2388
+ if (acc.mode === 'auto' && acc.token && orders.length && (!edgeWarn || orders.every((o) => o.action === 'sell' || o.action === 'cover'))) {
2374
2389
  return await executeBrokerOrders(id, { _lockHeld: true });
2375
2390
  }
2376
2391
  if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
@@ -2431,6 +2446,10 @@ async function sendOrderToBroker(acc, ord) {
2431
2446
  const jBook = await rBook.json().catch(() => null);
2432
2447
  const bestBid = qVal(jBook?.bids?.[0]?.price);
2433
2448
  const bestAsk = qVal(jBook?.asks?.[0]?.price);
2449
+ if (isEntry && ord.symbol !== 'LQDT' && (!(bestBid > 0) || !(bestAsk > 0))) {
2450
+ logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: пустая сторона стакана (bid ${bestBid || 0} / ask ${bestAsk || 0}, планка или аукцион)`);
2451
+ throw Object.assign(new Error(`В стакане ${ord.symbol} отсутствует ${!(bestAsk > 0) ? 'предложение (верхняя планка / аукцион)' : 'спрос (нижняя планка / аукцион)'} — вход пропущен`), { definitive: true });
2452
+ }
2434
2453
  if (bestBid > 0 && bestAsk >= bestBid) {
2435
2454
  const midPx = (bestBid + bestAsk) / 2;
2436
2455
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
@@ -2800,7 +2819,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2800
2819
  hadExits = true;
2801
2820
  delete acc.entryTsBySymbol[ord.symbol];
2802
2821
  delete acc.peakPriceBySymbol[ord.symbol];
2803
- if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2822
+ if (ord.symbol !== 'LQDT') {
2823
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
2824
+ }
2804
2825
  }
2805
2826
  } catch (e) {
2806
2827
  // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
@@ -3216,33 +3237,45 @@ async function reconcileBrokerStopsLocked(list, acc) {
3216
3237
  const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
3217
3238
  if (!aQty) continue;
3218
3239
  const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
3240
+ const wantOpenDir = aQty > 0 ? 'buy' : 'sell';
3219
3241
  if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
3242
+ const activeOpenOrd = activeOrders.find((o) => o.symbol === sym && o.direction === wantOpenDir);
3243
+ const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
3244
+ const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
3220
3245
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
3221
3246
  const avgPx = Number(pos.avgPrice) || 0;
3222
3247
  if (lots < 1 || avgPx <= 0) continue;
3223
3248
  const side = aQty > 0 ? 'long' : 'short';
3224
3249
  const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
3225
- want.set(sym, { lots, side, stopPx, sp });
3250
+ want.set(sym, { lots, side, stopPx, sp, filling: isFillingNow });
3226
3251
  }
3227
3252
  let changed = false;
3228
3253
  // 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
3229
3254
  for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
3230
3255
  const w = want.get(sym);
3231
3256
  const alive = t?.stopOrderId && active.has(t.stopOrderId);
3257
+ if (alive && w?.filling && w.side === t.side) continue;
3232
3258
  const same = alive && w && w.lots === t.lots && w.side === t.side && Math.abs(w.stopPx - t.stopPx) < (w.sp.minStep || 0.01) / 2;
3233
3259
  if (same) continue;
3234
3260
  if (alive) {
3235
3261
  try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
3236
3262
  }
3237
- logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!w ? 'позиции стратегии нет' : !alive ? 'стоп-заявка сработала или снята у брокера' : 'объём или цена позиции изменились'}`);
3238
- // стоп сработал, а позиция в данных ещё видна — не выставляем новый, пока портфель не обновится (иначе возможна повторная продажа)
3239
- if (!alive) acc.brokerStopCooldown[sym] = Date.now();
3263
+ logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!alive ? 'стоп-заявка сработала или снята у брокера' : !w ? 'позиции стратегии нет' : 'объём или цена позиции изменились'}`);
3264
+ // стоп сработал у брокера: включаем 60-минутную паузу повторного входа и блокируем перевыставление стопа до обновления портфеля
3265
+ if (!alive) {
3266
+ const nowStopTs = Date.now();
3267
+ acc.brokerStopCooldown[sym] = nowStopTs;
3268
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
3269
+ acc.exitCooldownBySymbol[sym] = nowStopTs;
3270
+ if (acc.entryTsBySymbol) delete acc.entryTsBySymbol[sym];
3271
+ if (acc.peakPriceBySymbol) delete acc.peakPriceBySymbol[sym];
3272
+ }
3240
3273
  delete acc.brokerStopBySymbol[sym];
3241
3274
  changed = true;
3242
3275
  }
3243
3276
  // 2) выставить недостающие
3244
3277
  for (const [sym, w] of want) {
3245
- if (acc.brokerStopBySymbol[sym]) continue;
3278
+ if (acc.brokerStopBySymbol[sym] || w.filling) continue;
3246
3279
  const cd = acc.brokerStopCooldown[sym];
3247
3280
  if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
3248
3281
  if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
@@ -3326,12 +3359,14 @@ async function checkIntradayProfitLock(accId) {
3326
3359
  const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
3327
3360
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
3328
3361
  if (avgPx <= 0) continue;
3362
+ const effEntryTs = Math.max(Number(acc.entryTsBySymbol?.[sym]) || 0, Number(lastHistEntryOp?.date) || 0);
3363
+ const heldMin = effEntryTs > 0 ? Math.max(0, (Date.now() - effEntryTs) / 60000) : 999;
3329
3364
 
3330
3365
  if (aQty > 0) {
3331
3366
  const lots = Math.floor(aQty / sp.lotSize);
3332
3367
  if (lots < 1) continue;
3333
3368
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3334
- const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3369
+ const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
3335
3370
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3336
3371
  if (hitHardStopLoss || profitLockReason) {
3337
3372
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
@@ -3346,7 +3381,7 @@ async function checkIntradayProfitLock(accId) {
3346
3381
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3347
3382
  if (lots < 1) continue;
3348
3383
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3349
- const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3384
+ const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
3350
3385
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3351
3386
  if (hitHardStopLoss || profitLockReason) {
3352
3387
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
@@ -3391,7 +3426,9 @@ async function checkIntradayProfitLock(accId) {
3391
3426
  sentLockOrders.push(ord);
3392
3427
  delete acc.entryTsBySymbol[ord.symbol];
3393
3428
  delete acc.peakPriceBySymbol[ord.symbol];
3394
- if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3429
+ if (ord.symbol !== 'LQDT') {
3430
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3431
+ }
3395
3432
  } catch (e) {
3396
3433
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
3397
3434
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
@@ -49,6 +49,12 @@ export function isRegimeShocked(ds, i, t, side = 'long') {
49
49
  if (p0 > 0 && p1 > 0 && m0 > 0 && m1 > 0) {
50
50
  const ex1d = (p1 / p0 - 1) - (m1 / m0 - 1);
51
51
  if (side === 'short' ? ex1d > REGIME_SHOCK_1D : ex1d < -REGIME_SHOCK_1D) return true;
52
+ // Защита от биржевой планки (верхней/нижней границы ценового коридора Мосбиржи):
53
+ // если при миллионном обороте за 10-минутную свечу все сделки идут по одной предельной цене (high === low) после сильного сдвига (>5% к индексу),
54
+ // встречная сторона в стакане отсутствует
55
+ if (tk.high?.[t] > 0 && tk.high[t] === tk.low[t] && tk.value?.[t] > 1e6) {
56
+ if (side === 'short' ? ex1d > 0.05 : ex1d < -0.05) return true;
57
+ }
52
58
  }
53
59
  if (side === 'long' && t >= 3) {
54
60
  const t3 = t - 3;
@@ -93,9 +99,15 @@ const MSK = 3 * 3600000;
93
99
  const DAY = 86400000;
94
100
  export const SESSION_END_MINS_MSK = 23 * 60 + 40;
95
101
 
96
- export const canEnterIntraday = (ts, horizonBars, barInterval = 10) => {
102
+ export const canEnterIntraday = (ts, horizonBars, barInterval = 10, side = null, symbol = null) => {
97
103
  const minsMsk = Math.floor(((ts + MSK) % DAY) / 60000);
98
- return minsMsk <= SESSION_END_MINS_MSK - horizonBars * barInterval;
104
+ if (minsMsk > SESSION_END_MINS_MSK - horizonBars * barInterval) return false;
105
+ // В первые 20 минут утренней сессии (свечи 07:00 и 07:10 МСК) в бумагах 2-го эшелона стакан ещё пустой,
106
+ // а ночные корпоративные новости и утренние гэпы искажают часовой остаток z — шорты 2-го эшелона в это окно блокируются
107
+ if (side === 'short' && minsMsk >= 7 * 60 && minsMsk < 7 * 60 + 20 && (!symbol || !TIER1_LIQUID_SYMBOLS.has(symbol))) {
108
+ return false;
109
+ }
110
+ return true;
99
111
  };
100
112
 
101
113
  export const shouldExitBeforeNight = (ts, entryTs = 0, stepBars = 3, barInterval = 10) => {
@@ -5,7 +5,7 @@ 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
7
  import { isMarketDown } from './research.js';
8
- import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M } from './edge.js';
8
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday } from './edge.js';
9
9
  export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
10
10
  import { newsForSymbols, blockingNews } from '../news/index.js';
11
11
  import { liveDividendGuard } from './dividends.js';
@@ -403,13 +403,18 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
403
403
  const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
404
404
  const symNeedL = Math.max(needL * tierMult, 0.0030);
405
405
  const symNeedS = Math.max(needS * tierMult, 0.0031);
406
- // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
406
+ // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу), биржевых планок и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
407
407
  // обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
408
408
  const EXTREME_BAR_MOVE_PCT = 4.5;
409
- const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M;
410
- const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D;
409
+ const isFlatLimitBar = tk.high?.[idx] > 0 && tk.high[idx] === tk.low[idx] && tk.value?.[idx] > 1e6;
410
+ const isLimitUpLocked = isFlatLimitBar && excess1d > 0.05;
411
+ const isLimitDownLocked = isFlatLimitBar && excess1d < -0.05;
412
+ const barDecisionTs = ds.timeline[idx] || Date.now();
413
+ const isMorningOpenShortBlocked = autoAdapt && !canEnterIntraday(barDecisionTs, H, 10, 'short', tk.symbol);
414
+ const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
415
+ const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
411
416
  const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
412
- const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
417
+ const isExtremeSpike = Boolean(isRegimeSqueeze || isMorningOpenShortBlocked || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
413
418
  const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
414
419
  const liveShortOk = !isExtremeSpike;
415
420
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
@@ -461,7 +461,7 @@ class LiveEngine {
461
461
  };
462
462
  const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
463
463
  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)).sort((a, b) => a.expected - b.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);
465
465
  const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
466
466
  if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
467
467
  const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
@@ -43,7 +43,8 @@ export function isMarketDown(ds, t) {
43
43
  // 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
44
44
  // 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
45
45
  // 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
46
- const DATA_VERSION = 14;
46
+ // 15: защита от биржевых планок (high === low) и блокировка утренних шортов 2-го эшелона в первые 20 минут сессии (07:00–07:20 МСК)
47
+ const DATA_VERSION = 15;
47
48
 
48
49
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
49
50
  function reuseParamsKey(params) {
@@ -466,7 +467,7 @@ async function run(runId, p, state) {
466
467
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
467
468
  const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
468
469
  const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
469
- 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'));
470
+ 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)));
470
471
  let order, entries;
471
472
  if (dir !== 'long') {
472
473
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
@@ -1124,7 +1125,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1124
1125
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
1125
1126
  const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
1126
1127
  const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
1127
- const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
1128
+ const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
1128
1129
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
1129
1130
  const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
1130
1131
  let order, entries;