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.
Files changed (41) hide show
  1. package/LICENSE +13 -17
  2. package/README.md +4 -2
  3. package/bin/cli.js +1 -1
  4. package/deploy/install.sh +1 -1
  5. 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
  6. 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
  7. package/package.json +2 -2
  8. package/public/admin.html +43 -20
  9. package/public/app.js +780 -226
  10. package/public/index.html +43 -3
  11. package/public/landing.html +328 -115
  12. package/public/legal.html +37 -16
  13. package/public/styles.css +24 -3
  14. package/server/api/routes.js +62 -30
  15. package/server/auth/index.js +109 -20
  16. package/server/billing/yookassa.js +1 -1
  17. package/server/brokers/index.js +256 -53
  18. package/server/config.js +1 -1
  19. package/server/data/moex.js +1 -1
  20. package/server/db.js +17 -3
  21. package/server/hub.js +1 -1
  22. package/server/index.js +4 -4
  23. package/server/llm/client.js +1 -1
  24. package/server/llm/providers.js +1 -1
  25. package/server/model/dataset.js +1 -1
  26. package/server/model/forecast.js +354 -25
  27. package/server/model/linalg.js +1 -1
  28. package/server/model/live.js +36 -25
  29. package/server/model/metrics.js +1 -1
  30. package/server/model/models.js +1 -1
  31. package/server/model/profiles.js +1 -1
  32. package/server/model/research.js +14 -38
  33. package/server/model/vocab.js +1 -1
  34. package/server/news/index.js +1 -1
  35. package/server/notify/index.js +1 -1
  36. package/server/params-meta.js +4 -3
  37. package/server/settings.js +1 -1
  38. package/server/strategist/analytics.js +2 -2
  39. package/server/strategist/index.js +2 -2
  40. package/server/strategist/tools.js +1 -1
  41. package/server/util.js +1 -1
@@ -1,48 +1,287 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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, BAR_INTERVAL } from './dataset.js';
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
- let cache = null;
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
- // Обучение на окне, заканчивающемся свечой idx, и прогноз по этой свече.
20
- // Возвращает строки по всем акциям: ожидаемое движение (относительно рынка), сигнал покупки, кто влияет.
21
- export async function predictAt(ds, p, idx, { explain = true } = {}) {
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 && dir !== 'neutral' ? threshold * 1.25 : threshold;
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
- // Прогноз на ближайшие H часов по последней закрытой свече
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
- if (!force && cache && cache.key === key && Date.now() - cache.at < 10 * 60000) return withNews(cache.result, p);
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 now = Date.now();
97
- const ds = await loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress });
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
- barTs: ds.timeline[last],
104
- bar: fmtBarMsk(ds.timeline[last]),
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: r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct),
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
- cache = { key, at: now, result };
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);
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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));
@@ -1,11 +1,11 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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
- recent: s.recent.slice(0, 20), metrics: s.metrics,
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, pnl: t.pnl, pnlPct: t.pnl_pct, entryTs: t.entry_ts, exitTs: t.exit_ts, commission: t.commission,
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 ds = await loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
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.45, Math.max(1 / K, 1.35 / K)) : (dir === 'neutral' ? Math.min(0.25, 1 / (2 * K)) : Math.min(0.35, 1.4 / (2 * K)));
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.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(o.expectedPct) / needPct))) : 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(2, Math.floor(H / 2)) : H;
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' && dir !== 'neutral' && r.marketDown;
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 && dir === 'longshort') step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
422
- let nL, nS;
423
- if (dir === 'longshort') {
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) {
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { round } from '../util.js';
3
3
 
4
4
  const MSK = 3 * 3600000;
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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
 
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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';