neuroalpha 0.6.1 → 0.6.3

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.1",
3
+ "version": "0.6.3",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1166,6 +1166,27 @@ async function loadForecast(force, silent = false) {
1166
1166
  nearByDay.get(dk).push(ep);
1167
1167
  }
1168
1168
  const nearDays = [...nearByDay.keys()].sort((a, b) => b.localeCompare(a));
1169
+ const dayCountsStr = (arr) => `${arr.filter((x) => x.status === 'signal').length} сигн., ${arr.filter((x) => x.status === 'blocked').length} блок., ${arr.filter((x) => x.status === 'near').length} почти`;
1170
+ const nearEpRow = (ep) => {
1171
+ const tRange = ep.firstBar && ep.lastBar && ep.firstBar !== ep.lastBar ? `${ep.firstBar}–${ep.lastBar} (${ep.barsCount} св.)` : `${ep.peakBar || ep.lastBar || ''}`;
1172
+ const isSh = ep.side === 'short';
1173
+ const thrStr = `${isSh ? '−' : '+'}${fmtNum(ep.thresholdPct, 2)}%`;
1174
+ const stBadge = ep.status === 'signal'
1175
+ ? `<span class="tag ok">${isSh ? '✓ сигнал шорт' : '✓ сигнал купить'}</span>`
1176
+ : ep.status === 'blocked'
1177
+ ? '<span class="tag blocked">⛔ блок входа</span>'
1178
+ : `<span class="tag mid">почти (${ep.peakRatioPct}%)</span>`;
1179
+ const rowCls = ep.status === 'signal' ? ' class="best"' : ep.status === 'blocked' ? ' class="fc-blocked"' : '';
1180
+ return `<tr${rowCls}>
1181
+ <td class="muted small" style="white-space:nowrap">${esc(tRange)}</td>
1182
+ <td style="white-space:nowrap"><span class="sym-link" data-sym="${esc(ep.symbol)}" title="Открыть досье акции ${esc(ep.symbol)}"><b>${esc(ep.symbol)}</b> <span class="muted small">${esc(ep.name || '')}</span></span></td>
1183
+ <td class="${isSh ? 'neg' : 'pos'} small" style="white-space:nowrap">${isSh ? 'В шорт' : 'В покупку'}</td>
1184
+ <td class="num ${cls(ep.peakExpectedPct)}" style="white-space:nowrap"><b>${fmtPct(ep.peakExpectedPct, 2)}</b></td>
1185
+ <td class="num muted small" style="white-space:nowrap">${thrStr}</td>
1186
+ <td class="num small" style="white-space:nowrap"><b>${ep.peakRatioPct}%</b></td>
1187
+ <td class="small" style="white-space:normal">${stBadge} <span class="muted">${esc(ep.reasonText || '')}</span></td>
1188
+ </tr>`;
1189
+ };
1169
1190
  const buildNearInnerHtml = () => {
1170
1191
  if (nearDayFilter !== 'all' && !nearByDay.has(nearDayFilter)) nearDayFilter = '';
1171
1192
  const activeNearDay = nearDayFilter || nearDays[0] || 'all';
@@ -1706,10 +1706,19 @@ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstT
1706
1706
  // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1707
1707
  // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
1708
1708
  // дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
1709
+ // Порог удержания уже взятого на прицел сигнала в entryImpulseWatch: пока ждём минутного отката,
1710
+ // само ожидание возврата к среднему неизбежно немного снижается — не теряем сигнал, пока он держится >= 90% порога (до 6 минут)
1711
+ const IMPULSE_WATCH_HOLD_RATIO = 0.90;
1712
+ const IMPULSE_WATCH_TTL_MS = 6 * 60000;
1713
+ const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
1714
+ const w = entryImpulseWatch.get(`${accId}:${sym}:${side}`);
1715
+ return Boolean(w && nowTs - w.firstTs <= IMPULSE_WATCH_TTL_MS && nowTs - w.lastTs <= IMPULSE_WATCH_TTL_MS);
1716
+ };
1717
+
1709
1718
  async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1710
1719
  if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1711
1720
  for (const [k, v] of entryImpulseWatch) {
1712
- if (nowTs - v.lastTs > 15 * 60000) entryImpulseWatch.delete(k);
1721
+ if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
1713
1722
  }
1714
1723
  const ready = [];
1715
1724
  const deferred = [];
@@ -1750,9 +1759,12 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1750
1759
  const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
1751
1760
  const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
1752
1761
  const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
1762
+ // Если внутри самой минутной свечи цена уже взлетела до recentHigh и откатилась от пика на >= 1,4× порога отката (и >= 0,55%),
1763
+ // разворот с вершины уже состоялся — не ждём следующей минутной свечи!
1764
+ const intrabarReversalDown = Boolean(last1m && pullbackPct >= Math.max(0.55, minPullbackPct * 1.4) && pullbackTicks >= 4 && curPx <= last1m.high - step * 3);
1753
1765
  const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
1754
1766
  const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
1755
- const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
1767
+ const readyShort = isStrongSurge ? (peakedAndPulledBack && (barTurnedDown || intrabarReversalDown)) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
1756
1768
  if (readyShort) {
1757
1769
  entryImpulseWatch.delete(wKey);
1758
1770
  r.price = curPx;
@@ -1765,10 +1777,11 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
1765
1777
  const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
1766
1778
  const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
1767
1779
  const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
1768
- const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
1780
+ const barTurnedUp = Boolean(last1m && prev1m && last1m.low >= prev1m.low - step * 0.5 && last1m.close >= last1m.open);
1781
+ const intrabarReversalUp = Boolean(last1m && bouncePct >= Math.max(0.55, minPullbackPct * 1.4) && bounceTicks >= 4 && curPx >= last1m.low + step * 3);
1769
1782
  const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
1770
1783
  const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
1771
- const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
1784
+ const readyLong = isStrongSurge ? (bottomedAndBounced && (barTurnedUp || intrabarReversalUp)) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
1772
1785
  if (readyLong) {
1773
1786
  entryImpulseWatch.delete(wKey);
1774
1787
  r.price = curPx;
@@ -1972,10 +1985,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1972
1985
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1973
1986
  const divBlocked = [];
1974
1987
  const entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
1975
- const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1988
+ const canBuyRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol))
1989
+ || (isOnImpulseWatch(acc.id, r.symbol, 'buy', nowTs) && r.liquid && r.inIndex !== false && !r.buyBlockedByDip && !r.buyBlockedByOverheat && !r.buyBlockedByDiv && !r.buyBlockedByNews && r.expectedPct >= reqBuyEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
1990
+ const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
1991
+ || (isOnImpulseWatch(acc.id, r.symbol, 'short', nowTs) && r.liquid && r.inIndex !== false && r.shortAllowed !== false && !r.shortBlockedBySqueeze && !r.shortBlockedByDiv && !r.shortBlockedByNews && r.expectedPct <= -reqShortEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
1992
+ const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1976
1993
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1977
1994
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1978
- const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1995
+ const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1979
1996
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1980
1997
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1981
1998
  const nonShortableSkipped = [];
@@ -19,17 +19,29 @@ const inflight = new Map();
19
19
  const inflightStartedAt = new Map();
20
20
  let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
21
21
 
22
- // Синхронная живая доходность индекса относительно закрытия свечи idx-1:
23
- // если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
24
- // считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
25
- // с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
22
+ // Синхронная живая доходность индекса относительно базовой закрытой свечи baseIdx:
23
+ // если последняя закрытая 10-минутная свеча idx уже закончилась (liveSnap.ts >= barTs + BAR_MS), живая цена относится к НОВОЙ
24
+ // формирующейся свече после idx — поэтому базой служит tk.close[idx], а не tk.close[idx - 1]. Иначе два подряд растущих бара
25
+ // складывались в одну точку и упирались в потолок 8σ одной свечи!
26
+ const liveBaseIndexOf = (ds, idx, liveSnap) => {
27
+ const barTs = ds.timeline?.[idx] || 0;
28
+ return (liveSnap?.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS) ? idx : Math.max(0, idx - 1);
29
+ };
30
+ const liveElapsedBarsOf = (ds, idx, liveSnap) => {
31
+ const barTs = ds.timeline?.[idx] || 0;
32
+ if (!(liveSnap?.ts && barTs > 0)) return 1;
33
+ const startTs = liveSnap.ts >= barTs + BAR_MS ? barTs + BAR_MS : barTs;
34
+ return Math.max(1, (liveSnap.ts - startTs) / BAR_MS);
35
+ };
36
+
26
37
  function computeLiveMarketReturn(ds, idx, liveSnap) {
27
38
  if (idx < 1 || idx >= ds.T) return 0;
28
39
  if (liveSnap?.prices?.size > 0) {
40
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
29
41
  let wSum = 0, rSum = 0, cnt = 0;
30
42
  for (const tk of ds.tokens) {
31
43
  if (tk.inIndex && !tk.inIndex[idx]) continue;
32
- const prevPx = tk.close[idx - 1];
44
+ const prevPx = tk.close[baseIdx];
33
45
  const livePx = liveSnap.prices.get(tk.symbol);
34
46
  if (livePx > 0 && prevPx > 0) {
35
47
  const w = tk.weight > 0 ? tk.weight : 1;
@@ -39,44 +51,53 @@ function computeLiveMarketReturn(ds, idx, liveSnap) {
39
51
  }
40
52
  }
41
53
  if (cnt >= 15 && wSum > 0) return rSum / wSum;
42
- const mPrev = ds.mclose?.[idx - 1] || 0;
54
+ const mPrev = ds.mclose?.[baseIdx] || 0;
43
55
  if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
44
56
  }
45
57
  return ds.mret[idx] || 0;
46
58
  }
47
59
 
48
- // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
60
+ // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс).
61
+ // Выделяется дополнительный живой слот u+1, чтобы при формировании новой свечи (после закрытия idx) не затирать закрытую свечу idx!
49
62
  function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
50
63
  const { tokens, mret } = ds;
51
64
  const N = fTrain.N;
52
65
  const { beta, sigma, cos } = fTrain;
53
66
  const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
54
- const u = idx - lo;
55
- const W = u + 1;
67
+ const uClosed = idx - lo;
68
+ const barTs = ds.timeline[idx] || 0;
69
+ const hasNewLiveBar = Boolean(liveSnap?.prices?.size > 0 && liveSnap.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS);
70
+ const u = hasNewLiveBar ? uClosed + 1 : uClosed;
71
+ const W = uClosed + 2;
56
72
 
57
73
  const z = new Float64Array(W * N);
58
74
  const vr = new Float64Array(W * N);
59
- const barTs = ds.timeline[idx] || 0;
60
- const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
61
- const liveScale = 1 / Math.sqrt(elapsedBars);
62
- const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
63
- for (let uk = 0; uk < W; uk++) {
75
+ for (let uk = 0; uk <= uClosed; uk++) {
64
76
  const t = lo + uk;
65
- const isLast = uk === u && liveSnap?.prices?.size > 0;
66
- const mt = isLast ? mLive : mret[t];
77
+ const mt = mret[t];
67
78
  for (let i = 0; i < N; i++) {
68
79
  const tk = tokens[i];
69
- let rt = tk.ret[t];
70
- if (isLast) {
71
- const prevPx = tk.close[t - 1];
72
- const livePx = liveSnap.prices.get(tk.symbol);
73
- if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
74
- }
75
- const e = (rt - beta[i] * mt) * (isLast ? liveScale : 1);
80
+ const e = tk.ret[t] - beta[i] * mt;
76
81
  z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
77
82
  vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
78
83
  }
79
84
  }
85
+ vr[(uClosed + 1) * N] = 1;
86
+ for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
87
+
88
+ if (liveSnap?.prices?.size > 0) {
89
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
90
+ const liveScale = 1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap));
91
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
92
+ for (let i = 0; i < N; i++) {
93
+ const tk = tokens[i];
94
+ const prevPx = tk.close[baseIdx];
95
+ const livePx = liveSnap.prices.get(tk.symbol);
96
+ const rt = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (hasNewLiveBar ? 0 : tk.ret[idx]);
97
+ const e = (rt - beta[i] * mLive) * liveScale;
98
+ z[u * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
99
+ }
100
+ }
80
101
 
81
102
  const X = KINDS.map(() => SCALES.map(() => new Float32Array(W * N)));
82
103
  SCALES.forEach((sc, si) => {
@@ -101,17 +122,19 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
101
122
  const list = fTrain.macro.list.map((mTrain) => {
102
123
  const dm = ds.macro.find((x) => x.id === mTrain.id);
103
124
  const zs = new Float32Array(SCALES.length * W);
104
- if (dm) {
105
- const msig = mTrain.msig || 1e-3;
106
- SCALES.forEach((sc, si) => {
107
- let sum = 0;
108
- for (let k = 0; k < sc.len; k++) {
109
- const t = idx - sc.from - k;
110
- if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
125
+ if (dm) {
126
+ const msig = mTrain.msig || 1e-3;
127
+ for (const uk of [uClosed, uClosed + 1]) {
128
+ SCALES.forEach((sc, si) => {
129
+ let sum = 0;
130
+ for (let k = 0; k < sc.len; k++) {
131
+ const t = Math.min(idx, lo + uk - sc.from - k);
132
+ if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
133
+ }
134
+ zs[si * W + uk] = sum / Math.sqrt(sc.len);
135
+ });
111
136
  }
112
- zs[si * W + u] = sum / Math.sqrt(sc.len);
113
- });
114
- }
137
+ }
115
138
  return { id: mTrain.id, label: mTrain.label, b: mTrain.b, zs };
116
139
  });
117
140
  macro = { list };
@@ -213,27 +236,33 @@ async function fetchLiveMoexSnapshot() {
213
236
  }
214
237
  }
215
238
 
216
- // Мгновенное обновление последней точки контекста u в уже обученной модели по живым котировкам (за ~2 мс)
217
- function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
218
- if (!liveSnap?.prices?.size) return;
219
- const idx = f.lo + u;
220
- if (idx < 1 || idx >= ds.T) return;
239
+ // Мгновенное обновление живой точки контекста в уже обученной модели по живым котировкам (за ~2 мс).
240
+ // Возвращает индекс активной точки u (uClosed или uClosed+1, если после закрытия свечи idx уже идёт новая свеча).
241
+ function applyLivePricesToFold(ds, f, idx, fitted, liveSnap) {
242
+ const uClosed = idx - f.lo;
243
+ if (!liveSnap?.prices?.size || idx < 1 || idx >= ds.T) return uClosed;
221
244
  const N = f.N;
222
- const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
223
245
  const barTs = ds.timeline[idx] || 0;
224
- const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
225
- const liveScale = 1 / Math.sqrt(elapsedBars);
246
+ const hasNewLiveBar = Boolean(liveSnap.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS && uClosed + 1 < f.W);
247
+ const u = hasNewLiveBar ? uClosed + 1 : uClosed;
248
+ // восстанавливаем чистую закрытую свечу idx на случай, если на предыдущем тике она обновлялась внутри свечи
249
+ for (let i = 0; i < N; i++) {
250
+ const tk = ds.tokens[i];
251
+ const e0 = tk.ret[idx] - f.beta[i] * ds.mret[idx];
252
+ f.z[uClosed * N + i] = Math.max(-8, Math.min(8, e0 / f.sigma[i]));
253
+ }
254
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
255
+ const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
256
+ const liveScale = 1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap));
226
257
 
227
- // 1) пересчитываем z[u*N + i] по живой цене акции с нормировкой к масштабу одной 10-минутной свечи
258
+ // 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия базовой свечи
228
259
  for (let i = 0; i < N; i++) {
229
260
  const tk = ds.tokens[i];
230
- const prevPx = tk.close[idx - 1];
261
+ const prevPx = tk.close[baseIdx];
231
262
  const livePx = liveSnap.prices.get(tk.symbol);
232
- if (livePx > 0 && prevPx > 0) {
233
- const ret = Math.log(livePx / prevPx);
234
- const e = (ret - f.beta[i] * mLive) * liveScale;
235
- f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
236
- }
263
+ const ret = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (hasNewLiveBar ? 0 : tk.ret[idx]);
264
+ const e = (ret - f.beta[i] * mLive) * liveScale;
265
+ f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
237
266
  }
238
267
 
239
268
  // 2) пересчитываем все масштабы SCALES в точке u (как в buildInferenceFoldAt)
@@ -246,8 +275,9 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
246
275
  const uk = u - sc.from - k;
247
276
  if (uk < 0) break;
248
277
  const zk = f.z[uk * N + i];
278
+ const t = Math.min(idx, f.lo + uk);
249
279
  sp += zk;
250
- sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
280
+ sf += zk * Math.min(4, ds.tokens[i].valRatio[t]);
251
281
  }
252
282
  Xp[u * N + i] = sp * invP;
253
283
  Xf[u * N + i] = sf * invF;
@@ -272,6 +302,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
272
302
  }
273
303
  }
274
304
  }
305
+ return u;
275
306
  }
276
307
 
277
308
  // Обучение ядра модели (карта рынка + веса внимания) выполняется не чаще 1 раза в торговый день для каждого набора параметров.
@@ -357,7 +388,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
357
388
  const idx30m = Math.max(0, idx - 3);
358
389
  const idx1d = Math.max(0, idx - bpd);
359
390
  const idx3d = Math.max(0, idx - 3 * bpd);
360
- const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
391
+ const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
392
+ const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
361
393
  const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
362
394
  const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
363
395
  ? mPrevBar * Math.exp(mLiveRet)
@@ -368,7 +400,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
368
400
  const rows = ds.tokens.map((tk, i) => {
369
401
  let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
370
402
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
371
- const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
403
+ const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
372
404
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
373
405
  const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
374
406
  const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
@@ -742,11 +774,12 @@ const fmtLiveBarLabel = (barTs, liveSnapTs) => {
742
774
  // Мгновенный пересчёт последнего среза прогноза по живым секундным котировкам (без обращения к истории свечей)
743
775
  async function tickLiveForecastState(p, now = Date.now()) {
744
776
  const liveSnap = await fetchLiveMoexSnapshot();
745
- if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
777
+ const uActive = liveSnap ? applyLivePricesToFold(baseState.ds, baseState.f, baseState.last, baseState.fitted, liveSnap) : baseState.u;
778
+ baseState.u = uActive;
746
779
  const effHorizon = p.modelAutoAdapt !== false ? 2.5 : p.modelHorizon;
747
780
  const effMinEdge = p.modelAutoAdapt !== false ? 2.5 : p.modelMinEdge;
748
781
  const H = barsFromHours(effHorizon);
749
- const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, p.modelAutoAdapt !== false, true, liveSnap);
782
+ const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, uActive, baseState.fitted, baseState.chosen, H, effMinEdge, p.modelAutoAdapt !== false, true, liveSnap);
750
783
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
751
784
  attachDeltas(sortedRows, baseState);
752
785
  const liveTs = liveSnap?.ts || now;