neuroalpha 0.3.15 → 0.3.16

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.
@@ -34,8 +34,8 @@ export function isMarketDown(ds, t) {
34
34
  }
35
35
 
36
36
  // Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
37
- // 7 — мульти-горизонтное обучение (0.6H / 1.0H / 1.4H) + взвешивание слота по силе сигнала (Conviction Sizing) в Авто-режиме
38
- const DATA_VERSION = 7;
37
+ // 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
38
+ const DATA_VERSION = 8;
39
39
 
40
40
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
41
41
  function reuseParamsKey(params) {
@@ -147,8 +147,13 @@ async function run(runId, p, state) {
147
147
  const H = barsFromHours(effHorizon); // горизонт в свечах по 10 минут
148
148
  const maxH = autoAdapt ? Math.round(H * 1.4) : H;
149
149
  const tillTs = Date.now();
150
- const researchFrom = tillTs - p.modelHistoryDays * DAY;
151
- const fromTs = researchFrom - Math.ceil(p.modelTrainDays * 1.5 + 20) * DAY;
150
+ // Выравниваем начало периода исследования по началу календарных суток МСК,
151
+ // а для чтения датасета из SQLite всегда берём единый широкий горизонт (fromTs = 0),
152
+ // чтобы смена «Окна обучения» (modelTrainDays) или «Периода исследования» (modelHistoryDays)
153
+ // НИКОГДА не сдвигала fromTs и не вызывала повторную загрузку котировок!
154
+ const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
155
+ const researchFrom = todayStartMsk - p.modelHistoryDays * DAY;
156
+ const fromTs = todayStartMsk - 1650 * DAY;
152
157
 
153
158
  emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
154
159
  const ds = await loadDataset({
@@ -250,18 +255,39 @@ async function run(runId, p, state) {
250
255
  self: [round(attM.coef[HEADS.length], 4), round(attM.coef[HEADS.length + 1], 4), round(attM.coef[HEADS.length + 2], 4)],
251
256
  } : {}),
252
257
  };
253
- emit({ stage: 'sim', pct: Math.round(((fi + 1) / folds) * 100), text: mainText, viz: liveViz });
258
+ emit({ stage: 'sim', pct: Math.round(((fi + 1) / folds) * 80), text: mainText, viz: liveViz });
254
259
  testStart = testEnd;
255
260
  fi++;
256
261
  }
257
262
 
258
- emit({ stage: 'sim', pct: 100, text: 'Считаем результаты…' });
263
+ emit({ stage: 'calc', pct: 81, text: 'Финальный пересчёт · подготовка временного ряда прогнозов…' });
259
264
  await tick();
260
265
  const testTs = [...yraw.keys()].sort((a, b) => a - b);
261
266
  const tsOf = (t) => timeline[t];
262
267
 
263
268
  // ---------- портфель из лучших прогнозов: покупки, продажи без покупки, пары «нейтрально к рынку» ----------
264
- const K = p.modelTopK;
269
+ const rawTopK = Number(p.modelTopK ?? 2);
270
+ const autoK = rawTopK === 0 || autoAdapt;
271
+ const K = rawTopK > 0 ? rawTopK : 4;
272
+ // Адаптивный отбор числа акций (Dynamic Top-K):
273
+ // лидер №1 берётся всегда при пробое порога; следующие бумаги (2…K) берутся только если их сигнал
274
+ // достаточно силён относительно лидера (>= 68% от силы №1) и уверенно перекрывает порог (>= 1.25 * need),
275
+ // чтобы не размывать капитал на слабые окраинные сигналы
276
+ const filterDynamicTopK = (cands, edgeFn, needVal, maxCount) => {
277
+ if (!cands.length || maxCount <= 0) return [];
278
+ const lim = Math.min(cands.length, maxCount);
279
+ if (!autoK || lim <= 1) return cands.slice(0, lim);
280
+ const bestE = Math.abs(edgeFn(cands[0]));
281
+ const out = [cands[0]];
282
+ for (let m = 1; m < lim; m++) {
283
+ const curE = Math.abs(edgeFn(cands[m]));
284
+ const relMin = m === 1 ? 0.62 : 0.78;
285
+ const absMin = m === 1 ? needVal * 1.15 : needVal * 1.45;
286
+ if (curE >= bestE * relMin && curE >= absMin) out.push(cands[m]);
287
+ else break;
288
+ }
289
+ return out;
290
+ };
265
291
  const dir = p.modelDirection;
266
292
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
267
293
  const mktFilter = p.modelMarketFilter === 'ma';
@@ -313,7 +339,7 @@ async function run(runId, p, state) {
313
339
  const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
314
340
  let curExitRatio = autoAdapt ? 0.95 : exitRatio;
315
341
  const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
316
- const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
342
+ const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
317
343
 
318
344
  const closePos = (i, h, t, end, why) => {
319
345
  const exitPrice = px(i, t);
@@ -365,9 +391,11 @@ async function run(runId, p, state) {
365
391
  const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
366
392
  const nL = K - countSide('long');
367
393
  const nS = K - countSide('short');
394
+ const pickedL = filterDynamicTopK(candL, edge, needL, Math.max(0, nL));
395
+ const pickedS = filterDynamicTopK(candS, edge, needS, Math.max(0, nS));
368
396
  entries = [
369
- ...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]),
370
- ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)]),
397
+ ...pickedL.map((i) => [i, 'long', edge(i)]),
398
+ ...pickedS.map((i) => [i, 'short', edge(i)]),
371
399
  ];
