neuroalpha 0.5.22 → 0.5.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.22",
3
+ "version": "0.5.24",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -118,15 +118,15 @@ export function roundToStep(price, minStep = 0.01, decimals = 2) {
118
118
  return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
119
119
  }
120
120
 
121
- // Снайперский расчёт лимитной цены (1-tick Limit):
122
- // отталкивается от живой секундной цены basePx и сдвигает её ровно на 1 минимальный шаг цены (minStep),
123
- // но не более заданного offsetPct, чтобы не переплачивать маркетмейкерам за пересечение спреда!
121
+ // Снайперский расчёт лимитной цены:
122
+ // отталкивается от живой секундной цены basePx и задаёт защитный предел исполнения по offsetPct (но не менее 1 минимального шага цены minStep),
123
+ // а в момент фактической отправки в стакан sendOrderToBroker дополнительно подстраивает цену под лучший bid/ask живого стакана 1×1!
124
124
  export function sniperLimitPrice(basePx, dir, offsetPct = 0.02, sp = { minStep: 0.01, decimals: 2 }) {
125
125
  const step = sp.minStep > 0 ? sp.minStep : 0.01;
126
- const maxOffRatio = Math.max(0, Math.min(0.0005, (Number(offsetPct) || 0.02) / 100));
126
+ const maxOffRatio = Math.max(0, Math.min(0.001, (Number(offsetPct) || 0.02) / 100));
127
127
  const byPct = basePx * (1 + dir * maxOffRatio);
128
128
  const byOneTick = basePx + dir * step;
129
- const rawLimit = dir > 0 ? Math.min(byPct, byOneTick) : Math.max(byPct, byOneTick);
129
+ const rawLimit = dir > 0 ? Math.max(byPct, byOneTick) : Math.min(byPct, byOneTick);
130
130
  return roundToStep(rawLimit, step, sp.decimals);
131
131
  }
132
132
 
@@ -498,6 +498,7 @@ export function saveBrokerAccount(input = {}) {
498
498
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
499
499
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
500
500
  lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
501
+ lastFullStepWallTs: strategyOrCapChanged ? 0 : (prev.lastFullStepWallTs || 0),
501
502
  // дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
502
503
  haltedReason: prev.haltedReason || null,
503
504
  ddOverrideDay: prev.ddOverrideDay || null,
@@ -710,8 +711,9 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
710
711
  // Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
711
712
  export async function fetchTbankRealtimePrices() {
712
713
  try {
713
- // только токен владельца текущих данных — токены других пользователей не используются никогда
714
- const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
714
+ // только токен владельца текущих данных — токены других пользователей не используются никогда (предпочитаем боевой контур песочнице)
715
+ const rawAccs = loadRawAccounts();
716
+ const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
715
717
  if (!tbAcc?.token) return null;
716
718
  await ensureTbankShortableMap(tbAcc);
717
719
  const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
@@ -744,7 +746,8 @@ export async function fetchTbankRealtimePrices() {
744
746
  // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
745
747
  export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
746
748
  try {
747
- const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
749
+ const rawAccs = loadRawAccounts();
750
+ const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
748
751
  if (!tbAcc?.token) return null;
749
752
  await ensureTbankShortableMap(tbAcc);
750
753
  const shortMap = kvGet('tbankShortableMap');
@@ -1771,7 +1774,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1771
1774
  if (aQty > 0) {
1772
1775
  const lots = Math.floor(aQty / sp.lotSize);
1773
1776
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1774
- const hitHardStopLoss = curGainPct <= -5.0;
1777
+ const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.buyBlockedByDip);
1775
1778
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1776
1779
  const dg = divGuardOf(sym, curPx);
1777
1780
  const divExit = Boolean(dg?.exitLong);
@@ -1796,7 +1799,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1796
1799
  } else if (aQty < 0) {
1797
1800
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1798
1801
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1799
- const hitHardStopLoss = curGainPct <= -4.5;
1802
+ const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.shortBlockedBySqueeze);
1800
1803
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1801
1804
  const dg = divGuardOf(sym, curPx);
1802
1805
  const divExit = Boolean(dg?.exitShort);
@@ -1899,7 +1902,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1899
1902
  }
1900
1903
  }
1901
1904
  const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
1902
- const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
1905
+ const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
1906
+ const openShortRub = [...heldMap.values()]
1907
+ .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
1908
+ .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
1909
+ const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
1903
1910
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
1904
1911
 
1905
1912
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
@@ -2111,9 +2118,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2111
2118
  return brokersState();
2112
2119
  }
2113
2120
 
2121
+ const batchNowTs = Date.now();
2122
+ if (!onlyEntries) {
2123
+ acc.lastFullStepWallTs = batchNowTs;
2124
+ }
2114
2125
  acc.pendingBatch = {
2115
- id: `batch_${Date.now()}`,
2116
- createdAt: Date.now(),
2126
+ id: `batch_${batchNowTs}`,
2127
+ createdAt: batchNowTs,
2117
2128
  bar: fc.bar,
2118
2129
  budgetRub: budget,
2119
2130
  availRub: round(availRub, 0),
@@ -2190,19 +2201,35 @@ async function sendOrderToBroker(acc, ord) {
2190
2201
  if (bestBid > 0 && bestAsk >= bestBid) {
2191
2202
  const midPx = (bestBid + bestAsk) / 2;
2192
2203
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
2204
+ const step = sp.minStep || 0.01;
2193
2205
  const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
2194
2206
  if (isEntry && spreadPct > maxSpreadPct) {
2195
2207
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2196
2208
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2197
2209
  }
2198
- if (isEntry && ord.symbol !== 'LQDT') {
2199
- // Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
2200
- const passivePx = isBuy
2201
- ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
2202
- : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
2203
- if (passivePx > 0) finalLimitPx = passivePx;
2204
- } else if (ord.symbol === 'LQDT') {
2210
+ if (ord.symbol === 'LQDT') {
2205
2211
  finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2212
+ } else if (!isEntry) {
2213
+ // Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
2214
+ // чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
2215
+ if (spreadPct <= 0.80) {
2216
+ const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2217
+ if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2218
+ }
2219
+ } else {
2220
+ // Входящие заявки (buy / short):
2221
+ // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
2222
+ // • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
2223
+ const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
2224
+ if (tightSpread) {
2225
+ const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2226
+ if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
2227
+ } else {
2228
+ const passivePx = isBuy
2229
+ ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
2230
+ : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
2231
+ if (passivePx > 0) finalLimitPx = passivePx;
2232
+ }
2206
2233
  }
2207
2234
  }
2208
2235
  }
@@ -2751,14 +2778,21 @@ async function tickBrokerSchedule() {
2751
2778
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
2752
2779
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
2753
2780
  const minStepMs = stepBars * BAR_MS;
2754
- const lastRunWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2781
+ const lastFullWallTs = acc.lastFullStepWallTs || 0;
2782
+ const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2783
+ const hasOpenAlgoPos = (acc.lastSync?.positions || []).some((p) => p.qty && p.symbol !== 'LQDT' && p.symbol !== 'TMON@' && p.symbol !== 'TMON' && p.symbol !== 'SBMM' && algoQtyFor(acc, p.symbol, p.qty) !== 0);
2784
+ const nowTs = Date.now();
2785
+ const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
2755
2786
  const dueFullStep = Boolean(
2756
- latest &&
2757
- (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2758
- !(lastRunWallTs > 0 && Date.now() - lastRunWallTs < minStepMs - 5 * 60000),
2787
+ nightExitDue || (
2788
+ latest &&
2789
+ (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2790
+ !(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
2791
+ ),
2759
2792
  );
2760
2793
  if (dueFullStep) {
2761
- acc.lastScheduledBarTs = latest;
2794
+ acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
2795
+ acc.lastFullStepWallTs = nowTs;
2762
2796
  saveRawAccounts(list);
2763
2797
  if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2764
2798
  await prepareBrokerOrders(acc.id).catch(() => {});
@@ -2769,7 +2803,7 @@ async function tickBrokerSchedule() {
2769
2803
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2770
2804
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2771
2805
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2772
- if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && Date.now() - lastRunWallTs >= 20000) {
2806
+ if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2773
2807
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2774
2808
  }
2775
2809
  }
@@ -3056,6 +3090,9 @@ async function checkIntradayProfitLock(accId) {
3056
3090
  for (const ord of lockOrders) {
3057
3091
  logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3058
3092
  try {
3093
+ if (acc.brokerStopBySymbol?.[ord.symbol]) {
3094
+ await closeBrokerStopBeforeExit(acc, ord.symbol);
3095
+ }
3059
3096
  const r = await sendOrderToBroker(acc, ord);
3060
3097
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
3061
3098
  delete acc.entryTsBySymbol[ord.symbol];
@@ -3068,10 +3105,10 @@ async function checkIntradayProfitLock(accId) {
3068
3105
  }
3069
3106
  acc.pendingBatch = {
3070
3107
  ...(acc.pendingBatch || {}),
3071
- id: `batch_tp_${execNowTs}`,
3108
+ id: `batch_stop_${execNowTs}`,
3072
3109
  createdAt: execNowTs,
3073
3110
  executedAt: execNowTs,
3074
- bar: `${fmtBarMsk(execNowTs)} (Take-Profit)`,
3111
+ bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
3075
3112
  orders: lockOrders,
3076
3113
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3077
3114
  results,
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
2
+ import { saveCandles, loadCandles, candleRange, candleMaxBetween, kvGet, kvSet } from '../db.js';
3
3
  import { DAY, MSK } from '../util.js';
4
4
 
5
5
  let realtimeCandleFetcher = null;
@@ -80,9 +80,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
80
80
  const now = Date.now();
81
81
  const pKey = `candProbe:${symbol}:${interval}`;
82
82
  const probe = kvGet(pKey);
83
- // Защита от ложных меток candProbe v1 (где из-за старого условия first.ts <= range.min записывалось fromTs без скачивания):
84
- // валидной считаем только метку v2 (или если range.min реально доходит до fromTs)
85
- const covered = probe && probe.v === 2 && probe.fromTs <= fromTs + 5 * DAY;
83
+ // Метка v3: fromTs — до какой даты левый край подтверждён (свечи есть в базе или на бирже раньше их нет).
84
+ // Метки v1/v2 не доверяем: старое условие range.n < 500 записывало fromTs без скачивания, и у бумаг с накопленной
85
+ // историей левый край (например, 2022 год) так и не догружался — датасет зависел от того, когда создана база.
86
+ const verifiedFrom = probe?.v === 3 ? probe.fromTs : Infinity;
87
+ let leftFill = probe?.v === 3 ? probe.leftFill || null : null;
86
88
 
87
89
  if (!range.n) {
88
90
  // В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
@@ -90,28 +92,35 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
90
92
  signal,
91
93
  onBatch: (page) => saveCandles(symbol, interval, page),
92
94
  });
93
- kvSet(pKey, { v: 2, fromTs });
95
+ kvSet(pKey, { v: 3, fromTs });
94
96
  lastTailCheckAt.set(`${symbol}:${interval}`, now);
95
97
  return loadCandles(symbol, interval, fromTs, tillTs);
96
98
  }
97
99
 
98
- // 1) ЛЕВЫЙ КРАЙ:
99
- // Если в локальной базе SQLite уже накоплена история (range.n >= 500 свечей), НИКОГДА не лезем на биржу докачивать левый край
100
- // при смене «Окна обучения» или «Периода исследования» — работаем мгновенно по уже сохранённым в SQLite историческим свечам!
101
- if (!covered && range.n < 500 && range.min > fromTs + 5 * DAY) {
100
+ // 1) ЛЕВЫЙ КРАЙ: история докачивается один раз, дальше работаем только по SQLite.
101
+ // Докачка идёт от ранних свечей к поздним; пока она не завершена, в метке хранится leftFill,
102
+ // чтобы после обрыва продолжить с последней сохранённой свечи, а не оставить дыру в середине истории.
103
+ let probeFrom = Math.min(verifiedFrom, range.min);
104
+ if (!leftFill && range.min > fromTs + 5 * DAY && verifiedFrom > fromTs + 5 * DAY) {
102
105
  const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
103
106
  if (!first || first.ts >= range.min - DAY) {
104
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
107
+ probeFrom = fromTs; // раньше имеющихся свечей на бирже истории нет
105
108
  } else {
106
- const leftTill = tsToDate(range.min + DAY);
107
- await fetchCandles(symbol, interval, tsToDate(fromTs), leftTill, {
108
- signal,
109
- onBatch: (page) => saveCandles(symbol, interval, page),
110
- });
111
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
109
+ leftFill = { from: fromTs, till: range.min };
110
+ kvSet(pKey, { v: 3, fromTs: probeFrom, leftFill, tailCheckedAt: probe?.tailCheckedAt });
112
111
  }
113
- } else if (!probe || probe.v !== 2 || probe.fromTs > fromTs) {
114
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, range.min) });
112
+ }
113
+ if (leftFill) {
114
+ const resumeTs = candleMaxBetween(symbol, interval, leftFill.from, leftFill.till - 1) ?? leftFill.from;
115
+ await fetchCandles(symbol, interval, tsToDate(resumeTs), tsToDate(leftFill.till + DAY), {
116
+ signal,
117
+ onBatch: (page) => saveCandles(symbol, interval, page),
118
+ });
119
+ probeFrom = Math.min(probeFrom, leftFill.from);
120
+ leftFill = null;
121
+ }
122
+ if (probe?.v !== 3 || probe.fromTs !== probeFrom || probe.leftFill) {
123
+ kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: probe?.tailCheckedAt });
115
124
  }
116
125
 
117
126
  // 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
@@ -137,7 +146,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
137
146
  // В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
138
147
  let filledByTbank = false;
139
148
  if (isTbankEligible) {
140
- const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 2 * DAY), Math.min(tillTs, now)).catch(() => null);
149
+ const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 3 * DAY + 120000), Math.min(tillTs, now)).catch(() => null);
141
150
  if (tbRows?.length) {
142
151
  saveCandles(symbol, interval, tbRows);
143
152
  filledByTbank = true;
@@ -149,7 +158,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
149
158
  onBatch: (page) => saveCandles(symbol, interval, page),
150
159
  });
151
160
  }
152
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
161
+ kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: now });
153
162
  }
154
163
 
155
164
  return loadCandles(symbol, interval, fromTs, tillTs);
package/server/db.js CHANGED
@@ -343,6 +343,10 @@ export function candleRange(symbol, interval) {
343
343
  return db.prepare('SELECT MIN(ts) AS min, MAX(ts) AS max, COUNT(*) AS n FROM candles WHERE symbol = ? AND interval = ?').get(symbol, interval);
344
344
  }
345
345
 
346
+ export function candleMaxBetween(symbol, interval, fromTs, tillTs) {
347
+ return db.prepare('SELECT MAX(ts) AS max FROM candles WHERE symbol = ? AND interval = ? AND ts >= ? AND ts <= ?').get(symbol, interval, fromTs, tillTs)?.max ?? null;
348
+ }
349
+
346
350
  export function createRun(mode, params, uid = getUid()) {
347
351
  const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
348
352
  return Number(r.lastInsertRowid);
@@ -5,7 +5,7 @@
5
5
  // По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
6
6
  // • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
7
7
  // • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
8
- export const ENTRY_EDGE_MULT = 1.65;
8
+ export const ENTRY_EDGE_MULT = 1.60;
9
9
  export const TIER2_EDGE_MULT = 1.20;
10
10
 
11
11
  // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
@@ -20,13 +20,45 @@ export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub =
20
20
  const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
21
21
  const base = isT1 ? 1.0 : TIER2_EDGE_MULT;
22
22
  if (!(sigma > 0 || avgBarValRub > 0)) return base;
23
- const refVal = isT1 ? 15e6 : 1.5e6;
24
- const liqAdj = avgBarValRub > 0
25
- ? Math.max(-0.07, Math.min(0.10, -0.05 * Math.log10(Math.max(5e4, avgBarValRub) / refVal)))
23
+ if (isT1) {
24
+ const refVal = 15e6;
25
+ const liqAdj = avgBarValRub > 0
26
+ ? Math.max(-0.07, Math.min(0.10, -0.05 * Math.log10(Math.max(5e4, avgBarValRub) / refVal)))
27
+ : 0;
28
+ const sigRatio = (sigma > 0 && avgSigma > 0) ? (sigma / avgSigma) : 1;
29
+ const volAdj = Math.max(-0.05, Math.min(0.10, 0.12 * (sigRatio - 1)));
30
+ return Math.max(0.88, Math.min(1.45, base * (1 + liqAdj + volAdj)));
31
+ }
32
+ const liqAdj2 = avgBarValRub > 0
33
+ ? Math.max(-0.015, Math.min(0.025, -0.012 * Math.log10(Math.max(1e5, avgBarValRub) / 1.5e6)))
26
34
  : 0;
27
- const sigRatio = (sigma > 0 && avgSigma > 0) ? (sigma / avgSigma) : 1;
28
- const volAdj = Math.max(-0.05, Math.min(0.10, 0.12 * (sigRatio - 1)));
29
- return Math.max(0.88, Math.min(1.45, base * (1 + liqAdj + volAdj)));
35
+ return Math.round(base * (1 + liqAdj2) * 1000) / 1000;
36
+ }
37
+
38
+ // Пороги защиты от структурного слома режима (взрывного шорт-сквиза ≥20% за торговый день или отвесного обвала ≥9,5% за 30 минут):
39
+ export const REGIME_SHOCK_1D = 0.20;
40
+ export const REGIME_CRASH_30M = 0.095;
41
+
42
+ export function isRegimeShocked(ds, i, t, side = 'long') {
43
+ const tk = ds?.tokens?.[i];
44
+ if (!tk || t < 1) return false;
45
+ const bpd = Math.max(1, Math.round(ds.barsPerDay || 54));
46
+ const t1d = Math.max(0, t - bpd);
47
+ const p0 = tk.close[t1d], p1 = tk.close[t];
48
+ const m0 = ds.mclose?.[t1d], m1 = ds.mclose?.[t];
49
+ if (p0 > 0 && p1 > 0 && m0 > 0 && m1 > 0) {
50
+ const ex1d = (p1 / p0 - 1) - (m1 / m0 - 1);
51
+ if (side === 'short' ? ex1d > REGIME_SHOCK_1D : ex1d < -REGIME_SHOCK_1D) return true;
52
+ }
53
+ if (side === 'long' && t >= 3) {
54
+ const t3 = t - 3;
55
+ const pa = tk.close[t3], ma = ds.mclose?.[t3];
56
+ if (pa > 0 && p1 > 0 && ma > 0 && m1 > 0) {
57
+ const ex30m = (p1 / pa - 1) - (m1 / ma - 1);
58
+ if (ex30m < -REGIME_CRASH_30M) return true;
59
+ }
60
+ }
61
+ return false;
30
62
  }
31
63
 
32
64
  export function computeTokenEdgeMults(tokens, sigma, fromIdx = 0, tillIdx = 0) {
@@ -5,7 +5,7 @@ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
5
  import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
6
6
  import { loadProfiles } from './profiles.js';
7
7
  import { isMarketDown } from './research.js';
8
- import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults } from './edge.js';
8
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M } from './edge.js';
9
9
  export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
10
10
  import { newsForSymbols, blockingNews } from '../news/index.js';
11
11
  import { liveDividendGuard } from './dividends.js';
@@ -17,9 +17,35 @@ const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, f
17
17
  let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
18
18
  const inflight = new Map();
19
19
 
20
+ // Синхронная живая доходность индекса относительно закрытия свечи idx-1:
21
+ // если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
22
+ // считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
23
+ // с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
24
+ function computeLiveMarketReturn(ds, idx, liveSnap) {
25
+ if (idx < 1 || idx >= ds.T) return 0;
26
+ if (liveSnap?.prices?.size > 0) {
27
+ let wSum = 0, rSum = 0, cnt = 0;
28
+ for (const tk of ds.tokens) {
29
+ if (tk.inIndex && !tk.inIndex[idx]) continue;
30
+ const prevPx = tk.close[idx - 1];
31
+ const livePx = liveSnap.prices.get(tk.symbol);
32
+ if (livePx > 0 && prevPx > 0) {
33
+ const w = tk.weight > 0 ? tk.weight : 1;
34
+ rSum += w * Math.log(livePx / prevPx);
35
+ wSum += w;
36
+ cnt++;
37
+ }
38
+ }
39
+ if (cnt >= 15 && wSum > 0) return rSum / wSum;
40
+ const mPrev = ds.mclose?.[idx - 1] || 0;
41
+ if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
42
+ }
43
+ return ds.mret[idx] || 0;
44
+ }
45
+
20
46
  // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
21
47
  function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
22
- const { tokens, mret, mclose } = ds;
48
+ const { tokens, mret } = ds;
23
49
  const N = fTrain.N;
24
50
  const { beta, sigma, cos } = fTrain;
25
51
  const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
@@ -31,11 +57,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
31
57
  const barTs = ds.timeline[idx] || 0;
32
58
  const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
33
59
  const liveScale = 1 / Math.sqrt(elapsedBars);
60
+ const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
34
61
  for (let uk = 0; uk < W; uk++) {
35
62
  const t = lo + uk;
36
63
  const isLast = uk === u && liveSnap?.prices?.size > 0;
37
- const mPrev = mclose?.[t - 1] || 0;
38
- const mt = isLast && liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : mret[t];
64
+ const mt = isLast ? mLive : mret[t];
39
65
  for (let i = 0; i < N; i++) {
40
66
  const tk = tokens[i];
41
67
  let rt = tk.ret[t];
@@ -180,8 +206,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
180
206
  const idx = f.lo + u;
181
207
  if (idx < 1 || idx >= ds.T) return;
182
208
  const N = f.N;
183
- const mPrev = ds.mclose?.[idx - 1] || 0;
184
- const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
209
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
185
210
  const barTs = ds.timeline[idx] || 0;
186
211
  const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
187
212
  const liveScale = 1 / Math.sqrt(elapsedBars);
@@ -280,7 +305,7 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
280
305
  emb: f.emb,
281
306
  embN: f.embN,
282
307
  eigenvalues: f.eigenvalues,
283
- macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
308
+ macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b, msig: m.msig })) } : null,
284
309
  };
