neuroalpha 0.6.2 → 0.6.4

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.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1076,6 +1076,10 @@ async function loadForecast(force, silent = false) {
1076
1076
  reasons.push(`Фильтр новостей (сильный позитив): ${r.shortBlockedByNews}`);
1077
1077
  if (!label) label = '⛔ новости';
1078
1078
  }
1079
+ if (r.shortBlockedByTime) {
1080
+ reasons.push('Вне внутридневного окна открытия новых шортов (утренний старт или конец сессии)');
1081
+ if (!label) label = '⛔ вне окна';
1082
+ }
1079
1083
  } else {
1080
1084
  const dip = Boolean(r.buyBlockedByDip || (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5));
1081
1085
  if (dip) {
@@ -4226,6 +4230,8 @@ function buildStockModalHeaderHtml(d) {
4226
4230
  ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4227
4231
  : fc?.shortBlockedByBroker
4228
4232
  ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4233
+ : fc?.shortBlockedByTime
4234
+ ? '<span class="tag blocked" title="Вне внутридневного окна открытия новых шортов">⛔ вне окна шорта</span>'
4229
4235
  : fc?.buyBlockedByDip
4230
4236
  ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4231
4237
  : fc?.buyBlockedByOverheat
@@ -1706,11 +1706,20 @@ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstT
1706
1706
  // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1707
1707
  // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
1708
1708
  // дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
1709
+ // Порог удержания уже взятого на прицел сигнала в entryImpulseWatch: пока ждём минутного отката,
1710
+ // само ожидание возврата к среднему неизбежно немного снижается — не теряем сигнал, пока он держится >= 90% порога (до 6 минут)
1711
+ const IMPULSE_WATCH_HOLD_RATIO = 0.90;
1712
+ const IMPULSE_WATCH_TTL_MS = 6 * 60000;
1713
+ const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
1714
+ const w = entryImpulseWatch.get(`${accId}:${sym}:${side}`);
1715
+ return Boolean(w && nowTs - w.firstTs <= IMPULSE_WATCH_TTL_MS && nowTs - w.lastTs <= IMPULSE_WATCH_TTL_MS);
1716
+ };
1717
+
1709
1718
  async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1710
- if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1711
1719
  for (const [k, v] of entryImpulseWatch) {
1712
- if (nowTs - v.lastTs > 15 * 60000) entryImpulseWatch.delete(k);
1720
+ if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
1713
1721
  }
1722
+ if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1714
1723
  const ready = [];
1715
1724
  const deferred = [];
1716
1725
  for (const r of cands) {
@@ -1750,9 +1759,12 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1750
1759
  const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
1751
1760
  const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
1752
1761
  const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
1762
+ // Если внутри самой минутной свечи цена уже взлетела до recentHigh и откатилась от пика на >= 1,4× порога отката (и >= 0,55%),
1763
+ // разворот с вершины уже состоялся — не ждём следующей минутной свечи!
1764
+ const intrabarReversalDown = Boolean(last1m && pullbackPct >= Math.max(0.55, minPullbackPct * 1.4) && pullbackTicks >= 4 && curPx <= last1m.high - step * 3);
1753
1765
  const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
1754
1766
  const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
1755
- const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
1767
+ const readyShort = isStrongSurge ? (peakedAndPulledBack && (barTurnedDown || intrabarReversalDown)) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
1756
1768
  if (readyShort) {
1757
1769
  entryImpulseWatch.delete(wKey);
1758
1770
  r.price = curPx;
@@ -1765,10 +1777,11 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1765
1777
  const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
1766
1778
  const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
1767
1779
  const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
1768
- const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
1780
+ const barTurnedUp = Boolean(last1m && prev1m && last1m.low >= prev1m.low - step * 0.5 && last1m.close >= last1m.open);
1781
+ const intrabarReversalUp = Boolean(last1m && bouncePct >= Math.max(0.55, minPullbackPct * 1.4) && bounceTicks >= 4 && curPx >= last1m.low + step * 3);
1769
1782
  const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
1770
1783
  const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
1771
- const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
1784
+ const readyLong = isStrongSurge ? (bottomedAndBounced && (barTurnedUp || intrabarReversalUp)) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
1772
1785
  if (readyLong) {
1773
1786
  entryImpulseWatch.delete(wKey);
1774
1787
  r.price = curPx;
@@ -1813,7 +1826,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1813
1826
  let list = loadRawAccounts();
1814
1827
  let acc = list.find((x) => x.id === id);
1815
1828
  if (!acc) throw new Error('Счёт не найден');
1816
- const exitsOnly = isDailyStopHalt(acc);
1829
+ let exitsOnly = isDailyStopHalt(acc);
1817
1830
  if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1818
1831
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1819
1832
  if (onlyEntries && (edgeWarn || exitsOnly)) {
@@ -1835,6 +1848,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1835
1848
  list = loadRawAccounts();
1836
1849
  acc = list.find((x) => x.id === id) || acc;
1837
1850
  }
1851
+ exitsOnly = isDailyStopHalt(acc);
1852
+ if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1853
+ if (onlyEntries && exitsOnly) return brokersState();
1838
1854
  const params = normalizeParams(getSettings());
1839
1855
  const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
1840
1856
  forecastNow(params),
@@ -1972,10 +1988,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1972
1988
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1973
1989
  const divBlocked = [];
1974
1990
  const entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
1975
- const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1991
+ const canBuyRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol))
1992
+ || (isOnImpulseWatch(acc.id, r.symbol, 'buy', nowTs) && r.liquid && r.inIndex !== false && !r.buyBlockedByDip && !r.buyBlockedByOverheat && !r.buyBlockedByDiv && !r.buyBlockedByNews && r.expectedPct >= reqBuyEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
1993
+ const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
1994
+ || (isOnImpulseWatch(acc.id, r.symbol, 'short', nowTs) && r.liquid && r.inIndex !== false && r.shortAllowed !== false && !r.shortBlockedBySqueeze && !r.shortBlockedByDiv && !r.shortBlockedByNews && r.expectedPct <= -reqShortEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
1995
+ const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1976
1996
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1977
1997
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1978
- 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) && !isDayBlocked(r.symbol, 'short') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1998
+ const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1979
1999
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1980
2000
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1981
2001
  const nonShortableSkipped = [];
@@ -2849,23 +2869,25 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2849
2869
  if (!acc) throw new Error('Счёт не найден');
2850
2870
  if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2851
2871
  const gate = tradingGate();
2852
- if (!gate.allowNew) throw new Error(gate.reason);
2872
+ if (!gate.allowNew && !gate.allowProtective) throw new Error(gate.reason);
2853
2873
  const batch = acc.pendingBatch;
2854
2874
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2855
2875
  if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
2856
- if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2857
- // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов или в окне начала сессии не отправляем
2876
+ if (acc.haltedReason && !isDailyStopHalt(acc)) throw new Error(acc.haltedReason);
2877
+ // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов, при дневном стопе или в окне начала сессии не отправляем (выходы сохраняются!)
2858
2878
  const execCheckTs = Date.now();
2859
2879
  const staleWhy = (() => {
2880
+ if (!gate.allowNew) return gate.reason;
2881
+ if (isDailyStopHalt(acc)) return acc.haltedReason;
2860
2882
  if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
2861
2883
  if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
2862
2884
  if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
2863
- if (isDailyStopHalt(acc)) return acc.haltedReason;
2864
2885
  return null;
2865
2886
  })();
2866
2887
  if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
2867
- const dropped = batch.orders.filter((o) => !isExitOrder(o) && o.symbol !== 'LQDT');
2868
- batch.orders = batch.orders.filter((o) => isExitOrder(o) || o.symbol === 'LQDT');
2888
+ const keepLqdt = !isDailyStopHalt(acc) && gate.allowNew;
2889
+ const dropped = batch.orders.filter((o) => !isExitOrder(o) && !(keepLqdt && o.symbol === 'LQDT'));
2890
+ batch.orders = batch.orders.filter((o) => isExitOrder(o) || (keepLqdt && o.symbol === 'LQDT'));
2869
2891
  logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
2870
2892
  if (!batch.orders.length) {
2871
2893
  batch.status = 'expired';
@@ -2875,6 +2897,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2875
2897
  throw new Error(`Входы не отправлены: ${staleWhy}`);
2876
2898
  }
2877
2899
  }
2900
+ if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2878
2901
  // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2879
2902
  // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2880
2903
  for (const ord of batch.orders) {
@@ -2927,8 +2950,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2927
2950
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2928
2951
  newlySentOrders.push(ord);
2929
2952
  if (ord.action === 'buy' || ord.action === 'short') {
2930
- acc.entryTsBySymbol[ord.symbol] = execNowTs;
2931
- acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2953
+ if (!acc.entryTsBySymbol[ord.symbol]) acc.entryTsBySymbol[ord.symbol] = execNowTs;
2954
+ const ordPx = Number(ord.limitPrice || ord.refPrice) || 0;
2955
+ const prevPeak = Number(acc.peakPriceBySymbol[ord.symbol]) || 0;
2956
+ acc.peakPriceBySymbol[ord.symbol] = prevPeak > 0 && ordPx > 0
2957
+ ? (ord.action === 'buy' ? Math.max(prevPeak, ordPx) : Math.min(prevPeak, ordPx))
2958
+ : (ordPx || prevPeak);
2932
2959
  delete acc.exitCooldownBySymbol[ord.symbol];
2933
2960
  delete acc.entryFailCooldownBySymbol[ord.symbol];
2934
2961
  delete acc.entryFailReasonBySymbol[ord.symbol];
@@ -3231,7 +3258,8 @@ async function tickBrokerSchedule() {
3231
3258
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3232
3259
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3233
3260
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3234
- if (acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3261
+ const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
3262
+ if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3235
3263
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3236
3264
  }
3237
3265
  }
@@ -3355,6 +3383,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
3355
3383
  const headers = tbankHeaders(acc);
3356
3384
  const want = new Map();
3357
3385
  const activeOrders = acc.lastSync?.activeOrders || [];
3386
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3358
3387
  for (const pos of acc.lastSync.positions || []) {
3359
3388
  const sym = pos.symbol;
3360
3389
  if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
@@ -3369,7 +3398,8 @@ async function reconcileBrokerStopsLocked(list, acc) {
3369
3398
  const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
3370
3399
  const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
3371
3400
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
3372
- const avgPx = Number(pos.avgPrice) || 0;
3401
+ const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenDir && op.date > 0);
3402
+ const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || Number(pos.price) || 0);
3373
3403
  if (lots < 1 || avgPx <= 0) continue;
3374
3404
  const side = aQty > 0 ? 'long' : 'short';
3375
3405
  const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
@@ -3435,16 +3465,19 @@ async function reconcileBrokerStopsLocked(list, acc) {
3435
3465
  }
3436
3466
  }
3437
3467
 
3438
- // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 2 минуты между шагами расписания
3468
+ // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 25 секунд между шагами расписания
3439
3469
  async function checkIntradayProfitLock(accId) {
3440
- const list = loadRawAccounts();
3441
- const acc = list.find((x) => x.id === accId);
3442
- if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3443
3470
  if (!tradingGate().allowProtective) return;
3444
3471
  const nowMsk = new Date(Date.now() + MSK);
3445
3472
  const dow = nowMsk.getUTCDay();
3446
3473
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
3447
3474
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
3475
+ const lockToken = acquireAccountLock(accId);
3476
+ if (!lockToken) return;
3477
+ try {
3478
+ const list = loadRawAccounts();
3479
+ const acc = list.find((x) => x.id === accId);
3480
+ if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3448
3481
  // В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
3449
3482
  const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
3450
3483
 
@@ -3561,7 +3594,7 @@ async function checkIntradayProfitLock(accId) {
3561
3594
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
3562
3595
  }
3563
3596
  }
3564
- acc.pendingBatch = {
3597
+ const stopBatchObj = {
3565
3598
  ...(acc.pendingBatch || {}),
3566
3599
  id: `batch_stop_${execNowTs}`,
3567
3600
  createdAt: execNowTs,
@@ -3571,13 +3604,19 @@ async function checkIntradayProfitLock(accId) {
3571
3604
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3572
3605
  results,
3573
3606
  };
3607
+ if (acc.pendingBatch?.status !== 'pending' && acc.pendingBatch?.status !== 'executing') {
3608
+ acc.pendingBatch = stopBatchObj;
3609
+ }
3574
3610
  saveRawAccounts(list);
3575
3611
  if (sentLockOrders.length > 0) {
3576
- notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
3612
+ notifyBrokerBatch(currentUserId(), acc, { ...stopBatchObj, orders: sentLockOrders }).catch(() => {});
3577
3613
  }
3578
3614
  await syncBrokerAccount(accId).catch(() => {});
3579
3615
  const st = brokersState();
3580
3616
  hub.emit({ type: 'brokers:update', state: st });
3617
+ } finally {
3618
+ releaseAccountLock(accId, lockToken);
3619
+ }
3581
3620
  }
3582
3621
 
3583
3622
  // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
@@ -230,8 +230,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
230
230
  const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
231
231
  const isCacheFreshForSlot = (entry) => {
232
232
  if (!isTradingNow || now - entry.at < 50000) return true;
233
- const lastBar = entry.ds?.timeline?.[entry.ds.T - 1] || 0;
234
- return lastBar >= latestClosedSlotTs;
233
+ if (entry.at < latestClosedSlotTs + bMs) return false;
234
+ const tl = entry.ds?.timeline;
235
+ if (!tl?.length) return false;
236
+ let lastClosedBar = 0;
237
+ for (let i = tl.length - 1; i >= 0; i--) {
238
+ if (tl[i] + bMs <= now) { lastClosedBar = tl[i]; break; }
239
+ }
240
+ return lastClosedBar >= latestClosedSlotTs;
235
241
  };
236
242
  const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
237
243
  const hit = datasetCache.get(key);
@@ -19,17 +19,29 @@ const inflight = new Map();
19
19
  const inflightStartedAt = new Map();
20
20
  let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
21
21
 
22
- // Синхронная живая доходность индекса относительно закрытия свечи idx-1:
23
- // если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
24
- // считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
25
- // с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
22
+ // Синхронная живая доходность индекса относительно базовой закрытой свечи baseIdx:
23
+ // если последняя закрытая 10-минутная свеча idx уже закончилась (liveSnap.ts >= barTs + BAR_MS), живая цена относится к НОВОЙ
24
+ // формирующейся свече после idx — поэтому базой служит tk.close[idx], а не tk.close[idx - 1]. Иначе два подряд растущих бара
25
+ // складывались в одну точку и упирались в потолок 8σ одной свечи!
26
+ const liveBaseIndexOf = (ds, idx, liveSnap) => {
27
+ const barTs = ds.timeline?.[idx] || 0;
28
+ return (liveSnap?.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS) ? idx : Math.max(0, idx - 1);
29
+ };
30
+ const liveElapsedBarsOf = (ds, idx, liveSnap) => {
31
+ const barTs = ds.timeline?.[idx] || 0;
32
+ if (!(liveSnap?.ts && barTs > 0)) return 1;
33
+ const startTs = liveSnap.ts >= barTs + BAR_MS ? barTs + BAR_MS : barTs;
34
+ return Math.max(1, (liveSnap.ts - startTs) / BAR_MS);
35
+ };
36
+
26
37
  function computeLiveMarketReturn(ds, idx, liveSnap) {
27
38
  if (idx < 1 || idx >= ds.T) return 0;
28
39
  if (liveSnap?.prices?.size > 0) {
40
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
29
41
  let wSum = 0, rSum = 0, cnt = 0;
30
42
  for (const tk of ds.tokens) {
31
43
  if (tk.inIndex && !tk.inIndex[idx]) continue;
32
- const prevPx = tk.close[idx - 1];
44
+ const prevPx = tk.close[baseIdx];
33
45
  const livePx = liveSnap.prices.get(tk.symbol);
34
46
  if (livePx > 0 && prevPx > 0) {
35
47
  const w = tk.weight > 0 ? tk.weight : 1;
@@ -39,44 +51,52 @@ function computeLiveMarketReturn(ds, idx, liveSnap) {
39
51
  }
40
52
  }
41
53
  if (cnt >= 15 && wSum > 0) return rSum / wSum;
42
- const mPrev = ds.mclose?.[idx - 1] || 0;
54
+ const mPrev = ds.mclose?.[baseIdx] || 0;
43
55
  if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
44
56
  }
45
57
  return ds.mret[idx] || 0;
46
58
  }
47
59
 
48
- // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
60
+ // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс).
61
+ // Выделяется дополнительный живой слот u+1, чтобы при формировании новой свечи (после закрытия idx) не затирать закрытую свечу idx!
49
62
  function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
50
63
  const { tokens, mret } = ds;
51
64
  const N = fTrain.N;
52
65
  const { beta, sigma, cos } = fTrain;
53
66
  const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
54
- const u = idx - lo;
55
- const W = u + 1;
67
+ const uClosed = idx - lo;
68
+ const barTs = ds.timeline[idx] || 0;
69
+ const hasNewLiveBar = Boolean(liveSnap?.prices?.size > 0 && liveSnap.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS);
70
+ const u = hasNewLiveBar ? uClosed + 1 : uClosed;
71
+ const W = uClosed + 2;
56
72
 
57
73
  const z = new Float64Array(W * N);
58
74
  const vr = new Float64Array(W * N);
59
- const barTs = ds.timeline[idx] || 0;
60
- const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
61
- const liveScale = 1 / Math.sqrt(elapsedBars);
62
- const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
63
- for (let uk = 0; uk < W; uk++) {
75
+ for (let uk = 0; uk <= uClosed; uk++) {
64
76
  const t = lo + uk;
65
- const isLast = uk === u && liveSnap?.prices?.size > 0;
66
- const mt = isLast ? mLive : mret[t];
77
+ const mt = mret[t];
67
78
  for (let i = 0; i < N; i++) {
68
79
  const tk = tokens[i];
69
- let rt = tk.ret[t];
70
- if (isLast) {
71
- const prevPx = tk.close[t - 1];
72
- const livePx = liveSnap.prices.get(tk.symbol);
73
- if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
74
- }
75
- const e = (rt - beta[i] * mt) * (isLast ? liveScale : 1);
80
+ const e = tk.ret[t] - beta[i] * mt;
76
81
  z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
77
82
  vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
78
83
  }
79
84
  }
85
+ for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
86
+
87
+ if (liveSnap?.prices?.size > 0) {
88
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
89
+ const liveScale = 1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap));
90
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
91
+ for (let i = 0; i < N; i++) {
92
+ const tk = tokens[i];
93
+ const prevPx = tk.close[baseIdx];
94
+ const livePx = liveSnap.prices.get(tk.symbol);
95
+ const rt = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (hasNewLiveBar ? 0 : tk.ret[idx]);
96
+ const e = (rt - beta[i] * mLive) * liveScale;
97
+ z[u * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
98
+ }
99
+ }
80
100
 
81
101
  const X = KINDS.map(() => SCALES.map(() => new Float32Array(W * N)));
82
102
  SCALES.forEach((sc, si) => {
@@ -101,17 +121,20 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
101
121
  const list = fTrain.macro.list.map((mTrain) => {
102
122
  const dm = ds.macro.find((x) => x.id === mTrain.id);
103
123
  const zs = new Float32Array(SCALES.length * W);
104
- if (dm) {
105
- const msig = mTrain.msig || 1e-3;
106
- SCALES.forEach((sc, si) => {
107
- let sum = 0;
108
- for (let k = 0; k < sc.len; k++) {
109
- const t = idx - sc.from - k;
110
- if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
124
+ if (dm) {
125
+ const msig = mTrain.msig || 1e-3;
126
+ for (const uk of [uClosed, uClosed + 1]) {
127
+ const baseT = Math.min(idx, lo + uk);
128
+ SCALES.forEach((sc, si) => {
129
+ let sum = 0;
130
+ for (let k = 0; k < sc.len; k++) {
131
+ const t = baseT - sc.from - k;
132
+ if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
133
+ }
134
+ zs[si * W + uk] = sum / Math.sqrt(sc.len);
135
+ });
111
136
  }
112
- zs[si * W + u] = sum / Math.sqrt(sc.len);
113
- });
114
- }
137
+ }
115
138
  return { id: mTrain.id, label: mTrain.label, b: mTrain.b, zs };
116
139
  });
117
140
  macro = { list };
@@ -213,27 +236,33 @@ async function fetchLiveMoexSnapshot() {
213
236
  }
214
237
  }
215
238
 
216
- // Мгновенное обновление последней точки контекста u в уже обученной модели по живым котировкам (за ~2 мс)
217
- function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
218
- if (!liveSnap?.prices?.size) return;
219
- const idx = f.lo + u;
220
- if (idx < 1 || idx >= ds.T) return;
239
+ // Мгновенное обновление живой точки контекста в уже обученной модели по живым котировкам (за ~2 мс).
240
+ // Возвращает индекс активной точки u (uClosed или uClosed+1, если после закрытия свечи idx уже идёт новая свеча).
241
+ function applyLivePricesToFold(ds, f, idx, fitted, liveSnap) {
242
+ const uClosed = idx - f.lo;
243
+ if (!liveSnap?.prices?.size || idx < 1 || idx >= ds.T) return uClosed;
221
244
  const N = f.N;
222
- const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
223
245
  const barTs = ds.timeline[idx] || 0;
224
- const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
225
- const liveScale = 1 / Math.sqrt(elapsedBars);
246
+ const hasNewLiveBar = Boolean(liveSnap.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS && uClosed + 1 < f.W);
247
+ const u = hasNewLiveBar ? uClosed + 1 : uClosed;
248
+ // восстанавливаем чистую закрытую свечу idx на случай, если на предыдущем тике она обновлялась внутри свечи
249
+ for (let i = 0; i < N; i++) {
250
+ const tk = ds.tokens[i];
251
+ const e0 = tk.ret[idx] - f.beta[i] * ds.mret[idx];
252
+ f.z[uClosed * N + i] = Math.max(-8, Math.min(8, e0 / f.sigma[i]));
253
+ }
254
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
255
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
256
+ const liveScale = 1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap));
226
257
 
227
- // 1) пересчитываем z[u*N + i] по живой цене акции с нормировкой к масштабу одной 10-минутной свечи
258
+ // 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия базовой свечи
228
259
  for (let i = 0; i < N; i++) {
229
260
  const tk = ds.tokens[i];
230
- const prevPx = tk.close[idx - 1];
261
+ const prevPx = tk.close[baseIdx];
231
262
  const livePx = liveSnap.prices.get(tk.symbol);
232
- if (livePx > 0 && prevPx > 0) {
233
- const ret = Math.log(livePx / prevPx);
234
- const e = (ret - f.beta[i] * mLive) * liveScale;
235
- f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
236
- }
263
+ const ret = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (hasNewLiveBar ? 0 : tk.ret[idx]);
264
+ const e = (ret - f.beta[i] * mLive) * liveScale;
265
+ f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
237
266
  }
238
267
 
239
268
  // 2) пересчитываем все масштабы SCALES в точке u (как в buildInferenceFoldAt)
@@ -246,8 +275,9 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
246
275
  const uk = u - sc.from - k;
247
276
  if (uk < 0) break;
248
277
  const zk = f.z[uk * N + i];
278
+ const t = Math.min(idx, f.lo + uk);
249
279
  sp += zk;
250
- sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
280
+ sf += zk * Math.min(4, ds.tokens[i].valRatio[t]);
251
281
  }
252
282
  Xp[u * N + i] = sp * invP;
253
283
  Xf[u * N + i] = sf * invF;
@@ -272,6 +302,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
272
302
  }
273
303
  }
274
304
  }
305
+ return u;
275
306
  }
276
307
 
277
308
  // Обучение ядра модели (карта рынка + веса внимания) выполняется не чаще 1 раза в торговый день для каждого набора параметров.
@@ -285,8 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
285
316
  const chosen = p.modelType in MODELS ? p.modelType : 'attention';
286
317
  const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
287
318
  const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
319
+ const dayFitKey = `${fitKey}:${barDayMsk}`;
288
320
 
289
- const cached = trainedModelCache.get(fitKey);
321
+ const cached = trainedModelCache.get(dayFitKey);
290
322
  if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
291
323
  const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
292
324
  return {
@@ -300,7 +332,6 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
300
332
  const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
301
333
  const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
302
334
  const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
303
- const u = idx - f.lo;
304
335
  // Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
305
336
  // не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
306
337
  const list = [...new Set(['attention', chosen])];
@@ -325,7 +356,11 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
325
356
  ...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
326
357
  };
327
358
  const trainedAt = Date.now();
328
- trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
359
+ if (trainedModelCache.size >= 16) {
360
+ const firstKey = trainedModelCache.keys().next().value;
361
+ if (firstKey) trainedModelCache.delete(firstKey);
362
+ }
363
+ trainedModelCache.set(dayFitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
329
364
 
330
365
  const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
331
366
  return {
@@ -357,7 +392,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
357
392
  const idx30m = Math.max(0, idx - 3);
358
393
  const idx1d = Math.max(0, idx - bpd);
359
394
  const idx3d = Math.max(0, idx - 3 * bpd);
360
- const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
395
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
396
+ const liveScale = liveSnap?.prices?.size > 0 ? (1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap))) : 1;
397
+ const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
361
398
  const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
362
399
  const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
363
400
  ? mPrevBar * Math.exp(mLiveRet)
@@ -368,11 +405,11 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
368
405
  const rows = ds.tokens.map((tk, i) => {
369
406
  let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
370
407
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
371
- const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
408
+ const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
372
409
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
373
410
  const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
374
- const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
375
- const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
411
+ const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * liveScale * 100;
412
+ const rawPriceMovePct = ((livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100)) * liveScale;
376
413
  const lastMovePct = round(rawResPct, 3);
377
414
  // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
378
415
  const p30m = tk.close[idx30m] || 0;
@@ -414,15 +451,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
414
451
  const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
415
452
  const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
416
453
  const isExtremeDip = Boolean(isRegimeCrash || (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)));
454
+ const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
418
455
  const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
419
- const liveShortOk = !isExtremeSpike;
456
+ const liveShortOk = !isExtremeSpike && !isMorningOpenShortBlocked;
420
457
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
421
458
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
422
459
  const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
423
460
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
424
461
  const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
425
462
  const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
463
+ const shortBlockedByTime = Boolean(exp < 0 && !isExtremeSpike && isMorningOpenShortBlocked);
426
464
  const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
427
465
  const row = {
428
466
  i, symbol: tk.symbol, name: tk.name,
@@ -449,6 +487,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
449
487
  row.shortSignal = rawShortHit && !shortBlockedByBroker;
450
488
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
451
489
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
490
+ if (shortBlockedByTime) row.shortBlockedByTime = true;
452
491
  }
453
492
  // контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
454
493
  const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
@@ -499,6 +538,11 @@ function withNews(result, p) {
499
538
  } else {
500
539
  delete r.shortBlockedBySqueeze;
501
540
  }
541
+ if (r.expectedPct < 0 && r.shortBlockedByTime) {
542
+ r.shortSignal = false;
543
+ } else if (r.expectedPct >= 0) {
544
+ delete r.shortBlockedByTime;
545
+ }
502
546
  if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
503
547
  r.buyBlockedByDip = true;
504
548
  r.signal = false;
@@ -616,6 +660,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
616
660
  if (side === 'short') {
617
661
  if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
618
662
  if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
663
+ if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
619
664
  if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
620
665
  if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
621
666
  if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
@@ -742,11 +787,12 @@ const fmtLiveBarLabel = (barTs, liveSnapTs) => {
742
787
  // Мгновенный пересчёт последнего среза прогноза по живым секундным котировкам (без обращения к истории свечей)
743
788
  async function tickLiveForecastState(p, now = Date.now()) {
744
789
  const liveSnap = await fetchLiveMoexSnapshot();
745
- if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
790
+ const uActive = liveSnap ? applyLivePricesToFold(baseState.ds, baseState.f, baseState.last, baseState.fitted, liveSnap) : baseState.u;
791
+ baseState.u = uActive;
746
792
  const effHorizon = p.modelAutoAdapt !== false ? 2.5 : p.modelHorizon;
747
793
  const effMinEdge = p.modelAutoAdapt !== false ? 2.5 : p.modelMinEdge;
748
794
  const H = barsFromHours(effHorizon);
749
- const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, p.modelAutoAdapt !== false, true, liveSnap);
795
+ const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, uActive, baseState.fitted, baseState.chosen, H, effMinEdge, p.modelAutoAdapt !== false, true, liveSnap);
750
796
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
751
797
  attachDeltas(sortedRows, baseState);
752
798
  const liveTs = liveSnap?.ts || now;
@@ -89,10 +89,10 @@ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, h
89
89
  if (st.mode === 'vol' && sigmaBarPct > 0) {
90
90
  const vol = Math.max(sigmaBarPct, st.useRealized ? realizedBarPct : 0);
91
91
  const raw = st.k * vol * Math.sqrt(horizonBars);
92
- const maxPct = side === 'long' ? (st.maxLongPct ?? 5.0) : (st.maxShortPct ?? 4.5);
92
+ const maxPct = side === 'long' ? (st.maxLongPct ?? 7.0) : (st.maxShortPct ?? 7.0);
93
93
  return Math.max(st.minPct ?? 1.0, Math.min(maxPct, raw));
94
94
  }
95
- return side === 'long' ? (st.longPct ?? 5.0) : (st.shortPct ?? 4.5);
95
+ return side === 'long' ? (st.longPct ?? 7.0) : (st.shortPct ?? 7.0);
96
96
  }
97
97
 
98
98
  // ---------- плечо и множители размера по эшелонам ----------
@@ -17,7 +17,7 @@ import {
17
17
  isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
18
18
  } from './edge.js';
19
19
  import { nextDividendAt, dividendRules } from './dividends.js';
20
- import { STRATEGY_POLICY, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
20
+ import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
21
21
 
22
22
  // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
23
  export function alignSeries(timeline, rows) {
@@ -52,7 +52,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
52
52
  const slotW = policy.slotPct ?? (plan === 'owner' ? 0.45 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
53
53
  const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
54
54
  const convK = plan === 'owner' ? 0.30 : 0.25;
55
- const lev = Math.max(1, Number(leverage) || 1);
55
+ const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
56
56
  const maxGross = policy.maxGross ?? 0.99 * lev;
57
57
  const capScale = policy.capScale ?? 1;
58
58
  const tierScale = (i) => (isT1(i) ? (policy.edgeScaleT1 ?? 1) : (policy.edgeScaleT2 ?? 1));
@@ -106,7 +106,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
106
106
  }
107
107
  held.delete(i);
108
108
  cooldownUntil[i] = exitT + (kind === 'stop' || kind === 'thesis' ? stopCooldownBars : cooldownBars);
109
- stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * realCost);
109
+ stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * c);
110
110
  stockCnt[i] += 1;
111
111
  };
112
112
  const mtmUnreal = (t) => {
@@ -124,13 +124,26 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
124
124
  if (firstK < 0) firstK = wf.testTs.length;
125
125
  let lastT = -1;
126
126
  let prevT = -1;
127
+ const maxDailyDdPct = policy.maxDailyDrawdownPct ?? 3.0;
128
+ let curDayMsk = -1;
129
+ let dayStartTotalRub = capital;
130
+ let dayDdBreachCount = 0;
131
+ let dayHalted = false;
127
132
  for (let k = firstK; k < wf.testTs.length; k++) {
128
133
  const t = wf.testTs[k];
129
134
  if (timeline[t] > tillTs || t + 1 >= T) break;
130
135
  lastT = t;
131
136
  const fo = wf.foldOf[k];
132
137
  const pr = wf.pred[k], sg = wf.foldSigma[fo], mult = wf.foldMult[fo], beta = wf.foldBeta[fo];
138
+ if (!pr || !sg || !mult || !beta) continue;
133
139
  const decTs = timeline[t] + BAR_MS;
140
+ const barDayMsk = Math.floor((timeline[t] + 3 * 3600000) / 86400000);
141
+ if (barDayMsk !== curDayMsk) {
142
+ curDayMsk = barDayMsk;
143
+ dayStartTotalRub = Math.max(1, equity + mtmUnreal(t));
144
+ dayDdBreachCount = 0;
145
+ dayHalted = false;
146
+ }
134
147
  const isStep = (k - firstK) % stepBars === 0;
135
148
  if (isStep) {
136
149
  for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
@@ -149,7 +162,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
149
162
  }
150
163
  prevT = t;
151
164
 
152
- // 1) внутри свечи t: фиксация прибыли, стоп отмены идеи и защитный стоп
165
+ // 1) внутри свечи t: сначала проверяем защитный стоп и стоп отмены идеи (консервативный приоритет неблагоприятного движения), затем фиксацию прибыли
153
166
  for (const [i, pos] of [...held]) {
154
167
  if (pos.entryT > t) continue;
155
168
  const tk = tokens[i];
@@ -159,19 +172,11 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
159
172
  const favorable = pos.side === 'long' ? h : l;
160
173
  const heldMinStart = (t - pos.entryT) * BAR_INTERVAL;
161
174
  let done = false;
162
- if (policy.profitLock) {
163
- const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
164
- if (lv) {
165
- const gO = gainOf(pos, o) * 100;
166
- if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
167
- else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
168
- }
169
- }
170
- if (done) continue;
171
175
  const stopPx = pxOfGain(pos, -pos.stopPct / 100);
172
176
  const levels = [[stopPx, 'stop']];
173
177
  if (pos.thesisPx > 0) levels.push([pos.thesisPx, 'thesis']);
174
- levels.sort((a, b) => (pos.side === 'long' ? b[0] - a[0] : a[0] - b[0]));
178
+ // При одновременном пробое нескольких стоп-уровней внутри свечи исполняем по более невыгодному (консервативно)
179
+ levels.sort((a, b) => (pos.side === 'long' ? a[0] - b[0] : b[0] - a[0]));
175
180
  for (const [lvPx, kind] of levels) {
176
181
  const hit = pos.side === 'long' ? adverse <= lvPx : adverse >= lvPx;
177
182
  if (!hit) continue;
@@ -182,6 +187,15 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
182
187
  break;
183
188
  }
184
189
  if (done) continue;
190
+ if (policy.profitLock) {
191
+ const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
192
+ if (lv) {
193
+ const gO = gainOf(pos, o) * 100;
194
+ if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
195
+ else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
196
+ }
197
+ }
198
+ if (done) continue;
185
199
  pos.peakGain = Math.max(pos.peakGain, gainOf(pos, favorable));
186
200
  if (policy.profitLock) {
187
201
  const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart + BAR_INTERVAL, roundTripFeePct);
@@ -248,8 +262,18 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
248
262
  if (kind === 'night') stats.night++; else if (kind === 'signal') stats.signalExits++; else if (kind === 'time') stats.timeExits++; else if (kind === 'regime') stats.regime++; else stats.div++;
249
263
  }
250
264
 
251
- // 3) входы
252
- const timeOk = canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
265
+ // 3) входы (блокируются до конца дня при срабатывании дневного стопа просадки, как в живом контуре)
266
+ if (maxDailyDdPct > 0 && !dayHalted && isStep) {
267
+ const curTotalRub = equity + mtmUnreal(t);
268
+ const dayDdPct = ((dayStartTotalRub - curTotalRub) / dayStartTotalRub) * 100;
269
+ if (dayDdPct >= maxDailyDdPct) {
270
+ dayDdBreachCount += 1;
271
+ if (dayDdBreachCount >= 2) dayHalted = true;
272
+ } else {
273
+ dayDdBreachCount = 0;
274
+ }
275
+ }
276
+ const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
253
277
  if (timeOk) {
254
278
  const baseOk = (i) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i]) && (policy.tier2 !== false || isT1(i));
255
279
  const candL = [];
@@ -5,6 +5,7 @@
5
5
  import { buildFold, MODELS, FEATURE_WARMUP } from './models.js';
6
6
  import { computeTokenEdgeMults } from './edge.js';
7
7
  import { barsFromHours } from './dataset.js';
8
+ import { loadProfiles } from './profiles.js';
8
9
 
9
10
  const DAY = 86400000;
10
11
  const MSK = 3 * 3600000;
@@ -33,6 +34,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
33
34
  const foldSigma = [];
34
35
  const foldBeta = [];
35
36
  const foldMult = [];
37
+ const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
36
38
  let fi = 0;
37
39
  while (testStart < T - maxH - 2) {
38
40
  if (signal?.aborted) throw new Error('Отменено');
@@ -40,7 +42,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
40
42
  const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
41
43
  onProgress({ fold: fi + 1, folds, from: timeline[testStart], till: timeline[testEnd - 1] });
42
44
  await tick();
43
- const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: 0, profiles: null, multiHorizon: autoAdapt });
45
+ const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight || 0, profiles, multiHorizon: autoAdapt });
44
46
  foldSigma.push(Float64Array.from(f.sigma));
45
47
  foldBeta.push(Float64Array.from(f.beta));
46
48
  foldMult.push(computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart));