neuroalpha 0.2.0 → 0.3.13

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 (52) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +9 -3
  3. package/bin/cli.js +69 -69
  4. package/config/seed-run.json +1 -0
  5. package/config/strategy.json +4 -10
  6. package/deploy/install.sh +128 -0
  7. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
  8. 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
  9. 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 +171 -0
  10. package/package.json +15 -5
  11. package/public/admin.html +449 -0
  12. package/public/app.js +1182 -379
  13. package/public/index.html +87 -22
  14. package/public/landing.html +1217 -870
  15. package/public/legal.html +51 -8
  16. package/public/styles.css +71 -16
  17. package/server/api/routes.js +220 -35
  18. package/server/auth/index.js +633 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1369 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +69 -4
  24. package/server/hub.js +1 -0
  25. package/server/index.js +10 -2
  26. package/server/llm/client.js +1 -0
  27. package/server/llm/providers.js +50 -3
  28. package/server/model/dataset.js +362 -352
  29. package/server/model/forecast.js +202 -37
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +476 -486
  32. package/server/model/metrics.js +1 -0
  33. package/server/model/models.js +31 -21
  34. package/server/model/profiles.js +1 -0
  35. package/server/model/research.js +128 -205
  36. package/server/model/vocab.js +1 -0
  37. package/server/news/index.js +1 -0
  38. package/server/notify/index.js +304 -168
  39. package/server/params-meta.js +22 -26
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +2 -1
  42. package/server/strategist/index.js +7 -7
  43. package/server/strategist/tools.js +2 -1
  44. package/server/util.js +1 -0
  45. package/server/model/gpu-worker.html +0 -11
  46. package/server/model/gpu-worker.js +0 -60
  47. package/server/model/tf-node.js +0 -26
  48. package/server/model/transformer-core.js +0 -330
  49. package/server/model/transformer-gpu.js +0 -221
  50. package/server/model/transformer-pool.js +0 -200
  51. package/server/model/transformer-worker.js +0 -28
  52. package/server/model/transformer.js +0 -57
@@ -1,12 +1,13 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  import { normalizeParams } from '../settings.js';
2
3
  import { round, DAY, fmtBarMsk } from '../util.js';
