neuroalpha 0.5.22 → 0.5.27
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 +1 -1
- package/public/app.js +54 -6
- package/server/api/routes.js +2 -2
- package/server/brokers/index.js +92 -41
- package/server/data/moex.js +29 -20
- package/server/db.js +4 -0
- package/server/model/dataset.js +59 -19
- package/server/model/edge.js +39 -7
- package/server/model/forecast.js +246 -28
- package/server/model/live.js +30 -8
- package/server/model/models.js +27 -5
- package/server/model/research.js +124 -37
- package/server/news/index.js +2 -2
- package/server/strategist/index.js +3 -3
- package/server/strategist/tools.js +4 -0
package/server/model/dataset.js
CHANGED
|
@@ -227,7 +227,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
227
227
|
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
228
228
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
229
229
|
for (const v of datasetCache.values()) {
|
|
230
|
-
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && v.fromTs
|
|
230
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
|
|
231
231
|
return v.ds;
|
|
232
232
|
}
|
|
233
233
|
}
|
|
@@ -255,19 +255,30 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
255
255
|
if (signal?.aborted) throw new Error('Отменено');
|
|
256
256
|
const symCtrl = new AbortController();
|
|
257
257
|
const effSignal = signal ? AbortSignal.any([signal, symCtrl.signal]) : symCtrl.signal;
|
|
258
|
-
|
|
258
|
+
let killer = null;
|
|
259
259
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
260
|
+
await Promise.race([
|
|
261
|
+
(async () => {
|
|
262
|
+
// акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
|
|
263
|
+
const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
|
|
264
|
+
const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
|
|
265
|
+
// объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
|
|
266
|
+
raw[s] = compact(rows);
|
|
267
|
+
if (divs?.length) {
|
|
268
|
+
// DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
|
|
269
|
+
const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
|
|
270
|
+
divStat.applied += st.applied;
|
|
271
|
+
divStat.skipped += st.skipped;
|
|
272
|
+
}
|
|
273
|
+
})(),
|
|
274
|
+
new Promise((_, rej) => {
|
|
275
|
+
killer = setTimeout(() => {
|
|
276
|
+
symCtrl.abort();
|
|
277
|
+
rej(new Error('Превышено время ожидания загрузки'));
|
|
278
|
+
}, WITHIN);
|
|
279
|
+
killer.unref?.();
|
|
280
|
+
}),
|
|
281
|
+
]);
|
|
271
282
|
} catch (e) {
|
|
272
283
|
if (signal?.aborted) throw new Error('Отменено');
|
|
273
284
|
raw[s] = EMPTY();
|
|
@@ -419,18 +430,47 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
419
430
|
}
|
|
420
431
|
// макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
|
|
421
432
|
const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
|
|
422
|
-
const
|
|
423
|
-
|
|
433
|
+
const modernTimeline = timeline.filter((t) => t >= 1705104000000);
|
|
434
|
+
const effTl = modernTimeline.length >= 1000 ? modernTimeline : timeline;
|
|
435
|
+
const days = new Set(effTl.map(dayKey)).size;
|
|
436
|
+
// ликвидность: в скольких из последних 20 свечей ОСНОВНОЙ сессии (10:00–18:50 МСК) были сделки.
|
|
437
|
+
// 1) Утренняя (до 10:00 МСК) и вечерняя (с 19:00 МСК) дополнительные сессии не списывают очки ликвидности за пустые 10-минутки
|
|
438
|
+
// (если на доп. сессии сделки есть — засчитываем 1 в окно, если нет — окно основной сессии не сдвигается и не штрафуется).
|
|
439
|
+
// 2) На самых свежих 4 свечах правого края шкалы (последние 40 минут), где возможен рассинхрон между мгновенными свечами T-Invest API
|
|
440
|
+
// и 20–30-минутной задержкой бесплатного MOEX ISS (или локального кэша SQLite), не штрафуем бумаги, у которых свеча ещё не подтянулась с биржи.
|
|
441
|
+
const isMainSessionBar = new Uint8Array(T);
|
|
442
|
+
for (let i = 0; i < T; i++) {
|
|
443
|
+
const h = hourMsk[i];
|
|
444
|
+
isMainSessionBar[i] = (h >= 10 && h < 19) ? 1 : 0;
|
|
445
|
+
}
|
|
446
|
+
const tailGraceStart = Math.max(0, T - 4);
|
|
424
447
|
for (const tk of tokens) {
|
|
425
448
|
tk.liq = new Uint8Array(T);
|
|
426
|
-
let
|
|
449
|
+
let lastRealIdx = -1;
|
|
450
|
+
for (let i = T - 1; i >= 0; i--) {
|
|
451
|
+
if (tk.has[i]) { lastRealIdx = i; break; }
|
|
452
|
+
}
|
|
453
|
+
// Если бумага торговалась сегодня/вчера и отстаёт от правого края шкалы не более чем на 12 свечей (2 часа рассинхрона кэша/MOEX ISS),
|
|
454
|
+
// считаем хвостовые свечи после её последнего полученного бара ожидающими догрузки, а не отсутствием ликвидности:
|
|
455
|
+
const tailPendingFrom = (lastRealIdx >= 0 && T - 1 - lastRealIdx <= 12) ? Math.max(lastRealIdx + 1, tailGraceStart) : T;
|
|
456
|
+
const win = new Uint8Array(20);
|
|
457
|
+
let wLen = 0, wPtr = 0, n = 0;
|
|
427
458
|
for (let i = 0; i < T; i++) {
|
|
428
|
-
|
|
429
|
-
if (i
|
|
459
|
+
const h = tk.has[i] || (i >= tailPendingFrom ? 1 : 0);
|
|
460
|
+
if (isMainSessionBar[i] || h) {
|
|
461
|
+
if (wLen < 20) {
|
|
462
|
+
win[wLen++] = h;
|
|
463
|
+
n += h;
|
|
464
|
+
} else {
|
|
465
|
+
n += h - win[wPtr];
|
|
466
|
+
win[wPtr] = h;
|
|
467
|
+
wPtr = (wPtr + 1) % 20;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
430
470
|
tk.liq[i] = n;
|
|
431
471
|
}
|
|
432
472
|
}
|
|
433
|
-
const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(
|
|
473
|
+
const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(effTl.length / days) : 54 };
|
|
434
474
|
datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
|
|
435
475
|
if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
|
|
436
476
|
return ds;
|
package/server/model/edge.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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) {
|
package/server/model/forecast.js
CHANGED
|
@@ -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';
|
|
@@ -16,10 +16,38 @@ import { hub } from '../hub.js';
|
|
|
16
16
|
const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
|
|
17
17
|
let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
|
|
18
18
|
const inflight = new Map();
|
|
19
|
+
const inflightStartedAt = new Map();
|
|
20
|
+
let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
21
|
+
|
|
22
|
+
// Синхронная живая доходность индекса относительно закрытия свечи idx-1:
|
|
23
|
+
// если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
|
|
24
|
+
// считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
|
|
25
|
+
// с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
|
|
26
|
+
function computeLiveMarketReturn(ds, idx, liveSnap) {
|
|
27
|
+
if (idx < 1 || idx >= ds.T) return 0;
|
|
28
|
+
if (liveSnap?.prices?.size > 0) {
|
|
29
|
+
let wSum = 0, rSum = 0, cnt = 0;
|
|
30
|
+
for (const tk of ds.tokens) {
|
|
31
|
+
if (tk.inIndex && !tk.inIndex[idx]) continue;
|
|
32
|
+
const prevPx = tk.close[idx - 1];
|
|
33
|
+
const livePx = liveSnap.prices.get(tk.symbol);
|
|
34
|
+
if (livePx > 0 && prevPx > 0) {
|
|
35
|
+
const w = tk.weight > 0 ? tk.weight : 1;
|
|
36
|
+
rSum += w * Math.log(livePx / prevPx);
|
|
37
|
+
wSum += w;
|
|
38
|
+
cnt++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (cnt >= 15 && wSum > 0) return rSum / wSum;
|
|
42
|
+
const mPrev = ds.mclose?.[idx - 1] || 0;
|
|
43
|
+
if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
|
|
44
|
+
}
|
|
45
|
+
return ds.mret[idx] || 0;
|
|
46
|
+
}
|
|
19
47
|
|
|
20
48
|
// Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
|
|
21
49
|
function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
22
|
-
const { tokens, mret
|
|
50
|
+
const { tokens, mret } = ds;
|
|
23
51
|
const N = fTrain.N;
|
|
24
52
|
const { beta, sigma, cos } = fTrain;
|
|
25
53
|
const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
|
|
@@ -31,11 +59,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
31
59
|
const barTs = ds.timeline[idx] || 0;
|
|
32
60
|
const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
33
61
|
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
62
|
+
const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
|
|
34
63
|
for (let uk = 0; uk < W; uk++) {
|
|
35
64
|
const t = lo + uk;
|
|
36
65
|
const isLast = uk === u && liveSnap?.prices?.size > 0;
|
|
37
|
-
const
|
|
38
|
-
const mt = isLast && liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : mret[t];
|
|
66
|
+
const mt = isLast ? mLive : mret[t];
|
|
39
67
|
for (let i = 0; i < N; i++) {
|
|
40
68
|
const tk = tokens[i];
|
|
41
69
|
let rt = tk.ret[t];
|
|
@@ -180,8 +208,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
|
180
208
|
const idx = f.lo + u;
|
|
181
209
|
if (idx < 1 || idx >= ds.T) return;
|
|
182
210
|
const N = f.N;
|
|
183
|
-
const
|
|
184
|
-
const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
|
|
211
|
+
const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
185
212
|
const barTs = ds.timeline[idx] || 0;
|
|
186
213
|
const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
187
214
|
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
@@ -280,7 +307,7 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
280
307
|
emb: f.emb,
|
|
281
308
|
embN: f.embN,
|
|
282
309
|
eigenvalues: f.eigenvalues,
|
|
283
|
-
macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
|
|
310
|
+
macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b, msig: m.msig })) } : null,
|
|
284
311
|
};
|
|
285
312
|
const lightFitted = {
|
|
286
313
|
...fitted,
|
|
@@ -289,9 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
289
316
|
const trainedAt = Date.now();
|
|
290
317
|
trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
|
|
291
318
|
|
|
292
|
-
|
|
319
|
+
const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
|
|
293
320
|
return {
|
|
294
|
-
...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
|
|
321
|
+
...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
|
|
295
322
|
snap,
|
|
296
323
|
trainedAt,
|
|
297
324
|
};
|
|
@@ -316,19 +343,31 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
316
343
|
const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
|
|
317
344
|
const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
|
|
318
345
|
const bpd = ds.barsPerDay || 54;
|
|
346
|
+
const idx30m = Math.max(0, idx - 3);
|
|
319
347
|
const idx1d = Math.max(0, idx - bpd);
|
|
320
348
|
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
321
|
-
const
|
|
349
|
+
const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
|
|
350
|
+
const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
351
|
+
const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
|
|
352
|
+
? mPrevBar * Math.exp(mLiveRet)
|
|
353
|
+
: (liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0));
|
|
354
|
+
const m30m = ds.mclose?.[idx30m] > 0 && mNow > 0 ? (mNow / ds.mclose[idx30m] - 1) : 0;
|
|
322
355
|
const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
|
|
323
356
|
const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
|
|
324
357
|
const rows = ds.tokens.map((tk, i) => {
|
|
325
358
|
let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
326
359
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
360
|
+
const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
|
|
327
361
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
328
|
-
const
|
|
362
|
+
const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
|
|
363
|
+
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
|
|
364
|
+
const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
|
|
365
|
+
const lastMovePct = round(rawResPct, 3);
|
|
329
366
|
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
367
|
+
const p30m = tk.close[idx30m] || 0;
|
|
330
368
|
const p1d = tk.close[idx1d] || 0;
|
|
331
369
|
const p3d = tk.close[idx3d] || 0;
|
|
370
|
+
const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
|
|
332
371
|
const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
|
|
333
372
|
const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
|
|
334
373
|
const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
|
|
@@ -336,8 +375,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
336
375
|
let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
|
|
337
376
|
if (isOverheated && selfRaw > 0) {
|
|
338
377
|
// Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
|
|
339
|
-
// ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» —
|
|
340
|
-
exp
|
|
378
|
+
// ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — гасим ложный отскок вверх, не переворачивая в искусственный шорт:
|
|
379
|
+
if (exp > 0) exp = Math.max(0, exp - selfRaw);
|
|
341
380
|
selfRaw = 0;
|
|
342
381
|
}
|
|
343
382
|
const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
|
|
@@ -347,18 +386,22 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
347
386
|
const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
|
|
348
387
|
const symNeedL = Math.max(needL * tierMult, 0.0030);
|
|
349
388
|
const symNeedS = Math.max(needS * tierMult, 0.0031);
|
|
350
|
-
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу
|
|
389
|
+
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
|
|
351
390
|
// обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
|
|
352
391
|
const EXTREME_BAR_MOVE_PCT = 4.5;
|
|
353
|
-
const
|
|
354
|
-
const
|
|
392
|
+
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M;
|
|
393
|
+
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D;
|
|
394
|
+
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
395
|
+
const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
396
|
+
const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
|
|
397
|
+
const liveShortOk = !isExtremeSpike;
|
|
355
398
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
356
399
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
357
400
|
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
358
401
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
359
402
|
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
360
|
-
const shortBlockedBySqueeze = Boolean(
|
|
361
|
-
const buyBlockedByDip = Boolean(
|
|
403
|
+
const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
|
|
404
|
+
const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
|
|
362
405
|
const row = {
|
|
363
406
|
i, symbol: tk.symbol, name: tk.name,
|
|
364
407
|
tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
|
|
@@ -374,6 +417,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
374
417
|
weight: round(weight, 3),
|
|
375
418
|
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
376
419
|
...(shortAllowed !== null ? { shortAllowed } : {}),
|
|
420
|
+
...(isRegimeCrash ? { regimeCrash: true } : {}),
|
|
421
|
+
...(isRegimeSqueeze ? { regimeSqueeze: true } : {}),
|
|
377
422
|
...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
|
|
378
423
|
...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
|
|
379
424
|
...(inIdx ? {} : { inIndex: false }),
|
|
@@ -398,8 +443,8 @@ function withNews(result, p) {
|
|
|
398
443
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
399
444
|
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
400
445
|
const nowTs = Date.now();
|
|
401
|
-
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.
|
|
402
|
-
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.
|
|
446
|
+
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
|
|
447
|
+
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
|
|
403
448
|
if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
|
|
404
449
|
if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
|
|
405
450
|
for (const r of result.rows) {
|
|
@@ -420,13 +465,13 @@ function withNews(result, p) {
|
|
|
420
465
|
} else if (r.expectedPct >= 0) {
|
|
421
466
|
delete r.shortBlockedByBroker;
|
|
422
467
|
}
|
|
423
|
-
if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
|
|
468
|
+
if (r.expectedPct < 0 && (r.shortBlockedBySqueeze || r.regimeSqueeze || (r.lastMovePct ?? 0) > 4.5)) {
|
|
424
469
|
r.shortBlockedBySqueeze = true;
|
|
425
470
|
r.shortSignal = false;
|
|
426
471
|
} else {
|
|
427
472
|
delete r.shortBlockedBySqueeze;
|
|
428
473
|
}
|
|
429
|
-
if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
|
|
474
|
+
if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
|
|
430
475
|
r.buyBlockedByDip = true;
|
|
431
476
|
r.signal = false;
|
|
432
477
|
} else {
|
|
@@ -460,10 +505,156 @@ function withNews(result, p) {
|
|
|
460
505
|
} else {
|
|
461
506
|
delete r.shortBlockedByNews;
|
|
462
507
|
}
|
|
508
|
+
|
|
509
|
+
const effThr = r.expectedPct < 0 ? (r.shortThresholdPct || baseShortThr) : (r.buyThresholdPct || baseBuyThr);
|
|
510
|
+
const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
|
|
511
|
+
r.thresholdRatioPct = Math.round(ratio * 100);
|
|
512
|
+
r.crossedThreshold = ratio >= 1;
|
|
513
|
+
}
|
|
514
|
+
if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
|
|
515
|
+
const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
|
|
516
|
+
result.nearSignals = savedNear;
|
|
463
517
|
}
|
|
464
518
|
return result;
|
|
465
519
|
}
|
|
466
520
|
|
|
521
|
+
// Формирует и инкрементально обновляет 5-дневный журнал сигналов, заблокированных входов (≥100% порога) и почти-сигналов (75–99% порога)
|
|
522
|
+
async function updateNearSignalsJournal(ds, p, last, key) {
|
|
523
|
+
if (!ds?.timeline?.length || last < 0) return [];
|
|
524
|
+
const lastBarTs = ds.timeline[last] || Date.now();
|
|
525
|
+
const minTs = lastBarTs - 7 * DAY;
|
|
526
|
+
let startIdx = ds.timeline.findIndex((t) => t >= minTs);
|
|
527
|
+
if (startIdx < 0) startIdx = Math.max(0, last - 480);
|
|
528
|
+
|
|
529
|
+
if (nearSignalsCache.key !== key || nearSignalsCache.scannedBarTs < ds.timeline[startIdx]) {
|
|
530
|
+
const saved = kvGet('nearSignalsLog');
|
|
531
|
+
if (saved?.key === key && saved.scannedBarTs >= ds.timeline[startIdx] && Array.isArray(saved.entries)) {
|
|
532
|
+
nearSignalsCache = {
|
|
533
|
+
key,
|
|
534
|
+
scannedBarTs: saved.scannedBarTs,
|
|
535
|
+
episodesMap: new Map(saved.entries),
|
|
536
|
+
list: saved.list || [],
|
|
537
|
+
};
|
|
538
|
+
} else {
|
|
539
|
+
nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
for (const [epKey, ep] of nearSignalsCache.episodesMap) {
|
|
544
|
+
if (ep.lastBarTs < minTs) nearSignalsCache.episodesMap.delete(epKey);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let updated = false;
|
|
548
|
+
for (let idx = startIdx; idx <= last; idx++) {
|
|
549
|
+
const barTs = ds.timeline[idx];
|
|
550
|
+
if (barTs <= nearSignalsCache.scannedBarTs) continue;
|
|
551
|
+
const r = await predictAt(ds, p, idx, { explain: false, liveSnap: null });
|
|
552
|
+
const nb = p.newsFilter ? blockingNews(6, barTs) : null;
|
|
553
|
+
const barMskIso = new Date(barTs + MSK).toISOString();
|
|
554
|
+
const dayStr = barMskIso.slice(0, 10);
|
|
555
|
+
const timeStr = barMskIso.slice(11, 16);
|
|
556
|
+
|
|
557
|
+
for (const row of r.rows) {
|
|
558
|
+
if (row.inIndex === false) continue;
|
|
559
|
+
const side = row.expectedPct < 0 ? 'short' : 'long';
|
|
560
|
+
const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
|
|
561
|
+
const absExp = Math.abs(row.expectedPct || 0);
|
|
562
|
+
const ratio = thr > 0 ? absExp / thr : 0;
|
|
563
|
+
if (ratio < 0.75) continue;
|
|
564
|
+
|
|
565
|
+
const crossed = absExp >= thr;
|
|
566
|
+
const dg = p.modelUseDividends !== false ? liveDividendGuard(row.symbol, row.price, barTs) : null;
|
|
567
|
+
const bNews = nb?.get(row.symbol);
|
|
568
|
+
const reasons = [];
|
|
569
|
+
if (side === 'short') {
|
|
570
|
+
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
571
|
+
if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
|
|
572
|
+
if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
|
|
573
|
+
if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
574
|
+
if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
|
|
575
|
+
} else {
|
|
576
|
+
if (row.buyBlockedByDip || row.regimeCrash || (row.lastMovePct ?? 0) < -4.5) reasons.push('защита от обвала свечи');
|
|
577
|
+
if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
|
|
578
|
+
if (dg?.blockLong) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
579
|
+
if (bNews?.bad) reasons.push(`фильтр новостей (${bNews.bad.gist})`);
|
|
580
|
+
}
|
|
581
|
+
if (row.liquid === false) reasons.push('низкая ликвидность');
|
|
582
|
+
|
|
583
|
+
const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
|
|
584
|
+
const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
|
|
585
|
+
const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
|
|
586
|
+
|
|
587
|
+
const epKey = `${dayStr}:${row.symbol}:${side}`;
|
|
588
|
+
const prev = nearSignalsCache.episodesMap.get(epKey);
|
|
589
|
+
if (!prev) {
|
|
590
|
+
nearSignalsCache.episodesMap.set(epKey, {
|
|
591
|
+
day: dayStr,
|
|
592
|
+
firstBar: timeStr,
|
|
593
|
+
lastBar: timeStr,
|
|
594
|
+
firstBarTs: barTs,
|
|
595
|
+
lastBarTs: barTs,
|
|
596
|
+
peakBar: timeStr,
|
|
597
|
+
barsCount: 1,
|
|
598
|
+
symbol: row.symbol,
|
|
599
|
+
name: row.name,
|
|
600
|
+
side,
|
|
601
|
+
price: row.price,
|
|
602
|
+
peakExpectedPct: row.expectedPct,
|
|
603
|
+
thresholdPct: thr,
|
|
604
|
+
peakRatioPct: Math.round(ratio * 100),
|
|
605
|
+
status,
|
|
606
|
+
reasons,
|
|
607
|
+
});
|
|
608
|
+
} else {
|
|
609
|
+
prev.lastBar = timeStr;
|
|
610
|
+
prev.lastBarTs = barTs;
|
|
611
|
+
prev.barsCount += 1;
|
|
612
|
+
const pctNow = Math.round(ratio * 100);
|
|
613
|
+
if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow >= prev.peakRatioPct)) {
|
|
614
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
615
|
+
prev.thresholdPct = thr;
|
|
616
|
+
prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
|
|
617
|
+
prev.peakBar = timeStr;
|
|
618
|
+
prev.price = row.price;
|
|
619
|
+
prev.status = status;
|
|
620
|
+
if (reasons.length) prev.reasons = reasons;
|
|
621
|
+
} else if (pctNow > prev.peakRatioPct) {
|
|
622
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
623
|
+
prev.thresholdPct = thr;
|
|
624
|
+
prev.peakRatioPct = pctNow;
|
|
625
|
+
prev.peakBar = timeStr;
|
|
626
|
+
}
|
|
627
|
+
if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
nearSignalsCache.scannedBarTs = barTs;
|
|
631
|
+
updated = true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (updated || !nearSignalsCache.list.length) {
|
|
635
|
+
const list = [...nearSignalsCache.episodesMap.values()]
|
|
636
|
+
.sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
|
|
637
|
+
.slice(0, 40)
|
|
638
|
+
.map((ep) => {
|
|
639
|
+
const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
|
|
640
|
+
const reasonText = ep.status === 'signal'
|
|
641
|
+
? 'Порог пройден — сигнал сформирован'
|
|
642
|
+
: ep.status === 'blocked'
|
|
643
|
+
? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
|
|
644
|
+
: `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
|
|
645
|
+
return { ...ep, gapPct, reasonText };
|
|
646
|
+
});
|
|
647
|
+
nearSignalsCache.list = list;
|
|
648
|
+
kvSet('nearSignalsLog', {
|
|
649
|
+
key,
|
|
650
|
+
scannedBarTs: nearSignalsCache.scannedBarTs,
|
|
651
|
+
entries: [...nearSignalsCache.episodesMap.entries()],
|
|
652
|
+
list,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return nearSignalsCache.list;
|
|
656
|
+
}
|
|
657
|
+
|
|
467
658
|
// Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
|
|
468
659
|
function attachDeltas(rows, stateObj) {
|
|
469
660
|
const prevExp = stateObj.prevExpMap;
|
|
@@ -549,7 +740,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
549
740
|
// ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
|
|
550
741
|
// а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
|
|
551
742
|
if (!force && baseState && baseState.key === key) {
|
|
552
|
-
|
|
743
|
+
const inflightAge = now - (inflightStartedAt.get(key) || 0);
|
|
744
|
+
if (inflight.has(key) && inflightAge > 90000) {
|
|
745
|
+
inflight.delete(key);
|
|
746
|
+
inflightStartedAt.delete(key);
|
|
747
|
+
}
|
|
748
|
+
const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
|
|
749
|
+
const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
|
|
750
|
+
const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
|
|
751
|
+
if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
|
|
553
752
|
setImmediate(() => {
|
|
554
753
|
refreshDatasetStateInBackground(p, key).catch(() => {});
|
|
555
754
|
});
|
|
@@ -574,8 +773,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
574
773
|
}
|
|
575
774
|
const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
|
|
576
775
|
const liveTs = liveSnap?.ts || now;
|
|
577
|
-
const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.
|
|
578
|
-
const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.
|
|
776
|
+
const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.40;
|
|
777
|
+
const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.50;
|
|
579
778
|
const rows = (saved.result.rows || []).map((r) => {
|
|
580
779
|
const px = liveSnap?.prices?.get(r.symbol);
|
|
581
780
|
const mult = symbolEdgeMult(r.symbol);
|
|
@@ -605,11 +804,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
605
804
|
|
|
606
805
|
// Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
|
|
607
806
|
function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
|
|
608
|
-
|
|
807
|
+
const now = Date.now();
|
|
808
|
+
if (!force && inflight.has(key)) {
|
|
809
|
+
if (now - (inflightStartedAt.get(key) || 0) < 90000) return inflight.get(key);
|
|
810
|
+
inflight.delete(key);
|
|
811
|
+
inflightStartedAt.delete(key);
|
|
812
|
+
}
|
|
609
813
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
610
814
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
611
815
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
612
|
-
const now = Date.now();
|
|
613
816
|
const task = (async () => {
|
|
614
817
|
hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
|
|
615
818
|
// Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
|
|
@@ -625,6 +828,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
625
828
|
]);
|
|
626
829
|
const last = lastClosedIndex(ds, now);
|
|
627
830
|
const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
|
|
831
|
+
const nearSignals = await updateNearSignalsJournal(ds, p, last, key).catch(() => []);
|
|
628
832
|
const snap = r.snap || mapSnapshot(r.f);
|
|
629
833
|
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
630
834
|
const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
|
|
@@ -645,6 +849,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
645
849
|
shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
|
|
646
850
|
marketDown: r.marketDown,
|
|
647
851
|
rows: sortedRows,
|
|
852
|
+
nearSignals,
|
|
648
853
|
map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
|
|
649
854
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
|
|
650
855
|
};
|
|
@@ -667,8 +872,21 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
667
872
|
};
|
|
668
873
|
kvSet('lastForecastSnap', { key, result });
|
|
669
874
|
hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
|
|
875
|
+
if (coldLocalFirst) {
|
|
876
|
+
setTimeout(() => {
|
|
877
|
+
if (!inflight.has(key)) {
|
|
878
|
+
refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
|
|
879
|
+
}
|
|
880
|
+
}, 2000);
|
|
881
|
+
}
|
|
670
882
|
return result;
|
|
671
|
-
})().finally(() =>
|
|
883
|
+
})().finally(() => {
|
|
884
|
+
if (inflight.get(key) === task) {
|
|
885
|
+
inflight.delete(key);
|
|
886
|
+
inflightStartedAt.delete(key);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
672
889
|
inflight.set(key, task);
|
|
890
|
+
inflightStartedAt.set(key, now);
|
|
673
891
|
return task;
|
|
674
892
|
}
|