neuroalpha 0.5.30 → 0.5.32

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.30",
3
+ "version": "0.5.32",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -1642,9 +1642,9 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
1642
1642
  function sessionEdgeWarning(now = Date.now()) {
1643
1643
  const d = new Date(now + MSK);
1644
1644
  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 минут сессии (широкий спред в стакане)';
1645
+ // открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1646
+ 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)) {
1647
+ return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
1648
1648
  }
1649
1649
  return null;
1650
1650
  }
@@ -1652,6 +1652,86 @@ function sessionEdgeWarning(now = Date.now()) {
1652
1652
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1653
1653
  const ACCOUNT_LOCK_TTL_MS = 75000;
1654
1654
  const accountBusyLocks = new Map(); // id -> { since, token }
1655
+ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstTs, lastTs, lastPx, extremePx }
1656
+
1657
+ // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1658
+ // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
1659
+ // дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
1660
+ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1661
+ if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1662
+ for (const [k, v] of entryImpulseWatch) {
1663
+ if (nowTs - v.lastTs > 15 * 60000) entryImpulseWatch.delete(k);
1664
+ }
1665
+ const ready = [];
1666
+ const deferred = [];
1667
+ for (const r of cands) {
1668
+ const sym = r.symbol;
1669
+ const isShort = side === 'short';
1670
+ const hasCounterImpulse = isShort
1671
+ ? ((r.lastMovePct || 0) > 0.15 || (r.selfPct || 0) < -0.10)
1672
+ : ((r.lastMovePct || 0) < -0.15 || (r.selfPct || 0) > 0.10);
1673
+ if (!hasCounterImpulse) {
1674
+ ready.push(r);
1675
+ continue;
1676
+ }
1677
+ const bars1m = await fetchTbankCandles(sym, nowTs - 15 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
1678
+ if (!bars1m?.length) {
1679
+ ready.push(r);
1680
+ continue;
1681
+ }
1682
+ const last1m = bars1m[bars1m.length - 1];
1683
+ const prev1m = bars1m.length >= 2 ? bars1m[bars1m.length - 2] : null;
1684
+ const curPx = Number(last1m?.close || r.price) || 0;
1685
+ const sp = specs[sym] || { minStep: 0.01 };
1686
+ const step = sp.minStep > 0 ? sp.minStep : 0.01;
1687
+ const wKey = `${acc.id}:${sym}:${side}`;
1688
+ const prevW = entryImpulseWatch.get(wKey) || null;
1689
+ let max1m = curPx, min1m = curPx > 0 ? curPx : Infinity;
1690
+ for (const b of bars1m) {
1691
+ if (b.high > max1m) max1m = b.high;
1692
+ if (b.low > 0 && b.low < min1m) min1m = b.low;
1693
+ }
1694
+ const windowRangePct = (Number.isFinite(min1m) && min1m > 0 && max1m > min1m) ? ((max1m - min1m) / min1m) * 100 : 0;
1695
+ const impulseMagPct = Math.max(Math.abs(Number(r.lastMovePct) || 0), windowRangePct);
1696
+ const minPullbackPct = Math.max(0.15, Math.min(0.55, impulseMagPct * 0.15));
1697
+ const isStrongSurge = impulseMagPct >= 1.5;
1698
+
1699
+ if (isShort) {
1700
+ const recentHigh = Math.max(prevW?.extremePx || 0, curPx, max1m);
1701
+ const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
1702
+ const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
1703
+ const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
1704
+ const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
1705
+ const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
1706
+ const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
1707
+ if (readyShort) {
1708
+ entryImpulseWatch.delete(wKey);
1709
+ r.price = curPx;
1710
+ ready.push(r);
1711
+ } else {
1712
+ entryImpulseWatch.set(wKey, { firstTs: prevW?.firstTs || nowTs, lastTs: nowTs, lastPx: curPx, extremePx: recentHigh });
1713
+ deferred.push({ symbol: sym, side, curPx, extremePx: recentHigh });
1714
+ }
1715
+ } else {
1716
+ const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
1717
+ const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
1718
+ const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
1719
+ const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
1720
+ const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
1721
+ const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
1722
+ const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
1723
+ if (readyLong) {
1724
+ entryImpulseWatch.delete(wKey);
1725
+ r.price = curPx;
1726
+ ready.push(r);
1727
+ } else {
1728
+ entryImpulseWatch.set(wKey, { firstTs: prevW?.firstTs || nowTs, lastTs: nowTs, lastPx: curPx, extremePx: recentLow });
1729
+ deferred.push({ symbol: sym, side, curPx, extremePx: recentLow });
1730
+ }
1731
+ }
1732
+ }
1733
+ return { ready, deferred };
1734
+ }
1655
1735
 
1656
1736
  function isAccountBusy(id) {
1657
1737
  const cur = accountBusyLocks.get(id);
@@ -1830,10 +1910,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1830
1910
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1831
1911
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1832
1912
  const divBlocked = [];
1833
- 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)
1913
+ const entryTimeOk = timeOk && !edgeWarn;
1914
+ 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)
1834
1915
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1835
1916
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1836
- 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)
1917
+ 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)
1837
1918
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1838
1919
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1839
1920
  const nonShortableSkipped = [];
@@ -2052,8 +2133,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2052
2133
  if (bQ > 0 && !closing.has(r.symbol)) return false;
2053
2134
  return !(aQ < 0 && !closing.has(r.symbol));
2054
2135
  });