3
- import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
4
- import { buildFold, MODELS, HEADS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
4
+ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
+ import { buildFold, MODELS, HEADS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
5
6
  import { loadProfiles } from './profiles.js';
6
7
  import { isMarketDown } from './research.js';
7
8
  import { newsForSymbols } from '../news/index.js';
8
9
 
9
- let cache = null;
10
+ let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
10
11
  const inflight = new Map();
11
12
 
12
13
  export const lastClosedIndex = (ds, now = Date.now()) => {
@@ -15,42 +16,131 @@ export const lastClosedIndex = (ds, now = Date.now()) => {
15
16
  return last;
16
17
  };
17
18
 
19
+ // Быстрое получение живых цен Мосбиржи (TQBR + IMOEX) за 1 лёгкий запрос (~80 мс)
20
+ async function fetchLiveMoexSnapshot() {
21
+ try {
22
+ const [rShares, rIdx] = await Promise.all([
23
+ 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) }),
24
+ 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),
25
+ ]);
26
+ if (!rShares.ok) return null;
27
+ const j = await rShares.json();
28
+ const cols = j.marketdata?.columns || [];
29
+ const iSec = cols.indexOf('SECID');
30
+ const iLast = cols.indexOf('LAST');
31
+ const iMkt = cols.indexOf('MARKETPRICE');
32
+ const iCur = cols.indexOf('LCURRENTPRICE');
33
+ const prices = new Map();
34
+ for (const row of j.marketdata?.data || []) {
35
+ const sym = row[iSec];
36
+ const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
37
+ if (sym && px > 0) prices.set(sym, px);
38
+ }
39
+ let imoexPrice = 0;
40
+ if (rIdx?.ok) {
41
+ const ji = await rIdx.json().catch(() => null);
42
+ const ic = ji?.marketdata?.columns || [];
43
+ const ir = ji?.marketdata?.data?.[0];
44
+ if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
45
+ }
46
+ return { prices, imoexPrice, ts: Date.now() };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // Мгновенное обновление последней точки контекста u в уже обученной модели по живым котировкам (за ~2 мс)
53
+ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
54
+ if (!liveSnap?.prices?.size) return;
55
+ const idx = f.lo + u;
56
+ if (idx < 1 || idx >= ds.T) return;
57
+ const N = f.N;
58
+ const mPrev = ds.mclose?.[idx - 1] || 0;
59
+ const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
60
+
61
+ // 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия предыдущей свечи
62
+ for (let i = 0; i < N; i++) {
63
+ const tk = ds.tokens[i];
64
+ const prevPx = tk.close[idx - 1];
65
+ const livePx = liveSnap.prices.get(tk.symbol);
66
+ if (livePx > 0 && prevPx > 0) {
67
+ const ret = Math.log(livePx / prevPx);
68
+ const e = ret - f.beta[i] * mLive;
69
+ f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
70
+ }
71
+ }
72
+
73
+ // 2) пересчитываем признаки масштаба 0 (последний час) в точке u
74
+ const sc0 = SCALES[0];
75
+ const invP = 1 / Math.sqrt(sc0.len), invF = invP / 1.5;
76
+ const Xp0 = f.X[0][0], Xf0 = f.X[1][0];
77
+ for (let i = 0; i < N; i++) {
78
+ let sp = 0, sf = 0;
79
+ for (let k = 0; k < sc0.len; k++) {
80
+ const uk = u - k;
81
+ if (uk < 0) break;
82
+ const zk = f.z[uk * N + i];
83
+ sp += zk;
84
+ sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
85
+ }
86
+ Xp0[u * N + i] = sp * invP;
87
+ Xf0[u * N + i] = sf * invF;
88
+ }
89
+
90
+ // 3) пересчитываем головы внимания F[hi][u*N + j] в точке u
91
+ const att = fitted.attention;
92
+ if (att?.As && att?.F) {
93
+ const off = u * N;
94
+ for (let hi = 0; hi < HEADS.length; hi++) {
95
+ const h = HEADS[hi];
96
+ const bIdx = BETAS.findIndex((b) => b.v === h.beta);
97
+ const A = att.As[bIdx];
98
+ const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
99
+ const Fh = att.F[hi];
100
+ for (let j = 0; j < N; j++) {
101
+ let s = 0;
102
+ const row = j * N;
103
+ for (let i = 0; i < N; i++) s += A[row + i] * X[off + i];
104
+ Fh[off + j] = s;
105
+ }
106
+ }
107
+ }
108
+ }
109
+
18
110
  // Обучение на окне, заканчивающемся свечой idx, и прогноз по этой свече.
19
111
  // Возвращает строки по всем акциям: ожидаемое движение (относительно рынка), сигнал покупки, кто влияет.
20
- export async function predictAt(ds, p, idx, { explain = true } = {}) {
21
- const H = barsFromHours(p.modelHorizon);
112
+ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null } = {}) {
113
+ const autoAdapt = p.modelAutoAdapt !== false;
114
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
115
+ const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
116
+ const H = barsFromHours(effHorizon);
117
+ const maxH = autoAdapt ? Math.round(H * 1.4) : H;
22
118
  const trainLen = Math.round(p.modelTrainDays * ds.barsPerDay);
23
- const trainStart = Math.max(FEATURE_WARMUP + H + 2, idx - trainLen);
119
+ const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
24
120
  // «текстовая» часть карты: устойчивые описания бизнеса (профили готовятся отдельно, здесь только читаем)
25
121
  const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
26
- const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles });
122
+ const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
27
123
  const u = idx - f.lo;
28
124
  const chosen = p.modelType in MODELS ? p.modelType : 'attention';
29
- const need = ['attention', chosen];
30
- // трансформер обучается здесь, только если торгует он (для сравнения — только в исследовании)
31
- const list = [...new Set(explain ? [...Object.keys(MODELS).filter((n) => !MODELS[n].heavy), chosen] : need)];
125
+ const list = [...new Set(explain ? Object.keys(MODELS) : ['attention', chosen])];
32
126
  const fitted = {};
