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,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  import { normalizeParams } from '../settings.js';
2
3
  import { computeReport, downsample, buildPeriods } from './metrics.js';
3
4
  import { round } from '../util.js';
@@ -18,9 +19,7 @@ const MAX_CONCURRENT_RESEARCH = 1;
18
19
  const tick = () => new Promise((r) => setImmediate(r));
19
20
 
20
21
  // Ключевые параметры для сравнения исследований (включая capital — он влияет на рубли)
21
- // transformerParallel сюда не входит: параллельное обучение даёт тот же результат
22
- const TF_KEYS = ['transformerEpochs', 'transformerStride', 'transformerGpu'];
23
- const REUSE_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelMinHoldHours', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelUseDividends', 'modelTextWeight', 'modelUseTransformer', ...TF_KEYS, 'commissionPct', 'slippagePct', 'oosPct', 'capital'];
22
+ const REUSE_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelUseDividends', 'modelTextWeight', 'commissionPct', 'slippagePct', 'oosPct', 'capital'];
24
23
 
25
24
  // Фильтр падающего рынка: индекс Мосбиржи в среднесрочном нисходящем тренде (ниже уровня 10 торговых дней назад)
26
25
  // или в сильном локальном обвале (ниже уровня дня и 3 дней назад на >1%)
@@ -35,14 +34,13 @@ export function isMarketDown(ds, t) {
35
34
  }
36
35
 
37
36
  // Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
38
- // 3 — затухающий карантин autoAdapt, полушаговое сопровождение сделок и адаптивная балансировка по режиму рынка
39
- const DATA_VERSION = 3;
37
+ // 7 — мульти-горизонтное обучение (0.6H / 1.0H / 1.4H) + взвешивание слота по силе сигнала (Conviction Sizing) в Авто-режиме
38
+ const DATA_VERSION = 7;
40
39
 
41
- // у старых исследований новых настроек нет — для них действуют умолчания;
42
- // настройки трансформера важны, только если он участвует
40
+ const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
43
41
  function reuseParamsKey(params) {
44
- const tfOn = modelNames({ ...strategyDefaults, ...params }).includes('transformer');
45
- return JSON.stringify([params._data ?? 0, ...REUSE_KEYS.map((k) => (TF_KEYS.includes(k) && !tfOn ? strategyDefaults[k] : params[k] ?? strategyDefaults[k]))]);
42
+ const autoOn = (params.modelAutoAdapt ?? strategyDefaults.modelAutoAdapt) !== false;
43
+ return JSON.stringify([params._data ?? 0, ...REUSE_KEYS.map((k) => (autoOn && k in AUTO_LOCKED ? AUTO_LOCKED[k] : (params[k] ?? strategyDefaults[k])))]);
46
44
  }
47
45
 
