neuroalpha 0.6.3 → 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.3",
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
@@ -1716,10 +1716,10 @@ const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
1716
1716
  };
1717
1717
 
1718
1718
  async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1719
- if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1720
1719
  for (const [k, v] of entryImpulseWatch) {
1721
1720
  if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
1722
1721
  }
1722
+ if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1723
1723
  const ready = [];
1724
1724
  const deferred = [];
1725
1725
  for (const r of cands) {
@@ -1826,7 +1826,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1826
1826
  let list = loadRawAccounts();
1827
1827
  let acc = list.find((x) => x.id === id);
1828
1828
  if (!acc) throw new Error('Счёт не найден');
1829
- const exitsOnly = isDailyStopHalt(acc);
1829
+ let exitsOnly = isDailyStopHalt(acc);
1830
1830
  if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1831
1831
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1832
1832
  if (onlyEntries && (edgeWarn || exitsOnly)) {
@@ -1848,6 +1848,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1848
1848
  list = loadRawAccounts();
1849
1849
  acc = list.find((x) => x.id === id) || acc;
1850
1850
  }
1851
+ exitsOnly = isDailyStopHalt(acc);
1852
+ if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1853
+ if (onlyEntries && exitsOnly) return brokersState();
1851
1854
  const params = normalizeParams(getSettings());
1852
1855
  const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
1853
1856
  forecastNow(params),
@@ -2866,23 +2869,25 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2866
2869
  if (!acc) throw new Error('Счёт не найден');
2867
2870
  if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2868
2871
  const gate = tradingGate();
2869
- if (!gate.allowNew) throw new Error(gate.reason);
2872
+ if (!gate.allowNew && !gate.allowProtective) throw new Error(gate.reason);
2870
2873
  const batch = acc.pendingBatch;
2871
2874
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2872
2875
  if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
2873
- if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2874
- // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов или в окне начала сессии не отправляем
2876
+ if (acc.haltedReason && !isDailyStopHalt(acc)) throw new Error(acc.haltedReason);
2877
+ // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов, при дневном стопе или в окне начала сессии не отправляем (выходы сохраняются!)
2875
2878
  const execCheckTs = Date.now();
2876
2879
  const staleWhy = (() => {
2880
+ if (!gate.allowNew) return gate.reason;
2881
+ if (isDailyStopHalt(acc)) return acc.haltedReason;
2877
2882
  if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
2878
2883
  if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
2879
2884
  if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
2880
- if (isDailyStopHalt(acc)) return acc.haltedReason;
2881
2885
  return null;
2882
2886
  })();
2883
2887
  if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
2884
- const dropped = batch.orders.filter((o) => !isExitOrder(o) && o.symbol !== 'LQDT');
2885
- 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'));
2886
2891
  logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
2887
2892
  if (!batch.orders.length) {
2888
2893
  batch.status = 'expired';
@@ -2892,6 +2897,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2892
2897
  throw new Error(`Входы не отправлены: ${staleWhy}`);
2893
2898
  }
2894
2899
  }
2900
+ if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2895
2901
  // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2896
2902
  // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2897
2903
  for (const ord of batch.orders) {
@@ -2944,8 +2950,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2944
2950
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2945
2951
  newlySentOrders.push(ord);
2946
2952
  if (ord.action === 'buy' || ord.action === 'short') {
2947
- acc.entryTsBySymbol[ord.symbol] = execNowTs;
2948
- 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);
2949
2959
  delete acc.exitCooldownBySymbol[ord.symbol];
2950
2960
  delete acc.entryFailCooldownBySymbol[ord.symbol];
2951
2961
  delete acc.entryFailReasonBySymbol[ord.symbol];
@@ -3248,7 +3258,8 @@ async function tickBrokerSchedule() {
3248
3258
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3249
3259
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3250
3260
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3251
- 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) {
3252
3263
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3253
3264
  }
3254
3265
  }
@@ -3372,6 +3383,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
3372
3383
  const headers = tbankHeaders(acc);
3373
3384
  const want = new Map();
3374
3385
  const activeOrders = acc.lastSync?.activeOrders || [];
3386
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3375
3387
  for (const pos of acc.lastSync.positions || []) {
3376
3388
  const sym = pos.symbol;
3377
3389
  if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
@@ -3386,7 +3398,8 @@ async function reconcileBrokerStopsLocked(list, acc) {
3386
3398
  const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
3387
3399
  const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
3388
3400
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
3389
- 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);
3390
3403
  if (lots < 1 || avgPx <= 0) continue;
3391
3404
  const side = aQty > 0 ? 'long' : 'short';
3392
3405
  const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
@@ -3452,16 +3465,19 @@ async function reconcileBrokerStopsLocked(list, acc) {
3452
3465
  }
3453
3466
  }
3454
3467
 
3455
- // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 2 минуты между шагами расписания
3468
+ // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 25 секунд между шагами расписания
3456
3469
  async function checkIntradayProfitLock(accId) {
3457
- const list = loadRawAccounts();
3458
- const acc = list.find((x) => x.id === accId);
3459
- if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3460
3470
  if (!tradingGate().allowProtective) return;
3461
3471
  const nowMsk = new Date(Date.now() + MSK);
3462
3472
  const dow = nowMsk.getUTCDay();
3463
3473
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
3464
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;
3465
3481
  // В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
3466
3482
  const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
3467
3483
 
@@ -3578,7 +3594,7 @@ async function checkIntradayProfitLock(accId) {
3578
3594
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
3579
3595
  }
3580
3596
  }
