neuroalpha 0.5.31 → 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.31",
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
  }
@@ -1656,7 +1656,7 @@ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstT
1656
1656
 
1657
1657
  // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1658
1658
  // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
1659
- // дожидаясь остановки разгона и первого микро-отката от пика/дна (с шагом проверки 20 секунд)
1659
+ // дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
1660
1660
  async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1661
1661
  if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1662
1662
  for (const [k, v] of entryImpulseWatch) {
@@ -1674,7 +1674,7 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1674
1674
  ready.push(r);
1675
1675
  continue;
1676
1676
  }
1677
- const bars1m = await fetchTbankCandles(sym, nowTs - 8 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
1677
+ const bars1m = await fetchTbankCandles(sym, nowTs - 15 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
1678
1678
  if (!bars1m?.length) {
1679
1679
  ready.push(r);
1680
1680
  continue;
@@ -1686,16 +1686,25 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1686
1686
  const step = sp.minStep > 0 ? sp.minStep : 0.01;
1687
1687
  const wKey = `${acc.id}:${sym}:${side}`;
1688
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;
1689
1698
 
1690
1699
  if (isShort) {
1691
- const recentHigh = Math.max(prevW?.extremePx || 0, curPx, last1m?.high || 0, prev1m?.high || 0);
1700
+ const recentHigh = Math.max(prevW?.extremePx || 0, curPx, max1m);
1692
1701
  const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
1693
1702
  const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
1694
1703
  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) {
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) {
1699
1708
  entryImpulseWatch.delete(wKey);
1700
1709
  r.price = curPx;
1701
1710
  ready.push(r);
@@ -1704,14 +1713,14 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1704
1713
  deferred.push({ symbol: sym, side, curPx, extremePx: recentHigh });
1705
1714
  }
1706
1715
  } 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);
1716
+ const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
1708
1717
  const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
1709
1718
  const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
1710
1719
  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) {
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) {
1715
1724
  entryImpulseWatch.delete(wKey);
1716
1725
  r.price = curPx;
1717
1726
  ready.push(r);
@@ -1901,10 +1910,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1901
1910
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1902
1911
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1903
1912
  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)
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)
1905
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; })
1906
1916
  .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)
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)
1908
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; })
1909
1919
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1910
1920
  const nonShortableSkipped = [];
@@ -2305,6 +2315,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2305
2315
  if (!orders.length) {
2306
2316
  if (!timeOk) {
2307
2317
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2318
+ } else if (edgeWarn) {
2319
+ diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
2308
2320
  } else if ((newBuys.length || newShorts.length) && availRub < 1500) {
2309
2321
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
2310
2322
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
@@ -2370,7 +2382,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2370
2382
  results: [],
2371
2383
  };
2372
2384
  saveRawAccounts(list);
2373
- 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'))) {
2374
2386
  return await executeBrokerOrders(id, { _lockHeld: true });
2375
2387
  }
2376
2388
  if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
@@ -2431,6 +2443,10 @@ async function sendOrderToBroker(acc, ord) {
2431
2443
  const jBook = await rBook.json().catch(() => null);
2432
2444
  const bestBid = qVal(jBook?.bids?.[0]?.price);
2433
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
+ }
2434
2450
  if (bestBid > 0 && bestAsk >= bestBid) {
2435
2451
  const midPx = (bestBid + bestAsk) / 2;
2436
2452
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
@@ -3234,9 +3250,16 @@ async function reconcileBrokerStopsLocked(list, acc) {
3234
3250
  if (alive) {
3235
3251
  try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
3236
3252
  }
3237
- logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!w ? 'позиции стратегии нет' : !alive ? 'стоп-заявка сработала или снята у брокера' : 'объём или цена позиции изменились'}`);
3238
- // стоп сработал, а позиция в данных ещё видна — не выставляем новый, пока портфель не обновится (иначе возможна повторная продажа)
3239
- 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
+ }
3240
3263
  delete acc.brokerStopBySymbol[sym];
3241
3264
  changed = true;
3242
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;