372
400
  } else if (effMinEdge > 0) {
373
401
  // ожидаемое движение (в долях) = прогноз × обычный размах × √H; входим, только если оно окупает сделку
@@ -383,7 +411,8 @@ async function run(runId, p, state) {
383
411
  }
384
412
  const free = K - held.size;
385
413
  const needL = autoAdapt && bear ? need * 1.25 : need;
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));
414
+ const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
415
+ const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
387
416
  order = [...held.keys(), ...cand];
388
417
  } else {
389
418
  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);
@@ -402,11 +431,29 @@ async function run(runId, p, state) {
402
431
  const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
403
432
  let avail = Math.max(0, equity * 0.99 - usedStart);
404
433
  const needEdge = effMinEdge * 2 * realCost;
434
+ // Динамическая концентрация капитала под реально работающие позиции:
435
+ // если при K=5 на рынке есть всего 1–2 сильных сигнала, не замораживаем 60% денег в кэше под пустые слоты,
436
+ // а взвешиваем слот от фактического числа занятых + новых позиций и силы каждого сигнала (Conviction Sizing)
437
+ const activeTotal = Math.max(2, held.size + newEntries.length);
438
+ const dynSlotWeight = autoAdapt
439
+ ? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
440
+ : maxSlotWeight;
441
+ // Сортируем новые входы по убыванию силы сигнала, чтобы лучшие идеи получали приоритетную долю капитала
442
+ if (autoAdapt && newEntries.length > 1) {
443
+ newEntries.sort((a, b) => Math.abs(b[2] ?? 0) - Math.abs(a[2] ?? 0));
444
+ }
445
+ const edgeWeights = newEntries.map(([i, , edgeAt]) => {
446
+ const effE = edgeAt !== null ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
447
+ return Math.max(0.0001, effE);
448
+ });
449
+ let remWeightSum = edgeWeights.reduce((a, b) => a + b, 0);
405
450
  for (let idx = 0; idx < newEntries.length; idx++) {
406
451
  const [i, side, edgeAt] = newEntries[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.25, Math.max(0.85, 0.80 + 0.25 * (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));
452
+ const effEdge = edgeWeights[idx];
453
+ const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (effEdge / needEdge))) : 1;
454
+ const propShare = autoAdapt && remWeightSum > 0 ? avail * (effEdge / remWeightSum) : avail / Math.max(1, newEntries.length - idx);
455
+ remWeightSum = Math.max(0, remWeightSum - effEdge);
456
+ const baseAlloc = autoAdapt ? Math.min(equity * dynSlotWeight * conv, propShare, avail) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
410
457
  let alloc = baseAlloc;
411
458
  if (invVol && avgInv > 0 && sg[i] > 0) {
412
459
  const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
@@ -465,9 +512,15 @@ async function run(runId, p, state) {
465
512
  const cost = realCost;
466
513
  const results = {};
467
514
  let splitTs = null;
468
- for (const n of names) {
515
+ for (let ni = 0; ni < names.length; ni++) {
516
+ const n = names[ni];
517
+ const lbl = MODELS[n]?.label || n;
518
+ emit({ stage: 'calc', pct: 82 + ni * 2, text: `Финальный пересчёт · симуляция портфеля (${ni + 1} из ${names.length}): «${lbl}»…` });
519
+ await tick();
520
+ if (state.cancelled) throw new Error('Отменено');
469
521
  const sim = simulate(n, cost);
470
522
  const report = computeReport(sim.trades, sim.curve, p.capital, p.oosPct);
523
+ await tick();
471
524
  const gross = simulate(n, 0, 0);
472
525
  const grossReport = computeReport(gross.trades, gross.curve, p.capital, p.oosPct);
473
526
  splitTs = report.splitTs;
@@ -478,11 +531,16 @@ async function run(runId, p, state) {
478
531
  return seg.length > 1 ? (seg[seg.length - 1].equity / seg[0].equity - 1) * 100 : 0;
479
532
  };
480
533
  const SEG = { full: [-Infinity, Infinity], inSample: [-Infinity, splitTs - 1], outOfSample: [splitTs, Infinity] };
481
- const icStats = (n) => {
534
+ const icStats = async (n) => {
482
535
  const out = {};
483
536
  const weekly = new Map();
484
537
  const all = [];
485
- for (const t of testTs) {
538
+ for (let idx = 0; idx < testTs.length; idx++) {
539
+ if ((idx & 2047) === 0) {
540
+ await tick();
541
+ if (state.cancelled) throw new Error('Отменено');
542
+ }
543
+ const t = testTs[idx];
486
544
  // совпадение прогноза с фактом — только по ликвидным акциям
487
545
  const pr = preds[n].get(t), y = yraw.get(t);
488
546
  const ok = [...pr.keys()].filter((i) => tokens[i].liq[t] >= LIQ_MIN);
@@ -508,8 +566,12 @@ async function run(runId, p, state) {
508
566
 
509
567
  const comparison = {};
510
568
  const weeklyIc = {};
511
- for (const n of names) {
512
- const ic = icStats(n);
569
+ for (let ni = 0; ni < names.length; ni++) {
570
+ const n = names[ni];
571
+ const lbl = MODELS[n]?.label || n;
572
+ emit({ stage: 'calc', pct: 88 + ni * 2, text: `Финальный пересчёт · ранговая точность IC по неделям (${ni + 1} из ${names.length}): «${lbl}»…` });
573
+ await tick();
574
+ const ic = await icStats(n);
513
575
  weeklyIc[n] = ic.weekly;
514
576
  const rep = results[n].report;
515
577
  comparison[n] = {};
@@ -529,7 +591,8 @@ async function run(runId, p, state) {
529
591
  }
530
592
 
531
593
  const chosen = names.includes(p.modelType) ? p.modelType : 'attention';
532
- for (const t of results[chosen].sim.trades) saveTrade(runId, t);
594
+ emit({ stage: 'calc', pct: 95, text: `Финальный пересчёт · проверка фазовой устойчивости (5 сдвигов точки входа)…` });
595
+ await tick();
533
596
  const report = results[chosen].report;
534
597
  report.period = { from: tsOf(testTs[0]), till: tsOf(testTs[testTs.length - 1]) };
535
598
  report.bars = testTs.length;
@@ -546,18 +609,29 @@ async function run(runId, p, state) {
546
609
  // Быстрая проверка устойчивости по сдвигам точки пересмотра и старта (на готовых прогнозах, без переобучения)
547
610
  const bpdInt = Math.max(1, Math.round(bpd));
548
611
  const offsets = [...new Set([0, Math.max(1, Math.floor(H / 3)), Math.max(2, Math.floor((2 * H) / 3)), bpdInt * 2, bpdInt * 4].filter((o) => o < Math.min(testTs.length / 4, bpdInt * 10)))];
549
- const stabRuns = offsets.map((off, idx) => {
612
+ const stabRuns = [];
613
+ for (let idx = 0; idx < offsets.length; idx++) {
614
+ const off = offsets[idx];
615
+ if (idx > 0) await tick();
550
616
  const sm = idx === 0 ? results[chosen].sim : simulate(chosen, cost, p.modelShortFeePct, off);
551
617
  const rep = idx === 0 ? results[chosen].report.full : computeReport(sm.trades, sm.curve, p.capital, p.oosPct).full;
552
- return {
618
+ stabRuns.push({
553
619
  label: off === 0 ? 'база' : off < H ? `фаза +${off * BAR_INTERVAL}м` : `старт +${Math.round(off / bpd)}д`,
554
620
  returnPct: rep?.returnPct ?? 0,
555
621
  maxDrawdownPct: rep?.maxDrawdownPct ?? 0,
556
622
  trades: rep?.trades ?? 0,
557
- };
558
- });
623
+ });
624
+ }
559
625
  const stability = summarizeStability(stabRuns, 'phase');
560
626
 
627
+ emit({ stage: 'calc', pct: 98, text: `Финальный пересчёт · запись ${results[chosen].sim.trades.length} сделок и кривых капитала в базу…` });
628
+ await tick();
629
+ const chosenTrades = results[chosen].sim.trades;
630
+ for (let ti = 0; ti < chosenTrades.length; ti++) {
631
+ saveTrade(runId, chosenTrades[ti]);
632
+ if ((ti & 511) === 511) await tick();
633
+ }
634
+
561
635
  saveRunExtra(runId, {
562
636
  kind: 'model',
563
637
  symbols: tokens.map((t) => t.symbol),
@@ -625,8 +699,8 @@ async function runStabilityLoop(targetId, runRow, st) {
625
699
  const shifts = [-8, -4, 0, 4, 8];
626
700
  const H = barsFromHours(p.modelHorizon);
627
701
  const tillTs = Date.now();
628
- // Используем округление до часа для совпадения ключа кэша датасета в памяти!
629
- const fromTs = tillTs - p.modelHistoryDays * DAY - Math.ceil(p.modelTrainDays * 1.5 + 20) * DAY;
702
+ const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
703
+ const fromTs = todayStartMsk - 1650 * DAY;
630
704
  const periodTill = runRow.metrics?.period?.till || tillTs;
631
705
  const emitStab = (m) => { if (!st.cancelled) hub.emit({ type: 'stability:progress', runId: targetId, ...m }); };
632
706
  emitStab({ pct: 2, text: 'Проверка устойчивости: чтение локальных цен…' });
@@ -2,27 +2,27 @@
2
2
  // Описание всех настроек: для формы в интерфейсе, для ИИ-стратега и для проверки значений.
3
3
  // hidden — не показывать в интерфейсе; ai:false — ИИ менять запрещено (честность проверки).
4
4
  export const PARAMS = [
5
- { key: 'modelAutoAdapt', label: 'Самообучение правил торговли (Авто)', hint: 'Полный автомат: стратегия сама учится на мульти-горизонте (1,5–3,5 ч), автоматически управляет порогами входа и выхода под вашу комиссию, масштабирует слоты по силе сигнала и временно ставит на карантин убыточные акции. Ручная настройка горизонта и порогов не требуется.', type: 'checkbox' },
6
- { key: 'modelTopK', label: 'Сколько акций держать', hint: 'Сколько акций с лучшим прогнозом можно держать одновременно (при продажах — и столько же акций на продажу).', min: 1, max: 20 },
7
- { key: 'modelDirection', label: 'Направление торговли', hint: '«Только в рост (без шорта)» — классическая покупка акций и их продажа при фиксации прибыли (работает на любом счёте и ИИС). «В рост и в падение (с шортом)» — дополнительно зарабатывает на падающих акциях за счёт коротких продаж (требуется включённая маржинальная торговля у брокера; на брокерских счетах без маржиналки шорты отключаются автоматически).', type: 'select', options: [['long', 'Только в рост (без шорта)'], ['longshort', 'В рост и в падение (с шортом)']] },
8
- { key: 'modelHorizon', label: 'Горизонт прогноза, часов (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (мульти-горизонт 1,5–3,5 ч). При выключенном Авто — на сколько часов вперёд предсказывать движение.', min: 0.5, max: 8 },
9
- { key: 'modelMinEdge', label: 'Порог выгоды на вход (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (2,5× издержек). При выключенном Авто — во сколько раз ожидаемое движение должно превышать стоимость сделки.', min: 0, max: 10 },
10
- { key: 'modelExitRatio', label: 'Порог на выход (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (двухступенчатый выход 0,35 / 0,95). При выключенном Авто — до какой доли от порога входа держать позицию.', min: 0, max: 1, step: 0.05 },
11
- { key: 'modelAllocMode', label: 'Распределение суммы между акциями', hint: '«Поровну» — одинаковая доля на каждую позицию; «Обратно размаху акции» — спокойным бумагам больше, волатильным — меньше.', type: 'select', options: [['equal', 'Поровну'], ['invvol', 'Обратно размаху акции']] },
12
- { key: 'modelMarketFilter', label: 'Фильтр падающего рынка', hint: 'Не открывать новые покупки, когда индекс Мосбиржи находится в среднесрочном нисходящем тренде.', type: 'select', options: [['off', 'Выключен'], ['ma', 'Не покупать при падении индекса']] },
13
- { key: 'modelTrainDays', label: 'Окно обучения, торговых дней', hint: 'На скольких последних торговых днях стратегия строит карту рынка и учится перед каждым месяцем прогноза.', min: 30, max: 400 },
14
- { key: 'modelHistoryDays', label: 'Период исследования, дней', hint: 'За сколько последних календарных дней проверять стратегию на истории.', min: 120, max: 1100 },
15
- { key: 'modelEmbedDim', label: 'Размерность карты рынка (эмбеддинг)', hint: 'Сколько скрытых факторов совместного движения описывают каждую акцию на карте рынка.', min: 2, max: 20 },
16
- { key: 'modelTextWeight', label: 'Вес бизнес-профилей компаний на карте', hint: '0 — карта рынка строится только по совместному поведению цен; 0,1…0,3 — примешивает фундаментальное сходство бизнеса компаний от ИИ.', min: 0, max: 1, step: 0.1 },
17
- { key: 'modelUseMacro', label: 'Учитывать нефть, валюту, облигации', hint: 'Дополнительно учитывает чувствительность акций к Brent, юаню, доллару, золоту и RGBI.', type: 'checkbox', hidden: true },
5
+ { key: 'modelAutoAdapt', label: 'Самообучение правил торговли (Авто)', hint: 'Полный автомат: стратегия сама учится на мульти-горизонте (1,5–3,5 ч), сама выбирает число акций (1–4) и размер слота по силе сигнала, управляет порогами входа и выхода и временно ставит на карантин убыточные акции.', type: 'checkbox', hidden: true, fixed: true },
6
+ { key: 'modelTopK', label: 'Сколько акций держать (0 — Авто)', hint: '0 — система сама выбирает от 1 до 4 акций на каждом шаге по силе и концентрации сигналов; 1…20 — верхний предел числа позиций на сторону.', min: 0, max: 20, hidden: true, fixed: true },
7
+ { key: 'modelDirection', label: 'Направление торговли', hint: 'Автоматически подстраивается под тариф и статус маржинальной торговли на брокерском счёте.', type: 'select', options: [['long', 'Только в рост (без шорта)'], ['longshort', 'В рост и в падение (с шортом)']], hidden: true, fixed: true },
8
+ { key: 'modelHorizon', label: 'Горизонт прогноза, часов (ручной режим)', hint: 'Управляется автоматически (мульти-горизонт 1,5–3,5 ч).', min: 0.5, max: 8, hidden: true, fixed: true },
9
+ { key: 'modelMinEdge', label: 'Порог выгоды на вход (ручной режим)', hint: 'Управляется автоматически (2,5× издержек).', min: 0, max: 10, hidden: true, fixed: true },
10
+ { key: 'modelExitRatio', label: 'Порог на выход (ручной режим)', hint: 'Управляется автоматически (двухступенчатый выход 0,35 / 0,95).', min: 0, max: 1, step: 0.05, hidden: true, fixed: true },
11
+ { key: 'modelAllocMode', label: 'Распределение суммы между акциями', hint: 'Управляется автоматически по силе сигнала (Conviction Sizing).', type: 'select', options: [['equal', 'Поровну'], ['invvol', 'Обратно размаху акции']], hidden: true, fixed: true },
12
+ { key: 'modelMarketFilter', label: 'Фильтр падающего рынка', hint: 'Адаптивная балансировка порогов к тренду индекса включена автоматически.', type: 'select', options: [['off', 'Выключен'], ['ma', 'Не покупать при падении индекса']], hidden: true, fixed: true },
13
+ { key: 'modelTrainDays', label: 'Окно обучения, торговых дней', hint: 'Фиксировано на эталонном окне 90 торговых дней.', min: 30, max: 400, hidden: true, fixed: true },
14
+ { key: 'modelHistoryDays', label: 'Период исследования, дней', hint: 'За сколько последних календарных дней проверять стратегию на истории.', min: 120, max: 1100, hidden: true, fixed: true },
15
+ { key: 'modelEmbedDim', label: 'Размерность карты рынка / эмбеддинг (0 — Авто)', hint: 'Авто-калибровка по спектру собственных чисел Марченко–Пастура.', min: 0, max: 1000, hidden: true, fixed: true },
16
+ { key: 'modelTextWeight', label: 'Вес бизнес-профилей компаний на карте', hint: '0 — карта рынка строится только по совместному поведению цен.', min: 0, max: 1, step: 0.1, hidden: true, fixed: true },
17
+ { key: 'modelUseMacro', label: 'Учитывать нефть, валюту, облигации', hint: 'Дополнительно учитывает чувствительность акций к Brent, юаню, доллару, золоту и RGBI.', type: 'checkbox', hidden: true, fixed: true },
18
18
  { key: 'modelType', label: 'Какая стратегия торгует', hint: 'Внимание через карту.', type: 'select', options: [['attention', 'Внимание через карту'], ['leadlag', 'Кто за кем ходит'], ['inertia', 'Своя инерция']], hidden: true, fixed: true },
19
19
  { key: 'modelUseDividends', label: 'Учитывать дивиденды', hint: 'Цены до даты отсечки уменьшаются на размер дивиденда со сверкой по реальному падению цены.', type: 'checkbox', hidden: true, fixed: true },
20
- { key: 'capital', label: 'Стартовая сумма, ₽', hint: 'С какой суммой начинает виртуальный счёт.', min: 10000, max: 1e9, hidden: true },
20
+ { key: 'capital', label: 'Стартовая сумма, ₽', hint: 'С какой суммой начинает счёт.', min: 10000, max: 1e9, hidden: true },
21
21
  { key: 'commissionPct', label: 'Комиссия брокера и биржи, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false },
22
22
  { key: 'slippagePct', label: 'Проскальзывание при исполнении, %', hint: 'На сколько реальная цена сделки в стакане обычно хуже ожидаемой.', min: 0, max: 1, ai: false },
23
23
  { key: 'modelShortFeePct', label: 'Плата брокеру за заём акций (шорт), % годовых', hint: 'Ставка брокера за удержание коротких позиций (начисляется пропорционально времени удержания сделки).', min: 0, max: 100 },
24
- { key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько экспериментов Стратег может провести подряд за одно задание в режиме «⚡ Авто».', min: 1, max: 30, step: 1, int: true, ai: false },
25
- { key: 'paperDailyReview', label: 'ИИ разбирает каждый торговый день', hint: 'После окончания торгового дня Стратег сам разбирает сделки виртуального счёта и пишет сводку в чат.', type: 'checkbox' },
24
+ { key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько экспериментов Стратег может провести подряд за одно задание.', min: 1, max: 30, step: 1, int: true, ai: false, hidden: true },
25
+ { key: 'paperDailyReview', label: 'ИИ разбирает каждый торговый день', hint: 'После окончания торгового дня Стратег сам разбирает сделки и пишет сводку в чат.', type: 'checkbox', hidden: true },
26
26
  { key: 'newsEnabled', label: 'Следить за новостями', hint: 'Раз в 10 минут собирает новости и раскрытия компаний.', type: 'checkbox', hidden: true, fixed: true },
27
27
  { key: 'newsFilter', label: 'Блокировать сделки против важных свежих новостей', hint: 'В живой торговле и брокерских заявках не покупать бумагу на сильном негативе и не шортить на сильном позитиве за последние 6 часов.', type: 'checkbox' },
28
28
  { key: 'oosPct', label: 'Контрольная часть, %', hint: 'Доля периода в конце, не используемая для подбора настроек.', min: 0, max: 60, ai: false, hidden: true },
@@ -94,6 +94,10 @@ get_news — свежие новости и раскрытия эмитенто
94
94
  Настройка modelUseMacro добавляет «макро-головы»: чувствительность акций к движению нефти Brent, юаня, доллара, золота и индекса гособлигаций RGBI (бета считается только по окну обучения).
95
95
  Настройка modelTextWeight (0…1) подмешивает к карте рынка «описания бизнеса»: заранее составленный ИИ-портрет каждой компании из 20 устойчивых признаков (экспортёр, банк, дивиденды, госдоля и т.д., без новостей и событий). 0 — карта только по поведению акций, 1 — только по описаниям, значения между — смесь. При первом запуске с весом > 0 составление описаний занимает пару минут.
96
96
  Живая проверка (paper_start) торгует моделью на текущих ценах: раз в modelHorizon часов прогноз и пересмотр портфеля, заявки по цене открытия следующей свечи.
97
+ Ты также умеешь помогать пользователю открывать и подключать реальные брокерские счета (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции):
98
+ - вызывай broker_onboarding_guide, чтобы получить пошаговую инструкцию по выбранному брокеру и проверить уже подключённые счета;
99
+ - если пользователь прислал токен Т-Банка (t.…), вызывай discover_broker_accounts, чтобы показать все его субсчета, баланс, статус маржинальной торговли и убедиться, что на счёте нет автоследования;
100
+ - вызывай connect_broker_account, чтобы прямо из чата добавить/подключить брокерский счёт, синхронизировать портфель и рассчитать первые заявки.
97
101
 
98
102
  ## Настройки (точные названия для инструментов)
99
103
  ${params}
@@ -8,6 +8,7 @@ import { startModelResearch } from '../model/research.js';
8
8
  import { forecastNow } from '../model/forecast.js';
9
9
  import { live } from '../model/live.js';
10
10
  import { listNews } from '../news/index.js';
11
+ import { BROKER_META, brokersState, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders } from '../brokers/index.js';
11
12
  import { plural, fmtBarMsk } from '../util.js';
12
13
 
13
14
  export const OOS_LIMIT = 3;
@@ -287,6 +288,234 @@ export const TOOLS = {
287
288
  return { result: { ok: true, итог: compactMetrics(getRun(st.runId)?.metrics?.full) }, summary: `Живая проверка №${st.runId} остановлена` };
288
289
  },
289
290
  },
291
+
292
+ broker_onboarding_guide: {
293
+ kind: 'read',
294
+ label: 'Справка по открытию и подключению брокерского счёта',
295
+ description: 'Получить подробную инструкцию по открытию брокерского счёта, включению маржинальной торговли, выпуску API-токена и список уже подключённых счетов пользователя.',
296
+ parameters: {
297
+ type: 'object',
298
+ properties: {
299
+ broker: {
300
+ type: 'string',
301
+ enum: ['tbank', 'finam', 'bcs', 'alor', 'cifra', 'iticapital', 'sber', 'vtb', 'alfa'],
302
+ description: 'код выбранного брокера',
303
+ },
304
+ },
305
+ },
306
+ async run({ broker = 'tbank' } = {}) {
307
+ const st = brokersState();
308
+ const meta = BROKER_META.find((b) => b.id === broker) || BROKER_META[0];
309
+ const guides = {
310
+ tbank: {
311
+ openAccountUrl: 'https://www.tbank.ru/invest/',
312
+ tokenUrl: 'https://www.tbank.ru/invest/settings/',
313
+ accountTypeAdvice: 'Открывайте обычный «Брокерский счёт» (не Инвесткопилку и не счёт автоследования; для шортов на тарифах Профи/VIP нужен обычный брокерский счёт с включённой маржинальной торговлей).',
314
+ stepsToGetToken: [
315
+ '1. Откройте веб-кабинет Т-Инвестиций: https://www.tbank.ru/invest/settings/ (раздел «Токены T-Invest API»).',
316
+ '2. Нажмите «Выпустить токен» → выберите «Полный доступ» (для выставления заявок) и «Все счета».',
317
+ '3. Скопируйте токен (начинается с t.…) и пришлите его сюда в чат или вставьте в форму — я сам найду все ваши субсчета через discover_broker_accounts и подключу нужный счёт.',
318
+ '4. Чтобы стратегия зарабатывала и на падении акций (шорт), в мобильном приложении Т-Инвестиций зайдите в настройки выбранного счёта (шестерёнка вверху справа) и включите «Маржинальная торговля».',
319
+ ],
320
+ },
321
+ finam: {
322
+ openAccountUrl: 'https://www.finam.ru/',
323
+ tokenUrl: 'https://edox.finam.ru/',
324
+ accountTypeAdvice: 'Единый брокерский счёт Мосбиржи (КлФ / ЕДП).',
325
+ stepsToGetToken: [
326
+ '1. В кабинете клиента Финам (edox.finam.ru) откройте «Торговля → Токены доступа (Finam Trade API)».',
327
+ '2. Выпустите новый токен (начинается на CA…) и скопируйте ваш торговый код клиента ClientId (например 123456R).',
328
+ '3. Пришлите токен и ClientId или нажмите кнопку подтверждения — я подключу счёт через connect_broker_account.',
329
+ ],
330
+ },
331
+ bcs: {
332
+ openAccountUrl: 'https://bcs.ru/',
333
+ tokenUrl: 'https://my.broker.ru/',
334
+ accountTypeAdvice: 'Обычный брокерский счёт БКС Мир инвестиций с доступом к Мосбирже TQBR.',
335
+ stepsToGetToken: [
336
+ '1. В веб-терминале «БКС Мир инвестиций» откройте Настройки профиля → «Торговый API (BCS Trade API)».',
337
+ '2. Выпустите Refresh-токен с правом «Чтение и торговля» (trade-api-write).',
338
+ '3. Передайте Refresh-токен — я создам и синхронизирую счёт.',
339
+ ],
340
+ },
341
+ alor: {
342
+ openAccountUrl: 'https://www.alorbroker.ru/',
343
+ tokenUrl: 'https://alor.dev/',
344
+ accountTypeAdvice: 'Фондовый счёт Мосбиржи (портфель с литерой D, например D12345).',
345
+ stepsToGetToken: [
346
+ '1. Авторизуйтесь под брокерским аккаунтом на портале разработчика https://alor.dev (или в кабинете my.alor.ru → OpenAPI).',
347
+ '2. Выпустите Refresh Token для боевого контура и укажите номер портфеля D…',
348
+ ],
349
+ },
350
+ cifra: {
351
+ openAccountUrl: 'https://cifra-broker.ru/',
352
+ tokenUrl: 'https://tradernet.ru/',
353
+ accountTypeAdvice: 'Брокерский счёт Tradernet (Мосбиржа).',
354
+ stepsToGetToken: [
355
+ '1. В кабинете tradernet.ru откройте Настройки профиля → «API ключи».',
356
+ '2. Сгенерируйте пару ключей PublicKey и SecretKey и передайте их через двоеточие: PublicKey:SecretKey.',
357
+ ],
358
+ },
359
+ iticapital: {
360
+ openAccountUrl: 'https://iticapital.ru/',
361
+ tokenUrl: 'https://iticapital.ru/',
362
+ accountTypeAdvice: 'Фондовый портфель Мосбиржи вида BP12345.',
363
+ stepsToGetToken: [
364
+ '1. В личном кабинете ITI Capital откройте «Торговые системы → OpenAPI» и выпустите Refresh Token.',
365
+ '2. Укажите Refresh-токен и код портфеля BP…',
366
+ ],
367
+ },
368
+ sber: {
369
+ openAccountUrl: 'https://www.sberbank.ru/ru/person/investments',
370
+ accountTypeAdvice: 'Обычный брокерский счёт СберИнвестиций.',
371
+ stepsToGetToken: [
372
+ 'У СберИнвестиций нет облачного REST-токена для физлиц, поэтому счёт подключается БЕЗ токена в режиме «Подтверждение заявок» (или через адрес локального HTTP-шлюза QUIK).',
373
+ 'Достаточно указать желаемый лимит суммы на стратегию (например 100 000 ₽) и номер счёта — я сразу создам счёт в платформе через connect_broker_account и рассчитаю первые заявки в лотах Мосбиржи!',
374
+ ],
375
+ },
376
+ vtb: {
377
+ openAccountUrl: 'https://broker.vtb.ru/',
378
+ accountTypeAdvice: 'Обычный брокерский счёт ВТБ Мои Инвестиции.',
379
+ stepsToGetToken: [
380
+ 'У ВТБ Мои Инвестиции нет облачного REST-токена для физлиц — счёт подключается без токена в режиме «Подтверждение заявок» (с уведомлениями в Telegram/MAX и учётом портфеля) или через адрес моста QUIK.',
381
+ ],
382
+ },
383
+ alfa: {
384
+ openAccountUrl: 'https://alfabank.ru/make-money/investments/',
385
+ accountTypeAdvice: 'Обычный брокерский счёт Альфа-Инвестиций.',
386
+ stepsToGetToken: [
387
+ 'У Альфа-Инвестиций нет облачного REST-токена для физлиц — счёт подключается без токена в режиме «Подтверждение заявок» или через адрес моста QUIK.',
388
+ ],
389
+ },
390
+ };
391
+ return {
392
+ result: {
393
+ брокер: meta,
394
+ инструкция: guides[broker] || guides.tbank,
395
+ уже_подключённые_счета: st.accounts.map((a) => ({
396
+ id: a.id,
397
+ название: a.name,
398
+ брокер: a.broker,
399
+ субсчёт: a.accountId,
400
+ режим: a.mode,
401
+ лимит_руб: a.maxCapitalRub,
402
+ баланс_руб: a.lastSync?.totalRub ?? null,
403
+ маржиналка: a.lastSync?.marginEnabled ?? null,
404
+ })),
405
+ },
406
+ summary: `Загрузил инструкцию по подключению брокера «${meta.name}»`,
407
+ };
408
+ },
409
+ },
410
+
411
+ discover_broker_accounts: {
412
+ kind: 'read',
413
+ label: 'Ищет субсчета брокера по токену',
414
+ description: 'Найти все открытые брокерские субсчета пользователя в Т-Банке (T-Invest API) по токену, показать их баланс, статус маржинальной торговли и проверить отсутствие автоследования.',
415
+ parameters: {
416
+ type: 'object',
417
+ properties: {
418
+ token: { type: 'string', description: 'Токен T-Invest API (начинается на t.…)' },
419
+ sandbox: { type: 'boolean', description: 'Контур песочницы (по умолчанию false)' },
420
+ },
421
+ required: ['token'],
422
+ },
423
+ async run({ token, sandbox = false }) {
424
+ const r = await discoverBrokerAccounts({ broker: 'tbank', token, sandbox });
425
+ return {
426
+ result: {
427
+ найдено_счетов: r.accounts.length,
428
+ счета: r.accounts.map((a) => ({
429
+ accountId: a.id,
430
+ название: a.name,
431
+ тип: a.type,
432
+ доступ: a.access,
433
+ баланс_руб: a.totalRub,
434
+ свободные_рубли: a.cashRub,
435
+ позиций: a.positionsCount,
436
+ маржинальная_торговля: a.marginEnabled ? 'включена (шорты доступны)' : 'выключена (только в рост)',
437
+ автоследование: a.autoFollowDetected ? 'ОБНАРУЖЕНО (подключать запрещено Офертой)' : 'нет (можно подключать)',
438
+ пригодность: a.eligibleForTrading ? 'подходит для торговли' : 'только просмотр / копилка',
439
+ })),
440
+ },
441
+ summary: `Найдено счетов в Т-Банке: ${r.accounts.length}`,
442
+ };
443
+ },
444
+ },
445
+
446
+ connect_broker_account: {
447
+ kind: 'write',
448
+ confirmAlways: true,
449
+ label: 'Подключает брокерский счёт',
450
+ description: 'Добавить или обновить брокерский счёт в платформе «НейроАльфа», сразу выполнить синхронизацию портфеля и рассчитать стартовый пакет заявок.',
451
+ parameters: {
452
+ type: 'object',
453
+ properties: {
454
+ id: { type: 'string', description: 'ID существующего счёта в платформе (если обновляем существующий счёт)' },
455
+ broker: {
456
+ type: 'string',
457
+ enum: ['tbank', 'finam', 'bcs', 'alor', 'cifra', 'iticapital', 'sber', 'vtb', 'alfa'],
458
+ description: 'Код брокера',
459
+ },
460
+ name: { type: 'string', description: 'Понятное название счёта (например «Т-Банк основной»)' },
461
+ token: { type: 'string', description: 'API-токен / Refresh-токен (для Сбера/ВТБ/Альфы без QUIK оставьте пустым)' },
462
+ accountId: { type: 'string', description: 'ID субсчёта / ClientId / номер портфеля' },
463
+ mode: { type: 'string', enum: ['readonly', 'confirm', 'auto'], description: 'Режим работы: confirm (подтверждение заявок), auto (автомат) или readonly' },
464
+ legacyMode: { type: 'string', enum: ['isolate', 'smooth', 'takeover'], description: 'Защита старых бумаг: isolate (не трогать старые бумаги, по умолчанию), smooth или takeover' },
465
+ maxCapitalRub: { type: 'number', description: 'Лимит суммы на стратегию в рублях (по умолчанию 100000)' },
466
+ maxDailyDrawdownPct: { type: 'number', description: 'Дневной стоп по просадке в % (по умолчанию 3)' },
467
+ limitOffsetPct: { type: 'number', description: 'Защитный отступ лимитной заявки в % (по умолчанию 0.1)' },
468
+ },
469
+ required: ['broker'],
470
+ },
471
+ preview(args) {
472
+ const meta = BROKER_META.find((b) => b.id === args.broker);
473
+ const modeLbl = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' }[args.mode || 'confirm'] || 'Подтверждение заявок';
474
+ return `Брокер: ${meta?.name || args.broker}${args.name ? ` («${args.name}»)` : ''}${args.accountId ? ` · счёт ${args.accountId}` : ''} · режим «${modeLbl}» · лимит ${args.maxCapitalRub || 100000} ₽`;
475
+ },
476
+ async run(args) {
477
+ const beforeIds = new Set(brokersState().accounts.map((a) => a.id));
478
+ const st = saveBrokerAccount({
479
+ id: args.id,
480
+ broker: args.broker,
481
+ name: args.name,
482
+ token: args.token,
483
+ accountId: args.accountId,
484
+ mode: args.mode || 'confirm',
485
+ legacyMode: args.legacyMode || 'isolate',
486
+ maxCapitalRub: args.maxCapitalRub || 100000,
487
+ maxDailyDrawdownPct: args.maxDailyDrawdownPct ?? 3,
488
+ limitOffsetPct: args.limitOffsetPct ?? 0.1,
489
+ });
490
+ const created = args.id ? st.accounts.find((a) => a.id === args.id) : st.accounts.find((a) => !beforeIds.has(a.id)) || st.accounts[st.accounts.length - 1];
491
+ let syncRes = null;
492
+ if (created && (created.hasToken || ['sber', 'vtb', 'alfa'].includes(created.broker))) {
493
+ try {
494
+ const synced = await syncBrokerAccount(created.id);
495
+ await prepareBrokerOrders(created.id).catch(() => {});
496
+ const accNow = synced.accounts.find((a) => a.id === created.id);
497
+ syncRes = {
498
+ синхронизировано: Boolean(accNow?.lastSync?.ok),
499
+ ошибка_синхронизации: accNow?.lastSync?.error || null,
500
+ баланс_руб: accNow?.lastSync?.totalRub ?? null,
501
+ свободные_рубли: accNow?.lastSync?.cashRub ?? null,
502
+ маржинальная_торговля: accNow?.lastSync?.marginEnabled ?? null,
503
+ подготовлено_заявок: accNow?.pendingBatch?.orders?.length ?? 0,
504
+ };
505
+ } catch (e) {
506
+ syncRes = { синхронизировано: false, ошибка_синхронизации: e.message };
507
+ }
508
+ }
509
+ return {
510
+ result: {
511
+ ok: true,
512
+ счёт: created ? { id: created.id, название: created.name, брокер: created.broker, субсчёт: created.accountId, режим: created.mode, лимит_руб: created.maxCapitalRub } : null,
513
+ статус_подключения: syncRes,
514
+ },
515
+ summary: `Подключён брокерский счёт «${created?.name || args.broker}»${syncRes?.баланс_руб !== null && syncRes?.баланс_руб !== undefined ? ` (баланс ${syncRes.баланс_руб} ₽)` : ''}`,
516
+ };
517
+ },
518
+ },
290
519
  };
291
520
 
292
521
  export const MODES = {