neuroalpha 0.2.0 → 0.3.12

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 +21 -0
  2. package/README.md +7 -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 +426 -0
  12. package/public/app.js +741 -251
  13. package/public/index.html +45 -20
  14. package/public/landing.html +1004 -870
  15. package/public/legal.html +22 -0
  16. package/public/styles.css +49 -15
  17. package/server/api/routes.js +176 -23
  18. package/server/auth/index.js +544 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1224 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +53 -2
  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 +15 -21
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +489 -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 +119 -172
  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 +20 -25
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +1 -0
  42. package/server/strategist/index.js +6 -6
  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. Licensed under the MIT License.
1
2
  import { round } from '../util.js';
2
3
 
3
4
  const MSK = 3 * 3600000;
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
2
  import { eigenSym, ridgeSolve, matrix, spearman, kmeans } from './linalg.js';
2
3
  import { BAR_INTERVAL } from './dataset.js';
3
4
 
@@ -19,11 +20,13 @@ for (const k of KINDS) for (const s of SCALES) for (const b of BETAS) HEADS.push
19
20
  // trainStart..testStart — окно обучения; testStart..testEnd — месяц прогноза.
20
21
  // Все массивы «по времени»: [u*N + i], u = t - lo.
21
22
  // textWeight (0..1) — вес «текстовой» близости по профилям бизнеса; profiles — Map(тикер → 20 чисел), загружается один раз на исследование.
22
- export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, textWeight = 0, profiles = null }) {
23
+ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, textWeight = 0, profiles = null, multiHorizon = false }) {
23
24
  const { tokens, mret, T } = ds;
24
25
  const N = tokens.length;
26
+ const Hfast = Math.max(2, Math.round(H * 0.6));
27
+ const Hslow = Math.round(H * 1.4);
25
28
  const lo = Math.max(1, trainStart - FEATURE_WARMUP);
26
- const hi = Math.min(T, testEnd + H + 2);
29
+ const hi = Math.min(T, testEnd + (multiHorizon ? Hslow : H) + 2);
27
30
  const W = hi - lo;
28
31
 
29
32
  // чувствительность к рынку и обычный размах — только по окну обучения
@@ -80,20 +83,35 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
80
83
  }
81
84
  });
82
85
  // цель: остаточное (без рынка) движение, которое реально можно получить: решение на закрытии свечи t,
83
- // вход по цене открытия t+1, выход по цене открытия t+1+H
86
+ // вход по цене открытия t+1, выход по цене открытия t+1+H (в режиме multiHorizon — взвешенная траектория 0.6H / 1.0H / 1.4H)
84
87
  const { mopen } = ds;
85
88
  const yz = new Float64Array(W * N), yraw = new Float64Array(W * N), yok = new Uint8Array(W);
89
+ const invFast = 1 / Math.sqrt(Hfast), invMid = 1 / Math.sqrt(H), invSlow = 1 / Math.sqrt(Hslow);
86
90
  for (let u = 0; u + 1 + H < W; u++) {
87
91
  const t = lo + u;
88
92
  const ma = mopen[t + 1], mb = mopen[t + 1 + H];
89
93
  if (!(ma > 0 && mb > 0)) continue;
90
94
  yok[u] = 1;
91
- const m = Math.log(mb / ma);
95
+ const mMid = Math.log(mb / ma);
96
+ const hasMulti = multiHorizon && (t + 1 + Hslow < T) && mopen[t + 1 + Hfast] > 0 && mopen[t + 1 + Hslow] > 0;
97
+ const mFast = hasMulti ? Math.log(mopen[t + 1 + Hfast] / ma) : 0;
98
+ const mSlow = hasMulti ? Math.log(mopen[t + 1 + Hslow] / ma) : 0;
92
99
  for (let i = 0; i < N; i++) {
93
- const a = tokens[i].open[t + 1], b = tokens[i].open[t + 1 + H];
94
- const r = a > 0 && b > 0 ? Math.log(b / a) - beta[i] * m : 0;
95
- yraw[u * N + i] = r;
96
- yz[u * N + i] = Math.max(-8, Math.min(8, r / (sigma[i] * Math.sqrt(H))));
100
+ const opn = tokens[i].open;
101
+ const a = opn[t + 1], b = opn[t + 1 + H];
102
+ const rMid = a > 0 && b > 0 ? Math.log(b / a) - beta[i] * mMid : 0;
103
+ yraw[u * N + i] = rMid;
104
+ const sig = sigma[i];
105
+ const zMid = Math.max(-8, Math.min(8, (rMid / sig) * invMid));
106
+ if (hasMulti && a > 0 && opn[t + 1 + Hfast] > 0 && opn[t + 1 + Hslow] > 0) {
107
+ const rFast = Math.log(opn[t + 1 + Hfast] / a) - beta[i] * mFast;
108
+ const rSlow = Math.log(opn[t + 1 + Hslow] / a) - beta[i] * mSlow;
109
+ const zFast = Math.max(-8, Math.min(8, (rFast / sig) * invFast));
110
+ const zSlow = Math.max(-8, Math.min(8, (rSlow / sig) * invSlow));
111
+ yz[u * N + i] = 0.25 * zFast + 0.50 * zMid + 0.25 * zSlow;
112
+ } else {
113
+ yz[u * N + i] = zMid;
114
+ }
97
115
  }
98
116
  }