3581
- acc.pendingBatch = {
3597
+ const stopBatchObj = {
3582
3598
  ...(acc.pendingBatch || {}),
3583
3599
  id: `batch_stop_${execNowTs}`,
3584
3600
  createdAt: execNowTs,
@@ -3588,13 +3604,19 @@ async function checkIntradayProfitLock(accId) {
3588
3604
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3589
3605
  results,
3590
3606
  };
3607
+ if (acc.pendingBatch?.status !== 'pending' && acc.pendingBatch?.status !== 'executing') {
3608
+ acc.pendingBatch = stopBatchObj;
3609
+ }
3591
3610
  saveRawAccounts(list);
3592
3611
  if (sentLockOrders.length > 0) {
3593
- notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
3612
+ notifyBrokerBatch(currentUserId(), acc, { ...stopBatchObj, orders: sentLockOrders }).catch(() => {});
3594
3613
  }
3595
3614
  await syncBrokerAccount(accId).catch(() => {});
3596
3615
  const st = brokersState();
3597
3616
  hub.emit({ type: 'brokers:update', state: st });
3617
+ } finally {
3618
+ releaseAccountLock(accId, lockToken);
3619
+ }
3598
3620
  }
3599
3621
 
3600
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);
@@ -82,7 +82,6 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
82
82
  vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
83
83
  }
84
84
  }
85
- vr[(uClosed + 1) * N] = 1;
86
85
  for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
87
86
 
88
87
  if (liveSnap?.prices?.size > 0) {
@@ -125,10 +124,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
125
124
  if (dm) {
126
125
  const msig = mTrain.msig || 1e-3;
127
126
  for (const uk of [uClosed, uClosed + 1]) {
127
+ const baseT = Math.min(idx, lo + uk);
128
128
  SCALES.forEach((sc, si) => {
129
129
  let sum = 0;
130
130
  for (let k = 0; k < sc.len; k++) {
131
- const t = Math.min(idx, lo + uk - sc.from - k);
131
+ const t = baseT - sc.from - k;
132
132
  if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
133
133
  }
134
134
  zs[si * W + uk] = sum / Math.sqrt(sc.len);
@@ -316,8 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
316
316
  const chosen = p.modelType in MODELS ? p.modelType : 'attention';
317
317
  const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
318
318
  const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
319
+ const dayFitKey = `${fitKey}:${barDayMsk}`;
319
320
 
320
- const cached = trainedModelCache.get(fitKey);
321
+ const cached = trainedModelCache.get(dayFitKey);
321
322
  if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
322
323
  const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
323
324
  return {
@@ -331,7 +332,6 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
331
332
  const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
332
333
  const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
333
334
  const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
334
- const u = idx - f.lo;
335
335
  // Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
336
336
  // не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
337
337
  const list = [...new Set(['attention', chosen])];
@@ -356,7 +356,11 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
356
356
  ...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
357
357
  };
358
358
  const trainedAt = Date.now();
359
- 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 });
360
364
 
361
365
  const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
362
366
  return {
@@ -389,6 +393,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
389
393
  const idx1d = Math.max(0, idx - bpd);
390
394
  const idx3d = Math.max(0, idx - 3 * bpd);
391
395
  const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
396
+ const liveScale = liveSnap?.prices?.size > 0 ? (1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap))) : 1;
392
397
  const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
393
398
  const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
394
399
  const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
@@ -403,8 +408,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
403
408
  const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
404
409
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
405
410
  const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
406
- const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
407
- 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;
408
413
  const lastMovePct = round(rawResPct, 3);
409
414
  // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
410
415
  const p30m = tk.close[idx30m] || 0;
@@ -446,15 +451,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
446
451
  const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
447
452
  const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
448
453
  const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
449
- 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)));
450
455
  const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
451
- const liveShortOk = !isExtremeSpike;
456
+ const liveShortOk = !isExtremeSpike && !isMorningOpenShortBlocked;
452
457
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
453
458
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
454
459
  const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
455
460
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
456
461
  const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
457
462
  const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
463
+ const shortBlockedByTime = Boolean(exp < 0 && !isExtremeSpike && isMorningOpenShortBlocked);
458
464
  const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
459
465
  const row = {
460
466
  i, symbol: tk.symbol, name: tk.name,
@@ -481,6 +487,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
481
487
  row.shortSignal = rawShortHit && !shortBlockedByBroker;
482
488
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
483
489
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
490
+ if (shortBlockedByTime) row.shortBlockedByTime = true;
484
491
  }
485
492
  // контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
486
493
  const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
@@ -531,6 +538,11 @@ function withNews(result, p) {
531
538
  } else {
532
539
  delete r.shortBlockedBySqueeze;
533
540
  }
541
+ if (r.expectedPct < 0 && r.shortBlockedByTime) {
542
+ r.shortSignal = false;
543
+ } else if (r.expectedPct >= 0) {
544
+ delete r.shortBlockedByTime;
545
+ }
534
546
  if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
535
547
  r.buyBlockedByDip = true;
536
548
  r.signal = false;
@@ -648,6 +660,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
648
660
  if (side === 'short') {
649
661
  if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
650
662
  if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
663
+ if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
651
664
  if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
652
665
  if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
653
666
  if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
@@ -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));