48
46
  function findReusableRun(params) {
@@ -143,7 +141,11 @@ const fmtDay = (ts) => new Date(ts + MSK).toISOString().slice(0, 10).split('-').
143
141
 
144
142
  async function run(runId, p, state) {
145
143
  const emit = (m) => hub.emit({ type: 'research:progress', runId, ...m });
146
- const H = barsFromHours(p.modelHorizon); // горизонт в свечах по 10 минут
144
+ const autoAdapt = p.modelAutoAdapt !== false;
145
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
146
+ const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
147
+ const H = barsFromHours(effHorizon); // горизонт в свечах по 10 минут
148
+ const maxH = autoAdapt ? Math.round(H * 1.4) : H;
147
149
  const tillTs = Date.now();
148
150
  const researchFrom = tillTs - p.modelHistoryDays * DAY;
149
151
  const fromTs = researchFrom - Math.ceil(p.modelTrainDays * 1.5 + 20) * DAY;
@@ -151,7 +153,7 @@ async function run(runId, p, state) {
151
153
  emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
152
154
  const ds = await loadDataset({
153
155
  fromTs, tillTs, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, signal: state.ctrl.signal,
154
- onProgress: (x) => emit({ stage: 'data', pct: Math.round((x.done / x.total) * 100), text: `Загружаем цены: ${x.done} из ${x.total}. Первый раз — до 10 минут` }),
156
+ onProgress: (x) => emit({ stage: 'data', pct: Math.round((x.done / x.total) * 100), text: `Загружаем цены: ${x.done} из ${x.total} (${x.symbol})…`, symbol: x.symbol, done: x.done, total: x.total }),
155
157
  });
156
158
  const { tokens, T, timeline } = ds;
157
159
  const N = tokens.length;
@@ -172,13 +174,8 @@ async function run(runId, p, state) {
172
174
  });
173
175
  }
174
176
 
175
- // три базовые модели всегда; трансформер — если включён или торгует
177
+ // три базовые модели всегда сравниваются между собой
176
178
  const names = modelNames(p);
177
- const useTf = names.includes('transformer');
178
- const tfMod = useTf ? await import('./transformer.js') : null;
179
- const tfCfg = useTf ? tfMod.transformerCfg(p) : null;
180
- const tfInfo = useTf ? { folds: [], device: 'процессор', threads: 1, note: '' } : null;
181
- const fmtTfInfo = ({ backend, device, ...i }) => ({ ...i, valIc: i.valIc === null ? null : round(i.valIc, 4), scale: round(i.scale, 4) });
182
179
  const preds = Object.fromEntries(names.map((n) => [n, new Map()])); // t -> Float64Array(N)
183
180
  const yraw = new Map(); // t -> Float64Array(N)
184
181
  const sigmaAt = new Map(); // t -> обычный размах акций в этом окне обучения
@@ -187,118 +184,75 @@ async function run(runId, p, state) {
187
184
  const folds = Math.ceil((T - H - firstTest) / testLen);
188
185
  let fi = 0;
189
186
 
190
- // Трансформер при параллельном обучении (или на видеокарте) — отдельный исполнитель: окна уходят ему
191
- // по мере построения (конвейер), базовые модели тем временем считаются здесь как обычно
192
- let tfRunner = null, tfOpened = false, tfDone = 0, mainText = null, tfFail = null;
193
- const tfJobs = []; // { lo, pr } по порядку окон
194
- const tfFrac = new Map(); // окно → доля пройденных эпох
195
- const progress = () => {
196
- const where = tfRunner.threads > 1 ? `потоков: ${tfRunner.threads}` : tfRunner.where;
197
- const tfText = `Трансформер: обучено ${tfDone} из ${folds} окон (${where})`;
198
- const part = [...tfFrac.values()].reduce((s, v) => s + v, 0);
199
- emit({ stage: 'sim', pct: Math.min(99, Math.round(((tfDone + part) / folds) * 100)), text: mainText ? `${mainText} · ${tfText}` : tfText });
200
- };
201
- const onAbort = () => tfRunner?.cancel();
202
- state.ctrl.signal.addEventListener('abort', onAbort, { once: true });
203
-
204
- try {
205
- while (testStart < T - H - 2) {
206
- if (state.cancelled) throw new Error('Отменено');
207
- const testEnd = Math.min(testStart + testLen, T - H - 2);
208
- const trainStart = Math.max(FEATURE_WARMUP + H + 2, testStart - trainLen);
209
- mainText = `Обучение ${fi + 1} из ${folds}: учимся до ${fmtDay(timeline[testStart])}, прогнозируем до ${fmtDay(timeline[testEnd - 1])}`;
210
- if (tfRunner) progress();
211
- else emit({ stage: 'sim', pct: Math.round((fi / folds) * 100), text: mainText });
187
+ while (testStart < T - maxH - 2) {
188
+ if (state.cancelled) throw new Error('Отменено');
189
+ const testEnd = Math.min(testStart + testLen, T - maxH - 2);
190
+ const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
191
+ const mainText = `Обучение ${fi + 1} из ${folds}: учимся до ${fmtDay(timeline[testStart])}, прогнозируем до ${fmtDay(timeline[testEnd - 1])}`;
192
+ await tick();
193
+ const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
194
+ const fitted = {};
195
+ for (const n of names) {
196
+ fitted[n] = await MODELS[n].fit(f);
212
197
  await tick();
213
- const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles });
214
- // исполнитель открываем на первом окне: для числа потоков нужен объём данных окна
215
- if (useTf && !tfOpened && (p.transformerParallel || p.transformerGpu)) {
216
- tfOpened = true;
217
- const { openTransformerRunner } = await import('./transformer-pool.js');
218
- const r = await openTransformerRunner({ gpu: p.transformerGpu, parallel: p.transformerParallel, jobs: folds, bytes: tfMod.inputBytes(tfMod.transformerInput(f)) });
219
- tfRunner = r.runner;
220
- tfInfo.note = r.note;
221
- if (r.note) emit({ stage: 'sim', pct: Math.round((fi / folds) * 100), text: `Трансформер: ${r.note}` });
222
- if (tfRunner) { tfInfo.device = tfRunner.where; tfInfo.threads = tfRunner.threads; }
223
- if (state.cancelled) throw new Error('Отменено');
224
- }
225
- const fitNames = tfRunner ? names.filter((n) => n !== 'transformer') : names;
226
- const fitted = {};
227
- const foldText = `Обучение ${fi + 1} из ${folds}`;
228
- try {
229
- for (const n of fitNames) {
230
- // трансформер учится по эпохам: прогресс и отмена — между батчами
231
- const opts = n === 'transformer' ? {
232
- cfg: tfCfg,
233
- isCancelled: () => state.cancelled,
234
- onProgress: (x) => emit({ stage: 'sim', pct: Math.round(((fi + (x.epoch - 1) / x.epochs) / folds) * 100), text: `${foldText}: трансформер, эпоха ${x.epoch}…` }),
235
- } : undefined;
236
- fitted[n] = await MODELS[n].fit(f, opts);
237
- await tick();
238
- if (state.cancelled) throw new Error('Отменено');
239
- }
240
- const us = [];
241
- for (let t = testStart; t < testEnd; t++) if (f.yok[t - f.lo]) us.push(t - f.lo);
242
- // тяжёлые модели (трансформер) предсказывают сразу пачкой моментов
243
- const many = Object.fromEntries(fitNames.filter((n) => MODELS[n].predictMany).map((n) => [n, MODELS[n].predictMany(fitted[n], f, us)]));
244
- for (const [k, u] of us.entries()) {
245
- if ((k & 511) === 0) {
246
- if (state.cancelled) throw new Error('Отменено');
247
- await tick();
248
- }
249
- const t = f.lo + u;
250
- for (const n of fitNames) preds[n].set(t, many[n] ? many[n].get(u) : MODELS[n].predict(fitted[n], f, u));
251
- yraw.set(t, Float64Array.from(f.yraw.subarray(u * N, u * N + N)));
252
- sigmaAt.set(t, f.sigma);
253
- }
254
- if (tfRunner) {
255
- // массивы окна уходят исполнителю без копирования: базовые модели этого окна уже посчитаны
256
- const k = fi;
257
- const pr = tfRunner.submit(tfMod.transformerInput(f), us, tfCfg, {
258
- transfer: true,
259
- onProgress: (x) => { tfFrac.set(k, (x.epoch - 1) / x.epochs); progress(); },
260
- }).then((r) => { tfDone++; tfFrac.delete(k); progress(); return r; });
261
- pr.catch((e) => { tfFail = tfFail || e; });
262
- tfJobs.push({ lo: f.lo, pr });
263
- }
264
- } finally {
265
- // веса нейросети освобождаем после каждого окна (и при отмене)
266
- for (const n of fitNames) if (fitted[n]?.dispose) {
267
- const m = fitted[n];
268
- m.dispose();
269
- if (tfInfo && n === 'transformer') tfInfo.folds.push(fmtTfInfo({ ...m.info, tensors: m.tf.memory().numTensors }));
270
- delete fitted[n];
271
- }
272
- }
273
- // снимок карты рынка на начало месяца прогноза
274
- const snap = mapSnapshot(f);
275
- const pts = alignTo(prevPts, snap.pts);
276
- const cl = matchClusters(prevCl, snap.clusters, 8);
277
- snapshots.push({ ts: timeline[testStart], pts: pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl });
278
- prevPts = pts; prevCl = cl;
279
- lastFold = f; lastModels = fitted;
280
- testStart = testEnd;
281
- fi++;
282
- // не копим очередь окон (память): ждём, пока у исполнителя не освободится место
283
- while (tfRunner && tfRunner.backlog > tfRunner.threads) {
198
+ if (state.cancelled) throw new Error('Отменено');
199
+ }
200
+ const us = [];
201
+ for (let t = testStart; t < testEnd; t++) if (f.yok[t - f.lo]) us.push(t - f.lo);
202
+ for (const [k, u] of us.entries()) {
203
+ if ((k & 511) === 0) {
284
204
  if (state.cancelled) throw new Error('Отменено');
285
- if (tfFail) throw tfFail;
286
- await new Promise((r) => setTimeout(r, 200));
205
+ await tick();
287
206
  }
207
+ const t = f.lo + u;
208
+ for (const n of names) preds[n].set(t, MODELS[n].predict(fitted[n], f, u));
209
+ yraw.set(t, Float64Array.from(f.yraw.subarray(u * N, u * N + N)));
210
+ sigmaAt.set(t, f.sigma);
288
211
  }
289
- if (tfRunner) {
290
- mainText = null;
291
- progress();
292
- const res = await Promise.all(tfJobs.map((j) => j.pr));
293
- tfJobs.forEach((j, k) => {
294
- for (const [u, pr] of res[k].preds) preds.transformer.set(j.lo + u, pr);
295
- tfInfo.folds.push(fmtTfInfo(res[k].info));
296
- });
212
+ // снимок карты рынка на начало месяца прогноза
213
+ const snap = mapSnapshot(f);
214
+ const pts = alignTo(prevPts, snap.pts);
215
+ const cl = matchClusters(prevCl, snap.clusters, 8);
216
+ const ptsRound = pts.map(([x, y]) => [round(x, 3), round(y, 3)]);
217
+ snapshots.push({ ts: timeline[testStart], pts: ptsRound, cl });
218
+ prevPts = pts; prevCl = cl;
219
+ lastFold = f; lastModels = fitted;
220
+
221
+ // живая телеметрия шага обучения для инфографики в интерфейсе
222
+ const attM = fitted.attention;
223
+ let foldIc = null;
224
+ if (preds.attention) {
225
+ let icS = 0, icK = 0;
226
+ for (let t = testStart; t < testEnd; t += Math.max(1, Math.floor(H / 2))) {
227
+ const pr = preds.attention.get(t), y = yraw.get(t);
228
+ if (!pr || !y) continue;
229
+ const ok = [...pr.keys()].filter((i) => tokens[i].liq[t] >= LIQ_MIN);
230
+ if (ok.length >= 10) { icS += spearman(ok.map((i) => pr[i]), ok.map((i) => y[i])); icK++; }
231
+ }
232
+ if (icK > 0) foldIc = round(icS / icK, 4);
297
233
  }
298
- } finally {
299
- state.ctrl.signal.removeEventListener('abort', onAbort);
300
- // потоки и браузер не переживают исследование
301
- if (tfRunner) await tfRunner.close();
234
+ const liveViz = {
235
+ fold: fi + 1,
236
+ folds,
237
+ tokens: N,
238
+ horizon: effHorizon,
239
+ trainFrom: timeline[trainStart],
240
+ testFrom: timeline[testStart],
241
+ testTill: timeline[testEnd - 1],
242
+ periodFrom: timeline[Math.max(0, firstTest - trainLen)],
243
+ periodTill: timeline[T - 1],
244
+ foldIc,
245
+ pts: ptsRound,
246
+ cl,
247
+ ...(fi === 0 ? { symbols: tokens.map((tk) => tk.symbol) } : {}),
248
+ ...(attM ? {
249
+ heads: HEADS.map((h, i) => ({ label: h.label, coef: round(attM.coef[i], 4) })),
250
+ self: [round(attM.coef[HEADS.length], 4), round(attM.coef[HEADS.length + 1], 4), round(attM.coef[HEADS.length + 2], 4)],
251
+ } : {}),
252
+ };
253
+ emit({ stage: 'sim', pct: Math.round(((fi + 1) / folds) * 100), text: mainText, viz: liveViz });
254
+ testStart = testEnd;
255
+ fi++;
302
256
  }
303
257
 
304
258
  emit({ stage: 'sim', pct: 100, text: 'Считаем результаты…' });
@@ -309,9 +263,7 @@ async function run(runId, p, state) {
309
263
  // ---------- портфель из лучших прогнозов: покупки, продажи без покупки, пары «нейтрально к рынку» ----------
310
264
  const K = p.modelTopK;
311
265
  const dir = p.modelDirection;
312
- const autoAdapt = p.modelAutoAdapt !== false;
313
266
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
314
- const minHoldBars = p.modelMinHoldHours > 0 ? barsFromHours(p.modelMinHoldHours) : 0;
315
267
  const mktFilter = p.modelMarketFilter === 'ma';
316
268
  const invVol = p.modelAllocMode === 'invvol';
317
269
  const realCost = (p.commissionPct + p.slippagePct) / 100;
@@ -359,9 +311,9 @@ async function run(runId, p, state) {
359
311
  // Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
360
312
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
361
313
  const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
362
- let curExitRatio = autoAdapt ? 0.75 : exitRatio;
314
+ let curExitRatio = autoAdapt ? 0.95 : exitRatio;
363
315
  const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
364
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : (dir === 'neutral' ? Math.min(0.22, 1 / (2 * K)) : Math.min(0.30, 1.2 / (2 * K)));
316
+ const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
365
317
 
366
318
  const closePos = (i, h, t, end, why) => {
367
319
  const exitPrice = px(i, t);
@@ -393,67 +345,49 @@ async function run(runId, p, state) {
393
345
  const pr = preds[name].get(t);
394
346
  const sg = sigmaAt.get(t);
395
347
  const bear = isMarketDown(ds, t);
396
- const mktDown = mktFilter && dir !== 'neutral' && bear;
348
+ const mktDown = mktFilter && bear;
397
349
  let order, entries;
398
350
  if (dir !== 'long') {
399
351
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
400
- const need = p.modelMinEdge * 2 * realCost;
352
+ const need = effMinEdge * 2 * realCost;
401
353
  const exitNeed = need * curExitRatio;
402
354
  const earlyExitNeed = need * 0.35;
403
- // держим позицию, пока ожидаемое движение выше порога выхода (на первой половине горизонта — мягкий порог, чтобы не резать сделку от шума)
355
+ // держим позицию, пока ожидаемое движение выше порога выхода (первые два полушага — мягкий порог, чтобы не резать сделку от шума)
404
356
  for (const [i, h] of [...held]) {
405
357
  const e = edge(i);
406
358
  const barsHeld = tIn - h.entryT;
407
- const lock = minHoldBars > 0 && barsHeld < minHoldBars && (h.side === 'short' ? e <= 0 : e >= 0);
408
- const req = autoAdapt && barsHeld < H ? earlyExitNeed : exitNeed;
409
- if ((!lock && !(h.side === 'long' ? e > req : e < -req)) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
359
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
360
+ if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
410
361
  }
411
- const needL = autoAdapt && dir === 'longshort' && bear ? need * 1.25 : need;
412
- const needS = autoAdapt && dir === 'longshort' ? (bear ? need * 0.90 : need * 1.25) : need;
362
+ const needL = autoAdapt && bear ? need * 1.25 : need;
363
+ const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
413
364
  const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
414
365
  const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
415
- let nL, nS;
416
- if (dir === 'longshort') {
417
- nL = K - countSide('long');
418
- nS = K - countSide('short');
419
- } else {
420
- // нейтрально: равное число покупок и продаж; при autoAdapt уже открытые прибыльные позиции не закрываются принудительно без сигнала
421
- const pairs = Math.min(K, countSide('long') + candL.length, countSide('short') + candS.length);
422
- if (!autoAdapt) {
423
- for (const side of ['long', 'short']) {
424
- const extra = [...held].filter(([, h]) => h.side === side).sort((a, b) => (side === 'long' ? 1 : -1) * (edge(a[0]) - edge(b[0])));
425
- for (const [i, h] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(i, h, tIn, false, 'Нет пары с другой стороны');
426
- }
427
- }
428
- nL = Math.max(0, pairs - countSide('long'));
429
- nS = Math.max(0, pairs - countSide('short'));
430
- }
366
+ const nL = K - countSide('long');
367
+ const nS = K - countSide('short');
431
368
  entries = [
432
369
  ...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]),
433
370
  ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)]),
434
371
  ];
435
- } else if (p.modelMinEdge > 0) {
372
+ } else if (effMinEdge > 0) {
436
373
  // ожидаемое движение (в долях) = прогноз × обычный размах × √H; входим, только если оно окупает сделку
437
374
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
438
- const need = p.modelMinEdge * 2 * realCost;
375
+ const need = effMinEdge * 2 * realCost;
439
376
  const exitNeed = need * curExitRatio;
440
377
  const earlyExitNeed = need * 0.35;
441
378
  for (const [i, h] of [...held]) {
442
379
  const e = edge(i);
443
380
  const barsHeld = tIn - h.entryT;
444
- const lock = minHoldBars > 0 && barsHeld < minHoldBars && e >= 0;
445
- const req = autoAdapt && barsHeld < H ? earlyExitNeed : exitNeed;
446
- if ((!lock && !(e > req)) || !liquid(i, t)) closePos(i, h, tIn, false);
381
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
382
+ if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn, false);
447
383
  }
448
384
  const free = K - held.size;
449
385
  const needL = autoAdapt && bear ? need * 1.25 : need;
450
386
  const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, free));
451
387
  order = [...held.keys(), ...cand];
452
388
  } else {
453
- const keepLocked = [...held].filter(([i, h]) => minHoldBars > 0 && (tIn - h.entryT) < minHoldBars && liquid(i, t) && pr[i] >= 0).map(([i]) => i);
454
- const lockedSet = new Set(keepLocked);
455
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => !lockedSet.has(i) && stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - keepLocked.length));
456
- order = [...keepLocked, ...cand];
389
+ const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
390
+ order = cand;
457
391
  const next = new Set(order);
458
392
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
459
393
  }
