neuroalpha 0.5.17 → 0.5.19

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.
@@ -19,10 +19,10 @@ export const BAR_MS = BAR_INTERVAL * 60000;
19
19
  // горизонт в часах → число свечей (минимум одна свеча)
20
20
  export const barsFromHours = (h) => Math.max(1, Math.round(Number(h) * (60 / BAR_INTERVAL)));
21
21
 
22
- function cacheKey(interval, fromTs, tillTs, useMacro, useDividends) {
22
+ function cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly = false) {
23
23
  const day = Math.floor(fromTs / DAY);
24
- const hour = Math.floor(tillTs / 3600000);
25
- return JSON.stringify([interval, day, hour, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
24
+ const slot = Math.floor(tillTs / BAR_MS);
25
+ return JSON.stringify([interval, day, slot, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0, localOnly ? 1 : 0]);
26
26
  }
27
27
 
28
28
  async function pool(items, limit, fn) {
@@ -219,14 +219,13 @@ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {
219
219
  // цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
220
220
  // Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
221
221
  export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, useMacro = false, useDividends = true, localOnly = false, onProgress = () => {}, signal }) {
222
- const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends);
222
+ const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
223
223
  const hit = datasetCache.get(key);
224
- if (hit && Date.now() - hit.at < 30 * 60000) return hit.ds;
225
- // Если в памяти уже есть более широкий датасет (например, при изменении Окна обучения modelTrainDays),
226
- // который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его мгновенно без пересборки!
224
+ if (hit && Date.now() - hit.at < 4 * 60000) return hit.ds;
225
+ // Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
227
226
  const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
228
227
  for (const v of datasetCache.values()) {
229
- if (v.modeKey === modeKey && Date.now() - v.at < 30 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 3600000) {
228
+ if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
230
229
  return v.ds;
231
230
  }
232
231
  }
@@ -412,7 +411,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
412
411
  }
413
412
  }
414
413
  const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(T / days) : 54 };
415
- datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs });
414
+ datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
416
415
  if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
417
416
  return ds;
418
417
  }
@@ -1,19 +1,41 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Множители порога входа и выхода стратегии и классификация 1-го эшелона ликвидности Мосбиржи.
3
- // Базовый порог = 2,5 × (комиссия + проскальзывание) × 2 стороны; множители поднимают его так,
4
- // чтобы ожидаемое движение покрывало РЕАЛЬНЫЕ издержки с запасом.
5
- // По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
6
- // • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
7
- // • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
8
- export const ENTRY_EDGE_MULT = 1.65;
9
- export const TIER2_EDGE_MULT = 1.20;
10
-
11
- // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
12
- export const TIER1_LIQUID_SYMBOLS = new Set([
13
- 'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
14
- 'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
15
- 'OZON', 'HEAD', 'PHOR', 'AFLT', 'VKCO', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'AFKS',
16
- 'PIKK', 'SMLT', 'POSI', 'BSPB', 'SVCB', 'SOVCOM', 'CBOM', 'FLOT', 'X5',
17
- ]);
18
-
19
- export const symbolEdgeMult = (symbol) => (TIER1_LIQUID_SYMBOLS.has(symbol) ? 1 : TIER2_EDGE_MULT);
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Множители порога входа и выхода стратегии и классификация 1-го эшелона ликвидности Мосбиржи.
3
+ // Базовый порог = 2,5 × (комиссия + проскальзывание) × 2 стороны; множители поднимают его так,
4
+ // чтобы ожидаемое движение покрывало РЕАЛЬНЫЕ издержки с запасом.
5
+ // По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
6
+ // • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
7
+ // • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
8
+ export const ENTRY_EDGE_MULT = 1.65;
9
+ export const TIER2_EDGE_MULT = 1.20;
10
+
11
+ // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
12
+ // высоковолатильные бумаги со спредом 2-го эшелона (SMLT, PIKK, POSI, AFKS, VKCO, CBOM, BSPB, SVCB) идут по порогу TIER2_EDGE_MULT.
13
+ export const TIER1_LIQUID_SYMBOLS = new Set([
14
+ 'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
15
+ 'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
16
+ 'OZON', 'HEAD', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
17
+ ]);
18
+
19
+ export const symbolEdgeMult = (symbol) => (TIER1_LIQUID_SYMBOLS.has(symbol) ? 1 : TIER2_EDGE_MULT);
20
+
21
+ // Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48)
22
+ export const QUARANTINE_DECAY = 0.9985;
23
+ export const isStockQuarantined = (cnt, pnl) => (cnt >= 2 && pnl < -0.012) || pnl < -0.025;
24
+
25
+ // Граница вечерней сессии Мосбиржи (последняя 10-минутная свеча 23:40–23:50 МСК):
26
+ // не открываем новые внутридневные позиции, когда до конца торгов остаётся меньше горизонта H, и закрываем позиции перед ночью
27
+ const MSK = 3 * 3600000;
28
+ const DAY = 86400000;
29
+ export const SESSION_END_MINS_MSK = 23 * 60 + 40;
30
+
31
+ export const canEnterIntraday = (ts, horizonBars, barInterval = 10) => {
32
+ const minsMsk = Math.floor(((ts + MSK) % DAY) / 60000);
33
+ return minsMsk <= SESSION_END_MINS_MSK - horizonBars * barInterval;
34
+ };
35
+
36
+ export const shouldExitBeforeNight = (ts, entryTs = 0, stepBars = 3, barInterval = 10) => {
37
+ const minsMsk = Math.floor(((ts + MSK) % DAY) / 60000);
38
+ if (minsMsk >= SESSION_END_MINS_MSK - stepBars * barInterval) return true;
39
+ if (entryTs > 0 && Math.floor((ts + MSK) / DAY) > Math.floor((entryTs + MSK) / DAY)) return true;
40
+ return false;
41
+ };
@@ -5,9 +5,10 @@ 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, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from './edge.js';
9
- export { TIER1_LIQUID_SYMBOLS };
10
- import { newsForSymbols } from '../news/index.js';
8
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from './edge.js';
9
+ export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult };
10
+ import { newsForSymbols, blockingNews } from '../news/index.js';
11
+ import { liveDividendGuard } from './dividends.js';
11
12
  import { fetchTbankRealtimePrices } from '../brokers/index.js';