285
310
  const lightFitted = {
286
311
  ...fitted,
@@ -289,9 +314,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
289
314
  const trainedAt = Date.now();
290
315
  trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
291
316
 
292
- if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
317
+ const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
293
318
  return {
294
- ...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
319
+ ...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
295
320
  snap,
296
321
  trainedAt,
297
322
  };
@@ -316,19 +341,31 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
316
341
  const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
317
342
  const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
318
343
  const bpd = ds.barsPerDay || 54;
344
+ const idx30m = Math.max(0, idx - 3);
319
345
  const idx1d = Math.max(0, idx - bpd);
320
346
  const idx3d = Math.max(0, idx - 3 * bpd);
321
- const mNow = liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0);
347
+ const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
348
+ const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
349
+ const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
350
+ ? mPrevBar * Math.exp(mLiveRet)
351
+ : (liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0));
352
+ const m30m = ds.mclose?.[idx30m] > 0 && mNow > 0 ? (mNow / ds.mclose[idx30m] - 1) : 0;
322
353
  const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
323
354
  const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
324
355
  const rows = ds.tokens.map((tk, i) => {
325
356
  let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
326
357
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
358
+ const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
327
359
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
328
- const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
360
+ const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
361
+ const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
362
+ const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
363
+ const lastMovePct = round(rawResPct, 3);
329
364
  // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
365
+ const p30m = tk.close[idx30m] || 0;
330
366
  const p1d = tk.close[idx1d] || 0;
331
367
  const p3d = tk.close[idx3d] || 0;
368
+ const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
332
369
  const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
333
370
  const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
334
371
  const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
@@ -336,8 +373,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
336
373
  let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
337
374
  if (isOverheated && selfRaw > 0) {
338
375
  // Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
339
- // ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — обнуляем ложный отскок вверх:
340
- exp -= selfRaw;
376
+ // ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — гасим ложный отскок вверх, не переворачивая в искусственный шорт:
377
+ if (exp > 0) exp = Math.max(0, exp - selfRaw);
341
378
  selfRaw = 0;
342
379
  }
343
380
  const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
@@ -347,18 +384,22 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
347
384
  const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
348
385
  const symNeedL = Math.max(needL * tierMult, 0.0030);
349
386
  const symNeedS = Math.max(needS * tierMult, 0.0031);
350
- // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
387
+ // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
351
388
  // обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
352
389
  const EXTREME_BAR_MOVE_PCT = 4.5;
353
- const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT) && !buyBlockedByOverheat;
354
- const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
390
+ const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M;
391
+ const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D;
392
+ const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
393
+ const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
394
+ const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
395
+ const liveShortOk = !isExtremeSpike;
355
396
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
356
397
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
357
398
  const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
358
399
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
359
400
  const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
360
- const shortBlockedBySqueeze = Boolean(liveSnap && exp < 0 && lastMovePct > EXTREME_BAR_MOVE_PCT);
361
- const buyBlockedByDip = Boolean(liveSnap && exp > 0 && lastMovePct < -EXTREME_BAR_MOVE_PCT);
401
+ const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
402
+ const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
362
403
  const row = {
363
404
  i, symbol: tk.symbol, name: tk.name,
364
405
  tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
@@ -6,7 +6,7 @@ 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
8
  import { predictAt, lastClosedIndex } from './forecast.js';
9
- import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
9
+ import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
10
10
  import { computeReport, downsample } from './metrics.js';
11
11
  import { MODELS } from './models.js';
12
12
  import { blockingNews } from '../news/index.js';
@@ -434,9 +434,14 @@ class LiveEngine {
434
434
  closePos(sym, 'Закрытие перед ночным клирингом');
435
435
  continue;
436
436
  }
437
+ const tkIdx = bySym.get(sym);
438
+ if (autoAdapt && tkIdx !== undefined && isRegimeShocked(ds, tkIdx, idx, h.side || 'long')) {
439
+ closePos(sym, 'Защита от экстремального сквиза/обвала');
440
+ continue;
441
+ }
437
442
  const x = row.get(sym);
438
443
  const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
439
- const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * symbolEdgeMult(sym);
444
+ const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * (x?.edgeMult || symbolEdgeMult(sym));
440
445
  if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
441
446
  }
442
447
  const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
@@ -454,16 +459,33 @@ class LiveEngine {
454
459
  }
455
460
  return out;
456
461
  };
462
+ const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
463
+ const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
464
+ const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
465
+ const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
466
+ if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
467
+ const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
468
+ const exitNeed = r.threshold * exitRatio;
469
+ if (bestNewEdge >= r.threshold * 2.2) {
470
+ let weakestSym = null, weakestE = Infinity;
471
+ for (const [sym, h] of s.held) {
472
+ if (closing.has(sym)) continue;
473
+ const xCur = row.get(sym);
474
+ const curSignedE = xCur ? (h.side === 'short' ? -xCur.expected : xCur.expected) : 0;
475
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(r.threshold, curSignedE) * 2.4) {
476
+ weakestE = curSignedE;
477
+ weakestSym = sym;
478
+ }
479
+ }
480
+ if (weakestSym) closePos(weakestSym, 'Ротация в более сильного лидера');
481
+ }
482
+ }
457
483
  let entries;
458
484
  if (dir === 'long') {
459
485
  const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
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);
461
486
  if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
462
487
  entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
463
488
  } else {
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);
467
489
  if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
468
490
  const nL = K - sideHeld('long').length;
469
491
  const nS = K - sideHeld('short').length;
@@ -472,7 +494,7 @@ class LiveEngine {
472
494
  for (const [x, action] of entries) {
473
495
  const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
474
496
  const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
475
- const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(p.modelHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
497
+ const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(effHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
476
498
  s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
477
499
  step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
478
500
  }
@@ -262,7 +262,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
262
262
  out[u] = a >= 0 ? sum / Math.sqrt(sc.len) : 0;
263
263
  }
264
264
  });
265
- list.push({ id: m.id, label: m.label, b, zs });
265
+ list.push({ id: m.id, label: m.label, b, msig, zs });
266
266
  }
