neuroalpha 0.0.0-stage → 0.2.0

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 (46) hide show
  1. package/README.md +164 -3
  2. package/bin/cli.js +69 -0
  3. package/config/strategy.json +33 -0
  4. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
  5. package/package.json +43 -6
  6. package/public/app.js +2410 -0
  7. package/public/index.html +337 -0
  8. package/public/landing.html +870 -0
  9. package/public/legal.html +90 -0
  10. package/public/styles.css +343 -0
  11. package/server/api/routes.js +333 -0
  12. package/server/auth/index.js +340 -0
  13. package/server/billing/yookassa.js +132 -0
  14. package/server/brokers/index.js +645 -0
  15. package/server/config.js +44 -0
  16. package/server/data/moex.js +165 -0
  17. package/server/db.js +420 -0
  18. package/server/hub.js +14 -0
  19. package/server/index.js +92 -0
  20. package/server/llm/client.js +187 -0
  21. package/server/llm/providers.js +355 -0
  22. package/server/model/dataset.js +352 -0
  23. package/server/model/forecast.js +121 -0
  24. package/server/model/gpu-worker.html +11 -0
  25. package/server/model/gpu-worker.js +60 -0
  26. package/server/model/linalg.js +142 -0
  27. package/server/model/live.js +486 -0
  28. package/server/model/metrics.js +134 -0
  29. package/server/model/models.js +459 -0
  30. package/server/model/profiles.js +166 -0
  31. package/server/model/research.js +880 -0
  32. package/server/model/tf-node.js +26 -0
  33. package/server/model/transformer-core.js +330 -0
  34. package/server/model/transformer-gpu.js +221 -0
  35. package/server/model/transformer-pool.js +200 -0
  36. package/server/model/transformer-worker.js +28 -0
  37. package/server/model/transformer.js +57 -0
  38. package/server/model/vocab.js +28 -0
  39. package/server/news/index.js +309 -0
  40. package/server/notify/index.js +168 -0
  41. package/server/params-meta.js +63 -0
  42. package/server/settings.js +79 -0
  43. package/server/strategist/analytics.js +150 -0
  44. package/server/strategist/index.js +484 -0
  45. package/server/strategist/tools.js +302 -0
  46. package/server/util.js +9 -0