2055
- const newBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
2056
- const newShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
2136
+ const candidateBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
2137
+ const candidateShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
2138
+ const [buyExhaustion, shortExhaustion] = await Promise.all([
2139
+ filterByImpulseExhaustion(acc, candidateBuys, 'buy', specs, nowTs),
2140
+ filterByImpulseExhaustion(acc, candidateShorts, 'short', specs, nowTs),
2141
+ ]);
2142
+ const newBuys = buyExhaustion.ready;
2143
+ const newShorts = shortExhaustion.ready;
2144
+ const deferredByImpulse = [...buyExhaustion.deferred, ...shortExhaustion.deferred];
2057
2145
  const totalNew = newBuys.length + newShorts.length;
2058
2146
  const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
2059
2147
  const dynSlotRub = (isOwnerPlan && autoAdapt)
@@ -2227,6 +2315,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2227
2315
  if (!orders.length) {
2228
2316
  if (!timeOk) {
2229
2317
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2318
+ } else if (edgeWarn) {
2319
+ diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
2230
2320
  } else if ((newBuys.length || newShorts.length) && availRub < 1500) {
2231
2321
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
2232
2322
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
@@ -2237,6 +2327,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2237
2327
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
2238
2328
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
2239
2329
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
2330
+ } else if (!newBuys.length && !newShorts.length && deferredByImpulse.length > 0) {
2331
+ diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
2240
2332
  } else if (!newBuys.length && !newShorts.length) {
2241
2333
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2242
2334
  const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
@@ -2290,7 +2382,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2290
2382
  results: [],
2291
2383
  };
2292
2384
  saveRawAccounts(list);
2293
- if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
2385
+ if (acc.mode === 'auto' && acc.token && orders.length && (!edgeWarn || orders.every((o) => o.action === 'sell' || o.action === 'cover'))) {
2294
2386
  return await executeBrokerOrders(id, { _lockHeld: true });
2295
2387
  }
2296
2388
  if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
@@ -2351,6 +2443,10 @@ async function sendOrderToBroker(acc, ord) {
2351
2443
  const jBook = await rBook.json().catch(() => null);
2352
2444
  const bestBid = qVal(jBook?.bids?.[0]?.price);
2353
2445
  const bestAsk = qVal(jBook?.asks?.[0]?.price);
2446
+ if (isEntry && ord.symbol !== 'LQDT' && (!(bestBid > 0) || !(bestAsk > 0))) {
2447
+ logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: пустая сторона стакана (bid ${bestBid || 0} / ask ${bestAsk || 0}, планка или аукцион)`);
2448
+ throw Object.assign(new Error(`В стакане ${ord.symbol} отсутствует ${!(bestAsk > 0) ? 'предложение (верхняя планка / аукцион)' : 'спрос (нижняя планка / аукцион)'} — вход пропущен`), { definitive: true });
2449
+ }
2354
2450
  if (bestBid > 0 && bestAsk >= bestBid) {
2355
2451
  const midPx = (bestBid + bestAsk) / 2;
2356
2452
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
@@ -2392,16 +2488,20 @@ async function sendOrderToBroker(acc, ord) {
2392
2488
  }
2393
2489
  } else {
2394
2490
  // Входящие заявки (buy / short):
2395
- // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
2491
+ // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx,
2492
+ // КРОМЕ случая, когда в первых 5 уровнях стакана встречный объём давит в >= 2,2 раза сильнее нашей стороны (тогда встаём пассивно на своей стороне спреда, чтобы остаток импульса налил нам по лучшей цене!);
2396
2493
  // • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
2397
- const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
2494
+ const bidTop5 = (jBook?.bids || []).slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2495
+ const askTop5 = (jBook?.asks || []).slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2496
+ const opposingBookPressure = isBuy ? (askTop5 > bidTop5 * 2.2) : (bidTop5 > askTop5 * 2.2);
2497
+ const tightSpread = (spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step) && !opposingBookPressure;
2398
2498
  if (tightSpread) {
2399
2499
  const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2400
2500
  if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
2401
2501
  } else {
2402
2502
  const passivePx = isBuy
2403
- ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
2404
- : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
2503
+ ? Math.min(finalLimitPx, roundToStep(opposingBookPressure ? bestBid : Math.min(midPx, bestBid + step), step, sp.decimals))
2504
+ : Math.max(finalLimitPx, roundToStep(opposingBookPressure ? bestAsk : Math.max(midPx, bestAsk - step), step, sp.decimals));
2405
2505
  if (passivePx > 0) finalLimitPx = passivePx;
2406
2506
  }
2407
2507
  }
@@ -3150,9 +3250,16 @@ async function reconcileBrokerStopsLocked(list, acc) {
3150
3250
  if (alive) {
3151
3251
  try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
3152
3252
  }
3153
- logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!w ? 'позиции стратегии нет' : !alive ? 'стоп-заявка сработала или снята у брокера' : 'объём или цена позиции изменились'}`);
3154
- // стоп сработал, а позиция в данных ещё видна — не выставляем новый, пока портфель не обновится (иначе возможна повторная продажа)
3155
- if (!alive) acc.brokerStopCooldown[sym] = Date.now();
3253
+ logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!alive ? 'стоп-заявка сработала или снята у брокера' : !w ? 'позиции стратегии нет' : 'объём или цена позиции изменились'}`);
3254
+ // стоп сработал у брокера: включаем 60-минутную паузу повторного входа и блокируем перевыставление стопа до обновления портфеля
3255
+ if (!alive) {
3256
+ const nowStopTs = Date.now();
3257
+ acc.brokerStopCooldown[sym] = nowStopTs;
3258
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
3259
+ acc.exitCooldownBySymbol[sym] = nowStopTs;
3260
+ if (acc.entryTsBySymbol) delete acc.entryTsBySymbol[sym];
3261
+ if (acc.peakPriceBySymbol) delete acc.peakPriceBySymbol[sym];
3262
+ }
3156
3263
  delete acc.brokerStopBySymbol[sym];
3157
3264
  changed = true;
3158
3265
  }
@@ -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;