267
267
  if (list.length) macro = { list };
268
268
  }
@@ -505,15 +505,37 @@ export const modelNames = () => Object.keys(MODELS);
505
505
  // Объяснение прогноза «внимания»: вклад каждого соседа в прогноз акции j
506
506
  export function explainAttention(m, f, u, j, top = 3) {
507
507
  const N = f.N;
508
+ const k = u * N + j;
508
509
  const contrib = new Float64Array(N);
509
- const wAtt = m.inertiaCoef ? 0.50 : 1, wIn = m.inertiaCoef ? 0.50 : 0;
510
+ let sAtt = 0;
510
511
  HEADS.forEach((h, hi) => {
511
512
  const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
512
513
  const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
513
- for (let i = 0; i < N; i++) contrib[i] += wAtt * m.coef[hi] * A[j * N + i] * X[u * N + i];
514
+ for (let i = 0; i < N; i++) {
515
+ const c = m.coef[hi] * A[j * N + i] * X[u * N + i];
516
+ contrib[i] += c;
517
+ sAtt += c;
518
+ }
514
519
  });
515
- let self = 0;
516
- for (let q = 0; q < 3; q++) self += (wAtt * m.coef[HEADS.length + q] + wIn * (m.inertiaCoef?.[q] || 0)) * f.X[0][q][u * N + j];
520
+ let selfAtt = 0, sIn = 0;
521
+ for (let q = 0; q < 3; q++) {
522
+ const xq = f.X[0][q][k];
523
+ selfAtt += m.coef[HEADS.length + q] * xq;
524
+ if (m.inertiaCoef) sIn += (m.inertiaCoef[q] || 0) * xq;
525
+ }
526
+ sAtt += selfAtt;
527
+ const ML = f.macro ? f.macro.list : [];
528
+ for (let mi = 0; mi < ML.length; mi++) {
529
+ let zz = 0;
530
+ const zs = ML[mi].zs;
531
+ for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
532
+ sAtt += ML[mi].b[j] * zz;
533
+ }
534
+ const conflictScale = (m.inertiaCoef && sAtt * sIn < 0) ? 0.5 : 1;
535
+ const wAtt = (m.inertiaCoef ? 0.50 : 1) * conflictScale;
536
+ const wIn = (m.inertiaCoef ? 0.50 : 0) * conflictScale;
537
+ for (let i = 0; i < N; i++) contrib[i] *= wAtt;
538
+ const self = wAtt * selfAtt + wIn * sIn;
517
539
  const idx = [...contrib.keys()].filter((i) => i !== j).sort((a, b) => Math.abs(contrib[b]) - Math.abs(contrib[a])).slice(0, top);
518
540
  return { neighbors: idx.map((i) => ({ i, contrib: contrib[i] })), self };
519
541
  }
@@ -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, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
12
+ import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
13
13
  import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
14
14
 
15
15
  const DAY = 86400000;
@@ -40,7 +40,9 @@ export function isMarketDown(ds, t) {
40
40
  // 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
41
41
  // 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
42
42
  // 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
43
- const DATA_VERSION = 11;
43
+ // 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
44
+ // 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
45
+ const DATA_VERSION = 13;
44
46
 
45
47
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
46
48
  function reuseParamsKey(params) {
@@ -461,8 +463,8 @@ async function run(runId, p, state) {
461
463
  }
462
464
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
463
465
  const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
464
- const okLong = (i) => !divRule(i, t)?.blockLong;
465
- const okShort = (i) => !divRule(i, t)?.blockShort;
466
+ const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
467
+ const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
466
468
  let order, entries;
467
469
  if (dir !== 'long') {
468
470
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
@@ -474,12 +476,15 @@ async function run(runId, p, state) {
474
476
  const e = edge(i);
475
477
  const barsHeld = tIn - h.entryT;
476
478
  const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
477
- if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
479
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
480
+ if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) {
481
+ closePos(i, h, tIn, false, shocked ? 'Защита от экстремального сквиза/обвала' : 'Прогноз ослаб');
482
+ }
478
483
  }
479
484
  const needL = autoAdapt && bear ? need * 1.25 : need;
480
485
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
481
- const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
482
- const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
486
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
487
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
483
488
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
484
489
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
485
490
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -516,11 +521,27 @@ async function run(runId, p, state) {
516
521
  const e = edge(i);
517
522
  const barsHeld = tIn - h.entryT;
518
523
  const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
519
- if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn, false);
524
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
525
+ if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn, false, shocked ? 'Защита от экстремального обвала' : '');
520
526
  }
521
- const free = K - held.size;
522
527
  const needL = autoAdapt && bear ? need * 1.25 : need;
523
- const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
528
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
529
+ const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
530
+ if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
531
+ const bestNewEdge = edge(rawCand[0]);
532
+ if (bestNewEdge >= need * 2.2) {
533
+ let weakestIdx = -1, weakestE = Infinity;
534
+ for (const [i] of held) {
535
+ const curSignedE = edge(i);
536
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
537
+ weakestE = curSignedE;
538
+ weakestIdx = i;
539
+ }
540
+ }
541
+ if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn, false, 'Ротация в более сильного лидера');
542
+ }
543
+ }
544
+ const free = K - held.size;
524
545
  const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
525
546
  order = [...held.keys(), ...cand];
526
547
  } else {
@@ -912,7 +933,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
912
933
  return round(((lo + hi) / 2) * 100, 3);
913
934
  };
914
935
  // Подбор порога входа под реальные издержки: живое исполнение, издержки 0,09% на сторону (комиссия 0,04% + проскальзывание ~0,05%)