12
13
  import { kvGet, kvSet } from '../db.js';
13
14
  import { hub } from '../hub.js';
@@ -27,6 +28,9 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
27
28
 
28
29
  const z = new Float64Array(W * N);
29
30
  const vr = new Float64Array(W * N);
31
+ const barTs = ds.timeline[idx] || 0;
32
+ const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
33
+ const liveScale = 1 / Math.sqrt(elapsedBars);
30
34
  for (let uk = 0; uk < W; uk++) {
31
35
  const t = lo + uk;
32
36
  const isLast = uk === u && liveSnap?.prices?.size > 0;
@@ -40,7 +44,7 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
40
44
  const livePx = liveSnap.prices.get(tk.symbol);
41
45
  if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
42
46
  }
43
- const e = rt - beta[i] * mt;
47
+ const e = (rt - beta[i] * mt) * (isLast ? liveScale : 1);
44
48
  z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
45
49
  vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
46
50
  }
@@ -178,15 +182,18 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
178
182
  const N = f.N;
179
183
  const mPrev = ds.mclose?.[idx - 1] || 0;
180
184
  const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
185
+ const barTs = ds.timeline[idx] || 0;
186
+ const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
187
+ const liveScale = 1 / Math.sqrt(elapsedBars);
181
188
 
182
- // 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия предыдущей свечи
189
+ // 1) пересчитываем z[u*N + i] по живой цене акции с нормировкой к масштабу одной 10-минутной свечи
183
190
  for (let i = 0; i < N; i++) {
184
191
  const tk = ds.tokens[i];
185
192
  const prevPx = tk.close[idx - 1];
186
193
  const livePx = liveSnap.prices.get(tk.symbol);
187
194
  if (livePx > 0 && prevPx > 0) {
188
195
  const ret = Math.log(livePx / prevPx);
189
- const e = ret - f.beta[i] * mLive;
196
+ const e = (ret - f.beta[i] * mLive) * liveScale;
190
197
  f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
191
198
  }
192
199
  }
@@ -316,29 +323,38 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
316
323
  const tierMult = symbolEdgeMult(tk.symbol);
317
324
  const symNeedL = Math.max(needL * tierMult, 0.0030);
318
325
  const symNeedS = Math.max(needS * tierMult, 0.0031);