33
- const preds = {};
34
127
  for (const n of list) {
35
- if (n === 'transformer') {
36
- // ~минута обучения — в отдельном потоке (или на видеокарте), чтобы сервер не замирал
37
- const tm = await import('./transformer.js');
38
- const { fitPredictIsolated } = await import('./transformer-pool.js');
39
- const r = await fitPredictIsolated(tm.transformerInput(f), [u], tm.transformerCfg(p), { gpu: p.transformerGpu });
40
- fitted[n] = { name: n, info: r.info, where: r.where };
41
- preds[n] = r.preds.get(u);
42
- continue;
43
- }
44
128
  fitted[n] = await MODELS[n].fit(f);
129
+ }
130
+ if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
131
+ return buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap);
132
+ }
133
+
134
+ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap = null) {
135
+ const preds = {};
136
+ for (const n of Object.keys(fitted)) {
45
137
  preds[n] = MODELS[n].predict(fitted[n], f, u);
46
- if (fitted[n].dispose) fitted[n].dispose();
47
138
  }
48
139
  const cost = (p.commissionPct + p.slippagePct) / 100;
49
- const threshold = p.modelMinEdge * 2 * cost;
140
+ const threshold = effMinEdge * 2 * cost;
50
141
  const bear = isMarketDown(ds, idx);
51
- const autoAdapt = p.modelAutoAdapt !== false;
52
142
  const dir = p.modelDirection || 'long';
53
- const needL = autoAdapt && bear && dir !== 'neutral' ? threshold * 1.25 : threshold;
143
+ const needL = autoAdapt && bear ? threshold * 1.25 : threshold;
54
144
  const needS = autoAdapt && dir === 'longshort' ? (bear ? threshold * 0.90 : threshold * 1.25) : threshold;
55
145
  let sInv = 0, nInv = 0;
56
146
  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++; }
@@ -58,8 +148,10 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
58
148
  const rows = ds.tokens.map((tk, i) => {
59
149
  const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
60
150
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
151
+ const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
61
152
  const row = {
62
153
  i, symbol: tk.symbol, name: tk.name,
154
+ price: round(livePx, 4),
63
155
  liquid: tk.liq[idx] >= LIQ_MIN,
64
156
  expected: exp,
65
157
  expectedPct: round(exp * 100, 3),
@@ -78,9 +170,7 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
78
170
  return { f, u, fitted, chosen, threshold, marketDown: bear, rows };
79
171
  }
80
172
 
81
- // Новости — только в прогнозе «сейчас»: в исторической проверке их нет (истории новостей не существует,
82
- // и ИИ, знающий будущее, исказил бы проверку). Строкам с новостями за последние 24 ч добавляем
83
- // news: { count, tone — средний взвешенный по важности, top — суть самой важной }
173
+ // Новости — только в прогнозе «сейчас»
84
174
  function withNews(result, p) {
85
175
  const nm = p.newsEnabled ? newsForSymbols(24) : null;
86
176
  for (const r of result.rows) {
@@ -91,29 +181,104 @@ function withNews(result, p) {
91
181
  return result;
92
182
  }
93
183
 
94
- // Прогноз на ближайшие H часов по последней закрытой свече
184
+ // Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
185
+ function attachDeltas(rows, stateObj) {
186
+ const prevExp = stateObj.prevExpMap;
187
+ const prevRank = stateObj.prevRankMap;
188
+ const nextExp = new Map();
189
+ const nextRank = new Map();
190
+ let changed = false;
191
+
192
+ rows.forEach((r, idx) => {
193
+ const rank = idx + 1;
194
+ nextExp.set(r.symbol, r.expectedPct);
195
+ nextRank.set(r.symbol, rank);
196
+ if (prevExp && prevExp.has(r.symbol)) {
197
+ const d = round(r.expectedPct - prevExp.get(r.symbol), 3);
198
+ if (Math.abs(d) >= 0.001) changed = true;
199
+ r.deltaPct = d !== 0 ? d : (r.deltaPct || 0);
200
+ r.rankDelta = prevRank?.has(r.symbol) ? (prevRank.get(r.symbol) - rank) : 0;
201
+ } else {
202
+ r.deltaPct = 0;
203
+ r.rankDelta = 0;
204
+ }
205
+ });
206
+
207
+ if (!prevExp || changed) {
208
+ stateObj.prevExpMap = nextExp;
209
+ stateObj.prevRankMap = nextRank;
210
+ }
211
+ }
212
+
213
+ // Прогноз на ближайшие H часов с живым поминутным обновлением котировок (без переобучения тяжёлого окна каждую минуту)
95
214
  export async function forecastNow(input, { force = false, onProgress } = {}) {
96
215
  const p = normalizeParams(input);
97
- const key = JSON.stringify([p.modelType, p.modelHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelMinEdge, p.modelDirection, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, p.commissionPct, p.slippagePct, ...(p.modelType === 'transformer' ? [p.transformerEpochs, p.transformerStride, p.transformerGpu] : [])]);
98
- if (!force && cache && cache.key === key && Date.now() - cache.at < 10 * 60000) return withNews(cache.result, p);
216
+ const autoAdapt = p.modelAutoAdapt !== false;
217
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
218
+ const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
219
+ 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]);
220
+
221
+ const now = Date.now();
222
+ // 1) Если с последнего живого тика прошло меньше 15 секунд и нет force — отдаём мгновенно из памяти
223
+ if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt < 15000) {
224
+ return withNews(baseState.lastTickResult, p);
225
+ }
226
+ // 2) Если базовая модель обучена менее 10 минут назад и нет force — делаем быстрый живой пересчёт (~80 мс запрос + 2 мс математика)
227
+ if (!force && baseState && baseState.key === key && now - baseState.trainedAt < 10 * 60000) {
228
+ const liveSnap = await fetchLiveMoexSnapshot();
229
+ if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
230
+ const H = barsFromHours(effHorizon);
231
+ const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, autoAdapt, true, liveSnap);
232
+ const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
233
+ attachDeltas(sortedRows, baseState);
234
+ const result = {
235
+ ...baseState.lastTickResult,
236
+ at: now,
237
+ liveTs: liveSnap?.ts || now,
238
+ rows: sortedRows,
239
+ };
240
+ baseState.lastTickResult = result;
241
+ baseState.lastTickAt = now;
242
+ return withNews(result, p);
243
+ }
244
+
99
245
  if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
100
246
  const task = (async () => {
101
- const now = Date.now();
102
- const ds = await loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress });
247
+ const [ds, liveSnap] = await Promise.all([
248
+ loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress }),
249
+ fetchLiveMoexSnapshot(),
250
+ ]);
103
251
  const last = lastClosedIndex(ds, now);