915
- const thresholdSweep = [1, 1.5, 1.65, 2, 2.5, 3].map((m) => {
936
+ const thresholdSweep = [1, 1.5, 1.6, 1.65, 2, 2.5, 3].map((m) => {
916
937
  const sim = simulate(chosen, 0.0009, p.modelShortFeePct, 0, { ...LIVE, needMult: m });
917
938
  const f = computeReport(sim.trades, sim.curve, p.capital, 0).full;
918
939
  return { needMult: m, current: m === ENTRY_EDGE_MULT, entryEdgePct: round(2.5 * 2 * base * m * 100, 3), returnPct: f.returnPct, cagrPct: cagr(f.returnPct, yearsOf(sim.curve)), profitFactor: f.profitFactor, maxDrawdownPct: f.maxDrawdownPct, trades: f.trades, avgTradePct: f.avgTradePct, last12Pct: segment(sim, lastTs - 365 * 86400000).returnPct, last6Pct: segment(sim, lastTs - 182 * 86400000).returnPct };
@@ -924,7 +945,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
924
945
  market: { full: mSeg(-Infinity), last12: mSeg(lastTs - 365 * 86400000), last6: mSeg(lastTs - 182 * 86400000) },
925
946
  shortableKnown: Boolean(shortable),
926
947
  caveats: [
927
- 'Состав рынка — сегодняшний индекс широкого рынка, бумаги отобраны по покрытию данных за весь период (ошибка выжившего не устранена).',
948
+ 'Состав рынка — исторический состав индекса MOEXBMI на каждую дату плюс текущий состав; отсекаются только бумаги с покрытием данных менее 15% периода.',
928
949
  'Константы стратегии подбирались на этой же истории, поэтому «последние 6 и 12 месяцев» не являются честно отложенной выборкой. Честная проверка — торговля после фиксации версии.',
929
950
  'Список бумаг для шорта — текущий, а не на каждую дату.',
930
951
  ],
@@ -974,7 +995,9 @@ async function runStabilityLoop(targetId, runRow, st) {
974
995
  await pause();
975
996
  const p = normalizeParams(runRow.params || {});
976
997
  const shifts = [-8, -4, 0, 4, 8];
977
- const H = barsFromHours(p.modelHorizon);
998
+ const autoAdapt = p.modelAutoAdapt !== false;
999
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
1000
+ const H = barsFromHours(effHorizon);
978
1001
  const tillTs = Date.now();
979
1002
  const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
980
1003
  const fromTs = todayStartMsk - 1650 * DAY;
@@ -991,11 +1014,18 @@ async function runStabilityLoop(targetId, runRow, st) {
991
1014
  const trainLen = Math.round(p.modelTrainDays * bpd), testLen = Math.round(20 * bpd);
992
1015
  const chosen = p.modelType in MODELS ? p.modelType : 'attention';
993
1016
  const realCost = (p.commissionPct + p.slippagePct) / 100;
994
- const K = p.modelTopK, dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
995
- const autoAdapt = p.modelAutoAdapt !== false;
1017
+ const rawTopK = Number(p.modelTopK ?? 2);
1018
+ const K = rawTopK > 0 ? rawTopK : 4;
1019
+ const dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
996
1020
  const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
997
1021
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
998
1022
  const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
1023
+ const baseSymMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
1024
+ const divCal = buildDividendCalendar(ds);
1025
+ const divRule = (i, t) => {
1026
+ const nd = nextDividendAt(divCal, i, t);
1027
+ return nd ? dividendRules(nd.daysToEx, nd.y) : null;
1028
+ };
999
1029
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
1000
1030
  const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
1001
1031
  const tsOf = (t) => timeline[Math.min(t, T - 1)];
@@ -1019,7 +1049,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1019
1049
  const maxH = autoAdapt ? Math.round(H * 1.4) : H;
1020
1050
  const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
1021
1051
  let fi = 0;
1022
- const preds = new Map(), sigmaAt = new Map();
1052
+ const preds = new Map(), sigmaAt = new Map(), multAt = new Map();
1023
1053
  while (testStart < T - maxH - 2) {
1024
1054
  if (st.cancelled) throw new Error('Отменено');
1025
1055
  const testEnd = Math.min(testStart + testLen, T - maxH - 2);
@@ -1028,12 +1058,14 @@ async function runStabilityLoop(targetId, runRow, st) {
1028
1058
  emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
1029
1059
  await tick();
1030
1060
  const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
1061
+ const foldEdgeMults = computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart);
1031
1062
  const m = await MODELS[chosen].fit(f);
1032
1063
  for (let t = testStart; t < testEnd; t++) {
1033
1064
  const u = t - f.lo;
1034
1065
  if (!f.yok[u]) continue;
1035
1066
  preds.set(t, MODELS[chosen].predict(m, f, u));
1036
1067
  sigmaAt.set(t, f.sigma);
1068
+ multAt.set(t, foldEdgeMults);
1037
1069
  }
1038
1070
  testStart = testEnd;
1039
1071
  fi++;
@@ -1051,7 +1083,7 @@ async function runStabilityLoop(targetId, runRow, st) {
1051
1083
  const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
1052
1084
  const curExitRatio = autoAdapt ? 0.95 : exitRatio;
1053
1085
  const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
1054
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
1086
+ const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
1055
1087
  let closedThisBar = new Set();
1056
1088
  const closePos = (i, h, t) => {
1057
1089
  const exitPrice = px(i, t);
@@ -1075,9 +1107,14 @@ async function runStabilityLoop(targetId, runRow, st) {
1075
1107
  for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
1076
1108
  }
1077
1109
  const pr = preds.get(t), sg = sigmaAt.get(t);
1110
+ const symMult = multAt.get(t) || baseSymMult;
1078
1111
  const bear = isMarketDown(ds, t);
1079
1112
  const mktDown = mktFilter && bear;
1080
1113
  const barTs = tsOf(t);
1114
+ for (const [i, h] of [...held]) {
1115
+ const r = divRule(i, t);
1116
+ if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) closePos(i, h, tIn);
1117
+ }
1081
1118
  if (autoAdapt) {
1082
1119
  for (const [i, h] of [...held]) {
1083
1120
  if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) closePos(i, h, tIn);
@@ -1085,29 +1122,64 @@ async function runStabilityLoop(targetId, runRow, st) {
1085
1122
  }
1086
1123
  const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
1087
1124
  const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
1125
+ const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
1126
+ const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
1088
1127
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
1089
1128
  const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
1090
1129
  let order, entries;
1091
1130
  if (dir !== 'long') {
1092
1131
  for (const [i, h] of [...held]) {
1093
1132
  const e = edge(i), barsHeld = tIn - h.entryT;
1094
- const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symbolEdgeMult(tokens[i].symbol);
1095
- if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
1133
+ const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
1134
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
1135
+ if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
1096
1136
  }
1097
1137
  const needL = autoAdapt && bear ? need * 1.25 : need;
1098
1138
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
1099
- const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
1100
- const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
1139
+ const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1140
+ const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
1141
+ const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1142
+ if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
1143
+ const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
1144
+ if (bestNewEdge >= need * 2.2) {
1145
+ let weakestIdx = -1, weakestE = Infinity;
1146
+ for (const [i, h] of held) {
1147
+ const curSignedE = h.side === 'long' ? edge(i) : -edge(i);
1148
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
1149
+ weakestE = curSignedE;
1150
+ weakestIdx = i;
1151
+ }
1152
+ }
1153
+ if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
1154
+ }
1155
+ }
1101
1156
  const nL = K - countSide('long'), nS = K - countSide('short');
1102
1157
  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)])];
1103
1158
  } else if (effMinEdge > 0) {
1104
1159
  for (const [i, h] of [...held]) {
1105
1160
  const e = edge(i), barsHeld = tIn - h.entryT;
1106
- const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
1107
- if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1161
+ const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
1162
+ const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
1163
+ if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn);
1108
1164
  }
1109
1165
  const needL = autoAdapt && bear ? need * 1.25 : need;
1110
- 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));
1166
+ const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
1167
+ const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1168
+ if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
1169
+ const bestNewEdge = edge(rawCand[0]);
1170
+ if (bestNewEdge >= need * 2.2) {
1171
+ let weakestIdx = -1, weakestE = Infinity;
1172
+ for (const [i] of held) {
1173
+ const curSignedE = edge(i);
1174
+ if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
1175
+ weakestE = curSignedE;
1176
+ weakestIdx = i;
1177
+ }
1178
+ }
1179
+ if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
1180
+ }
1181
+ }
1182
+ const cand = rawCand.slice(0, Math.max(0, K - held.size));
1111
1183
  order = [...held.keys(), ...cand];
