neuroalpha 0.5.21 → 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 +1 -1
- package/public/app.js +7 -1
- package/server/api/routes.js +28 -5
- package/server/brokers/index.js +137 -31
- package/server/data/moex.js +59 -28
- package/server/db.js +4 -0
- package/server/model/dataset.js +32 -12
- package/server/model/edge.js +39 -7
- package/server/model/forecast.js +83 -19
- package/server/model/live.js +29 -7
- package/server/model/models.js +27 -5
- package/server/model/research.js +114 -28
- package/server/strategist/tools.js +1 -0
package/server/model/dataset.js
CHANGED
|
@@ -121,35 +121,37 @@ const MACRO = [
|
|
|
121
121
|
|
|
122
122
|
const MCODES = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z']; // месяцы в кодах фьючерсов
|
|
123
123
|
|
|
124
|
-
// Список существующих контрактов BR с их периодами (кэш на
|
|
125
|
-
async function brentContracts(fromTs, tillTs, signal) {
|
|
124
|
+
// Список существующих контрактов BR с их периодами (кэш на 7 суток, чтобы не пробовать все коды каждый раз)
|
|
125
|
+
async function brentContracts(fromTs, tillTs, signal, { localOnly = false } = {}) {
|
|
126
126
|
const cached = kvGet('brContracts');
|
|
127
127
|
let list = cached?.list;
|
|
128
|
-
if (!cached || Date.now() - cached.at > DAY) {
|
|
128
|
+
if ((!cached || Date.now() - cached.at > 7 * DAY) && !(localOnly && list?.length)) {
|
|
129
129
|
list = [];
|
|
130
130
|
const codes = [];
|
|
131
|
-
const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs +
|
|
131
|
+
const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs + 50 * DAY).getUTCFullYear();
|
|
132
132
|
for (let y = y0; y <= y1; y++) for (const m of MCODES) codes.push(`BR${m}${y % 10}`);
|
|
133
|
-
await pool(codes,
|
|
133
|
+
await pool(codes, 4, async (code) => {
|
|
134
134
|
try {
|
|
135
|
-
const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal });
|
|
135
|
+
const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal, localOnly });
|
|
136
136
|
if (rows.length) list.push({ code, first: rows[0].ts, last: rows[rows.length - 1].ts });
|
|
137
137
|
} catch { /* контракта нет */ }
|
|
138
138
|
});
|
|
139
139
|
list.sort((a, b) => a.first - b.first);
|
|
140
|
-
kvSet('brContracts', { at: Date.now(), list });
|
|
140
|
+
if (list.length) kvSet('brContracts', { at: Date.now(), list });
|
|
141
141
|
}
|
|
142
|
-
return list.filter((c) => c.last >= fromTs && c.first <= tillTs);
|
|
142
|
+
return (list || []).filter((c) => c.last >= fromTs && c.first <= tillTs);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// Непрерывный ряд цены нефти: в каждый день — контракт с наибольшим оборотом дня,
|
|
146
146
|
// на стыке контрактов цена продолжается без скачка (доходность стыка = 0)
|
|
147
|
-
async function loadBrent(fromTs, tillTs, signal) {
|
|
148
|
-
const list = await brentContracts(fromTs, tillTs, signal);
|
|
147
|
+
async function loadBrent(fromTs, tillTs, signal, { localOnly = false } = {}) {
|
|
148
|
+
const list = await brentContracts(fromTs, tillTs, signal, { localOnly });
|
|
149
149
|
const raw = [];
|
|
150
|
+
const now = Date.now();
|
|
150
151
|
await pool(list, 4, async (c) => {
|
|
151
152
|
try {
|
|
152
|
-
const
|
|
153
|
+
const isExpired = c.last < now - 10 * DAY;
|
|
154
|
+
const rows = await ensureCandles(c.code, BAR_INTERVAL, fromTs, tillTs, { signal, localOnly: localOnly || isExpired });
|
|
153
155
|
if (rows.length) raw.push(compact(rows));
|
|
154
156
|
} catch { /* пропускаем */ }
|
|
155
157
|
});
|
|
@@ -280,7 +282,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
280
282
|
if (useMacro) {
|
|
281
283
|
try {
|
|
282
284
|
onProgress({ symbol: 'BR', done: done, total: all.length });
|
|
283
|
-
raw.BR = await loadBrent(fromTs, tillTs, signal);
|
|
285
|
+
raw.BR = await loadBrent(fromTs, tillTs, signal, { localOnly });
|
|
284
286
|
} catch (e) {
|
|
285
287
|
if (signal?.aborted) throw new Error('Отменено');
|
|
286
288
|
raw.BR = EMPTY();
|
|
@@ -397,6 +399,24 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
397
399
|
}
|
|
398
400
|
}
|
|
399
401
|
const mret = lret(mk.close);
|
|
402
|
+
// На самых свежих барах текущего дня (последние 2 часа), когда 10-минутные свечи акций уже получены в реальном времени из Т-Банка,
|
|
403
|
+
// а индекс IMOEX в бесплатном MOEX ISS ещё отстаёт на 20 минут, достраиваем mret и mclose по средневзвешенной доходности акций индекса:
|
|
404
|
+
for (let i = Math.max(1, T - 12); i < T; i++) {
|
|
405
|
+
if (!mk.has[i] && mk.close[i - 1] > 0) {
|
|
406
|
+
let wSum = 0, rSum = 0;
|
|
407
|
+
for (const tk of tokens) {
|
|
408
|
+
if (tk.has[i] && (!tk.inIndex || tk.inIndex[i])) {
|
|
409
|
+
const w = tk.weight > 0 ? tk.weight : 1;
|
|
410
|
+
rSum += w * tk.ret[i];
|
|
411
|
+
wSum += w;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (wSum > 0) {
|
|
415
|
+
mret[i] = rSum / wSum;
|
|
416
|
+
mk.close[i] = mk.close[i - 1] * Math.exp(mret[i]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
400
420
|
// макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
|
|
401
421
|
const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
|
|
402
422
|
const days = new Set(timeline.map(dayKey)).size;
|
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';
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
};
|
|
@@ -315,27 +340,66 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
315
340
|
const avgInv = nInv ? sInv / nInv : 0;
|
|
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);
|
|
343
|
+
const bpd = ds.barsPerDay || 54;
|
|
344
|
+
const idx30m = Math.max(0, idx - 3);
|
|
345
|
+
const idx1d = Math.max(0, idx - bpd);
|
|
346
|
+
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
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;
|
|
353
|
+
const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
|
|
354
|
+
const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
|
|
318
355
|
const rows = ds.tokens.map((tk, i) => {
|
|
319
|
-
|
|
356
|
+
let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
320
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;
|
|
321
359
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
322
|
-
const
|
|
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);
|
|
364
|
+
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
365
|
+
const p30m = tk.close[idx30m] || 0;
|
|
366
|
+
const p1d = tk.close[idx1d] || 0;
|
|
367
|
+
const p3d = tk.close[idx3d] || 0;
|
|
368
|
+
const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
|
|
369
|
+
const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
|
|
370
|
+
const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
|
|
371
|
+
const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
|
|
372
|
+
const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
|
|
373
|
+
let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
|
|
374
|
+
if (isOverheated && selfRaw > 0) {
|
|
375
|
+
// Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
|
|
376
|
+
// ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — гасим ложный отскок вверх, не переворачивая в искусственный шорт:
|
|
377
|
+
if (exp > 0) exp = Math.max(0, exp - selfRaw);
|
|
378
|
+
selfRaw = 0;
|
|
379
|
+
}
|
|
380
|
+
const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
|
|
381
|
+
? round((excess1d >= 0.06 ? excess1d : excess3d) * 100, 1)
|
|
382
|
+
: 0;
|
|
323
383
|
// Индивидуальный множитель порога каждой акции по её фактическому обороту в рублях и волатильности свечи:
|
|
324
384
|
const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
|
|
325
385
|
const symNeedL = Math.max(needL * tierMult, 0.0030);
|
|
326
386
|
const symNeedS = Math.max(needS * tierMult, 0.0031);
|
|
327
|
-
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу
|
|
387
|
+
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
|
|
328
388
|
// обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
|
|
329
389
|
const EXTREME_BAR_MOVE_PCT = 4.5;
|
|
330
|
-
const
|
|
331
|
-
const
|
|
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;
|
|
332
396
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
333
397
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
334
398
|
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
335
399
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
336
400
|
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
337
|
-
const shortBlockedBySqueeze = Boolean(
|
|
338
|
-
const buyBlockedByDip = Boolean(
|
|
401
|
+
const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
|
|
402
|
+
const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
|
|
339
403
|
const row = {
|
|
340
404
|
i, symbol: tk.symbol, name: tk.name,
|
|
341
405
|
tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
|
|
@@ -352,6 +416,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
352
416
|
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
353
417
|
...(shortAllowed !== null ? { shortAllowed } : {}),
|
|
354
418
|
...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
|
|
419
|
+
...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
|
|
355
420
|
...(inIdx ? {} : { inIndex: false }),
|
|
356
421
|
};
|
|
357
422
|
if (dir !== 'long') {
|
|
@@ -359,10 +424,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
359
424
|
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
360
425
|
if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
|
|
361
426
|
}
|
|
362
|
-
if (explain) {
|
|
363
|
-
const ex = explainAttention(fitted.attention, f, u, i);
|
|
427
|
+
if (explain && ex) {
|
|
364
428
|
row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
|
|
365
|
-
row.selfPct = round(
|
|
429
|
+
row.selfPct = round(selfRaw * 100, 3);
|
|
366
430
|
}
|
|
367
431
|
return row;
|
|
368
432
|
});
|
package/server/model/live.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/server/model/models.js
CHANGED
|
@@ -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
|
-
|
|
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++)
|
|
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
|
|
516
|
-
for (let q = 0; q < 3; q++)
|
|
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
|
}
|