neuroalpha 0.3.12 → 0.3.14
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/LICENSE +13 -17
- package/README.md +4 -2
- package/bin/cli.js +1 -1
- package/deploy/install.sh +1 -1
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +1 -1
- package/package.json +2 -2
- package/public/admin.html +43 -20
- package/public/app.js +780 -226
- package/public/index.html +43 -3
- package/public/landing.html +328 -115
- package/public/legal.html +37 -16
- package/public/styles.css +24 -3
- package/server/api/routes.js +62 -30
- package/server/auth/index.js +109 -20
- package/server/billing/yookassa.js +1 -1
- package/server/brokers/index.js +256 -53
- package/server/config.js +1 -1
- package/server/data/moex.js +1 -1
- package/server/db.js +17 -3
- package/server/hub.js +1 -1
- package/server/index.js +4 -4
- package/server/llm/client.js +1 -1
- package/server/llm/providers.js +1 -1
- package/server/model/dataset.js +1 -1
- package/server/model/forecast.js +354 -25
- package/server/model/linalg.js +1 -1
- package/server/model/live.js +36 -25
- package/server/model/metrics.js +1 -1
- package/server/model/models.js +1 -1
- package/server/model/profiles.js +1 -1
- package/server/model/research.js +14 -38
- package/server/model/vocab.js +1 -1
- package/server/news/index.js +1 -1
- package/server/notify/index.js +1 -1
- package/server/params-meta.js +4 -3
- package/server/settings.js +1 -1
- package/server/strategist/analytics.js +2 -2
- package/server/strategist/index.js +2 -2
- package/server/strategist/tools.js +1 -1
- package/server/util.js +1 -1
package/server/model/forecast.js
CHANGED
|
@@ -1,48 +1,287 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { normalizeParams } from '../settings.js';
|
|
3
|
-
import { round, DAY, fmtBarMsk } from '../util.js';
|
|
4
|
-
import { loadDataset, barsFromHours, BAR_MS
|
|
5
|
-
import { buildFold, MODELS, HEADS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
|
|
3
|
+
import { round, DAY, MSK, fmtBarMsk } from '../util.js';
|
|
4
|
+
import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
|
|
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
8
|
import { newsForSymbols } from '../news/index.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
|
|
11
|
+
let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
|
|
11
12
|
const inflight = new Map();
|
|
12
13
|
|
|
14
|
+
// Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
|
|
15
|
+
function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
16
|
+
const { tokens, mret, mclose } = ds;
|
|
17
|
+
const N = fTrain.N;
|
|
18
|
+
const { beta, sigma, cos } = fTrain;
|
|
19
|
+
const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
|
|
20
|
+
const u = idx - lo;
|
|
21
|
+
const W = u + 1;
|
|
22
|
+
|
|
23
|
+
const z = new Float64Array(W * N);
|
|
24
|
+
const vr = new Float64Array(W * N);
|
|
25
|
+
for (let uk = 0; uk < W; uk++) {
|
|
26
|
+
const t = lo + uk;
|
|
27
|
+
const isLast = uk === u && liveSnap?.prices?.size > 0;
|
|
28
|
+
const mPrev = mclose?.[t - 1] || 0;
|
|
29
|
+
const mt = isLast && liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : mret[t];
|
|
30
|
+
for (let i = 0; i < N; i++) {
|
|
31
|
+
const tk = tokens[i];
|
|
32
|
+
let rt = tk.ret[t];
|
|
33
|
+
if (isLast) {
|
|
34
|
+
const prevPx = tk.close[t - 1];
|
|
35
|
+
const livePx = liveSnap.prices.get(tk.symbol);
|
|
36
|
+
if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
|
|
37
|
+
}
|
|
38
|
+
const e = rt - beta[i] * mt;
|
|
39
|
+
z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
|
|
40
|
+
vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const X = KINDS.map(() => SCALES.map(() => new Float32Array(W * N)));
|
|
45
|
+
SCALES.forEach((sc, si) => {
|
|
46
|
+
const Xp = X[0][si], Xf = X[1][si];
|
|
47
|
+
const invP = 1 / Math.sqrt(sc.len), invF = invP / 1.5;
|
|
48
|
+
for (let i = 0; i < N; i++) {
|
|
49
|
+
let sp = 0, sf = 0;
|
|
50
|
+
for (let k = 0; k < sc.len; k++) {
|
|
51
|
+
const uk = u - sc.from - k;
|
|
52
|
+
if (uk < 0) break;
|
|
53
|
+
const zk = z[uk * N + i];
|
|
54
|
+
sp += zk;
|
|
55
|
+
sf += zk * vr[uk * N + i];
|
|
56
|
+
}
|
|
57
|
+
Xp[u * N + i] = sp * invP;
|
|
58
|
+
Xf[u * N + i] = sf * invF;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let macro = null;
|
|
63
|
+
if (fTrain.macro?.list?.length && ds.macro?.length) {
|
|
64
|
+
const list = fTrain.macro.list.map((mTrain) => {
|
|
65
|
+
const dm = ds.macro.find((x) => x.id === mTrain.id);
|
|
66
|
+
const zs = new Float32Array(SCALES.length * W);
|
|
67
|
+
if (dm) {
|
|
68
|
+
const msig = mTrain.msig || 1e-3;
|
|
69
|
+
SCALES.forEach((sc, si) => {
|
|
70
|
+
let sum = 0;
|
|
71
|
+
for (let k = 0; k < sc.len; k++) {
|
|
72
|
+
const t = idx - sc.from - k;
|
|
73
|
+
if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
|
|
74
|
+
}
|
|
75
|
+
zs[si * W + u] = sum / Math.sqrt(sc.len);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return { id: mTrain.id, label: mTrain.label, b: mTrain.b, zs };
|
|
79
|
+
});
|
|
80
|
+
macro = { list };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const f = { N, lo, hi: idx + 1, W, beta, sigma, z, X, cos, emb: fTrain.emb, embN: fTrain.embN, macro };
|
|
84
|
+
const att = fitted.attention;
|
|
85
|
+
let fittedForStep = fitted;
|
|
86
|
+
if (att?.As) {
|
|
87
|
+
const F = HEADS.map(() => new Float32Array(W * N));
|
|
88
|
+
const off = u * N;
|
|
89
|
+
for (let hi = 0; hi < HEADS.length; hi++) {
|
|
90
|
+
const h = HEADS[hi];
|
|
91
|
+
const bIdx = BETAS.findIndex((b) => b.v === h.beta);
|
|
92
|
+
const A = att.As[bIdx];
|
|
93
|
+
const Xh = X[h.kind === 'price' ? 0 : 1][h.scale];
|
|
94
|
+
const Fh = F[hi];
|
|
95
|
+
for (let j = 0; j < N; j++) {
|
|
96
|
+
let s = 0;
|
|
97
|
+
const row = j * N;
|
|
98
|
+
for (let i = 0; i < N; i++) s += A[row + i] * Xh[off + i];
|
|
99
|
+
Fh[off + j] = s;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
fittedForStep = { ...fitted, attention: { ...att, F } };
|
|
103
|
+
}
|
|
104
|
+
return { f, u, fitted: fittedForStep };
|
|
105
|
+
}
|
|
106
|
+
|
|
13
107
|
export const lastClosedIndex = (ds, now = Date.now()) => {
|
|
14
108
|
let last = ds.T - 1;
|
|
15
109
|
while (last > 0 && ds.timeline[last] + BAR_MS > now) last--;
|
|
16
110
|
return last;
|
|
17
111
|
};
|
|
18
112
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
113
|
+
export const isModelTrainedToday = (p, now = Date.now()) => {
|
|
114
|
+
const autoAdapt = p.modelAutoAdapt !== false;
|
|
115
|
+
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
116
|
+
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
117
|
+
const todayMsk = new Date(now + MSK).toISOString().slice(0, 10);
|
|
118
|
+
const prefix = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight]).slice(0, -1);
|
|
119
|
+
for (const [k, v] of trainedModelCache) {
|
|
120
|
+
if (v.dayMsk === todayMsk && k.startsWith(prefix)) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Быстрое получение живых цен Мосбиржи (TQBR + IMOEX) за 1 лёгкий запрос (~80 мс)
|
|
126
|
+
async function fetchLiveMoexSnapshot() {
|
|
127
|
+
try {
|
|
128
|
+
const [rShares, rIdx] = await Promise.all([
|
|
129
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }),
|
|
130
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/index/boards/SNDX/securities/IMOEX.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,CURRENTVALUE,LASTVALUE', { signal: AbortSignal.timeout(6000) }).catch(() => null),
|
|
131
|
+
]);
|
|
132
|
+
if (!rShares.ok) return null;
|
|
133
|
+
const j = await rShares.json();
|
|
134
|
+
const cols = j.marketdata?.columns || [];
|
|
135
|
+
const iSec = cols.indexOf('SECID');
|
|
136
|
+
const iLast = cols.indexOf('LAST');
|
|
137
|
+
const iMkt = cols.indexOf('MARKETPRICE');
|
|
138
|
+
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
139
|
+
const prices = new Map();
|
|
140
|
+
for (const row of j.marketdata?.data || []) {
|
|
141
|
+
const sym = row[iSec];
|
|
142
|
+
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
143
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
144
|
+
}
|
|
145
|
+
let imoexPrice = 0;
|
|
146
|
+
if (rIdx?.ok) {
|
|
147
|
+
const ji = await rIdx.json().catch(() => null);
|
|
148
|
+
const ic = ji?.marketdata?.columns || [];
|
|
149
|
+
const ir = ji?.marketdata?.data?.[0];
|
|
150
|
+
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
151
|
+
}
|
|
152
|
+
return { prices, imoexPrice, ts: Date.now() };
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Мгновенное обновление последней точки контекста u в уже обученной модели по живым котировкам (за ~2 мс)
|
|
159
|
+
function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
160
|
+
if (!liveSnap?.prices?.size) return;
|
|
161
|
+
const idx = f.lo + u;
|
|
162
|
+
if (idx < 1 || idx >= ds.T) return;
|
|
163
|
+
const N = f.N;
|
|
164
|
+
const mPrev = ds.mclose?.[idx - 1] || 0;
|
|
165
|
+
const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
|
|
166
|
+
|
|
167
|
+
// 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия предыдущей свечи
|
|
168
|
+
for (let i = 0; i < N; i++) {
|
|
169
|
+
const tk = ds.tokens[i];
|
|
170
|
+
const prevPx = tk.close[idx - 1];
|
|
171
|
+
const livePx = liveSnap.prices.get(tk.symbol);
|
|
172
|
+
if (livePx > 0 && prevPx > 0) {
|
|
173
|
+
const ret = Math.log(livePx / prevPx);
|
|
174
|
+
const e = ret - f.beta[i] * mLive;
|
|
175
|
+
f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2) пересчитываем признаки масштаба 0 (последний час) в точке u
|
|
180
|
+
const sc0 = SCALES[0];
|
|
181
|
+
const invP = 1 / Math.sqrt(sc0.len), invF = invP / 1.5;
|
|
182
|
+
const Xp0 = f.X[0][0], Xf0 = f.X[1][0];
|
|
183
|
+
for (let i = 0; i < N; i++) {
|
|
184
|
+
let sp = 0, sf = 0;
|
|
185
|
+
for (let k = 0; k < sc0.len; k++) {
|
|
186
|
+
const uk = u - k;
|
|
187
|
+
if (uk < 0) break;
|
|
188
|
+
const zk = f.z[uk * N + i];
|
|
189
|
+
sp += zk;
|
|
190
|
+
sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
|
|
191
|
+
}
|
|
192
|
+
Xp0[u * N + i] = sp * invP;
|
|
193
|
+
Xf0[u * N + i] = sf * invF;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3) пересчитываем головы внимания F[hi][u*N + j] в точке u
|
|
197
|
+
const att = fitted.attention;
|
|
198
|
+
if (att?.As && att?.F) {
|
|
199
|
+
const off = u * N;
|
|
200
|
+
for (let hi = 0; hi < HEADS.length; hi++) {
|
|
201
|
+
const h = HEADS[hi];
|
|
202
|
+
const bIdx = BETAS.findIndex((b) => b.v === h.beta);
|
|
203
|
+
const A = att.As[bIdx];
|
|
204
|
+
const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
|
|
205
|
+
const Fh = att.F[hi];
|
|
206
|
+
for (let j = 0; j < N; j++) {
|
|
207
|
+
let s = 0;
|
|
208
|
+
const row = j * N;
|
|
209
|
+
for (let i = 0; i < N; i++) s += A[row + i] * X[off + i];
|
|
210
|
+
Fh[off + j] = s;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Обучение ядра модели (карта рынка + веса внимания) выполняется не чаще 1 раза в торговый день для каждого набора параметров.
|
|
217
|
+
// На всех свечах внутри дня выполняется быстрый расчёт прогноза по уже обученным весам (buildInferenceFoldAt).
|
|
218
|
+
export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, forceTrain = false } = {}) {
|
|
22
219
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
23
220
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
24
221
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
25
222
|
const H = barsFromHours(effHorizon);
|
|
26
223
|
const maxH = autoAdapt ? Math.round(H * 1.4) : H;
|
|
224
|
+
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
225
|
+
const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
|
|
226
|
+
const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
|
|
227
|
+
|
|
228
|
+
const cached = trainedModelCache.get(fitKey);
|
|
229
|
+
if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
|
|
230
|
+
const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
|
|
231
|
+
return {
|
|
232
|
+
...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
|
|
233
|
+
snap: cached.snap,
|
|
234
|
+
trainedAt: cached.trainedAt,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
27
238
|
const trainLen = Math.round(p.modelTrainDays * ds.barsPerDay);
|
|
28
239
|
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
|
|
29
|
-
// «текстовая» часть карты: устойчивые описания бизнеса (профили готовятся отдельно, здесь только читаем)
|
|
30
240
|
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
31
241
|
const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
32
242
|
const u = idx - f.lo;
|
|
33
|
-
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
34
243
|
const list = [...new Set(explain ? Object.keys(MODELS) : ['attention', chosen])];
|
|
35
244
|
const fitted = {};
|
|
36
|
-
const preds = {};
|
|
37
245
|
for (const n of list) {
|
|
38
246
|
fitted[n] = await MODELS[n].fit(f);
|
|
247
|
+
}
|
|
248
|
+
const snap = mapSnapshot(f);
|
|
249
|
+
// Освобождаем тяжёлые полные 90-дневные буферы признаков из памяти, оставляя только структурное ядро модели на день
|
|
250
|
+
const fTrain = {
|
|
251
|
+
N: f.N,
|
|
252
|
+
beta: f.beta,
|
|
253
|
+
sigma: f.sigma,
|
|
254
|
+
cos: f.cos,
|
|
255
|
+
emb: f.emb,
|
|
256
|
+
embN: f.embN,
|
|
257
|
+
eigenvalues: f.eigenvalues,
|
|
258
|
+
macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
|
|
259
|
+
};
|
|
260
|
+
const lightFitted = {
|
|
261
|
+
...fitted,
|
|
262
|
+
...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
|
|
263
|
+
};
|
|
264
|
+
const trainedAt = Date.now();
|
|
265
|
+
trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
|
|
266
|
+
|
|
267
|
+
if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
|
|
268
|
+
return {
|
|
269
|
+
...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
|
|
270
|
+
snap,
|
|
271
|
+
trainedAt,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap = null) {
|
|
276
|
+
const preds = {};
|
|
277
|
+
for (const n of Object.keys(fitted)) {
|
|
39
278
|
preds[n] = MODELS[n].predict(fitted[n], f, u);
|
|
40
279
|
}
|
|
41
280
|
const cost = (p.commissionPct + p.slippagePct) / 100;
|
|
42
281
|
const threshold = effMinEdge * 2 * cost;
|
|
43
282
|
const bear = isMarketDown(ds, idx);
|
|
44
283
|
const dir = p.modelDirection || 'long';
|
|
45
|
-
const needL = autoAdapt && bear
|
|
284
|
+
const needL = autoAdapt && bear ? threshold * 1.25 : threshold;
|
|
46
285
|
const needS = autoAdapt && dir === 'longshort' ? (bear ? threshold * 0.90 : threshold * 1.25) : threshold;
|
|
47
286
|
let sInv = 0, nInv = 0;
|
|
48
287
|
for (let i = 0; i < ds.tokens.length; i++) if (f.sigma[i] > 0 && ds.tokens[i].liq[idx] >= LIQ_MIN) { sInv += 1 / f.sigma[i]; nInv++; }
|
|
@@ -50,8 +289,10 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
|
|
|
50
289
|
const rows = ds.tokens.map((tk, i) => {
|
|
51
290
|
const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
52
291
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
292
|
+
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
53
293
|
const row = {
|
|
54
294
|
i, symbol: tk.symbol, name: tk.name,
|
|
295
|
+
price: round(livePx, 4),
|
|
55
296
|
liquid: tk.liq[idx] >= LIQ_MIN,
|
|
56
297
|
expected: exp,
|
|
57
298
|
expectedPct: round(exp * 100, 3),
|
|
@@ -70,9 +311,7 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
|
|
|
70
311
|
return { f, u, fitted, chosen, threshold, marketDown: bear, rows };
|
|
71
312
|
}
|
|
72
313
|
|
|
73
|
-
// Новости — только в прогнозе
|
|
74
|
-
// и ИИ, знающий будущее, исказил бы проверку). Строкам с новостями за последние 24 ч добавляем
|
|
75
|
-
// news: { count, tone — средний взвешенный по важности, top — суть самой важной }
|
|
314
|
+
// Новости — только в прогнозе «сейчас»
|
|
76
315
|
function withNews(result, p) {
|
|
77
316
|
const nm = p.newsEnabled ? newsForSymbols(24) : null;
|
|
78
317
|
for (const r of result.rows) {
|
|
@@ -83,31 +322,121 @@ function withNews(result, p) {
|
|
|
83
322
|
return result;
|
|
84
323
|
}
|
|
85
324
|
|
|
86
|
-
//
|
|
325
|
+
// Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
|
|
326
|
+
function attachDeltas(rows, stateObj) {
|
|
327
|
+
const prevExp = stateObj.prevExpMap;
|
|
328
|
+
const prevRank = stateObj.prevRankMap;
|
|
329
|
+
const nextExp = new Map();
|
|
330
|
+
const nextRank = new Map();
|
|
331
|
+
let changed = false;
|
|
332
|
+
|
|
333
|
+
rows.forEach((r, idx) => {
|
|
334
|
+
const rank = idx + 1;
|
|
335
|
+
nextExp.set(r.symbol, r.expectedPct);
|
|
336
|
+
nextRank.set(r.symbol, rank);
|
|
337
|
+
if (prevExp && prevExp.has(r.symbol)) {
|
|
338
|
+
const d = round(r.expectedPct - prevExp.get(r.symbol), 3);
|
|
339
|
+
if (Math.abs(d) >= 0.001) changed = true;
|
|
340
|
+
r.deltaPct = d !== 0 ? d : (r.deltaPct || 0);
|
|
341
|
+
r.rankDelta = prevRank?.has(r.symbol) ? (prevRank.get(r.symbol) - rank) : 0;
|
|
342
|
+
} else {
|
|
343
|
+
r.deltaPct = 0;
|
|
344
|
+
r.rankDelta = 0;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (!prevExp || changed) {
|
|
349
|
+
stateObj.prevExpMap = nextExp;
|
|
350
|
+
stateObj.prevRankMap = nextRank;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const fmtLiveBarLabel = (barTs, liveSnapTs) => {
|
|
355
|
+
if (liveSnapTs && (!barTs || liveSnapTs > barTs + BAR_MS)) {
|
|
356
|
+
const liveTime = fmtBarMsk(liveSnapTs).slice(-5);
|
|
357
|
+
return `${fmtBarMsk(barTs || liveSnapTs)} · котировки ${liveTime}`;
|
|
358
|
+
}
|
|
359
|
+
return fmtBarMsk(barTs || liveSnapTs || Date.now());
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Прогноз на ближайшие H часов с живым поминутным обновлением котировок (без переобучения тяжёлого окна каждую минуту)
|
|
87
363
|
export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
88
364
|
const p = normalizeParams(input);
|
|
89
365
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
90
366
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
91
367
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
92
368
|
const key = JSON.stringify([autoAdapt, p.modelType, effHorizon, p.modelTrainDays, p.modelEmbedDim, effMinEdge, p.modelDirection, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, p.commissionPct, p.slippagePct]);
|
|
93
|
-
|
|
369
|
+
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
// 1) Если с последнего живого тика прошло меньше 15 секунд и нет force — отдаём мгновенно из памяти
|
|
372
|
+
if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt < 15000) {
|
|
373
|
+
return withNews(baseState.lastTickResult, p);
|
|
374
|
+
}
|
|
375
|
+
// 2) Если срез свечей обновлялся менее 2 минут назад и нет force — делаем быстрый живой пересчёт (~80 мс запрос + 2 мс математика)
|
|
376
|
+
if (!force && baseState && baseState.key === key && now - baseState.dsLoadedAt < 2 * 60000) {
|
|
377
|
+
const liveSnap = await fetchLiveMoexSnapshot();
|
|
378
|
+
if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
|
|
379
|
+
const H = barsFromHours(effHorizon);
|
|
380
|
+
const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, autoAdapt, true, liveSnap);
|
|
381
|
+
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
382
|
+
attachDeltas(sortedRows, baseState);
|
|
383
|
+
const liveTs = liveSnap?.ts || now;
|
|
384
|
+
const barTs = baseState.ds.timeline[baseState.last];
|
|
385
|
+
const result = {
|
|
386
|
+
...baseState.lastTickResult,
|
|
387
|
+
at: now,
|
|
388
|
+
liveTs,
|
|
389
|
+
bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
|
|
390
|
+
rows: sortedRows,
|
|
391
|
+
};
|
|
392
|
+
baseState.lastTickResult = result;
|
|
393
|
+
baseState.lastTickAt = now;
|
|
394
|
+
return withNews(result, p);
|
|
395
|
+
}
|
|
396
|
+
|
|
94
397
|
if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
|
|
95
398
|
const task = (async () => {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
399
|
+
const trainedToday = !force && isModelTrainedToday(p, now);
|
|
400
|
+
const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
|
|
401
|
+
const [ds, liveSnap] = await Promise.all([
|
|
402
|
+
loadDataset({ fromTs: now - windowDays * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress }),
|
|
403
|
+
fetchLiveMoexSnapshot(),
|
|
404
|
+
]);
|
|
98
405
|
const last = lastClosedIndex(ds, now);
|
|
99
|
-
const r = await predictAt(ds, p, last);
|
|
100
|
-
const snap = mapSnapshot(r.f);
|
|
406
|
+
const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
|
|
407
|
+
const snap = r.snap || mapSnapshot(r.f);
|
|
408
|
+
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
409
|
+
const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
|
|
410
|
+
attachDeltas(sortedRows, stateObj);
|
|
411
|
+
const liveTs = liveSnap?.ts || now;
|
|
412
|
+
const barTs = ds.timeline[last];
|
|
101
413
|
const result = {
|
|
102
414
|
at: now,
|
|
103
|
-
|
|
104
|
-
|
|
415
|
+
trainedAt: r.trainedAt || now,
|
|
416
|
+
liveTs,
|
|
417
|
+
barTs,
|
|
418
|
+
bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
|
|
105
419
|
horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3), marketDown: r.marketDown,
|
|
106
|
-
rows:
|
|
420
|
+
rows: sortedRows,
|
|
107
421
|
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 },
|
|
108
422
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
|
|
109
423
|
};
|
|
110
|
-
|
|
424
|
+
baseState = {
|
|
425
|
+
key,
|
|
426
|
+
trainedAt: r.trainedAt || now,
|
|
427
|
+
dsLoadedAt: now,
|
|
428
|
+
ds,
|
|
429
|
+
last,
|
|
430
|
+
f: r.f,
|
|
431
|
+
u: r.u,
|
|
432
|
+
fitted: r.fitted,
|
|
433
|
+
chosen: r.chosen,
|
|
434
|
+
snap,
|
|
435
|
+
prevExpMap: stateObj.prevExpMap,
|
|
436
|
+
prevRankMap: stateObj.prevRankMap,
|
|
437
|
+
lastTickResult: result,
|
|
438
|
+
lastTickAt: now,
|
|
439
|
+
};
|
|
111
440
|
return result;
|
|
112
441
|
})().finally(() => inflight.delete(key));
|
|
113
442
|
inflight.set(key, task);
|
package/server/model/linalg.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
// Небольшая линейная алгебра без зависимостей: матрицы — массивы Float64Array (строки)
|
|
3
3
|
|
|
4
4
|
export const matrix = (n, m) => Array.from({ length: n }, () => new Float64Array(m));
|
package/server/model/live.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { createRun, finishRun, saveTrade, updateRunMetrics, getRun, getTrades, deleteRun, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { normalizeParams } from '../settings.js';
|
|
4
4
|
import { round, DAY, MSK, fmtBarMsk } from '../util.js';
|
|
5
5
|
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
|
-
import { predictAt, lastClosedIndex } from './forecast.js';
|
|
8
|
+
import { predictAt, lastClosedIndex, isModelTrainedToday } from './forecast.js';
|
|
9
9
|
import { computeReport, downsample } from './metrics.js';
|
|
10
10
|
import { MODELS } from './models.js';
|
|
11
11
|
import { blockingNews } from '../news/index.js';
|
|
@@ -90,12 +90,32 @@ class LiveEngine {
|
|
|
90
90
|
const status = returnPct < expPct - bandPct ? 'below' : returnPct > expPct + bandPct ? 'above' : 'inside';
|
|
91
91
|
corridor = { runId: s.benchmark.runId, expectedPct: expPct, bandPct, minPct: round(expPct - bandPct, 1), maxPct: round(expPct + bandPct, 1), histReturnPct: s.benchmark.returnPct, histMaxDdPct: s.benchmark.maxDrawdownPct, status };
|
|
92
92
|
}
|
|
93
|
+
const tradesList = [...(s.trades || [])].reverse().slice(0, 120).map((t) => ({
|
|
94
|
+
symbol: t.symbol,
|
|
95
|
+
side: t.side || 'long',
|
|
96
|
+
qty: t.qty || 0,
|
|
97
|
+
entryTs: t.entryTs,
|
|
98
|
+
exitTs: t.exitTs,
|
|
99
|
+
entryPrice: t.entryPrice || 0,
|
|
100
|
+
exitPrice: t.exitPrice || 0,
|
|
101
|
+
pnl: round(Number(t.pnl || 0), 2),
|
|
102
|
+
pnlPct: round(Number(t.pnlPct || 0), 2),
|
|
103
|
+
commission: round(Number(t.commission || 0), 2),
|
|
104
|
+
barsHeld: t.barsHeld || 0,
|
|
105
|
+
reasonEntry: t.reasonEntry || '',
|
|
106
|
+
reasonExit: t.reasonExit || '',
|
|
107
|
+
}));
|
|
108
|
+
const realizedPnl = round((s.trades || []).reduce((sum, t) => sum + (Number(t.pnl) || 0), 0), 2);
|
|
109
|
+
const totalCommission = round((s.trades || []).reduce((sum, t) => sum + (Number(t.commission) || 0), 0), 2);
|
|
93
110
|
return {
|
|
94
111
|
running: s.running, runId: s.runId, name: s.name || `Виртуальный счёт №${s.runId}`,
|
|
95
112
|
baseRunId: s.baseRunId || null, benchmark: s.benchmark || null, corridor,
|
|
96
113
|
params: s.params, startedAt: s.startedAt, stoppedAt: s.stoppedAt || null, lastBarTs: s.lastBarTs,
|
|
97
114
|
error: s.error, busy: s.busy, snapshot: snap, returnPct, pnl,
|
|
98
|
-
|
|
115
|
+
realizedPnl, totalCommission,
|
|
116
|
+
trades: tradesList,
|
|
117
|
+
curve: downsample(s.curve || [], 120),
|
|
118
|
+
recent: s.recent.slice(0, 25), metrics: s.metrics,
|
|
99
119
|
};
|
|
100
120
|
}
|
|
101
121
|
|
|
@@ -179,7 +199,9 @@ class LiveEngine {
|
|
|
179
199
|
if (!run || run.mode !== 'paper' || !st.params?.modelHorizon) continue;
|
|
180
200
|
const isRunning = Boolean(st.running && run.status === 'running');
|
|
181
201
|
const trades = getTrades(st.runId).map((t) => ({
|
|
182
|
-
symbol: t.symbol, side: t.side,
|
|
202
|
+
symbol: t.symbol, side: t.side, qty: t.qty, entryPrice: t.entry_price, exitPrice: t.exit_price,
|
|
203
|
+
pnl: t.pnl, pnlPct: t.pnl_pct, entryTs: t.entry_ts, exitTs: t.exit_ts, commission: t.commission,
|
|
204
|
+
reasonEntry: t.reason_entry, reasonExit: t.reason_exit,
|
|
183
205
|
barsHeld: Math.round((t.exit_ts - t.entry_ts) / 600000) * BAR_INTERVAL,
|
|
184
206
|
}));
|
|
185
207
|
const curve = st.curve || [];
|
|
@@ -239,7 +261,9 @@ class LiveEngine {
|
|
|
239
261
|
try {
|
|
240
262
|
const p = s.params;
|
|
241
263
|
const now = Date.now();
|
|
242
|
-
const
|
|
264
|
+
const trainedToday = isModelTrainedToday(p, now);
|
|
265
|
+
const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
|
|
266
|
+
const ds = await loadDataset({ fromTs: now - windowDays * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
|
|
243
267
|
s.lastSeenTs = latest;
|
|
244
268
|
changed = true;
|
|
245
269
|
const bySym = new Map(ds.tokens.map((t, i) => [t.symbol, i]));
|
|
@@ -308,7 +332,7 @@ class LiveEngine {
|
|
|
308
332
|
const dir = this.#direction(s);
|
|
309
333
|
const slots = dir === 'long' ? K : 2 * K;
|
|
310
334
|
const autoAdapt = s.params.modelAutoAdapt !== false;
|
|
311
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
335
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
|
|
312
336
|
const invVol = s.params.modelAllocMode === 'invvol';
|
|
313
337
|
const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
|
|
314
338
|
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
|
|
@@ -320,7 +344,7 @@ class LiveEngine {
|
|
|
320
344
|
const side = o.action === 'short' ? 'short' : 'long';
|
|
321
345
|
const px = price(o.symbol);
|
|
322
346
|
if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) continue;
|
|
323
|
-
const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.
|
|
347
|
+
const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(o.expectedPct) / needPct))) : 1;
|
|
324
348
|
const baseAlloc = autoAdapt ? Math.min(s.equity * maxSlotWeight * conv, avail / Math.max(1, buyOrders.length - idx)) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
|
|
325
349
|
let alloc = baseAlloc;
|
|
326
350
|
if (invVol && o.weight > 0) {
|
|
@@ -356,7 +380,7 @@ class LiveEngine {
|
|
|
356
380
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
357
381
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
358
382
|
const H = barsFromHours(effHorizon);
|
|
359
|
-
const stepBars = autoAdapt ? Math.max(
|
|
383
|
+
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
360
384
|
if (s.barsSinceRebalance >= stepBars) {
|
|
361
385
|
await new Promise((r) => setImmediate(r));
|
|
362
386
|
const r = await predictAt(ds, p, idx);
|
|
@@ -373,7 +397,7 @@ class LiveEngine {
|
|
|
373
397
|
closing.add(sym);
|
|
374
398
|
};
|
|
375
399
|
const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
|
|
376
|
-
const mktDown = p.modelMarketFilter === 'ma' &&
|
|
400
|
+
const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
|
|
377
401
|
const quarantined = new Set();
|
|
378
402
|
if (autoAdapt && s.trades.length >= 3) {
|
|
379
403
|
const stMap = new Map();
|
|
@@ -418,22 +442,9 @@ class LiveEngine {
|
|
|
418
442
|
const open = (x) => !s.held.has(x.symbol) || closing.has(x.symbol);
|
|
419
443
|
const candL = mktDown ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
420
444
|
const candS = r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
421
|
-
if (mktDown
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
nL = K - sideHeld('long').length;
|
|
425
|
-
nS = K - sideHeld('short').length;
|
|
426
|
-
} else {
|
|
427
|
-
const pairs = Math.min(K, sideHeld('long').length + candL.length, countSideShort(sideHeld('short').length, candS.length));
|
|
428
|
-
if (!autoAdapt) {
|
|
429
|
-
for (const side of ['long', 'short']) {
|
|
430
|
-
const extra = sideHeld(side).sort((a, b) => (side === 'long' ? 1 : -1) * ((row.get(a[0])?.expected ?? 0) - (row.get(b[0])?.expected ?? 0)));
|
|
431
|
-
for (const [sym] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(sym, 'Нет пары с другой стороны');
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
nL = Math.max(0, pairs - sideHeld('long').length);
|
|
435
|
-
nS = Math.max(0, pairs - sideHeld('short').length);
|
|
436
|
-
}
|
|
445
|
+
if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
446
|
+
const nL = K - sideHeld('long').length;
|
|
447
|
+
const nS = K - sideHeld('short').length;
|
|
437
448
|
entries = [...take(candL, 'buy', nL).map((x) => [x, 'buy']), ...take(candS, 'short', nS).map((x) => [x, 'short'])];
|
|
438
449
|
}
|
|
439
450
|
for (const [x, action] of entries) {
|
package/server/model/metrics.js
CHANGED
package/server/model/models.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { eigenSym, ridgeSolve, matrix, spearman, kmeans } from './linalg.js';
|
|
3
3
|
import { BAR_INTERVAL } from './dataset.js';
|
|
4
4
|
|
package/server/model/profiles.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { ensureVocab } from './vocab.js';
|
|
4
4
|
import { chatStream, extractJson, isLlmEnabled } from '../llm/client.js';
|