@@ -0,0 +1,26 @@
1
+ // Загрузка TensorFlow.js в Node (главный поток или рабочий поток): WASM, если не вышло — обычный процессорный движок.
2
+ // Отдельный модуль без других зависимостей проекта: его грузят рабочие потоки, которым не нужна база и биржа.
3
+ let tfReady = null;
4
+ export function loadTf() {
5
+ if (!tfReady) tfReady = (async () => {
6
+ // tfjs в Node при загрузке советует поставить tfjs-node — нативную сборку мы сознательно не используем
7
+ const warn = console.warn;
8
+ console.warn = (...a) => { if (!String(a[0] ?? '').includes('tfjs-node')) warn(...a); };
9
+ let tf;
10
+ try {
11
+ tf = await import('@tensorflow/tfjs');
12
+ tf.enableProdMode();
13
+ let ok = false;
14
+ try {
15
+ await import('@tensorflow/tfjs-backend-wasm');
16
+ ok = await tf.setBackend('wasm');
17
+ } catch { ok = false; }
18
+ if (!ok) await tf.setBackend('cpu');
19
+ await tf.ready();
20
+ } finally {
21
+ console.warn = warn;
22
+ }
23
+ return tf;
24
+ })().catch((e) => { tfReady = null; throw e; });
25
+ return tfReady;
26
+ }
@@ -0,0 +1,330 @@
1
+ // Ядро трансформера: обучение и прогноз. Один и тот же код работает и в Node (главный поток, рабочие потоки),
2
+ // и в браузере (обучение на видеокарте через WebGL), поэтому здесь нет импортов Node — tf передаётся параметром.
3
+ //
4
+ // Модель: в момент t каждая акция — «токен»: её признаки контекста (+ обучаемый эмбеддинг акции);
5
+ // слои энкодера смешивают информацию между акциями; на выходе — прогноз нормированной цели yz для каждой акции.
6
+ //
7
+ // На вход — «облегчённое окно» (см. transformerInput в transformer.js): только нужные для обучения массивы
8
+ // { N, W, H, X, liq, has, yz, macro: { list: [{ b, zs }] } | null, tr: моменты обучения, liqMin }.
9
+ import { mulberry32, spearman } from './linalg.js';
10
+
11
+ export const TF_CFG = {
12
+ d: 24, heads: 2, layers: 2, ff: 48, dropout: 0.1,
13
+ lr: 2e-3, weightDecay: 0.1, batch: 32, epochs: 8, patience: 2,
14
+ // соседние 10-минутные моменты почти одинаковы: в эпоху берём каждую 6-ю свечу,
15
+ // со сдвигом от эпохи к эпохе (за 6 эпох модель видит все моменты); проверка — каждая 3-я свеча
16
+ stride: 6, valStride: 3,
17
+ valFrac: 0.2, seed: 20261013,
18
+ };
19
+
20
+ // Короткая пауза, чтобы успели обработаться другие события (отмена, прогресс).
21
+ // В Node — setImmediate, в браузере — MessageChannel (setTimeout в браузере медленнее)
22
+ const tick = (() => {
23
+ if (typeof setImmediate === 'function') return () => new Promise((r) => setImmediate(r));
24
+ const ch = new MessageChannel();
25
+ const q = [];
26
+ ch.port1.onmessage = () => q.shift()?.();
27
+ return () => new Promise((r) => { q.push(r); ch.port2.postMessage(0); });
28
+ })();
29
+
30
+ // Признаки акции i в момент u: 6 признаков контекста, флаги ликвидности, макро (бета акции × движение макро)
31
+ function featCount(f) {
32
+ return 6 + 2 + (f.macro ? f.macro.list.length * 3 : 0);
33
+ }
34
+ function fillFeat(f, u, out, off) {
35
+ const { N } = f;
36
+ const ML = f.macro ? f.macro.list : [];
37
+ const F = featCount(f);
38
+ for (let i = 0; i < N; i++) {
39
+ const k = u * N + i, o = off + i * F;
40
+ for (let s = 0; s < 3; s++) { out[o + s] = f.X[0][s][k]; out[o + 3 + s] = f.X[1][s][k]; }
41
+ out[o + 6] = f.liq[k] / 20;
42
+ out[o + 7] = f.has[k];
43
+ for (let m = 0; m < ML.length; m++) for (let s = 0; s < 3; s++) out[o + 8 + m * 3 + s] = ML[m].b[i] * ML[m].zs[s * f.W + u];
44
+ }
45
+ }
46
+ const liquidAt = (f, u, i) => f.has[u * f.N + i] && f.liq[u * f.N + i] >= f.liqMin;
47
+
48
+ // Нормировка признаков — среднее и разброс только по моментам окна обучения (ликвидные акции)
49
+ function featStats(f, us) {
50
+ const N = f.N, F = featCount(f);
51
+ const buf = new Float32Array(N * F);
52
+ const s1 = new Float64Array(F), s2 = new Float64Array(F);
53
+ let n = 0;
54
+ for (const u of us) {
55
+ fillFeat(f, u, buf, 0);
56
+ for (let i = 0; i < N; i++) {
57
+ if (!liquidAt(f, u, i)) continue;
58
+ n++;
59
+ for (let c = 0; c < F; c++) { const v = buf[i * F + c]; s1[c] += v; s2[c] += v * v; }
60
+ }
61
+ }
62
+ const mean = new Float32Array(F), inv = new Float32Array(F);
63
+ for (let c = 0; c < F; c++) {
64
+ mean[c] = n ? s1[c] / n : 0;
65
+ const sd = n ? Math.sqrt(Math.max(s2[c] / n - mean[c] ** 2, 0)) : 1;
66
+ inv[c] = sd > 1e-6 ? 1 / sd : 0;
67
+ }
68
+ // флаги ликвидности не центрируем — только масштаб
69
+ mean[6] = 0; mean[7] = 0; inv[6] = 1; inv[7] = 1;
70
+ return { mean, inv };
71
+ }
72
+
73
+ // Цель момента: yz, поперечно стандартизированная по ликвидным акциям (модель учит порядок «кто сильнее»)
74
+ function targetAt(f, u, y, lm, off) {
75
+ const N = f.N;
76
+ let s = 0, s2 = 0, n = 0;
77
+ for (let i = 0; i < N; i++) if (liquidAt(f, u, i)) { const v = f.yz[u * N + i]; s += v; s2 += v * v; n++; }
78
+ if (n < 5) { for (let i = 0; i < N; i++) { y[off + i] = 0; lm[off + i] = 0; } return null; }
79
+ const m = s / n, sd = Math.sqrt(Math.max(s2 / n - m * m, 1e-9));
80
+ for (let i = 0; i < N; i++) {
81
+ const ok = liquidAt(f, u, i);
82
+ lm[off + i] = ok ? 1 : 0;
83
+ y[off + i] = ok ? Math.max(-5, Math.min(5, (f.yz[u * N + i] - m) / sd)) : 0;
84
+ }
85
+ return sd;
86
+ }
87
+
88
+ function makeBatch(f, us, norm) {
89
+ const N = f.N, F = featCount(f), B = us.length;
90
+ const x = new Float32Array(B * N * F), km = new Float32Array(B * N);
91
+ us.forEach((u, b) => {
92
+ fillFeat(f, u, x, b * N * F);
93
+ for (let i = 0; i < N; i++) {
94
+ const o = (b * N + i) * F;
95
+ for (let c = 0; c < F; c++) x[o + c] = Math.max(-6, Math.min(6, (x[o + c] - norm.mean[c]) * norm.inv[c]));
96
+ // ключи внимания — акции, торговавшиеся в последние 20 свечей
97
+ km[b * N + i] = f.liq[u * N + i] > 0 ? 0 : -1e9;
98
+ }
99
+ });
100
+ return { x, km, B };
101
+ }
102
+
103
+ function initWeights(tf, cfg, F, N) {
104
+ const { d, ff, layers } = cfg;
105
+ let seed = cfg.seed;
106
+ const glorot = (shape) => tf.variable(tf.initializers.glorotUniform({ seed: seed++ }).apply(shape));
107
+ const zeros = (n) => tf.variable(tf.zeros([n]));
108
+ const ones = (n) => tf.variable(tf.ones([n]));
109
+ const w = {
110
+ Win: glorot([F, d]), bin: zeros(d),
111
+ E: tf.variable(tf.initializers.randomNormal({ mean: 0, stddev: 0.02, seed: seed++ }).apply([N, d])),
112
+ L: [],
113
+ lnfg: ones(d), lnfb: zeros(d),
114
+ Wout: glorot([d, 1]), bout: zeros(1),
115
+ Wlin: tf.variable(tf.zeros([F, 1])),
116
+ };
117
+ for (let l = 0; l < layers; l++) {
118
+ w.L.push({
119
+ ln1g: ones(d), ln1b: zeros(d),
120
+ Wq: glorot([d, d]), Wk: glorot([d, d]), Wv: glorot([d, d]), Wo: glorot([d, d]), bo: zeros(d),
121
+ ln2g: ones(d), ln2b: zeros(d),
122
+ W1: glorot([d, ff]), b1: zeros(ff), W2: glorot([ff, d]), b2: zeros(d),
123
+ });
124
+ }
125
+ return w;
126
+ }
127
+ const allVars = (w) => [w.Win, w.bin, w.E, w.lnfg, w.lnfb, w.Wout, w.bout, w.Wlin, ...w.L.flatMap((l) => Object.values(l))];
128
+ // затухание весов — только у матриц (не у сдвигов и норм)
129
+ const decayVars = (w) => [w.Win, w.E, w.Wout, w.Wlin, ...w.L.flatMap((l) => [l.Wq, l.Wk, l.Wv, l.Wo, l.W1, l.W2])];
130
+
131
+ // Прямой проход: x [B,N,F], km [B,N] (0 или −1e9) → прогноз [B,N]
132
+ function forward(tf, w, cfg, x, km, B, N, train, seedRef) {
133
+ const { d, heads } = cfg;
134
+ const dh = d / heads;
135
+ const drop = (t) => (train && cfg.dropout > 0 ? tf.dropout(t, cfg.dropout, undefined, seedRef.v++) : t);
136
+ const ln = (t, g, b) => {
137
+ const { mean, variance } = tf.moments(t, -1, true);
138
+ return t.sub(mean).div(variance.add(1e-5).sqrt()).mul(g).add(b);
139
+ };
140
+ const lin = (t2, W, b) => (b ? tf.matMul(t2, W).add(b) : tf.matMul(t2, W));
141
+ const F = x.shape[2];
142
+ let h = lin(x.reshape([B * N, F]), w.Win, w.bin).reshape([B, N, d]).add(w.E);
143
+ const mask = km.reshape([B, 1, 1, N]);
144
+ const split = (t2) => t2.reshape([B, N, heads, dh]).transpose([0, 2, 1, 3]);
145
+ for (const L of w.L) {
146
+ const a = ln(h, L.ln1g, L.ln1b).reshape([B * N, d]);
147
+ const q = split(lin(a, L.Wq).mul(1 / Math.sqrt(dh))), k = split(lin(a, L.Wk)), v = split(lin(a, L.Wv));
148
+ // dropout — на выходах внимания и MLP (на самих весах внимания он втрое дороже по времени)
149
+ const att = tf.softmax(tf.matMul(q, k, false, true).add(mask));
150
+ const o = tf.matMul(att, v).transpose([0, 2, 1, 3]).reshape([B * N, d]);
151
+ h = h.add(drop(lin(o, L.Wo, L.bo)).reshape([B, N, d]));
152
+ const a2 = ln(h, L.ln2g, L.ln2b).reshape([B * N, d]);
153
+ const m = lin(tf.relu(lin(a2, L.W1, L.b1)), L.W2, L.b2);
154
+ h = h.add(drop(m).reshape([B, N, d]));
155
+ }
156
+ // + линейная «обходная» часть прямо от признаков: LayerNorm сглаживает величину движения,
157
+ // а для порога выгоды важны именно крупные прогнозы
158
+ return lin(ln(h, w.lnfg, w.lnfb).reshape([B * N, d]), w.Wout, w.bout).add(tf.matMul(x.reshape([B * N, F]), w.Wlin)).reshape([B, N]);
159
+ }
160
+
161
+ // Прогноз по моментам us: Map(u → Float64Array(N)) в единицах yz
162
+ export function predictCore(tf, model, f, us) {
163
+ const N = f.N, F = featCount(f);
164
+ const out = new Map();
165
+ const step = 64;
166
+ for (let s = 0; s < us.length; s += step) {
167
+ const part = Array.from(us.slice(s, s + step));
168
+ const { x, km, B } = makeBatch(f, part, model.norm);
169
+ const t = tf.tidy(() => forward(tf, model.w, model.cfg, tf.tensor3d(x, [B, N, F]), tf.tensor2d(km, [B, N]), B, N, false, null));
170
+ const pr = t.dataSync();
171
+ t.dispose();
172
+ part.forEach((u, b) => {
173
+ const r = new Float64Array(N);
174
+ for (let i = 0; i < N; i++) r[i] = pr[b * N + i] * model.scale;
175
+ out.set(u, r);
176
+ });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ const meanIc = (f, preds) => {
182
+ let s = 0, n = 0;
183
+ for (const [u, pr] of preds) {
184
+ const ok = [...pr.keys()].filter((i) => liquidAt(f, u, i));
185
+ if (ok.length < 5) continue;
186
+ s += spearman(ok.map((i) => pr[i]), ok.map((i) => f.yz[u * f.N + i])); n++;
187
+ }
188
+ return n ? s / n : 0;
189
+ };
190
+
191
+ // Обучение в окне. onProgress({ epoch, epochs }) — в начале каждой эпохи; isCancelled() → прерывание между батчами
192
+ export async function fitCore(tf, f, { onProgress = () => {}, isCancelled = () => false, cfg: over = {} } = {}) {
193
+ const cfg = { ...TF_CFG, ...over };
194
+ const N = f.N, F = featCount(f);
195
+ // только моменты, цель которых целиком внутри окна обучения
196
+ const all = Array.from(f.tr);
197
+ const nVal = Math.max(1, Math.floor(all.length * cfg.valFrac));
198
+ // проверка для ранней остановки — последние 20% окна обучения
199
+ const valUs = all.slice(all.length - nVal).filter((u, k) => k % cfg.valStride === 0);
200
+ // зазор H+1 свечей: цели обучающих моментов не заходят в период проверки
201
+ const trUs0 = all.slice(0, all.length - nVal).filter((u) => u + f.H + 1 < valUs[0]);
202
+ const norm = featStats(f, all.filter((u, k) => k % cfg.valStride === 0));
203
+
204
+ // цели обучения заранее (поперечная стандартизация по моменту)
205
+ const Y = new Float32Array(trUs0.length * N), LM = new Float32Array(trUs0.length * N);
206
+ const trUs = [];
207
+ const rowOf = new Map();
208
+ trUs0.forEach((u, k) => {
209
+ if (targetAt(f, u, Y, LM, k * N) === null) return;
210
+ rowOf.set(u, k);
211
+ trUs.push(u);
212
+ });
213
+ if (trUs.length < cfg.batch * cfg.stride) throw new Error('Трансформер: слишком мало моментов для обучения');
214
+
215
+ // tidy освобождает начальные тензоры инициализаторов (переменные остаются)
216
+ const w = tf.tidy(() => initWeights(tf, cfg, F, N));
217
+ const vars = allVars(w), dvars = decayVars(w);
218
+ const nParams = vars.reduce((s, v) => s + v.size, 0);
219
+ const opt = tf.train.adam(cfg.lr);
220
+ const rnd = mulberry32(cfg.seed);
221
+ const seedRef = { v: cfg.seed * 7 };
222
+ // scale — множитель выхода; при обучении 1 (единицы поперечного разброса), после — калибровка
223
+ const model = { name: 'transformer', w, cfg, norm, scale: 1, nParams };
224
+ let best = null, bad = 0, epochsRun = 0;
225
+ const snapshot = () => vars.map((v) => tf.keep(v.clone()));
226
+ const freeSnap = (s) => s && s.forEach((t) => t.dispose());
227
+
228
+ try {
229
+ for (let ep = 0; ep < cfg.epochs; ep++) {
230
+ onProgress({ epoch: ep + 1, epochs: cfg.epochs });
231
+ await tick();
232
+ // фиксированный (по seed) порядок батчей
233
+ const order = trUs.filter((u, k) => k % cfg.stride === ep % cfg.stride);
234
+ for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
235
+ for (let s = 0; s + cfg.batch <= order.length; s += cfg.batch) {
236
+ if (isCancelled()) throw new Error('Отменено');
237
+ const part = order.slice(s, s + cfg.batch);
238
+ const B = part.length;
239
+ const { x, km } = makeBatch(f, part, norm);
240
+ const y = new Float32Array(B * N), lm = new Float32Array(B * N);
241
+ part.forEach((u, b) => { const r = rowOf.get(u) * N; y.set(Y.subarray(r, r + N), b * N); lm.set(LM.subarray(r, r + N), b * N); });
242
+ tf.tidy(() => {
243
+ const xt = tf.tensor3d(x, [B, N, F]), kt = tf.tensor2d(km, [B, N]);
244
+ const yt = tf.tensor2d(y, [B, N]), mt = tf.tensor2d(lm, [B, N]);
245
+ // MSE только по ликвидным акциям
246
+ opt.minimize(() => forward(tf, w, cfg, xt, kt, B, N, true, seedRef).sub(yt).square().mul(mt).sum().div(mt.sum().maximum(1)), false, vars);
247
+ // AdamW: затухание весов отдельно от градиента
248
+ const k = 1 - cfg.lr * cfg.weightDecay;
249
+ for (const v of dvars) v.assign(v.mul(k));
250
+ });
251
+ if ((s / cfg.batch) % 4 === 3) await tick();
252
+ }
253
+ epochsRun = ep + 1;
254
+ // ранняя остановка по совпадению прогноза с целью на последних 20% окна обучения
255
+ const valIc = meanIc(f, predictCore(tf, model, f, valUs));
256
+ if (!best || valIc > best.valIc) {
257
+ freeSnap(best?.snap);
258
+ best = { valIc, epoch: ep + 1, snap: snapshot() };
259
+ bad = 0;
260
+ } else if (++bad >= cfg.patience) break;
261
+ }
262
+ // возвращаем веса лучшей эпохи
263
+ if (best) vars.forEach((v, k) => v.assign(best.snap[k]));
264
+ // калибровка масштаба на проверочной части окна обучения: прогноз в единицах yz (как у гребневых моделей),
265
+ // иначе осторожная нейросеть редко преодолевает «Порог выгоды». Отрицательный наклон → прогноз 0.
266
+ model.scale = 1;
267
+ let sxy = 0, sxx = 0;
268
+ for (const [u, pr] of predictCore(tf, model, f, valUs)) {
269
+ const ok = [...pr.keys()].filter((i) => liquidAt(f, u, i));
270
+ if (ok.length < 5) continue;
271
+ const mp = ok.reduce((s, i) => s + pr[i], 0) / ok.length, my = ok.reduce((s, i) => s + f.yz[u * N + i], 0) / ok.length;
272
+ for (const i of ok) { const a = pr[i] - mp; sxy += a * (f.yz[u * N + i] - my); sxx += a * a; }
273
+ }
274
+ model.scale = sxx > 0 ? Math.max(0, sxy / sxx) : 0;
275
+ } catch (e) {
276
+ freeSnap(best?.snap);
277
+ vars.forEach((v) => v.dispose());
278
+ opt.dispose();
279
+ throw e;
280
+ }
281
+ freeSnap(best?.snap);
282
+ opt.dispose();
283
+ model.info = { params: nParams, epochs: epochsRun, bestEpoch: best?.epoch ?? 0, valIc: best?.valIc ?? null, scale: model.scale, trainMoments: trUs.length, valMoments: valUs.length };
284
+ model.dispose = () => vars.forEach((v) => v.dispose());
285
+ return model;
286
+ }
287
+
288
+ // Обучение и сразу прогноз на моменты месяца прогноза: { preds: Map(u → Float64Array), info }
289
+ export async function fitPredictCore(tf, f, us, opts) {
290
+ const model = await fitCore(tf, f, opts);
291
+ try {
292
+ const preds = predictCore(tf, model, f, us);
293
+ return { preds, info: model.info };
294
+ } finally {
295
+ model.dispose();
296
+ }
297
+ }
298
+
299
+ // ---------- упаковка в один двоичный блок (для передачи через WebSocket) ----------
300
+ // [4 байта: длина заголовка][заголовок JSON][выравнивание до 8][массивы подряд, каждый с выравниванием до 8]
301
+ const TA = { Float64Array, Float32Array, Int32Array, Uint32Array, Uint8Array, Int16Array, Uint16Array, Int8Array };
302
+ export function pack(obj) {
303
+ const arrs = [];
304
+ let off = 0;
305
+ const header = JSON.stringify(obj, (k, v) => {
306
+ if (ArrayBuffer.isView(v) && !(v instanceof DataView)) {
307
+ const rec = { $ta: v.constructor.name, off, len: v.length };
308
+ arrs.push([v, off]);
309
+ off += Math.ceil(v.byteLength / 8) * 8;
310
+ return rec;
311
+ }
312
+ return v;
313
+ });
314
+ const hb = new TextEncoder().encode(header);
315
+ const base = Math.ceil((4 + hb.length) / 8) * 8;
316
+ const out = new Uint8Array(base + off);
317
+ new DataView(out.buffer).setUint32(0, hb.length, true);
318
+ out.set(hb, 4);
319
+ for (const [v, o] of arrs) out.set(new Uint8Array(v.buffer, v.byteOffset, v.byteLength), base + o);
320
+ return out;
321
+ }
322
+ export function unpack(data) {
323
+ // копия в собственный буфер: так массивы гарантированно выровнены
324
+ const u8 = new Uint8Array(data.byteLength);
325
+ u8.set(data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
326
+ const hl = new DataView(u8.buffer).getUint32(0, true);
327
+ const base = Math.ceil((4 + hl) / 8) * 8;
328
+ const header = new TextDecoder().decode(u8.subarray(4, 4 + hl));
329
+ return JSON.parse(header, (k, v) => (v && typeof v === 'object' && v.$ta ? new TA[v.$ta](u8.buffer, base + v.off, v.len) : v));
330
+ }
@@ -0,0 +1,221 @@
1
+ // Обучение трансформера на видеокарте без нативных сборок: TensorFlow.js (WebGL) в безголовом браузере
2
+ // на базе Chromium (Chrome, Edge, Chromium, Brave). Браузер запускается со своим временным профилем,
3
+ // подключается к служебному мини-серверу (только 127.0.0.1) по WebSocket, получает данные окна двоично
4
+ // и обучает тем же кодом, что и в Node (transformer-core.js).
5
+ // Если браузера нет, WebGL недоступен или отрисовка программная — ошибка, вызывающий откатывается на процессор.
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import http from 'node:http';
11
+ import crypto from 'node:crypto';
12
+ import { fileURLToPath } from 'node:url';
13
+ import express from 'express';
14
+ import { WebSocketServer } from 'ws';
15
+ import { pack, unpack } from './transformer-core.js';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const ROOT = path.resolve(HERE, '../..');
19
+ // программная отрисовка вместо видеокарты
20
+ const SOFTWARE = /swiftshader|llvmpipe|softpipe|software|basic render|mesa offscreen|lavapipe/i;
21
+
22
+ const exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
23
+
24
+ // Поиск браузера: BROWSER_PATH → стандартные места установки для Windows / macOS / Linux
25
+ export function findBrowser() {
26
+ if (process.env.BROWSER_PATH) return exists(process.env.BROWSER_PATH) ? process.env.BROWSER_PATH : null;
27
+ const list = [];
28
+ if (process.platform === 'win32') {
29
+ const bases = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean);
30
+ const rel = ['Google/Chrome/Application/chrome.exe', 'Microsoft/Edge/Application/msedge.exe', 'Chromium/Application/chrome.exe', 'BraveSoftware/Brave-Browser/Application/brave.exe'];
31
+ for (const r of rel) for (const b of bases) list.push(path.join(b, r));
32
+ } else if (process.platform === 'darwin') {
33
+ const apps = [['Google Chrome', 'Google Chrome'], ['Microsoft Edge', 'Microsoft Edge'], ['Chromium', 'Chromium'], ['Brave Browser', 'Brave Browser']];
34
+ for (const dir of ['/Applications', path.join(os.homedir(), 'Applications')]) for (const [app, bin] of apps) list.push(`${dir}/${app}.app/Contents/MacOS/${bin}`);
35
+ } else {
36
+ const names = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'microsoft-edge-stable', 'brave-browser'];
37
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
38
+ for (const n of names) for (const d of [...dirs, '/usr/bin', '/usr/local/bin', '/snap/bin']) list.push(path.join(d, n));
39
+ }
40
+ return list.find(exists) || null;
41
+ }
42
+
43
+ // Короткое название видеокарты из строки WebGL («ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x…) Direct3D11 …)»)
44
+ function gpuName(renderer) {
45
+ const m = /^ANGLE \(([^,]*),\s*(.+?)(?:\s*\(0x[0-9a-f]+\))?(?:\s+Direct3D.*|\s+OpenGL.*|\s+Metal.*|,.*)?\)?$/i.exec(renderer || '');
46
+ return (m ? m[2] : renderer || 'неизвестно').trim();
47
+ }
48
+
49
+ // Завершение браузера со всеми его процессами — строго по своему PID (не по имени: у пользователя открыт свой браузер)
50
+ function killTree(pid) {
51
+ if (!pid) return;
52
+ try {
53
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
54
+ else process.kill(-pid, 'SIGKILL'); // отдельная группа процессов (detached)
55
+ } catch {
56
+ try { process.kill(pid, 'SIGKILL'); } catch { /* уже завершён */ }
57
+ }
58
+ }
59
+
60
+ // при выходе сервера браузеры не должны остаться висеть
61
+ const alive = new Set();
62
+ process.once('exit', () => { for (const r of alive) killTree(r.proc?.pid); });
63
+
64
+ export class GpuRunner {
65
+ threads = 1;
66
+ where = 'видеокарта';
67
+ closed = false;
68
+ job = null;
69
+ chain = Promise.resolve();
70
+
71
+ async start({ timeoutMs = 60000 } = {}) {
72
+ const exe = findBrowser();
73
+ if (!exe) throw new Error(process.env.BROWSER_PATH ? `нет браузера по пути BROWSER_PATH: ${process.env.BROWSER_PATH}` : 'не найден Chrome / Edge / Chromium / Brave (путь можно указать в BROWSER_PATH)');
74
+ try {
75
+ await this.#serve();
76
+ const hello = new Promise((resolve, reject) => { this.onHello = resolve; this.onStartFail = reject; });
77
+ this.dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trading-gpu-'));
78
+ const args = [
79
+ '--headless=new', `--user-data-dir=${this.dir}`,
80
+ '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-component-extensions-with-background-pages',
81
+ '--disable-sync', '--disable-component-update', '--disable-default-apps', '--disable-background-networking',
82
+ '--mute-audio', '--password-store=basic', '--use-mock-keychain',
83
+ '--ignore-gpu-blocklist', '--enable-gpu', '--enable-webgl', '--enable-gpu-rasterization',
84
+ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows',
85
+ '--disable-features=Translate,MediaRouter,OptimizationHints',
86
+ ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
87
+ `http://127.0.0.1:${this.port}/gpu-worker.html?token=${this.token}`,
88
+ ];
89
+ this.proc = spawn(exe, args, { stdio: 'ignore', detached: process.platform !== 'win32', windowsHide: true });
90
+ alive.add(this);
91
+ this.exited = new Promise((r) => this.proc.once('exit', r));
92
+ this.proc.once('error', (e) => this.onStartFail?.(e));
93
+ this.proc.once('exit', (code) => {
94
+ alive.delete(this);
95
+ this.onStartFail?.(new Error(`браузер завершился (код ${code})`));
96
+ this.#failJob(new Error('браузер завершился'));
97
+ });
98
+ let timer;
99
+ const h = await Promise.race([hello, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('браузер не ответил за минуту')), timeoutMs); })]).finally(() => clearTimeout(timer));
100
+ this.onStartFail = null;
101
+ if (!h.webgl) throw new Error('WebGL недоступен');
102
+ if (SOFTWARE.test(h.renderer || '')) throw new Error(`нет видеокарты, программная отрисовка: ${h.renderer}`);
103
+ if (h.backend !== 'webgl') throw new Error(`TensorFlow.js не включил WebGL${h.error ? `: ${h.error}` : ''}`);
104
+ if (!h.float32) throw new Error('видеокарта не умеет считать в 32-битных числах');
105
+ this.renderer = h.renderer;
106
+ this.where = `видеокарта: ${gpuName(h.renderer)}`;
107
+ return this;
108
+ } catch (e) {
109
+ await this.close();
110
+ throw e;
111
+ }
112
+ }
113
+
114
+ // Служебный мини-сервер: страница обучения, общий код модели, tfjs из node_modules и WebSocket
115
+ async #serve() {
116
+ this.token = crypto.randomBytes(16).toString('hex');
117
+ const app = express();
118
+ const file = (url, p) => app.get(url, (req, res) => res.type(path.extname(p)).sendFile(p));
119
+ file('/gpu-worker.html', path.join(HERE, 'gpu-worker.html'));
120
+ file('/gpu/gpu-worker.js', path.join(HERE, 'gpu-worker.js'));
121
+ file('/gpu/transformer-core.js', path.join(HERE, 'transformer-core.js'));
122
+ file('/gpu/linalg.js', path.join(HERE, 'linalg.js'));
123
+ app.use('/tf', express.static(path.join(ROOT, 'node_modules/@tensorflow/tfjs/dist')));
124
+ this.server = http.createServer(app);
125
+ this.wss = new WebSocketServer({ server: this.server, path: '/gpu-ws', maxPayload: 1 << 30 });
126
+ this.wss.on('connection', (ws, req) => {
127
+ if (new URL(req.url, 'http://x').searchParams.get('token') !== this.token || this.ws) { ws.close(); return; }
128
+ this.ws = ws;
129
+ ws.on('message', (data, isBinary) => this.#onMessage(data, isBinary));
130
+ ws.on('close', () => { this.ws = null; this.#failJob(new Error('связь с браузером потеряна')); });
131
+ });
132
+ this.wss.on('error', () => {});
133
+ await new Promise((resolve, reject) => {
134
+ this.server.once('error', reject);
135
+ this.server.listen(0, '127.0.0.1', () => { this.port = this.server.address().port; resolve(); });
136
+ });
137
+ }
138
+
139
+ #onMessage(data, isBinary) {
140
+ if (!isBinary) {
141
+ const m = JSON.parse(String(data));
142
+ if (m.type === 'hello') { this.onHello?.(m); this.onHello = null; return; }
143
+ const job = this.job;
144
+ if (!job || m.id !== job.id) return;
145
+ if (m.type === 'progress') job.onProgress?.(m);
146
+ else if (m.type === 'error') { this.job = null; job.reject(m.cancelled || this.closed ? new Error('Отменено') : new Error(m.error)); }
147
+ return;
148
+ }
149
+ const m = unpack(data);
150
+ const job = this.job;
151
+ if (!job || m.id !== job.id) return;
152
+ this.job = null;
153
+ const preds = new Map();
154
+ Array.from(m.us).forEach((u, k) => preds.set(u, m.flat.subarray(k * job.N, (k + 1) * job.N)));
155
+ job.resolve({ preds, info: { ...m.info, device: this.where } });
156
+ }
157
+
158
+ #failJob(e) {
159
+ const job = this.job;
160
+ if (!job) return;
161
+ this.job = null;
162
+ job.reject(this.closed ? new Error('Отменено') : e);
163
+ }
164
+
165
+ get backlog() { return this.pending || 0; }
166
+
167
+ // Окна — строго по очереди (видеокарта одна)
168
+ submit(input, us, cfg, { onProgress } = {}) {
169
+ this.pending = (this.pending || 0) + 1;
170
+ const run = () => new Promise((resolve, reject) => {
171
+ if (this.closed || !this.ws) { reject(new Error(this.closed ? 'Отменено' : 'связь с браузером потеряна')); return; }
172
+ this.job = { id: crypto.randomUUID(), N: input.N, onProgress, resolve, reject };
173
+ this.ws.send(pack({ id: this.job.id, input, us: Int32Array.from(us), cfg }));
174
+ });
175
+ const p = this.chain.then(run, run).finally(() => { this.pending--; this.touch?.(); });
176
+ this.chain = p.catch(() => {});
177
+ return p;
178
+ }
179
+
180
+ // Отмена — просто закрываем браузер
181
+ cancel() { this.close(); }
182
+
183
+ async close() {
184
+ if (this.closed) return;
185
+ this.closed = true;
186
+ this.#failJob(new Error('Отменено'));
187
+ this.onStartFail = null;
188
+ if (this.proc && this.proc.exitCode === null) {
189
+ killTree(this.proc.pid);
190
+ await Promise.race([this.exited, new Promise((r) => setTimeout(r, 5000))]);
191
+ }
192
+ alive.delete(this);
193
+ try { this.wss?.close(); } catch { /* уже закрыт */ }
194
+ for (const c of this.wss?.clients || []) c.terminate();
195
+ await new Promise((r) => (this.server?.listening ? this.server.close(() => r()) : r()));
196
+ if (this.dir) fs.rm(this.dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }, () => {});
197
+ }
198
+ }
199
+
200
+ // Общий браузер для прогноза «сейчас» и живой проверки: закрывается после 10 минут простоя
201
+ let shared = null, lastFail = null;
202
+ const IDLE_MS = 10 * 60000;
203
+ export async function sharedGpu() {
204
+ if (shared) {
205
+ const r = await shared;
206
+ if (!r.closed && r.ws) return r;
207
+ shared = null;
208
+ }
209
+ // недавно не получилось — не запускаем браузер на каждый прогноз заново
210
+ if (lastFail && Date.now() - lastFail.at < IDLE_MS) throw lastFail.error;
211
+ shared = new GpuRunner().start().catch((e) => { shared = null; lastFail = { at: Date.now(), error: e }; throw e; });
212
+ const r = await shared;
213
+ let timer = null;
214
+ r.touch = () => {
215
+ clearTimeout(timer);
216
+ timer = setTimeout(() => { if (!r.backlog) { r.close(); if (shared) shared.then((x) => { if (x === r) shared = null; }); } }, IDLE_MS);
217
+ timer.unref();
218
+ };
219
+ r.touch();
220
+ return r;
221
+ }