@@ -467,9 +401,12 @@ async function run(runId, p, state) {
467
401
  }
468
402
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
469
403
  let avail = Math.max(0, equity * 0.99 - usedStart);
404
+ const needEdge = effMinEdge * 2 * realCost;
470
405
  for (let idx = 0; idx < newEntries.length; idx++) {
471
406
  const [i, side, edgeAt] = newEntries[idx];
472
- const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
407
+ const effEdge = edgeAt !== null ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
408
+ const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / needEdge))) : 1;
409
+ const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
473
410
  let alloc = baseAlloc;
474
411
  if (invVol && avgInv > 0 && sg[i] > 0) {
475
412
  const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
@@ -499,7 +436,7 @@ async function run(runId, p, state) {
499
436
  function tradeRec(name, i, h, t, exitPrice, pnl, commission, borrow, end = false, why = '') {
500
437
  const heldMin = (t - h.entryT) * BAR_INTERVAL; // минуты
501
438
  const short = h.side === 'short';
502
- const hz = round(p.modelHorizon, 2);
439
+ const hz = round(effHorizon, 2);
503
440
  const label = MODELS[name].label;
504
441
  const reasonEntry = dir === 'long' ? `Модель «${label}»: акция среди ${K} лучших прогнозов на ${hz} ч вперёд`
505
442
  : short ? `Модель «${label}»: продажа: ожидаемо слабее рынка на ${Math.abs(h.expectedPct)}% за ${hz} ч`
@@ -638,9 +575,6 @@ async function run(runId, p, state) {
638
575
  heads, self, macroHeads,
639
576
  map: { snapshots, eigenvalues: lastFold.eigenvalues.map((v) => round(v, 3)) },
640
577
  neighbors,
641
- // трансформер: число весов и ход обучения по окнам (эпохи, совпадение на проверке)
642
- // где шло обучение: «процессор» или «видеокарта: <название>»; потоков — сколько окон обучалось одновременно
643
- ...(tfInfo ? { transformer: { params: tfInfo.folds[0]?.params ?? null, epochs: tfCfg.epochs, stride: tfCfg.stride, device: tfInfo.device, threads: tfInfo.threads, ...(tfInfo.note ? { note: tfInfo.note } : {}), folds: tfInfo.folds } } : {}),
644
578
  });
645
579
  finishRun(runId, { metrics: report, equity: downsample(results[chosen].sim.curve) });
646
580
  return report;
@@ -704,12 +638,12 @@ async function runStabilityLoop(targetId, runRow, st) {
704
638
  const { tokens, T, timeline } = ds;
705
639
  const N = tokens.length, bpd = ds.barsPerDay;
706
640
  const trainLen = Math.round(p.modelTrainDays * bpd), testLen = Math.round(20 * bpd);
707
- const chosen = p.modelType === 'transformer' ? 'attention' : (p.modelType in MODELS ? p.modelType : 'attention');
641
+ const chosen = p.modelType in MODELS ? p.modelType : 'attention';
708
642
  const realCost = (p.commissionPct + p.slippagePct) / 100;
709
643
  const K = p.modelTopK, dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
710
644
  const autoAdapt = p.modelAutoAdapt !== false;
645
+ const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
711
646
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
712
- const minHoldBars = p.modelMinHoldHours > 0 ? barsFromHours(p.modelMinHoldHours) : 0;
713
647
  const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
714
648
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
715
649
  const tsOf = (t) => timeline[Math.min(t, T - 1)];
@@ -730,17 +664,18 @@ async function runStabilityLoop(targetId, runRow, st) {
730
664
  const histDays = Math.max(120, Math.min(1100, p.modelHistoryDays + sh));
731
665
  const resFrom = periodTill - histDays * DAY;
732
666
  let testStart = Math.max(timeline.findIndex((t) => t >= resFrom), trainLen + 10);
733
- const folds = Math.max(1, Math.ceil((T - H - testStart) / testLen));
667
+ const maxH = autoAdapt ? Math.round(H * 1.4) : H;
668
+ const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
734
669
  let fi = 0;
735
670
  const preds = new Map(), sigmaAt = new Map();
736
- while (testStart < T - H - 2) {
671
+ while (testStart < T - maxH - 2) {
737
672
  if (st.cancelled) throw new Error('Отменено');
738
- const testEnd = Math.min(testStart + testLen, T - H - 2);
739
- const trainStart = Math.max(FEATURE_WARMUP + H + 2, testStart - trainLen);
673
+ const testEnd = Math.min(testStart + testLen, T - maxH - 2);
674
+ const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
740
675
  const pct = Math.min(99, 10 + Math.round(((si + fi / folds) / shifts.length) * 90));
741
676
  emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
742
677
  await tick();
743
- const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles });
678
+ const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
744
679
  const m = await MODELS[chosen].fit(f);
745
680
  for (let t = testStart; t < testEnd; t++) {
746
681
  const u = t - f.lo;
@@ -762,9 +697,9 @@ async function runStabilityLoop(targetId, runRow, st) {
762
697
  const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
763
698
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
764
699
  const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
765
- const curExitRatio = autoAdapt ? 0.75 : exitRatio;
700
+ const curExitRatio = autoAdapt ? 0.95 : exitRatio;
766
701
  const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
767
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : (dir === 'neutral' ? Math.min(0.22, 1 / (2 * K)) : Math.min(0.30, 1.2 / (2 * K)));
702
+ const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
768
703
  const closePos = (i, h, t) => {
769
704
  const exitPrice = px(i, t);
770
705
  const gross = h.side === 'short' ? h.alloc * (1 - exitPrice / h.entryPrice) : h.alloc * (exitPrice / h.entryPrice - 1);
@@ -786,52 +721,38 @@ async function runStabilityLoop(targetId, runRow, st) {
786
721
  }
787
722
  const pr = preds.get(t), sg = sigmaAt.get(t);
788
723
  const bear = isMarketDown(ds, t);
789
- const mktDown = mktFilter && dir !== 'neutral' && bear;
724
+ const mktDown = mktFilter && bear;
790
725
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
791
- const need = p.modelMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
726
+ const need = effMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
792
727
  let order, entries;
793
728
  if (dir !== 'long') {
794
729
  for (const [i, h] of [...held]) {
795
730
  const e = edge(i), barsHeld = tIn - h.entryT;
796
- const lock = minHoldBars > 0 && barsHeld < minHoldBars && (h.side === 'long' ? e >= 0 : e <= 0);
797
- const req = autoAdapt && barsHeld < H ? earlyExitNeed : exitNeed;
798
- if ((!lock && !(h.side === 'long' ? e > req : e < -req)) || !liquid(i, t)) closePos(i, h, tIn);
731
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
732
+ if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
799
733
  }
800
- const needL = autoAdapt && dir === 'longshort' && bear ? need * 1.25 : need;
801
- const needS = autoAdapt && dir === 'longshort' ? (bear ? need * 0.90 : need * 1.25) : need;
734
+ const needL = autoAdapt && bear ? need * 1.25 : need;
735
+ const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
802
736
  const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
803
737
  const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
804
- let nL = K - countSide('long'), nS = K - countSide('short');
805
- if (dir === 'neutral') {
806
- const pairs = Math.min(K, countSide('long') + candL.length, countSide('short') + candS.length);
807
- if (!autoAdapt) {
808
- for (const side of ['long', 'short']) {
809
- const extra = [...held].filter(([, h]) => h.side === side).sort((a, b) => (side === 'long' ? 1 : -1) * (edge(a[0]) - edge(b[0])));
810
- for (const [i, h] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(i, h, tIn);
811
- }
812
- }
813
- nL = Math.max(0, pairs - countSide('long')); nS = Math.max(0, pairs - countSide('short'));
814
- }
815
- entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long']), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short'])];
816
- } else if (p.modelMinEdge > 0) {
738
+ const nL = K - countSide('long'), nS = K - countSide('short');
739
+ entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
740
+ } else if (effMinEdge > 0) {
817
741
  for (const [i, h] of [...held]) {
818
742
  const e = edge(i), barsHeld = tIn - h.entryT;
819
- const lock = minHoldBars > 0 && barsHeld < minHoldBars && e >= 0;
820
- const req = autoAdapt && barsHeld < H ? earlyExitNeed : exitNeed;
821
- if ((!lock && !(e > req)) || !liquid(i, t)) closePos(i, h, tIn);
743
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
744
+ if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
822
745
  }
823
746
  const needL = autoAdapt && bear ? need * 1.25 : need;
824
747
  const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
825
748
  order = [...held.keys(), ...cand];
826
749
  } else {
827
- const keepLocked = [...held].filter(([i, h]) => minHoldBars > 0 && (tIn - h.entryT) < minHoldBars && liquid(i, t) && pr[i] >= 0).map(([i]) => i);
828
- const lockedSet = new Set(keepLocked);
829
- const cand = mktDown ? [] : [...pr.keys()].filter((i) => !lockedSet.has(i) && stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - keepLocked.length));
830
- order = [...keepLocked, ...cand];
750
+ const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
751
+ order = cand;
831
752
  const next = new Set(order);
832
753
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);
833
754
  }
834
- if (!entries) entries = order.map((i) => [i, 'long']);
755
+ if (!entries) entries = order.map((i) => [i, 'long', null]);
835
756
  const newEntries = entries.filter(([i]) => !held.has(i));
836
757
  let avgInv = 0;
837
758
  if (invVol && newEntries.length && sg) {
@@ -842,8 +763,10 @@ async function runStabilityLoop(targetId, runRow, st) {
842
763
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
843
764
  let avail = Math.max(0, equity * 0.99 - usedStart);
844
765
  for (let idx = 0; idx < newEntries.length; idx++) {
845
- const [i, side] = newEntries[idx];
846
- const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
766
+ const [i, side, edgeAt] = newEntries[idx];
767
+ const effEdge = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
768
+ const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / need))) : 1;
769
+ const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
847
770
  let alloc = baseAlloc;
848
771
  if (invVol && avgInv > 0 && sg[i] > 0) {
849
772
  const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  import { kvGet, kvSet } from '../db.js';
2
3
 
3
4
  // «Словарь» модели: акции индекса широкого рынка МосБиржи (MOEXBMI, ~100 бумаг)
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  import { db } from '../db.js';
2
3
  import { hub } from '../hub.js';
3
4
  import { getSettings } from '../settings.js';