99
117
 
@@ -196,7 +214,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
196
214
  if (list.length) macro = { list };
197
215
  }
198
216
 
199
- return { N, lo, hi, W, trainStart, testStart, testEnd, H, beta, sigma, z, has, liq, X, yz, yraw, yok, emb, embN, cos, eigenvalues: eigVals, macro };
217
+ return { N, lo, hi, W, trainStart, testStart, testEnd, H, Hfast, Hslow, multiHorizon, beta, sigma, z, has, liq, X, yz, yraw, yok, emb, embN, cos, eigenvalues: eigVals, macro };
200
218
  }
201
219
 
202
220
  // Ликвидная акция: сделки хотя бы в 15 из последних 20 свечей
@@ -205,7 +223,8 @@ export const LIQ_MIN = 15;
205
223
  // В обучение — только моменты, цель которых целиком лежит в окне обучения (без заглядывания в месяц прогноза)
206
224
  export const trainUs = (f) => {
207
225
  const us = [];
208
- for (let t = f.trainStart + FEATURE_WARMUP; t < f.testStart - f.H - 1; t++) { const u = t - f.lo; if (f.yok[u]) us.push(u); }
226
+ const maxH = f.multiHorizon ? f.Hslow : f.H;
227
+ for (let t = f.trainStart + FEATURE_WARMUP; t < f.testStart - maxH - 1; t++) { const u = t - f.lo; if (f.yok[u]) us.push(u); }
209
228
  return us;
210
229
  };
211
230
 
@@ -386,19 +405,10 @@ export const MODELS = {
386
405
  inertia: { label: 'Своя инерция', fit: fitInertia, predict: predictInertia },
387
406
  leadlag: { label: 'Кто за кем ходит', fit: fitLeadLag, predict: (m, f, u) => predictLin(m.W, f, u) },
388
407
  attention: { label: 'Внимание через карту', fit: fitAttention, predict: predictAttention },
389
- // ступень 3: нейросеть с вниманием между акциями (tfjs грузится только при первом обучении);
390
- // fit асинхронный, после использования модель нужно освободить: model.dispose()
391
- transformer: {
392
- label: 'Трансформер',
393
- fit: async (f, opts) => (await import('./transformer.js')).fitTransformer(f, opts),
394
- predict: (m, f, u) => m.predictOne(m, f, u),
395
- predictMany: (m, f, us) => m.predictMany(m, f, us),
396
- heavy: true,
397
- },
398
408
  };
399
409
 
400
- // Какие модели обучать: три базовые всегда, трансформер — если включён для сравнения или торгует
401
- export const modelNames = (p) => Object.keys(MODELS).filter((n) => !MODELS[n].heavy || (n === 'transformer' && (p.modelUseTransformer || p.modelType === 'transformer')));
410
+ // Какие модели обучать: три базовые модели
411
+ export const modelNames = () => Object.keys(MODELS);
402
412
 
403
413
  // Объяснение прогноза «внимания»: вклад каждого соседа в прогноз акции j