104
- const r = await predictAt(ds, p, last);
252
+ const r = await predictAt(ds, p, last, { explain: true, liveSnap });
105
253
  const snap = mapSnapshot(r.f);
254
+ const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
255
+ const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
256
+ attachDeltas(sortedRows, stateObj);
106
257
  const result = {
107
258
  at: now,
259
+ liveTs: liveSnap?.ts || now,
108
260
  barTs: ds.timeline[last],
109
261
  bar: fmtBarMsk(ds.timeline[last]),
110
- horizon: p.modelHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3), marketDown: r.marketDown,
111
- rows: r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct),
262
+ horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3), marketDown: r.marketDown,
263
+ rows: sortedRows,
112
264
  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 },
113
265
  heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
114
- ...(r.fitted.transformer ? { transformer: { device: r.fitted.transformer.where, bestEpoch: r.fitted.transformer.info.bestEpoch, epochs: r.fitted.transformer.info.epochs } } : {}),
115
266
  };
116
- cache = { key, at: now, result };
267
+ baseState = {
268
+ key,
269
+ trainedAt: now,
270
+ ds,
271
+ last,
272
+ f: r.f,
273
+ u: r.u,
274
+ fitted: r.fitted,
275
+ chosen: r.chosen,
276
+ snap,
277
+ prevExpMap: stateObj.prevExpMap,
278
+ prevRankMap: stateObj.prevRankMap,
279
+ lastTickResult: result,
280
+ lastTickAt: now,
281
+ };
117
282
  return result;
118
283
  })().finally(() => inflight.delete(key));
119
284
  inflight.set(key, task);
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  // Небольшая линейная алгебра без зависимостей: матрицы — массивы Float64Array (строки)
2
3
 
3
4
  export const matrix = (n, m) => Array.from({ length: n }, () => new Float64Array(m));