319
- // Внутрисвечной предохранитель реального времени:
320
- // не даём сигнал на покупку («не ловим падающий нож»), если на текущей живой свече акция проваливается против рынка сильнее чем на -0.35%,
321
- // и не даём сигнал на шорт, если на текущей живой свече акция взлетает вверх сильнее чем на +0.35%!
322
- const liveBuyOk = !(liveSnap?.realtime && lastMovePct < -0.35);
323
- const liveShortOk = !(liveSnap?.realtime && lastMovePct > 0.35);
326
+ // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
327
+ // обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
328
+ const EXTREME_BAR_MOVE_PCT = 4.5;
329
+ const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT);
330
+ const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
324
331
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
325
332
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
333
+ const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
326
334
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
327
- const shortBlockedByBroker = Boolean(rawShortHit && shortableMap && shortableMap[tk.symbol] === false);
335
+ const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
336
+ const shortBlockedBySqueeze = Boolean(liveSnap && exp < 0 && lastMovePct > EXTREME_BAR_MOVE_PCT);
337
+ const buyBlockedByDip = Boolean(liveSnap && exp > 0 && lastMovePct < -EXTREME_BAR_MOVE_PCT);
328
338
  const row = {
329
339
  i, symbol: tk.symbol, name: tk.name,
340
+ tier: tierMult === 1 ? 1 : 2,
330
341
  price: round(livePx, 4),
331
342
  liquid: tk.liq[idx] >= LIQ_MIN,
332
343
  expected: exp,
333
344
  expectedPct: round(exp * 100, 3),
345
+ buyThresholdPct: round(symNeedL * 100, 3),
346
+ shortThresholdPct: round(symNeedS * 100, 3),
334
347
  lastMovePct,
335
348
  weight: round(weight, 3),
336
349
  signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
350
+ ...(shortAllowed !== null ? { shortAllowed } : {}),
351
+ ...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
337
352
  ...(inIdx ? {} : { inIndex: false }),
338
353
  };
339
354
  if (dir !== 'long') {
340
355
  row.shortSignal = rawShortHit && !shortBlockedByBroker;
341
356
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
357
+ if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
342
358
  }
343
359
  if (explain) {
344
360
  const ex = explainAttention(fitted.attention, f, u, i);
@@ -350,13 +366,74 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
350
366
  return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
351
367
  }
352
368
 
353
- // Новости — только в прогнозе «сейчас»
369
+ // Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр) — в прогнозе «сейчас»
354
370
  function withNews(result, p) {
355
371
  const nm = p.newsEnabled ? newsForSymbols(24) : null;
372
+ const nb = p.newsFilter ? blockingNews(6) : null;
373
+ const shortableMap = kvGet('tbankShortableMap')?.map || null;
374
+ const nowTs = Date.now();
375
+ const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.413;
376
+ const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.516;
377
+ if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
378
+ if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
356
379
  for (const r of result.rows) {
380
+ const mult = symbolEdgeMult(r.symbol);
381
+ if (r.tier === undefined) r.tier = mult === 1 ? 1 : 2;
382
+ if (r.buyThresholdPct === undefined) r.buyThresholdPct = round(Math.max(baseBuyThr * mult, 0.30), 3);
383
+ if (r.shortThresholdPct === undefined) r.shortThresholdPct = round(Math.max(baseShortThr * mult, 0.31), 3);
357
384
  const n = nm?.get(r.symbol);
358
385
  if (n) r.news = n;
359
386
  else delete r.news;
387
+
388
+ if (shortableMap && r.symbol in shortableMap) {
389
+ r.shortAllowed = Boolean(shortableMap[r.symbol]);
390
+ }
391
+ if (r.expectedPct < 0 && r.shortAllowed === false) {
392
+ r.shortBlockedByBroker = true;
393
+ r.shortSignal = false;
394
+ } else if (r.expectedPct >= 0) {
395
+ delete r.shortBlockedByBroker;
396
+ }
397
+ if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
398
+ r.shortBlockedBySqueeze = true;
399
+ r.shortSignal = false;
400
+ } else {
401
+ delete r.shortBlockedBySqueeze;
402
+ }
403
+ if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
404
+ r.buyBlockedByDip = true;
405
+ r.signal = false;
406
+ } else {
407
+ delete r.buyBlockedByDip;
408
+ }
409
+
410
+ const dg = p.modelUseDividends !== false ? liveDividendGuard(r.symbol, r.price, nowTs) : null;
411
+ if (r.expectedPct > 0 && dg?.blockLong) {
412
+ r.buyBlockedByDiv = `Отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%)`;
413
+ r.signal = false;
414
+ } else {
415
+ delete r.buyBlockedByDiv;
416
+ }
417
+ if (r.expectedPct < 0 && dg?.blockShort) {
418
+ r.shortBlockedByDiv = `Отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%)`;
419
+ r.shortSignal = false;
420
+ } else {
421
+ delete r.shortBlockedByDiv;
422
+ }
423
+
424
+ const bNews = nb?.get(r.symbol);
425
+ if (r.expectedPct > 0 && bNews?.bad) {
426
+ r.buyBlockedByNews = bNews.bad.gist;
427
+ r.signal = false;
428
+ } else {
429
+ delete r.buyBlockedByNews;
430
+ }
431
+ if (r.expectedPct < 0 && bNews?.good) {
432
+ r.shortBlockedByNews = bNews.good.gist;
433
+ r.shortSignal = false;
434
+ } else {
435
+ delete r.shortBlockedByNews;
436
+ }
360
437
  }