404
414
  export function explainAttention(m, f, u, j, top = 3) {
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
2
  import { kvGet, kvSet } from '../db.js';
2
3
  import { ensureVocab } from './vocab.js';
3
4
  import { chatStream, extractJson, isLlmEnabled } from '../llm/client.js';
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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)) : (dir === 'neutral' ? Math.min(0.25, 1 / (2 * 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);
@@ -397,16 +349,15 @@ async function run(runId, p, state) {
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
362
  const needL = autoAdapt && dir === 'longshort' && bear ? need * 1.25 : need;
412
363
  const needS = autoAdapt && dir === 'longshort' ? (bear ? need * 0.90 : need * 1.25) : need;
@@ -432,28 +383,25 @@ async function run(runId, p, state) {
432
383
  ...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]),
433
384
  ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)]),
434
385
  ];
435
- } else if (p.modelMinEdge > 0) {
386
+ } else if (effMinEdge > 0) {
436
387
  // ожидаемое движение (в долях) = прогноз × обычный размах × √H; входим, только если оно окупает сделку
437
388
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
438
- const need = p.modelMinEdge * 2 * realCost;
389
+ const need = effMinEdge * 2 * realCost;
439
390
  const exitNeed = need * curExitRatio;
440
391
  const earlyExitNeed = need * 0.35;
441
392
  for (const [i, h] of [...held]) {
442
393
  const e = edge(i);
443
394
  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);
395
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
396
+ if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn, false);
447
397
  }
448
398
  const free = K - held.size;
449
399
  const needL = autoAdapt && bear ? need * 1.25 : need;
450
400
  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
401
  order = [...held.keys(), ...cand];
452
402
  } 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];
403
+ 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);
404
+ order = cand;
457
405
  const next = new Set(order);
458
406
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
459
407
  }
@@ -467,9 +415,12 @@ async function run(runId, p, state) {
467
415
  }
468
416
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
469
417
  let avail = Math.max(0, equity * 0.99 - usedStart);
418
+ const needEdge = effMinEdge * 2 * realCost;
470
419
  for (let idx = 0; idx < newEntries.length; idx++) {
471
420
  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));
421
+ const effEdge = edgeAt !== null ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
422
+ const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / needEdge))) : 1;
423
+ 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
424
  let alloc = baseAlloc;