1112
1184
  } else {
1113
1185
  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);
@@ -1125,11 +1197,25 @@ async function runStabilityLoop(targetId, runRow, st) {
1125
1197
  }
1126
1198
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
1127
1199
  let avail = Math.max(0, equity * 0.99 - usedStart);
1200
+ const activeTotal = Math.max(2, held.size + newEntries.length);
1201
+ const dynSlotWeight = autoAdapt
1202
+ ? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
1203
+ : maxSlotWeight;
1204
+ if (autoAdapt && newEntries.length > 1) {
1205
+ newEntries.sort((a, b) => Math.abs(b[2] ?? 0) - Math.abs(a[2] ?? 0));
1206
+ }
1207
+ const edgeWeights = newEntries.map(([i, , edgeAt]) => {
1208
+ const effE = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
1209
+ return Math.max(0.0001, effE);
1210
+ });
1211
+ let remWeightSum = edgeWeights.reduce((a, b) => a + b, 0);
1128
1212
  for (let idx = 0; idx < newEntries.length; idx++) {
1129
- const [i, side, edgeAt] = newEntries[idx];
1130
- const effEdge = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
1131
- const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / need))) : 1;
1132
- const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
1213
+ const [i, side] = newEntries[idx];
1214
+ const effEdge = edgeWeights[idx];
1215
+ const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (effEdge / need))) : 1;
1216
+ const propShare = autoAdapt && remWeightSum > 0 ? avail * (effEdge / remWeightSum) : avail / Math.max(1, newEntries.length - idx);
1217
+ remWeightSum = Math.max(0, remWeightSum - effEdge);
1218
+ const baseAlloc = autoAdapt ? Math.min(equity * dynSlotWeight * conv, propShare, avail) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
1133
1219
  let alloc = baseAlloc;
1134
1220
  if (invVol && avgInv > 0 && sg[i] > 0) {
1135
1221
  const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
@@ -283,6 +283,7 @@ export const TOOLS = {
283
283
  динамика_проц: x.deltaPct || 0,
284
284
  сигнал_покупки: !!x.signal,
285
285
  ...(buyBlockedByDip ? { покупка_заблокирована: `сейчас бумага падает на текущей свече (${x.lastMovePct}%), сработала защита от ловли падающего ножа` } : {}),
286
+ ...(x.buyBlockedByOverheat ? { покупка_заблокирована_перегревом: `бумага уже перегрета (+${x.buyBlockedByOverheat}% сверх рынка за 1–3 дня), сработала защита от покупки на вершине пампа` } : {}),
286
287
  ...(x.buyBlockedByDiv ? { покупка_заблокирована_дивидендами: x.buyBlockedByDiv } : {}),
287
288
  ...(x.buyBlockedByNews ? { покупка_заблокирована_новостями: x.buyBlockedByNews } : {}),
288
289
  ...(x.shortSignal !== undefined ? { сигнал_шорта: !!x.shortSignal } : {}),