361
438
  return result;
362
439
  }
@@ -417,6 +494,8 @@ async function tickLiveForecastState(p, now = Date.now()) {
417
494
  bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
418
495
  buyThresholdPct: round(Math.max((r.needL ?? r.threshold) * 100, 0.30), 3),
419
496
  shortThresholdPct: round(Math.max((r.needS ?? r.threshold) * 100, 0.31), 3),
497
+ buyThresholdTier2Pct: round(Math.max((r.needL ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.30), 3),
498
+ shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
420
499
  marketDown: r.marketDown,
421
500
  rows: sortedRows,
422
501
  };
@@ -444,7 +523,7 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
444
523
  // ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
445
524
  // а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
446
525
  if (!force && baseState && baseState.key === key) {
447
- if (now - baseState.dsLoadedAt >= 10 * 60000 && !inflight.has(key)) {
526
+ if (now - baseState.dsLoadedAt >= 4 * 60000 && !inflight.has(key)) {
448
527
  setImmediate(() => {
449
528
  refreshDatasetStateInBackground(p, key).catch(() => {});
450
529
  });
@@ -469,15 +548,26 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
469
548
  }
470
549
  const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
471
550
  const liveTs = liveSnap?.ts || now;
551
+ const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.413;
552
+ const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.516;
472
553
  const rows = (saved.result.rows || []).map((r) => {
473
554
  const px = liveSnap?.prices?.get(r.symbol);
474
- return px > 0 ? { ...r, price: round(px, 4) } : r;
555
+ const mult = symbolEdgeMult(r.symbol);
556
+ return {
557
+ ...r,
558
+ ...(px > 0 ? { price: round(px, 4) } : {}),
559
+ tier: r.tier ?? (mult === 1 ? 1 : 2),
560
+ buyThresholdPct: r.buyThresholdPct ?? round(Math.max(baseBuyThr * mult, 0.30), 3),
561
+ shortThresholdPct: r.shortThresholdPct ?? round(Math.max(baseShortThr * mult, 0.31), 3),
562
+ };
475
563
  });
476
564
  const updated = {
477
565
  ...saved.result,
478
566
  at: now,
479
567
  liveTs,
480
568
  bar: fmtLiveBarLabel(saved.result.barTs || saved.result.at, liveSnap ? liveTs : null),
569
+ buyThresholdTier2Pct: saved.result.buyThresholdTier2Pct ?? round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3),
570
+ shortThresholdTier2Pct: saved.result.shortThresholdTier2Pct ?? round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3),
481
571
  rows,
482
572
  };
483
573
  return withNews(updated, p);
@@ -496,8 +586,10 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
496
586
  const now = Date.now();
497
587
  const task = (async () => {
498
588
  hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
499
- const trainedToday = !force && isModelTrainedToday(p, now);
500
- const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
589
+ // Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
590
+ // чтение из локальной SQLite занимает ~0,3 с, гарантируя неизменность списка бумаг N (попадание в trainedModelCache за 1 мс без переобучения),
591
+ // прогретый суточный профиль объёма valRatio и корректный 10-дневный фильтр тренда isMarketDown!
592
+ const windowDays = Math.ceil(p.modelTrainDays * 1.5 + 25);
501
593
  // При первом холодном прогреве после перезапуска сервера (!baseState && !force) сначала читаем 100% локально из SQLite (за ~1-2 сек без похода в сеть по 100 акциям),
502
594
  // а сетевую догрузку свежих свечей запускаем уже после того, как baseState готов в RAM!
503
595
  const coldLocalFirst = !baseState && !force;
@@ -523,6 +615,8 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
523
615
  horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3),
524
616
  buyThresholdPct: round(Math.max((r.needL ?? r.threshold) * 100, 0.30), 3),
525
617
  shortThresholdPct: round(Math.max((r.needS ?? r.threshold) * 100, 0.31), 3),
618
+ buyThresholdTier2Pct: round(Math.max((r.needL ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.30), 3),
619
+ shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
526
620
  marketDown: r.marketDown,
527
621
  rows: sortedRows,
528
622
  map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
@@ -5,7 +5,8 @@ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
5
5
  import { hub } from '../hub.js';
6
6
  import { latestBarTs } from '../data/moex.js';
7
7
  import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
8
- import { predictAt, lastClosedIndex, isModelTrainedToday } from './forecast.js';
8
+ import { predictAt, lastClosedIndex } from './forecast.js';
9
+ import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
9
10
  import { computeReport, downsample } from './metrics.js';
10
11
  import { MODELS } from './models.js';
11
12
  import { blockingNews } from '../news/index.js';
@@ -261,8 +262,7 @@ class LiveEngine {
261
262
  try {
262
263
  const p = s.params;
263
264
  const now = Date.now();
264
- const trainedToday = isModelTrainedToday(p, now);
265
- const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
265
+ const windowDays = Math.ceil(p.modelTrainDays * 1.5 + 25);
266
266
  const ds = await loadDataset({ fromTs: now - windowDays * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
267
267
  s.lastSeenTs = latest;
268
268
  changed = true;
@@ -416,25 +416,30 @@ class LiveEngine {
416
416
  const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
417
417
  const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
418
418
  const quarantined = new Set();
419
- if (autoAdapt && s.trades.length >= 3) {
419
+ if (autoAdapt && s.trades.length >= 1) {
420
420
  const stMap = new Map();
421
421
  for (const t of s.trades) {
422
422
  // затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
423
423
  const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
424
- const wDecay = Math.pow(0.99, ageSteps);
424
+ const wDecay = Math.pow(QUARANTINE_DECAY, ageSteps);
425
425
  const cur = stMap.get(t.symbol) || { pnl: 0, cnt: 0 };
426
426
  cur.pnl = cur.pnl * 0.90 + (Number(t.pnlPct || 0) / 100) * wDecay;
427
427
  cur.cnt += wDecay;
428
428
  stMap.set(t.symbol, cur);
429
429
  }
430
- for (const [sym, st] of stMap) if (st.cnt >= 3 && st.pnl < -0.012) quarantined.add(sym);
430
+ for (const [sym, st] of stMap) if (isStockQuarantined(st.cnt, st.pnl)) quarantined.add(sym);
431
431
  }
432
432
  for (const [sym, h] of s.held) {
433
+ if (autoAdapt && shouldExitBeforeNight(ts, h.entryTs, stepBars, BAR_INTERVAL)) {
434
+ closePos(sym, 'Закрытие перед ночным клирингом');
435
+ continue;
436
+ }
433
437
  const x = row.get(sym);
434
438
  const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
435
- const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio);
439
+ const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * symbolEdgeMult(sym);
436
440
  if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
437
441
  }
442
+ const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
438
443
  const sideHeld = (side) => [...s.held].filter(([sym, h]) => h.side === side && !closing.has(sym));
439
444
  const nb = p.newsFilter ? blockingNews(6) : null;
440
445
  const take = (list, side, n) => {
@@ -445,13 +450,6 @@ class LiveEngine {
445
450
  if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
446
451
  const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
447
452
  if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
448
- if (autoK && out.length >= 1) {
449
- const bestE = Math.abs(out[0].expected);
450
- const curE = Math.abs(x.expected);
451
- const relMin = out.length === 1 ? 0.62 : 0.78;
452
- const absMin = out.length === 1 ? r.threshold * 1.15 : r.threshold * 1.45;
453
- if (!(curE >= bestE * relMin && curE >= absMin)) break;
454
- }
455
453
  out.push(x);
456
454
  }
457
455
  return out;
@@ -459,13 +457,13 @@ class LiveEngine {
459
457
  let entries;
460
458
  if (dir === 'long') {
461
459
  const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
462
- const candL = mktDown ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol)).sort((a, b) => b.expected - a.expected);
460
+ const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol) && !closing.has(x.symbol)).sort((a, b) => b.expected - a.expected);
463
461
  if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
464
462
  entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
465
463
  } else {
466
- const open = (x) => !s.held.has(x.symbol) || closing.has(x.symbol);
467
- const candL = mktDown ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
468
- const candS = r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
464
+ const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
465
+ const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
466
+ const candS = !timeOk ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
469
467
  if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
470
468
  const nL = K - sideHeld('long').length;
471
469
  const nS = K - sideHeld('short').length;
@@ -9,7 +9,7 @@ import { ensureProfiles } from './profiles.js';
9
9
  import { spearman } from './linalg.js';
10
10
  import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
11
11
  import { strategyDefaults } from '../config.js';
12
- import { ENTRY_EDGE_MULT, symbolEdgeMult } from './edge.js';
12
+ import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
13
13
  import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
14
14
 
15
15
  const DAY = 86400000;
@@ -38,7 +38,8 @@ export function isMarketDown(ds, t) {
38
38
  // Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
39
39
  // 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
40
40
  // 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
41
- const DATA_VERSION = 9;
41
+ // 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
42
+ const DATA_VERSION = 10;
42
43
 
43
44
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
44
45
  function reuseParamsKey(params) {
@@ -389,10 +390,11 @@ async function run(runId, p, state) {
389
390
  const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
390
391
  // Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
391
392
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
392
- const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
393
+ const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
393
394
  let curExitRatio = autoAdapt ? 0.95 : exitRatio;
394
395
  const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
395
396
  const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
397
+ let closedThisBar = new Set();
396
398
 
397
399
  const closePos = (i, h, t, end, why, forcedExitPrice = null) => {
398
400
  const exitPrice = forcedExitPrice > 0 ? forcedExitPrice : exitPx(i, t);
@@ -417,6 +419,7 @@ async function run(runId, p, state) {
417
419
  stats.feesRub += fee + h.entryCost;
418
420
  trades.push(tradeRec(name, i, h, t, exitPrice, gross - fee - borrow - divLoss - h.entryCost, h.entryCost + fee, borrow, end, why));
419
421
  held.delete(i);
422
+ closedThisBar.add(i);
420
423
  if (autoAdapt) {
421
424
  const realGross = h.side === 'short' ? (1 - exitPrice / h.entryPrice) : (exitPrice / h.entryPrice - 1);
422
425
  const realNetPct = realGross - 2 * realCost;
@@ -427,13 +430,15 @@ async function run(runId, p, state) {
427
430
  for (let k = startIdx; k < testTs.length; k += stepBars) {
428
431
  const t = testTs[k];
429
432
  const tIn = Math.min(t + lag, T - 1), tOut = Math.min(t + lag + stepBars, T - 1);
433
+ closedThisBar = new Set();
430
434
  if (autoAdapt) {
431
- for (let i = 0; i < N; i++) { stockPnl[i] *= 0.99; stockCnt[i] *= 0.99; }
435
+ for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
432
436
  }
433
437
  const pr = preds[name].get(t);
434
438
  const sg = sigmaAt.get(t);
435
439
  const bear = isMarketDown(ds, t);
436
440
  const mktDown = mktFilter && bear;
441
+ const barTs = tsOf(t);
437
442
  // дивидендный предохранитель: закрываем позиции в последний день с правом на дивиденд и не входим перед отсечкой
438
443
  if (divGuard) {
439
444
  for (const [i, h] of [...held]) {
@@ -441,6 +446,16 @@ async function run(runId, p, state) {
441
446
  if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) { closePos(i, h, tIn, false, 'Дивидендная отсечка'); stats.divExits++; }
442
447
  }
443
448
  }
449
+ // не переносим внутридневные позиции через ночь и выходные
450
+ if (autoAdapt) {
451
+ for (const [i, h] of [...held]) {
452
+ if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) {
453
+ closePos(i, h, tIn, false, 'Закрытие перед ночным клирингом');
454
+ }
455
+ }
456
+ }
457
+ const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
458
+ const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
444
459
  const okLong = (i) => !divRule(i, t)?.blockLong;
445
460
  const okShort = (i) => !divRule(i, t)?.blockShort;
446
461
  let order, entries;
@@ -458,8 +473,8 @@ async function run(runId, p, state) {
458
473
  }
459
474
  const needL = autoAdapt && bear ? need * 1.25 : need;
460
475
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
461
- const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
462
- const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
476
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
477
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
463
478
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
464
479
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
465
480
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -500,11 +515,11 @@ async function run(runId, p, state) {
500
515
  }
501
516
  const free = K - held.size;
502
517
  const needL = autoAdapt && bear ? need * 1.25 : need;
503
- const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
518
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
504
519
  const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
505
520
  order = [...held.keys(), ...cand];
506
521
  } else {
507
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
522
+ const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => !closedThisBar.has(i) && stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
508
523
  order = cand;
509
524
  const next = new Set(order);
510
525
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
@@ -1028,10 +1043,11 @@ async function runStabilityLoop(targetId, runRow, st) {
1028
1043
  const borrowFee = (h, t) => h.alloc * (p.modelShortFeePct / 100) * ((tsOf(t) - tsOf(h.entryT)) / 3600000 / 24 / 365);
1029
1044
  const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
1030
1045
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
1031
- const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
1046
+ const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
1032
1047
  const curExitRatio = autoAdapt ? 0.95 : exitRatio;
1033
- const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
1048
+ const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
1034
1049
  const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
1050
+ let closedThisBar = new Set();
1035
1051
  const closePos = (i, h, t) => {
1036
1052
  const exitPrice = px(i, t);
1037
1053
  const gross = h.side === 'short' ? h.alloc * (1 - exitPrice / h.entryPrice) : h.alloc * (exitPrice / h.entryPrice - 1);
@@ -1040,6 +1056,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1040
1056
  equity += gross - fee - borrow;
1041
1057
  trades.push({ pnl: gross - fee - borrow - h.entryCost, entryTs: tsOf(h.entryT), exitTs: tsOf(t) });
1042
1058
  held.delete(i);
1059
+ closedThisBar.add(i);
1043
1060
  if (autoAdapt) {
1044
1061
  const realGross = h.side === 'short' ? (1 - exitPrice / h.entryPrice) : (exitPrice / h.entryPrice - 1);
1045
1062
  stockPnl[i] = stockPnl[i] * 0.90 + (realGross - 2 * realCost);
@@ -1048,12 +1065,21 @@ async function runStabilityLoop(targetId, runRow, st) {
1048
1065
  };
1049
1066
  for (let k = 0; k < testTs.length; k += stepBars) {
1050
1067
  const t = testTs[k], tIn = t + 1, tOut = Math.min(t + 1 + stepBars, T - 1);
1068
+ closedThisBar = new Set();
1051
1069
  if (autoAdapt) {
1052
- for (let i = 0; i < N; i++) { stockPnl[i] *= 0.99; stockCnt[i] *= 0.99; }
1070
+ for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
1053
1071
  }
1054
1072
  const pr = preds.get(t), sg = sigmaAt.get(t);
1055
1073
  const bear = isMarketDown(ds, t);
1056
1074
  const mktDown = mktFilter && bear;
1075
+ const barTs = tsOf(t);
1076
+ if (autoAdapt) {
1077
+ for (const [i, h] of [...held]) {
1078
+ if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) closePos(i, h, tIn);
1079
+ }
1080
+ }
1081
+ const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
1082
+ const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
1057
1083
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
1058
1084
  const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
1059
1085
  let order, entries;
@@ -1065,8 +1091,8 @@ async function runStabilityLoop(targetId, runRow, st) {
1065
1091
  }
1066
1092
  const needL = autoAdapt && bear ? need * 1.25 : need;
1067
1093
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
1068
- const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
1069
- const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
1094
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
1095
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
1070
1096
  const nL = K - countSide('long'), nS = K - countSide('short');
1071
1097
  entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
1072
1098
  } else if (effMinEdge > 0) {
@@ -1076,10 +1102,10 @@ async function runStabilityLoop(targetId, runRow, st) {
1076
1102
  if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1077
1103
  }
1078
1104
  const needL = autoAdapt && bear ? need * 1.25 : need;
1079
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
1105
+ const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
1080
1106
  order = [...held.keys(), ...cand];
1081
1107
  } else {
1082
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
1108
+ const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => !closedThisBar.has(i) && stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
1083
1109
  order = cand;
1084
1110
  const next = new Set(order);
1085
1111
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);