474
425
  if (invVol && avgInv > 0 && sg[i] > 0) {
475
426
  const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
@@ -499,7 +450,7 @@ async function run(runId, p, state) {
499
450
  function tradeRec(name, i, h, t, exitPrice, pnl, commission, borrow, end = false, why = '') {
500
451
  const heldMin = (t - h.entryT) * BAR_INTERVAL; // минуты
501
452
  const short = h.side === 'short';
502
- const hz = round(p.modelHorizon, 2);
453
+ const hz = round(effHorizon, 2);
503
454
  const label = MODELS[name].label;
504
455
  const reasonEntry = dir === 'long' ? `Модель «${label}»: акция среди ${K} лучших прогнозов на ${hz} ч вперёд`
505
456
  : short ? `Модель «${label}»: продажа: ожидаемо слабее рынка на ${Math.abs(h.expectedPct)}% за ${hz} ч`
@@ -638,9 +589,6 @@ async function run(runId, p, state) {
638
589
  heads, self, macroHeads,
639
590
  map: { snapshots, eigenvalues: lastFold.eigenvalues.map((v) => round(v, 3)) },
640
591
  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
592
  });
645
593
  finishRun(runId, { metrics: report, equity: downsample(results[chosen].sim.curve) });
646
594
  return report;
@@ -704,12 +652,12 @@ async function runStabilityLoop(targetId, runRow, st) {
704
652
  const { tokens, T, timeline } = ds;
705
653
  const N = tokens.length, bpd = ds.barsPerDay;
706
654
  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');
655
+ const chosen = p.modelType in MODELS ? p.modelType : 'attention';
708
656
  const realCost = (p.commissionPct + p.slippagePct) / 100;
709
657
  const K = p.modelTopK, dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
710
658
  const autoAdapt = p.modelAutoAdapt !== false;
659
+ const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
711
660
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
712
- const minHoldBars = p.modelMinHoldHours > 0 ? barsFromHours(p.modelMinHoldHours) : 0;
713
661
  const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
714
662
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
715
663
  const tsOf = (t) => timeline[Math.min(t, T - 1)];
@@ -730,17 +678,18 @@ async function runStabilityLoop(targetId, runRow, st) {
730
678
  const histDays = Math.max(120, Math.min(1100, p.modelHistoryDays + sh));
731
679
  const resFrom = periodTill - histDays * DAY;
732
680
  let testStart = Math.max(timeline.findIndex((t) => t >= resFrom), trainLen + 10);
733
- const folds = Math.max(1, Math.ceil((T - H - testStart) / testLen));
681
+ const maxH = autoAdapt ? Math.round(H * 1.4) : H;
682
+ const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
734
683
  let fi = 0;
735
684
  const preds = new Map(), sigmaAt = new Map();
736
- while (testStart < T - H - 2) {
685
+ while (testStart < T - maxH - 2) {
737
686
  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);
687
+ const testEnd = Math.min(testStart + testLen, T - maxH - 2);
688
+ const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
740
689
  const pct = Math.min(99, 10 + Math.round(((si + fi / folds) / shifts.length) * 90));
741
690
  emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
742
691
  await tick();
743
- const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles });
692
+ const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
744
693
  const m = await MODELS[chosen].fit(f);
745
694
  for (let t = testStart; t < testEnd; t++) {
746
695
  const u = t - f.lo;
@@ -762,9 +711,9 @@ async function runStabilityLoop(targetId, runRow, st) {
762
711
  const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
763
712
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
764
713
  const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
765
- const curExitRatio = autoAdapt ? 0.75 : exitRatio;
714
+ const curExitRatio = autoAdapt ? 0.95 : exitRatio;
766
715
  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)));
716
+ 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)));
768
717
  const closePos = (i, h, t) => {
769
718
  const exitPrice = px(i, t);
770
719
  const gross = h.side === 'short' ? h.alloc * (1 - exitPrice / h.entryPrice) : h.alloc * (exitPrice / h.entryPrice - 1);
@@ -788,14 +737,13 @@ async function runStabilityLoop(targetId, runRow, st) {
788
737
  const bear = isMarketDown(ds, t);
789
738
  const mktDown = mktFilter && dir !== 'neutral' && bear;
790
739
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
791
- const need = p.modelMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
740
+ const need = effMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
792
741
  let order, entries;
793
742
  if (dir !== 'long') {
794
743
  for (const [i, h] of [...held]) {
795
744
  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);
745
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
746
+ if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
799
747
  }
800
748
  const needL = autoAdapt && dir === 'longshort' && bear ? need * 1.25 : need;
801
749
  const needS = autoAdapt && dir === 'longshort' ? (bear ? need * 0.90 : need * 1.25) : need;
@@ -812,26 +760,23 @@ async function runStabilityLoop(targetId, runRow, st) {
812
760
  }
813
761
  nL = Math.max(0, pairs - countSide('long')); nS = Math.max(0, pairs - countSide('short'));
814
762
  }
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) {
763
+ 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)])];
764
+ } else if (effMinEdge > 0) {
817
765
  for (const [i, h] of [...held]) {
818
766
  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);
767
+ const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
768
+ if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
822
769
  }
823
770
  const needL = autoAdapt && bear ? need * 1.25 : need;
824
771
  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
772
  order = [...held.keys(), ...cand];
826
773
  } 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];
774
+ 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);
775
+ order = cand;
831
776
  const next = new Set(order);
832
777
  for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);
833
778
  }
834
- if (!entries) entries = order.map((i) => [i, 'long']);
779
+ if (!entries) entries = order.map((i) => [i, 'long', null]);
835
780
  const newEntries = entries.filter(([i]) => !held.has(i));
836
781
  let avgInv = 0;
837
782
  if (invVol && newEntries.length && sg) {
@@ -842,8 +787,10 @@ async function runStabilityLoop(targetId, runRow, st) {
842
787
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
843
788
  let avail = Math.max(0, equity * 0.99 - usedStart);
844
789
  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));
790
+ const [i, side, edgeAt] = newEntries[idx];
791
+ const effEdge = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
792
+ const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / need))) : 1;
793
+ 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
794
  let alloc = baseAlloc;
848
795
  if (invVol && avgInv > 0 && sg[i] > 0) {
849
796
  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. Licensed under the MIT License.
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. Licensed under the MIT License.
1
2
  import { db } from '../db.js';
2
3
  import { hub } from '../hub.js';
3
4
  import { getSettings } from '../settings.js';