neuroalpha 0.3.16 → 0.5.1

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 (41) hide show
  1. package/README.md +0 -1
  2. package/config/certs/russian_trusted_root_ca.pem +33 -0
  3. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  4. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -6
  5. 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 +2 -2
  6. package/package.json +60 -53
  7. package/public/activate.html +36 -0
  8. package/public/activate.js +33 -0
  9. package/public/admin.html +126 -7
  10. package/public/app.js +608 -182
  11. package/public/index.html +57 -47
  12. package/public/landing.html +106 -37
  13. package/public/legal.html +4 -4
  14. package/public/styles.css +152 -36
  15. package/server/api/routes.js +308 -37
  16. package/server/auth/index.js +79 -15
  17. package/server/brokers/index.js +1022 -172
  18. package/server/build-info.js +7 -0
  19. package/server/config.js +13 -6
  20. package/server/data/moex.js +6 -4
  21. package/server/db.js +22 -18
  22. package/server/desktop-entry.js +103 -0
  23. package/server/hub.js +28 -14
  24. package/server/index.js +82 -12
  25. package/server/instance/agent.js +259 -0
  26. package/server/instance/central.js +491 -0
  27. package/server/instance/identity.js +79 -0
  28. package/server/instance/provision.js +166 -0
  29. package/server/instance/signing.js +71 -0
  30. package/server/model/edge.js +19 -0
  31. package/server/model/forecast.js +107 -57
  32. package/server/model/live.js +1 -1
  33. package/server/model/models.js +12 -3
  34. package/server/model/research.js +218 -30
  35. package/server/notify/index.js +51 -16
  36. package/server/params-meta.js +3 -3
  37. package/server/role.js +44 -0
  38. package/server/security.js +40 -0
  39. package/server/strategist/index.js +462 -488
  40. package/server/strategist/tools.js +675 -532
  41. package/server/tls-ca.js +32 -0
package/public/app.js CHANGED
@@ -4,7 +4,13 @@ const MSK = 3 * 3600000;
4
4
 
5
5
  // ---------- утилиты ----------
6
6
  const api = async (url, opts = {}) => {
7
- const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined });
7
+ const timeoutMs = opts.timeoutMs || 35000;
8
+ const res = await fetch(url, {
9
+ headers: { 'Content-Type': 'application/json' },
10
+ signal: AbortSignal.timeout(timeoutMs),
11
+ ...opts,
12
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
13
+ });
8
14
  const j = await res.json().catch(() => ({}));
9
15
  if (!res.ok) throw new Error(j.error || res.statusText);
10
16
  return j;
@@ -43,9 +49,30 @@ function fmtDayGroup(ts) {
43
49
  }
44
50
  const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
45
51
  const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
52
+ const KNOWN_MOEX_TICKERS = new Set([
53
+ 'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
54
+ 'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
55
+ 'OZON', 'HEAD', 'PHOR', 'AFLT', 'VKCO', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'AFKS',
56
+ 'PIKK', 'SMLT', 'POSI', 'BSPB', 'SVCB', 'CBOM', 'FLOT', 'X5', 'FEES', 'HYDR',
57
+ 'TRMK', 'MRKC', 'MRKP', 'MRKV', 'MRKU', 'MRKZ', 'NKHP', 'NKNCP', 'NKNC', 'SFIN',
58
+ 'AQUA', 'FESH', 'ASTR', 'SOFL', 'DATA', 'WUSH', 'LENT', 'BELU', 'ETLN', 'LSRG',
59
+ 'SELG', 'UGLD', 'RAGR', 'AGRO', 'FIVE', 'NMTP', 'UPRO', 'MSNG', 'ELFV', 'TGKA',
60
+ 'BANEP', 'BANE', 'RNFT', 'SGZH', 'MTLR', 'MTLRP', 'RASP', 'VSMO', 'ENPG', 'POLY',
61
+ 'GEMC', 'MDMG', 'PRMD', 'APTK', 'CNRU', 'DELI', 'DIAS', 'EUTR', 'FIXP', 'HNFG',
62
+ 'IVAT', 'LEAS', 'MBNK', 'MVID', 'OGKB', 'OKEY', 'RENI', 'RTKM', 'RTKMP', 'SPBE', 'SVAV',
63
+ ]);
64
+ // Текст ИИ и новостей недоверенный: HTML после markdown всегда проходит через DOMPurify.
65
+ // Если библиотека очистки не загрузилась — показываем только экранированный текст.
46
66
  const md = (s) => {
47
- const raw = window.marked ? marked.parse(String(s || ''), { breaks: true }) : `<p>${esc(s)}</p>`;
48
- return raw.replace(/№(\d{1,6})\b/g, '<span class="link" data-run="$1" title="Открыть эксперимент №$1">№$1</span>');
67
+ if (!window.marked || !window.DOMPurify) return `<p>${esc(s).replace(/\n/g, '<br>')}</p>`;
68
+ const raw = marked.parse(String(s || ''), { breaks: true });
69
+ const withRuns = raw.replace(/№(\d{1,6})\b/g, '<span class="link" data-run="$1" title="Открыть эксперимент №$1">№$1</span>');
70
+ const withSyms = withRuns.replace(/(^|[^A-Za-z0-9_/"'=])([A-Z]{2,6})(?=$|[^A-Za-z0-9_])/g, (m, p1, sym) => (
71
+ KNOWN_MOEX_TICKERS.has(sym)
72
+ ? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
73
+ : m
74
+ ));
75
+ return DOMPurify.sanitize(withSyms, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
49
76
  };
50
77
  const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
51
78
  const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
@@ -165,8 +192,7 @@ function updateTabBadges() {
165
192
  }
166
193
 
167
194
  function showMainSec(sec) {
168
- if (sec === 'now') sec = 'brokers';
169
- if (sec === 'settings' || sec === 'g1') sec = 'g0';
195
+ if (sec === 'now' || sec === 'settings' || sec === 'g0' || sec === 'g1') sec = 'brokers';
170
196
  if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
171
197
  document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
172
198
  document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
@@ -372,7 +398,11 @@ function syncUiAvailability() {
372
398
  if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
373
399
  if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
374
400
 
375
- // 3. Кнопки обращения к Стратегу в прогнозе и виртуальных счетах
401
+ // 3. Кнопки обновления и обращения к Стратегу в прогнозе
402
+ if ($('#btn-refresh-forecast')) {
403
+ $('#btn-refresh-forecast').disabled = Boolean(forecastBusy);
404
+ $('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
405
+ }
376
406
  if ($('#btn-ask-forecast')) {
377
407
  $('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
378
408
  $('#btn-ask-forecast').title = !state.forecast
@@ -391,35 +421,8 @@ function syncUiAvailability() {
391
421
  }
392
422
 
393
423
  function syncSettingsDependencies() {
394
- const form = $('#params');
395
- if (!form || !state.settings) return;
396
- const s = state.settings;
397
- const autoOn = s.modelAutoAdapt !== false;
398
- const dir = s.modelDirection || 'long';
399
-
400
- const setRowEnabled = (key, enabled, whyDisabled = '') => {
401
- const row = form.querySelector(`.srow[data-key="${key}"]`);
402
- if (!row) return;
403
- row.style.opacity = enabled ? '1' : '0.45';
404
- const ctrl = form.elements[key];
405
- if (ctrl) {
406
- ctrl.disabled = !enabled;
407
- ctrl.title = enabled ? '' : whyDisabled;
408
- }
409
- };
410
-
411
- setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
412
- setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
413
- setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
414
- setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
415
- setRowEnabled('modelMarketFilter', true, '');
416
-
417
- const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
418
- if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
419
- if ($('#btn-emb-auto')) {
420
- const isAutoEmb = Number(s.modelEmbedDim ?? 0) === 0;
421
- $('#btn-emb-auto').classList.toggle('primary', isAutoEmb);
422
- $('#btn-emb-auto').textContent = isAutoEmb ? '✓ Авто (0)' : '⚡ Авто (0)';
424
+ if ($('#chk-news-filter') && state.settings) {
425
+ $('#chk-news-filter').checked = Boolean(state.settings.newsFilter);
423
426
  }
424
427
  }
425
428
  function syncShowcaseBtn() {
@@ -475,10 +478,12 @@ function verdictHtml(c, oosA) {
475
478
  : '';
476
479
  let vs = '';
477
480
  if (base) {
478
- const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
479
- if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = ` · Карта рынка даёт преимущество над простой инерцией (<b>${fmtPct(a.returnPct)}</b> против <b>${fmtPct(base.returnPct)}</b>)`;
480
- else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = ` · Карта рынка и инерция дали близкий результат (${fmtPct(a.returnPct)} и ${fmtPct(base.returnPct)})`;
481
- else vs = ` · Простая инерция опередила карту рынка (${fmtPct(base.returnPct)} против ${fmtPct(a.returnPct)})`;
481
+ const ar = a.realisticReturnPct ?? a.returnPct, br = base.realisticReturnPct ?? base.returnPct;
482
+ const pf = a.realisticProfitFactor ?? a.profitFactor ?? 0, bpf = base.realisticProfitFactor ?? base.profitFactor ?? 0;
483
+ const lbl = a.realisticReturnPct !== undefined && a.realisticReturnPct !== null ? ' при реалистичном исполнении' : '';
484
+ if (ar - br > 5 && pf >= bpf) vs = ` · Карта рынка даёт преимущество над простой инерцией${lbl} (<b>${fmtPct(ar)}</b> против <b>${fmtPct(br)}</b>)`;
485
+ else if (Math.abs(ar - br) <= 5) vs = ` · Карта рынка и инерция дали близкий результат${lbl} (${fmtPct(ar)} и ${fmtPct(br)})`;
486
+ else vs = ` · Простая инерция опередила карту рынка${lbl} (${fmtPct(br)} против ${fmtPct(ar)})`;
482
487
  }
483
488
  if (a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) {
484
489
  return `<span class="tag ok">Стратегия прибыльна</span> Превосходит индекс Мосбиржи на <b class="pos">${fmtPct(a.returnPct - a.marketPct)}</b>${vs}${oosPart}`;
@@ -545,6 +550,31 @@ function renderStability(st) {
545
550
  box.innerHTML = `<div class="stab-head"><span class="tag ${vCls}">${vLbl}</span><span><b>Медиана ${fmtPct(st.medianPct)}</b> · разброс от <span class="${cls(st.minPct)}">${fmtPct(st.minPct)}</span> до <span class="${cls(st.maxPct)}">${fmtPct(st.maxPct)}</span> · в плюсе ${st.positive} из ${st.count} · худшая просадка ${fmtNum(st.maxDrawdownPct, 1)}%</span><span class="muted small">(${kindLbl})</span></div><div class="stab-chips">${chips}</div>`;
546
551
  }
547
552
 
553
+ // Стресс-тест исполнения: та же стратегия с реалистичными издержками, задержкой входа, ликвидностью, лотами и шортами
554
+ function renderStress(sx) {
555
+ const box = $('#stress');
556
+ if (!box) return;
557
+ if (!sx?.scenarios?.length) { box.hidden = true; return; }
558
+ box.hidden = false;
559
+ const base = sx.scenarios.find((s) => s.key === 'base');
560
+ const real = sx.scenarios.find((s) => s.key === 'realistic');
561
+ const verdict = !real ? '' : real.full.returnPct > (sx.market?.full ?? 0) && (real.full.profitFactor ?? 0) > 1.1
562
+ ? '<span class="tag ok">Держится при реалистичном исполнении</span>'
563
+ : real.full.returnPct > 0 ? '<span class="tag mid">При реалистичном исполнении преимущество слабое</span>'
564
+ : '<span class="tag no">При реалистичном исполнении — убыток</span>';
565
+ const cell = (v, d = 1, pct = true) => (v === null || v === undefined ? '<span class="muted">—</span>' : `<span class="${pct ? cls(v) : ''}">${pct ? fmtPct(v, d) : fmtNum(v, d)}</span>`);
566
+ const rows = sx.scenarios.map((s) => `<tr${s.key === 'realistic' ? ' style="font-weight:600"' : ''}>
567
+ <td title="${esc(s.note || '')}">${esc(s.label)}</td>
568
+ <td class="num">${cell(s.full.returnPct)}</td><td class="num">${cell(s.full.cagrPct)}</td>
569
+ <td class="num">${cell(s.full.profitFactor, 2, false)}</td><td class="num">${cell(s.full.maxDrawdownPct === null ? null : -s.full.maxDrawdownPct)}</td>
570
+ <td class="num">${fmtNum(s.full.trades, 0)}</td><td class="num">${cell(s.full.avgTradePct, 3)}</td>
571
+ <td class="num">${cell(s.last12.returnPct)}</td><td class="num">${cell(s.last6.returnPct)}</td></tr>`).join('');
572
+ box.innerHTML = `<div class="stab-head">${verdict}<span><b>Стресс-тест исполнения</b>${base && real ? ` · итог ${fmtPct(base.full.returnPct)} → <b class="${cls(real.full.returnPct)}">${fmtPct(real.full.returnPct)}</b> при реалистичном исполнении` : ''} · рынок за период ${fmtPct(sx.market?.full)}</span></div>
573
+ <div style="overflow-x:auto"><table class="cmp"><thead><tr><th>Сценарий</th><th class="num">Итог</th><th class="num">Годовых</th><th class="num">PF</th><th class="num">Просадка</th><th class="num">Сделок</th><th class="num">Ср. сделка</th><th class="num">12 мес.</th><th class="num">6 мес.</th></tr></thead><tbody>${rows}
574
+ <tr class="muted"><td>Рынок (индекс МосБиржи)</td><td class="num">${cell(sx.market?.full)}</td><td></td><td></td><td></td><td></td><td></td><td class="num">${cell(sx.market?.last12)}</td><td class="num">${cell(sx.market?.last6)}</td></tr></tbody></table></div>
575
+ <p class="muted small note">${(sx.caveats || []).map(esc).join(' ')}</p>`;
576
+ }
577
+
548
578
  // Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
549
579
  const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
550
580
 
@@ -557,6 +587,16 @@ function resetLiveTrain(runId) {
557
587
  liveTrainState.text = '';
558
588
  }
559
589
 
590
+ function layout(f, W, H) {
591
+ const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
592
+ const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
593
+ const pad = 28;
594
+ return f.pts.map(([x, y]) => [
595
+ pad + ((x - x0) / (x1 - x0 || 1)) * (W - 2 * pad),
596
+ H - pad - ((y - y0) / (y1 - y0 || 1)) * (H - 2 * pad),
597
+ ]);
598
+ }
599
+
560
600
  function drawLiveTrainMap(viz) {
561
601
  const cv = $('#ld-map-cv');
562
602
  if (!cv || !viz?.pts?.length) return;
@@ -565,7 +605,7 @@ function drawLiveTrainMap(viz) {
565
605
  cv.width = W * dpr; cv.height = H * dpr;
566
606
  const ctx = cv.getContext('2d');
567
607
  ctx.scale(dpr, dpr);
568
- const syms = liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
608
+ const syms = viz.symbols?.length ? viz.symbols : liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
569
609
  const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
570
610
  const xy = layout(f, W, H);
571
611
  const light = document.body.classList.contains('light');
@@ -656,7 +696,7 @@ function renderLiveTraining() {
656
696
 
657
697
  empty.innerHTML = `
658
698
  ${calcBanner}
659
- <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
699
+ <div class="kpis" style="margin-bottom:12px">
660
700
  <div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
661
701
  <div class="kpi"><div class="v ${cls(meanIc)}">${meanIc > 0 ? '+' : ''}${fmtNum(meanIc, 3)}</div><div class="l">среднее совпадение (IC)</div><div class="sub">шагов в плюсе: ${posFolds} из ${ics.length} (${ics.length ? Math.round((posFolds / ics.length) * 100) : 0}%)</div></div>
662
702
  <div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
@@ -764,6 +804,7 @@ function renderOverview() {
764
804
  ].join('');
765
805
  $('#sides').hidden = true;
766
806
  renderStability(ex.stability);
807
+ renderStress(ex.stress);
767
808
 
768
809
  const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
769
810
  for (const n of ['market', ...MODELS]) eqLines[n].setData(uniq(ex.curves[n]).map((p) => ({ time: chartTime(p.ts), value: p.equity })));
@@ -776,11 +817,18 @@ function renderOverview() {
776
817
 
777
818
  renderPeriods();
778
819
 
779
- const best = MODELS.reduce((b, n) => (c[n] && (!b || c[n].returnPct > c[b].returnPct) ? n : b), null);
820
+ const hasReal = MODELS.some((n) => c[n]?.realisticReturnPct !== undefined && c[n]?.realisticReturnPct !== null);
821
+ const best = MODELS.reduce((b, n) => {
822
+ if (!c[n]) return b;
823
+ if (!b) return n;
824
+ const vn = hasReal ? (c[n].realisticReturnPct ?? c[n].returnPct) : c[n].returnPct;
825
+ const vb = hasReal ? (c[b].realisticReturnPct ?? c[b].returnPct) : c[b].returnPct;
826
+ return vn > vb ? n : b;
827
+ }, null);
780
828
  const th = (t, h, num = true) => `<th class="${num ? 'num' : ''}" title="${esc(h)}">${t}</th>`;
781
- $('#cmp').innerHTML = `<table><thead><tr>${th('Стратегия', '', false)}${th('Совпадение', 'Насколько порядок акций «кто сильнее рынка» по прогнозу совпадает с фактом (−1…1). На рынке 0,03+ уже ценно.')}${th('Недель с плюсом', 'В какой доле недель прогноз угадывал лучше случайного')}${th('Итог', 'После комиссий')}${th('Без комиссий', 'Если бы торговля была бесплатной — есть ли сигнал вообще')}${th('Сделок', '')}${th('На 1 ₽ убытка', 'Сколько заработано на каждый потерянный рубль')}</tr></thead><tbody>${
829
+ $('#cmp').innerHTML = `<table><thead><tr>${th('Стратегия', '', false)}${th('Совпадение', 'Насколько порядок акций «кто сильнее рынка» по прогнозу совпадает с фактом (−1…1). На рынке 0,03+ уже ценно.')}${th('Недель с плюсом', 'В какой доле недель прогноз угадывал лучше случайного')}${hasReal ? th('Реалистично', 'Исполнение как в живом контуре: издержки по эшелонам (0,065% / 0,125%), вход внутри свечи, ≤5% оборота свечи, целые лоты, шорты по списку Т-Банка') : ''}${th('Итог (отчёт)', 'При издержках 0,05% на сторону и входе по цене открытия')}${th('Без комиссий', 'Если бы торговля была бесплатной — есть ли сигнал вообще')}${th('Сделок', '')}${th('На 1 ₽ убытка', 'Сколько заработано на каждый потерянный рубль')}</tr></thead><tbody>${
782
830
  MODELS.map((n) => c[n] && `<tr class="${n === best ? 'best' : ''}"><td title="${esc(MODEL_HINT[n])}">${MODEL_LABEL[n]}${n === chosen ? ' <span class="tag">торгует</span>' : ''}</td>
783
- <td class="num">${fmtNum(c[n].ic, 3)}</td><td class="num">${fmtNum(c[n].weeksPositivePct, 0)}%</td><td class="num ${cls(c[n].returnPct)}">${fmtPct(c[n].returnPct)}</td>
831
+ <td class="num">${fmtNum(c[n].ic, 3)}</td><td class="num">${fmtNum(c[n].weeksPositivePct, 0)}%</td>${hasReal ? `<td class="num ${cls(c[n].realisticReturnPct)}" title="PF при реалистичном исполнении: ${fmtNum(c[n].realisticProfitFactor)}">${c[n].realisticReturnPct !== null && c[n].realisticReturnPct !== undefined ? fmtPct(c[n].realisticReturnPct) : '—'}</td>` : ''}<td class="num ${cls(c[n].returnPct)}">${fmtPct(c[n].returnPct)}</td>
784
832
  <td class="num ${cls(c[n].grossReturnPct)}">${fmtPct(c[n].grossReturnPct)}</td><td class="num">${c[n].trades}</td><td class="num">${fmtNum(c[n].profitFactor)}</td></tr>`).join('')}</tbody></table>`;
785
833
 
786
834
  const w = uniq(ex.weeklyIc?.[chosen]);
@@ -798,7 +846,7 @@ function renderOverview() {
798
846
  $('#trades-sum').textContent = `Сделки торгующей стратегии (${trades.length})`;
799
847
  const shorts = trades.some((t) => t.side === 'short');
800
848
  $('#trades').innerHTML = trades.length ? `<table><thead><tr><th>Акция</th>${shorts ? '<th></th>' : ''}<th>${shorts ? 'Открыли' : 'Купили'}</th><th>${shorts ? 'Закрыли' : 'Продали'}</th><th class="num">Результат</th><th class="num">%</th></tr></thead><tbody>${
801
- trades.slice(-300).reverse().map((t) => `<tr><td>${t.symbol}</td>${shorts ? `<td class="muted small">${t.side === 'short' ? 'продажа' : 'покупка'}</td>` : ''}<td>${fmtTs(t.entry_ts)}</td><td>${fmtTs(t.exit_ts)}</td><td class="num ${cls(t.pnl)}">${fmtNum(t.pnl, 0)} ₽</td><td class="num ${cls(t.pnl_pct)}">${fmtPct(t.pnl_pct, 2)}</td></tr>`).join('')}</tbody></table>${trades.length > 300 ? '<p class="muted small">Показаны последние 300.</p>' : ''}` : '<p class="muted">Сделок нет.</p>';
849
+ trades.slice(-300).reverse().map((t) => `<tr><td><span class="sym-link" data-sym="${esc(t.symbol)}" title="Открыть досье акции ${esc(t.symbol)}"><b>${esc(t.symbol)}</b></span></td>${shorts ? `<td class="muted small">${t.side === 'short' ? 'продажа' : 'покупка'}</td>` : ''}<td>${fmtTs(t.entry_ts)}</td><td>${fmtTs(t.exit_ts)}</td><td class="num ${cls(t.pnl)}">${fmtNum(t.pnl, 0)} ₽</td><td class="num ${cls(t.pnl_pct)}">${fmtPct(t.pnl_pct, 2)}</td></tr>`).join('')}</tbody></table>${trades.length > 300 ? '<p class="muted small">Показаны последние 300.</p>' : ''}` : '<p class="muted">Сделок нет.</p>';
802
850
 
803
851
  }
804
852
 
@@ -821,6 +869,10 @@ $('#run-bar').addEventListener('click', async (e) => {
821
869
  sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
822
870
  }
823
871
  });
872
+ $('#btn-refresh-forecast')?.addEventListener('click', () => {
873
+ forecastBusy = false;
874
+ loadForecast(false, false);
875
+ });
824
876
  $('#btn-ask-forecast').addEventListener('click', () => {
825
877
  sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
826
878
  });
@@ -877,6 +929,7 @@ $('#btn-cancel').addEventListener('click', async () => {
877
929
  // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
878
930
  // ======================================================================
879
931
  let forecastBusy = false;
932
+ let forecastBusyAt = 0;
880
933
  let pulseClearTimer = null;
881
934
  const prevClientExp = new Map();
882
935
  const prevClientRank = new Map();
@@ -907,8 +960,10 @@ function triggerTradingPulse(text = 'Обновление котировок и
907
960
  }
908
961
 
909
962
  async function loadForecast(force, silent = false) {
910
- if (forecastBusy) return;
963
+ if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
911
964
  forecastBusy = true;
965
+ forecastBusyAt = Date.now();
966
+ syncUiAvailability();
912
967
  triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
913
968
  const box = $('#forecast');
914
969
  if (!silent && !state.forecast) {
@@ -921,7 +976,7 @@ async function loadForecast(force, silent = false) {
921
976
  </div>`;
922
977
  }
923
978
  try {
924
- const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
979
+ const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`, { timeoutMs: force ? 90000 : 18000 });
925
980
  // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
926
981
  f.rows.forEach((r, idx) => {
927
982
  const rank = idx + 1;
@@ -961,12 +1016,12 @@ async function loadForecast(force, silent = false) {
961
1016
  return '<span class="muted small">—</span>';
962
1017
  };
963
1018
  const row = (r) => `<tr>
964
- <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
965
- <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
1019
+ <td><span class="sym-link" data-sym="${esc(r.symbol)}" title="Открыть досье акции ${esc(r.symbol)} (${esc(r.name)})"><b>${esc(r.symbol)}</b> <span class="muted small">${esc(r.name)}</span></span>${rankBadge(r)}${dot(r.news)}</td>
1020
+ <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
966
1021
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
967
1022
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
968
1023
  <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
969
- <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
1024
+ <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => `<span class="sym-link" data-sym="${esc(x.symbol)}" title="Сосед по карте рынка: вклад ${x.contribPct > 0 ? '+' : ''}${x.contribPct}%, близость ${x.closeness}">${esc(x.symbol)}</span>`).join(', ')}</td>
970
1025
  </tr>`;
971
1026
  const buyThr = f.buyThresholdPct ?? f.thresholdPct;
972
1027
  const shortThr = f.shortThresholdPct ?? f.thresholdPct;
@@ -974,9 +1029,9 @@ async function loadForecast(force, silent = false) {
974
1029
  ...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
975
1030
  ...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
976
1031
  ].slice(0, 4);
977
- box.innerHTML = `<table style="table-layout:fixed">${colgroup}<thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
978
- <table style="margin-top:14px;table-layout:fixed">${colgroup}<thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${shortRows.map(row).join('')}</tbody></table>
979
- <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
1032
+ box.innerHTML = `<div style="overflow-x:auto"><table><thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
1033
+ <table style="margin-top:14px"><thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${shortRows.map(row).join('')}</tbody></table></div>
1034
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
980
1035
  if (state.brokers?.accounts?.length) renderBrokers();
981
1036
  } catch (e) {
982
1037
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -985,12 +1040,12 @@ async function loadForecast(force, silent = false) {
985
1040
  syncUiAvailability();
986
1041
  }
987
1042
  }
988
- const FORECAST_TICK_MS = 45000; // живое обновление каждые 45 секунд
1043
+ const FORECAST_TICK_MS = 15000; // живое обновление каждые 15 секунд
989
1044
  function refreshForecastIfStale() {
990
- if (forecastBusy) return;
1045
+ if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
991
1046
  if (!state.forecast?.at) {
992
1047
  loadForecast(false, false);
993
- } else if (Date.now() - state.forecast.at >= 20000) {
1048
+ } else if (Date.now() - state.forecast.at >= 12000) {
994
1049
  loadForecast(false, true);
995
1050
  }
996
1051
  }
@@ -1003,50 +1058,21 @@ async function loadNews() {
1003
1058
  if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
1004
1059
  if ($('#news-list')) {
1005
1060
  $('#news-list').innerHTML = items.length
1006
- ? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}">${s.symbol}</b>`).join(', ')} · ` : ''}<a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`).join('')
1061
+ ? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="sym-link ${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}" data-sym="${esc(s.symbol)}" title="Открыть досье акции ${esc(s.symbol)}">${esc(s.symbol)}</b>`).join(', ')} · ` : ''}<a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`).join('')
1007
1062
  : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
1008
1063
  }
1009
1064
  } catch { /* ignore */ }
1010
1065
  }
1011
1066
 
1012
1067
  // ======================================================================
1013
- // НАСТРОЙКИ (ИЗДЕРЖКИ И НОВОСТИ)
1068
+ // НАСТРОЙКА ФИЛЬТРА НОВОСТЕЙ
1014
1069
  // ======================================================================
1015
- const GROUPS = [
1016
- ['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
1017
- ];
1018
- function buildForm() {
1019
- const field = (p) => {
1020
- let input;
1021
- if (p.type === 'select') input = `<select name="${p.key}" class="sctrl">${p.options.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select>`;
1022
- else if (p.type === 'checkbox') input = `<input type="checkbox" name="${p.key}" class="switch">`;
1023
- else input = `<input type="number" step="${p.step ?? 'any'}" name="${p.key}" min="${p.min}" max="${p.max}" class="sctrl">`;
1024
- return `<div class="srow" data-key="${p.key}"><div class="stxt"><div class="slbl">${esc(p.label)}<button type="button" class="mark" data-def="${p.key}" hidden title="Отличается от умолчания — нажмите, чтобы вернуть">изменено</button></div><div class="hint">${esc(p.hint)}</div></div>${input}</div>`;
1025
- };
1026
- const shown = state.config.params.filter((p) => !p.hidden);
1027
- const curSec = document.querySelector('#main-nav [data-sec].active')?.dataset.sec || 'brokers';
1028
- $('#params').innerHTML = GROUPS.map(([name, keys], i) => {
1029
- const items = keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean);
1030
- const secKey = `g${i}`;
1031
- return items.length ? `<section class="ssec" data-sec="${secKey}"${curSec === secKey ? '' : ' hidden'}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}</div></section>` : '';
1032
- }).join('');
1033
- }
1070
+ function buildForm() {}
1034
1071
  function fillForm(s) {
1035
- for (const el of $('#params').elements) {
1036
- if (!el.name || el === document.activeElement) continue;
1037
- if (el.type === 'checkbox') el.checked = !!s[el.name];
1038
- else el.value = s[el.name];
1039
- }
1040
- markDefaults(s);
1041
- }
1042
- // Метка «изменено» у параметров, отличающихся от умолчания
1043
- function markDefaults(s) {
1044
- const d = state.config?.defaults || {};
1045
- $('#params').querySelectorAll('[data-def]').forEach((b) => {
1046
- b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
1047
- });
1072
+ if ($('#chk-news-filter') && s) {
1073
+ $('#chk-news-filter').checked = Boolean(s.newsFilter);
1074
+ }
1048
1075
  syncApplyBtn();
1049
- syncSettingsDependencies();
1050
1076
  updateTabBadges();
1051
1077
  }
1052
1078
  const saveSettings = async (body) => {
@@ -1054,28 +1080,30 @@ const saveSettings = async (body) => {
1054
1080
  const r = await api('/api/settings', { method: 'PUT', body });
1055
1081
  if (r.errors?.length) toast(r.errors.join('; '), 'error');
1056
1082
  state.settings = r.settings;
1057
- markDefaults(state.settings);
1083
+ fillForm(state.settings);
1058
1084
  } catch (e) { toast(e.message, 'error'); }
1059
1085
  };
1060
1086
  const saveSetting = (k, v) => saveSettings({ [k]: v });
1061
- $('#params').addEventListener('change', (e) => e.target.name && saveSetting(e.target.name, e.target.type === 'checkbox' ? e.target.checked : e.target.value));
1062
- // клик по метке «изменено» — вернуть параметр к умолчанию
1063
- $('#params').addEventListener('click', async (e) => {
1064
- const b = e.target.closest('[data-def]');
1065
- if (!b) return;
1066
- await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
1067
- fillForm(state.settings);
1087
+ $('#chk-news-filter')?.addEventListener('change', (e) => {
1088
+ saveSetting('newsFilter', e.target.checked);
1068
1089
  });
1069
1090
 
1070
1091
  // ======================================================================
1071
1092
  // ЧАТ СО СТРАТЕГОМ
1072
1093
  // ======================================================================
1073
1094
  const TOOL_LABELS = {
1074
- get_settings: 'Смотрит настройки', list_runs: 'Смотрит список исследований', analyze_run: 'Разбирает исследование', compare_runs: 'Сравнивает исследования',
1075
- run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
1076
- journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
1077
- set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
1078
- broker_onboarding_guide: 'Смотрит инструкцию по брокеру', discover_broker_accounts: 'Ищет субсчета по токену', connect_broker_account: 'Подключение брокерского счёта',
1095
+ get_brokers_status: 'Смотрит брокерские счета и портфель',
1096
+ get_settings: 'Смотрит настройки издержек',
1097
+ list_runs: 'Смотрит список исследований',
1098
+ analyze_run: 'Разбирает исследование',
1099
+ compare_runs: 'Сравнивает исследования',
1100
+ model_forecast: 'Смотрит живой прогноз',
1101
+ get_news: 'Смотрит свежие новости',
1102
+ market_neighbors: 'Смотрит соседей на карте',
1103
+ set_settings: 'Изменение настроек',
1104
+ broker_onboarding_guide: 'Смотрит инструкцию по брокеру',
1105
+ discover_broker_accounts: 'Ищет субсчета по токену',
1106
+ connect_broker_account: 'Подключение брокерского счёта',
1079
1107
  };
1080
1108
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1081
1109
  const feed = $('#feed');
@@ -1252,34 +1280,28 @@ function renderChatState() {
1252
1280
  const s = state.chat.state;
1253
1281
  if (!s) return;
1254
1282
  const noTools = s.tools === false;
1255
- const curMode = noTools ? 'discuss' : (s.mode in MODE_META ? s.mode : 'advisor');
1256
- const mm = MODE_META[curMode] || MODE_META.advisor;
1257
- if ($('#chat-mode-ico')) $('#chat-mode-ico').innerHTML = mm.svg;
1258
- if ($('#chat-mode-lbl')) $('#chat-mode-lbl').textContent = mm.label;
1259
- if ($('#chat-mode-btn')) $('#chat-mode-btn').title = noTools ? 'Выбранная модель не умеет пользоваться инструментами — Стратег только беседует' : MODE_TITLE;
1260
- document.querySelectorAll('#mode-menu [data-mode-val]').forEach((b) => {
1261
- const v = b.dataset.modeVal;
1262
- b.disabled = noTools && v !== 'discuss';
1263
- b.classList.toggle('active', v === curMode);
1264
- });
1265
1283
  $('#chat-stop').hidden = !s.busy && !s.waiting;
1266
1284
  syncActionBtn();
1267
1285
  let st = '';
1268
- if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Настройки → Модели ИИ)';
1269
- else if (s.waiting) st = 'Стратег ждёт вашего решения (или просто напишите ответ)';
1286
+ if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Модели ИИ)';
1287
+ else if (s.waiting) st = 'Стратег ждёт вашего подтверждения (или напишите ответ)';
1270
1288
  else if (s.busy) {
1271
1289
  const m = [...state.chat.msgs.values()].reverse().find((x) => x.tool_calls && Object.values(x.data?.tools || {}).some((t) => t.status === 'running'));
1272
1290
  const c = m?.tool_calls.find((x) => m.data.tools[x.id]?.status === 'running');
1273
1291
  st = c ? `${TOOL_LABELS[c.function.name] || 'Работает'}…` : 'Думает…';
1274
- if (s.mode === 'autopilot') st += ` · осталось исследований: ${s.budgetLeft}`;
1275
- } else if (noTools) st = 'У этой модели нет инструментов: Стратег только беседует';
1292
+ } else if (noTools) st = 'У этой модели нет инструментов: Стратег отвечает по контексту';
1276
1293
  $('#chat-status').textContent = st;
1277
1294
  for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
1278
1295
  renderSuggest();
1279
1296
  syncUiAvailability();
1280
1297
  }
1281
1298
 
1282
- const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
1299
+ const SUGGEST = [
1300
+ 'Помоги подключить брокерский счёт',
1301
+ 'Что стратегия ждёт в ближайшие часы?',
1302
+ 'Разбери мой портфель и текущие заявки',
1303
+ 'Какие сейчас важные новости по рынку?',
1304
+ ];
1283
1305
  function renderSuggest() {
1284
1306
  const has = [...state.chat.msgs.values()].some((m) => m.kind === 'user');
1285
1307
  $('#suggest').innerHTML = has || state.chat.state?.busy ? '' : SUGGEST.map((s) => `<button>${esc(s)}</button>`).join('');
@@ -1312,31 +1334,12 @@ function closeModeMenu() {
1312
1334
  if (m) m.hidden = true;
1313
1335
  }
1314
1336
 
1315
- $('#chat-mode-btn')?.addEventListener('click', (e) => {
1316
- e.stopPropagation();
1317
- closeModelMenu();
1318
- const m = $('#mode-menu');
1319
- if (m) m.hidden = !m.hidden;
1320
- });
1321
-
1322
- $('#mode-menu')?.addEventListener('click', async (e) => {
1323
- const b = e.target.closest('button[data-mode-val]');
1324
- if (!b || b.disabled) return;
1325
- closeModeMenu();
1326
- try {
1327
- state.chat.state = await post('/api/chat/mode', { mode: b.dataset.modeVal });
1328
- renderChatState();
1329
- } catch (err) { toast(err.message, 'error'); }
1330
- });
1331
-
1332
1337
  async function sendChat(text) {
1333
1338
  if (speechRec && speechActive) stopVoiceInput(false);
1334
1339
  text = (text ?? $('#chat-input').value).trim();
1335
1340
  if (!text) return;
1336
1341
  try {
1337
- const s = state.chat.state;
1338
- const mode = s?.tools === false ? 'discuss' : (s?.mode || 'advisor');
1339
- await post('/api/chat/send', { text, mode });
1342
+ await post('/api/chat/send', { text, mode: 'advisor' });
1340
1343
  $('#chat-input').value = '';
1341
1344
  resizeChatInput();
1342
1345
  toBottom();
@@ -1939,7 +1942,11 @@ function renderBrokers() {
1939
1942
  openBrokerId = accs[0].id;
1940
1943
  }
1941
1944
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
1942
- tradeEl.innerHTML = accs.map((a) => {
1945
+ const gate = state.brokers?.trading;
1946
+ const gateHtml = gate && !gate.allowNew
1947
+ ? `<div class="live-card" style="border-color:var(--yellow);margin-bottom:10px"><b style="color:var(--yellow)">⏸ Новые заявки приостановлены.</b> <span class="muted">${esc(gate.reason || '')}</span></div>`
1948
+ : '';
1949
+ tradeEl.innerHTML = gateHtml + accs.map((a) => {
1943
1950
  const isOpen = a.id === openBrokerId;
1944
1951
  const ls = a.lastSync;
1945
1952
  const pb = a.pendingBatch;
@@ -2021,11 +2028,11 @@ function renderBrokers() {
2021
2028
  const fcRow = fcRowMap?.get(p.symbol);
2022
2029
  const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
2023
2030
  return `<tr>
2024
- <td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
2031
+ <td><span class="sym-link" data-sym="${esc(p.symbol)}" title="Открыть досье ${esc(p.symbol)}"><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></span></td>
2025
2032
  <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
2026
2033
  <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
2027
2034
  <td class="num">${fmtOwnership(p)}</td>
2028
- <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
2035
+ <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, p.avgPrice < 1 ? 5 : p.avgPrice < 10 ? 3 : 2)} →</span> ` : ''}<b>${fmtNum(p.price, p.price < 1 ? 5 : p.price < 10 ? 3 : 2)} ₽</b></td>
2029
2036
  <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
2030
2037
  <td class="num ${cls(p.pnlRub)}"><b>${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</b>${p.pnlPct ? ` <span class="small">(${p.pnlPct >= 0 ? '+' : ''}${fmtNum(p.pnlPct, 1)}%)</span>` : ''}</td>
2031
2038
  <td>${posStatus(p)}${fcBadge}</td>
@@ -2041,7 +2048,7 @@ function renderBrokers() {
2041
2048
  <span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
2042
2049
  </div>
2043
2050
  <table><thead><tr><th>Бумага</th><th>Направление</th><th class="num">Лотов (исп./всего)</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${ls.activeOrders.map((o) => `<tr>
2044
- <td><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></td>
2051
+ <td><span class="sym-link" data-sym="${esc(o.symbol)}" title="Открыть досье ${esc(o.symbol)}"><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></span></td>
2045
2052
  <td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
2046
2053
  <td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
2047
2054
  <td class="num">${fmtNum(o.price, 4)} ₽</td>
@@ -2052,7 +2059,7 @@ function renderBrokers() {
2052
2059
  let batchHtml = '';
2053
2060
  if (pb) {
2054
2061
  const ordRows = pb.orders?.length
2055
- ? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
2062
+ ? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><span class="sym-link" data-sym="${esc(o.symbol)}" title="Открыть досье ${esc(o.symbol)}"><b>${esc(o.symbol)}</b></span></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
2056
2063
  : `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
2057
2064
  const resHtml = pb.results?.length
2058
2065
  ? `<div class="muted small" style="margin-top:6px">Итог отправки (${fmtTs(pb.executedAt)}): ${pb.results.map((r) => `${esc(r.symbol)}: ${r.ok ? `<span class="pos">отправлена (${esc(r.orderId)})</span>` : `<span class="neg">${esc(r.error)}</span>`}`).join(' · ')}</div>`
@@ -2087,43 +2094,58 @@ function renderBrokers() {
2087
2094
  const rows = [];
2088
2095
  for (const [, dayOps] of byDay) {
2089
2096
  let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2090
- const symQty = new Map(), symCash = new Map(), symBuyRub = new Map();
2097
+ const symBuyQty = new Map(), symSellQty = new Map(), symBuyRub = new Map(), symSellRub = new Map(), symFeeRub = new Map();
2098
+ let unlinkedFeeRub = 0;
2091
2099
  for (const op of dayOps) {
2092
2100
  const pay = Number(op.paymentRub) || 0;
2093
2101
  if (op.kind === 'buy') {
2094
2102
  tradesCnt++;
2095
2103
  buyRub += Math.abs(pay);
2096
2104
  if (op.symbol) {
2097
- symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2098
- symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2105
+ symBuyQty.set(op.symbol, (symBuyQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2099
2106
  symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
2100
2107
  }
2101
2108
  } else if (op.kind === 'sell') {
2102
2109
  tradesCnt++;
2103
2110
  sellRub += Math.abs(pay);
2104
2111
  if (op.symbol) {
2105
- symQty.set(op.symbol, (symQty.get(op.symbol) || 0) - (Number(op.qty) || 0));
2106
- symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2112
+ symSellQty.set(op.symbol, (symSellQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2113
+ symSellRub.set(op.symbol, (symSellRub.get(op.symbol) || 0) + Math.abs(pay));
2107
2114
  }
2108
2115
  } else if (op.kind === 'fee') {
2109
- if (/дивиденд|купон/i.test(op.title || '')) divTaxRub += pay;
2110
- else feeRub += pay;
2116
+ if (/дивиденд|купон/i.test(op.title || '')) {
2117
+ divTaxRub += pay;
2118
+ } else {
2119
+ feeRub += pay;
2120
+ if (op.symbol) symFeeRub.set(op.symbol, (symFeeRub.get(op.symbol) || 0) + pay);
2121
+ else unlinkedFeeRub += pay;
2122
+ }
2111
2123
  } else if (op.kind === 'div') {
2112
2124
  divTaxRub += pay;
2113
2125
  } else if (op.kind === 'in' || op.kind === 'out') {
2114
2126
  inOutRub += pay;
2115
2127
  }
2116
2128
  }
2117
- // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
2129
+ // Считаем точный чистый результат закрытого объёма внутри дня по каждой бумаге (включая частичные закрытия и комиссии закрытых сделок)
2118
2130
  let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
2119
- for (const [sym, netQ] of symQty) {
2120
- if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2121
- closedTradePnl += symCash.get(sym);
2122
- closedBaseRub += symBuyRub.get(sym) || 0;
2131
+ const allDaySyms = new Set([...symBuyQty.keys(), ...symSellQty.keys()]);
2132
+ for (const sym of allDaySyms) {
2133
+ const bQ = symBuyQty.get(sym) || 0;
2134
+ const sQ = symSellQty.get(sym) || 0;
2135
+ const matchedQ = Math.min(bQ, sQ);
2136
+ if (matchedQ > 0) {
2137
+ const avgBuy = (symBuyRub.get(sym) || 0) / bQ;
2138
+ const avgSell = (symSellRub.get(sym) || 0) / sQ;
2139
+ const matchedBuyCost = matchedQ * avgBuy;
2140
+ const matchedGrossPnl = matchedQ * (avgSell - avgBuy);
2141
+ const totalSymQ = bQ + sQ;
2142
+ const matchedFeeShare = totalSymQ > 0 ? (symFeeRub.get(sym) || 0) * ((2 * matchedQ) / totalSymQ) : 0;
2143
+ closedTradePnl += matchedGrossPnl + matchedFeeShare;
2144
+ closedBaseRub += matchedBuyCost;
2123
2145
  closedSyms++;
2124
2146
  }
2125
2147
  }
2126
- if (closedSyms > 0) closedTradePnl += feeRub;
2148
+ if (closedSyms > 0) closedTradePnl += unlinkedFeeRub;
2127
2149
  const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
2128
2150
 
2129
2151
  const summaryParts = [];
@@ -2151,9 +2173,9 @@ function renderBrokers() {
2151
2173
  rows.push(`<tr>
2152
2174
  <td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
2153
2175
  <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}" title="${esc(op.title || op.kindLabel)}">${esc(op.kindLabel)}</span></td>
2154
- <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2176
+ <td>${op.symbol ? `<span class="sym-link" data-sym="${esc(op.symbol)}" title="Открыть досье ${esc(op.symbol)}"><b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span></span>` : '<span class="muted small">—</span>'}</td>
2155
2177
  <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2156
- <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
2178
+ <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, op.price < 1 ? 5 : op.price < 10 ? 3 : 2)} ₽` : '—'}</td>
2157
2179
  <td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
2158
2180
  </tr>`);
2159
2181
  }
@@ -2238,7 +2260,7 @@ function renderBrokers() {
2238
2260
  }).join('');
2239
2261
 
2240
2262
  const histHtml = lh?.operations?.length || lh?.loadedYears?.length
2241
- ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2263
+ ? `<div class="kpis" style="margin-bottom:10px">
2242
2264
  <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
2243
2265
  <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2244
2266
  <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
@@ -2262,11 +2284,13 @@ function renderBrokers() {
2262
2284
  <span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
2263
2285
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2264
2286
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2287
+ ${a.haltedReason && a.haltedReason.startsWith('Сработал дневной стоп') ? `<button type="button" class="brk-toggle" data-btrade="resetstop" data-bid="${esc(a.id)}" title="Снять дневной стоп по просадке вручную — до конца сегодняшнего дня он больше не сработает">Снять стоп до конца дня</button>` : ''}
2265
2288
  ${collapsedMetricsHtml}
2266
2289
  </div>
2267
2290
  <div class="row-gap" style="align-items:center">
2268
- <button type="button" class="brk-toggle ${!isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
2291
+ <button type="button" class="brk-toggle ${isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
2269
2292
  ${isOpen && ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
2293
+ ${isOpen ? `<button type="button" class="brk-icon-btn" data-btrade="events" data-bid="${esc(a.id)}" title="Журнал исполнения: отправленные заявки, ошибки брокера, стопы, ручные действия" aria-label="Журнал исполнения">📋</button>` : ''}
2270
2294
  ${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
2271
2295
  </div>
2272
2296
  </div>
@@ -2279,13 +2303,13 @@ function renderBrokers() {
2279
2303
  <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2280
2304
  <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2281
2305
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2282
- <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2306
+ <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2283
2307
  </div>
2284
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2308
+ ${ls?.ok ? `<div class="kpis">
2285
2309
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2286
2310
  <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2287
2311
  <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2288
- <div class="kpi"><div class="v" style="white-space:nowrap">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2312
+ <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2289
2313
  </div>` : ''}
2290
2314
  ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2291
2315
  <div class="brk-subtabs">
@@ -2311,6 +2335,18 @@ function renderBrokers() {
2311
2335
  }
2312
2336
  }
2313
2337
 
2338
+ $('#btn-broker-stop-all')?.addEventListener('click', async () => {
2339
+ const n = (state.brokers?.accounts || []).length;
2340
+ if (!n) return toast('Брокерских счетов нет');
2341
+ if (!confirm(`Аварийная остановка ${n} счёт(ов):\n\n• авто-торговля на всех счетах встанет на паузу;\n• все активные заявки у брокера будут сняты;\n• открытые позиции НЕ закрываются, защитные стопы продолжают работать.\n\nВозобновить торговлю можно кнопкой «▶ Запустить» на каждом счёте.`)) return;
2342
+ try {
2343
+ const r = await post('/api/brokers-stop-all');
2344
+ state.brokers = r;
2345
+ renderBrokers();
2346
+ toast(r.stopErrors?.length ? `Остановлено, но не все заявки сняты: ${r.stopErrors.join('; ')}` : 'Авто-торговля остановлена, активные заявки сняты', r.stopErrors?.length ? 'error' : 'info');
2347
+ } catch (err) { toast(err.message, 'error'); }
2348
+ });
2349
+
2314
2350
  $('#btn-broker-add')?.addEventListener('click', () => {
2315
2351
  const formBox = $('#broker-add-box');
2316
2352
  const formSlot = $('#broker-form-slot');
@@ -2347,6 +2383,8 @@ function getBrokerFormSnap() {
2347
2383
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2348
2384
  autoSchedule: Boolean(f.autoSchedule.checked),
2349
2385
  skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
2386
+ brokerStops: Boolean(f.brokerStops?.checked),
2387
+ cashSweep: Boolean(f.cashSweep?.checked),
2350
2388
  sandbox: Boolean(f.sandbox.checked),
2351
2389
  });
2352
2390
  }
@@ -2356,8 +2394,23 @@ function syncBrokerGuideUi() {
2356
2394
  if (!f) return;
2357
2395
  const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2358
2396
  const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
2397
+ if ($('#broker-stops-row')) $('#broker-stops-row').hidden = f.broker.value !== 'tbank';
2398
+ if ($('#broker-cash-sweep-row')) $('#broker-cash-sweep-row').hidden = f.broker.value !== 'tbank';
2399
+ // «Автомат» — только для Т-Банка (у остальных нет средней цены для защитных стопов, отмены заявок и истории)
2400
+ const autoOpt = f.mode?.querySelector('option[value="auto"]');
2401
+ if (autoOpt) {
2402
+ const autoAllowed = f.broker.value === 'tbank';
2403
+ autoOpt.disabled = !autoAllowed;
2404
+ autoOpt.title = autoAllowed ? '' : 'Режим «Автомат» пока доступен только для Т-Банка';
2405
+ if (!autoAllowed && f.mode.value === 'auto') f.mode.value = 'confirm';
2406
+ }
2359
2407
  if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2360
- if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
2408
+ if ($('#broker-token-input')) {
2409
+ const editAcc = editingBrokerId ? (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId) : null;
2410
+ $('#broker-token-input').placeholder = editAcc?.hasToken && editAcc.broker === f.broker.value
2411
+ ? `Токен сохранён (${editAcc.tokenMask || '…'}) — оставьте пустым или вставьте новый`
2412
+ : g.tokenPh;
2413
+ }
2361
2414
  if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2362
2415
  if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2363
2416
  if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
@@ -2379,10 +2432,11 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2379
2432
  const accId = f.accountId.value.trim();
2380
2433
  const cap = Number(f.maxCapitalRub.value) || 100000;
2381
2434
  let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
2435
+ // Сам токен в чат не передаётся: он уходил бы во внешнюю модель ИИ и сохранялся в истории переписки
2382
2436
  if (tok && accId) {
2383
- promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
2437
+ promptText += ` Токен я уже вставил в форму (в чат его не пишу), номер счёта "${accId}". Подскажи, что проверить перед сохранением счёта.`;
2384
2438
  } else if (tok) {
2385
- promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
2439
+ promptText += ` Токен я уже вставил в форму (в чат его не пишу). Подскажи, как выбрать подходящий субсчёт кнопкой «🔍 Найти мои счета по токену» и что проверить перед сохранением.`;
2386
2440
  } else if (accId) {
2387
2441
  promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
2388
2442
  } else {
@@ -2486,7 +2540,8 @@ function startBrokerEdit(acc) {
2486
2540
  f.name.value = acc.name;
2487
2541
  f.broker.value = acc.broker;
2488
2542
  f.accountId.value = acc.accountId || '';
2489
- f.token.value = acc.token || '';
2543
+ // токен с сервера не приходит: пустое поле = оставить сохранённый токен без изменений
2544
+ f.token.value = '';
2490
2545
  f.mode.value = acc.mode || 'confirm';
2491
2546
  if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2492
2547
  f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
@@ -2494,6 +2549,8 @@ function startBrokerEdit(acc) {
2494
2549
  f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2495
2550
  f.autoSchedule.checked = acc.autoSchedule !== false;
2496
2551
  f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2552
+ if (f.brokerStops) f.brokerStops.checked = Boolean(acc.brokerStops);
2553
+ if (f.cashSweep) f.cashSweep.checked = Boolean(acc.cashSweep);
2497
2554
  f.sandbox.checked = !!acc.sandbox;
2498
2555
  $('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
2499
2556
  $('#broker-save-btn').textContent = 'Сохранить изменения';
@@ -2592,8 +2649,9 @@ brokerForm.addEventListener('submit', async (e) => {
2592
2649
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2593
2650
  autoSchedule: f.autoSchedule.checked,
2594
2651
  skipEdgeMinutes: f.skipEdgeMinutes.checked,
2652
+ brokerStops: Boolean(f.brokerStops?.checked),
2653
+ cashSweep: Boolean(f.cashSweep?.checked),
2595
2654
  sandbox: f.sandbox.checked,
2596
- resetHalt: true,
2597
2655
  };
2598
2656
  try {
2599
2657
  const savedId = editingBrokerId;
@@ -2694,6 +2752,20 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2694
2752
  }
2695
2753
  return;
2696
2754
  }
2755
+ if (act === 'events') {
2756
+ openBrokerEvents(id);
2757
+ return;
2758
+ }
2759
+ if (act === 'resetstop') {
2760
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2761
+ if (!confirm(`Снять дневной стоп на счёте «${acc?.name || id}»?\n\n${acc?.haltedReason || ''}\n\nДо конца сегодняшнего дня стоп по просадке на этом счёте больше не сработает, и авто-торговля продолжится с текущими убытками.`)) return;
2762
+ try {
2763
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reset-daily-stop`);
2764
+ renderBrokers();
2765
+ toast('Дневной стоп снят до конца дня');
2766
+ } catch (err) { toast(err.message, 'error'); }
2767
+ return;
2768
+ }
2697
2769
  b.disabled = true;
2698
2770
  const old = b.textContent;
2699
2771
  try {
@@ -2775,7 +2847,16 @@ function connectWs() {
2775
2847
  loadChat().catch(() => {});
2776
2848
  loadLlm().catch(() => {});
2777
2849
  };
2778
- ws.onclose = () => { connected = false; setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)'); setTimeout(connectWs, 2000); };
2850
+ ws.onclose = (ev) => {
2851
+ connected = false;
2852
+ if (ev.code === 4401) {
2853
+ setStatus('bad', 'Сессия не авторизована — войдите в аккаунт');
2854
+ location.href = '/landing';
2855
+ return;
2856
+ }
2857
+ setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)');
2858
+ setTimeout(connectWs, 2000);
2859
+ };
2779
2860
  ws.onmessage = (ev) => {
2780
2861
  const m = JSON.parse(ev.data);
2781
2862
  switch (m.type) {
@@ -2815,7 +2896,6 @@ function connectWs() {
2815
2896
  state.settings = m.settings;
2816
2897
  fillForm(m.settings);
2817
2898
  if (m.source === 'ai') {
2818
- for (const k of Object.keys(m.changed || {})) { const l = $('#params').elements[k]?.closest('.srow, label'); if (l) { l.classList.remove('flash'); void l.offsetWidth; l.classList.add('flash'); } }
2819
2899
  toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
2820
2900
  }
2821
2901
  break;
@@ -2978,7 +3058,7 @@ async function renderCloudPane() {
2978
3058
  }
2979
3059
 
2980
3060
  const nt = me.notify || {};
2981
- const code = u.notifyCode || 'NA-OWNER1';
3061
+ const code = u.notifyCode || '';
2982
3062
  const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
2983
3063
  const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
2984
3064
 
@@ -3020,9 +3100,6 @@ async function renderCloudPane() {
3020
3100
  : u.limits?.maxCapitalPerAccountRub > 0
3021
3101
  ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
3022
3102
  : 'подписка не активна';
3023
- const retLimStr = u.limits?.maxAnnualReturnPct > 0
3024
- ? `до ${u.limits.maxAnnualReturnPct}% годовых`
3025
- : (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
3026
3103
  const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
3027
3104
  updateTabBadges();
3028
3105
 
@@ -3031,7 +3108,7 @@ async function renderCloudPane() {
3031
3108
  <div class="stxt">
3032
3109
  <div class="slbl">${esc(dispName)}${dispEmail ? ` · <span class="muted">${esc(dispEmail)}</span>` : ''} <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
3033
3110
  <div class="hint">
3034
- Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Целевая доходность: <b>${retLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3111
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3035
3112
  </div>
3036
3113
  </div>
3037
3114
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
@@ -3043,9 +3120,9 @@ async function renderCloudPane() {
3043
3120
  <div class="stxt">
3044
3121
  <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
3045
3122
  <div class="hint">
3046
- • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)», задавленная доходность до 60% годовых;<br>
3047
- • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)», задавленная доходность до 120% годовых;<br>
3048
- • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами, задавленная доходность до 240% годовых.
3123
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
3124
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
3125
+ • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
3049
3126
  </div>
3050
3127
  </div>
3051
3128
  <div class="row-gap" style="flex-wrap:wrap">
@@ -3076,8 +3153,8 @@ async function renderCloudPane() {
3076
3153
  `;
3077
3154
 
3078
3155
  box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
3079
- await post('/api/auth/logout');
3080
- location.href = '/landing';
3156
+ const r = await post('/api/auth/logout').catch(() => ({}));
3157
+ location.href = r?.redirect || '/landing';
3081
3158
  });
3082
3159
  box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
3083
3160
  try {
@@ -3147,6 +3224,38 @@ if (localStorage.getItem('theme') === 'light') applyTheme(true);
3147
3224
 
3148
3225
  // ---------- справка ----------
3149
3226
  const helpCache = new Map();
3227
+ // ---------- журнал исполнения по счёту ----------
3228
+ const EVENT_LABELS = {
3229
+ order_sent: ['заявка отправлена', ''],
3230
+ order_error: ['ошибка заявки', 'neg'],
3231
+ protective_stop: ['защитный стоп', 'neg'],
3232
+ protective_stop_error: ['ошибка защитного стопа', 'neg'],
3233
+ daily_stop: ['дневной стоп', 'neg'],
3234
+ daily_stop_reset: ['стоп снят вручную', ''],
3235
+ stop_all: ['аварийная остановка', 'neg'],
3236
+ broker_stop_set: ['стоп-заявка у брокера выставлена', 'pos'],
3237
+ broker_stop_cancel: ['стоп-заявка у брокера снята', ''],
3238
+ broker_stop_gone: ['стоп-заявка исчезла у брокера', 'neg'],
3239
+ broker_stop_error: ['ошибка стоп-заявки', 'neg'],
3240
+ };
3241
+ async function openBrokerEvents(id) {
3242
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
3243
+ $('#events-title').textContent = `Журнал исполнения · ${acc?.name || id}`;
3244
+ $('#events-body').textContent = 'Загрузка…';
3245
+ $('#events-modal').hidden = false;
3246
+ try {
3247
+ const { events } = await api(`/api/brokers/${encodeURIComponent(id)}/events?limit=300`);
3248
+ $('#events-body').innerHTML = events.length
3249
+ ? `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Событие</th><th>Подробности</th></tr></thead><tbody>${events.map((ev) => {
3250
+ const [label, cls] = EVENT_LABELS[ev.kind] || [ev.kind, ''];
3251
+ return `<tr><td class="small" style="white-space:nowrap">${esc(new Date(ev.ts + MSK).toISOString().slice(0, 19).replace('T', ' '))}</td><td class="small ${cls}">${esc(label)}</td><td class="small">${esc(ev.text)}</td></tr>`;
3252
+ }).join('')}</tbody></table></div>`
3253
+ : '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>';
3254
+ } catch (e) { $('#events-body').textContent = e.message; }
3255
+ }
3256
+ $('#events-close')?.addEventListener('click', () => { $('#events-modal').hidden = true; });
3257
+ $('#events-modal')?.addEventListener('click', (e) => { if (e.target.id === 'events-modal') $('#events-modal').hidden = true; });
3258
+
3150
3259
  async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
3151
3260
  $('#help').hidden = false;
3152
3261
  document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
@@ -3171,9 +3280,326 @@ $('#help-tabs')?.addEventListener('click', (e) => {
3171
3280
  });
3172
3281
  $('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
3173
3282
  $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
3283
+
3284
+ // ---------- интерактивное досье акции (#stock-modal) ----------
3285
+ let stockModalChart = null;
3286
+ let stockChartRo = null;
3287
+ let currentStockData = null;
3288
+ let currentStockRange = '1m';
3289
+
3290
+ function renderStockChart(d, range = '1m') {
3291
+ currentStockRange = range;
3292
+ const box = $('#stock-chart-box');
3293
+ if (!box) return;
3294
+ if (stockChartRo) {
3295
+ try { stockChartRo.disconnect(); } catch { /* ignore */ }
3296
+ stockChartRo = null;
3297
+ }
3298
+ if (stockModalChart) {
3299
+ try { stockModalChart.remove(); } catch { /* ignore */ }
3300
+ stockModalChart = null;
3301
+ }
3302
+ box.innerHTML = '';
3303
+ const light = document.body.classList.contains('light');
3304
+ const initW = Math.max(260, box.clientWidth || 720);
3305
+ stockModalChart = LightweightCharts.createChart(box, {
3306
+ width: initW,
3307
+ height: 260,
3308
+ layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
3309
+ grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
3310
+ rightPriceScale: { borderVisible: false },
3311
+ timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
3312
+ crosshair: { mode: 0 },
3313
+ });
3314
+ if (window.ResizeObserver) {
3315
+ stockChartRo = new ResizeObserver(() => {
3316
+ if (stockModalChart && box.clientWidth > 100) {
3317
+ stockModalChart.applyOptions({ width: box.clientWidth });
3318
+ }
3319
+ });
3320
+ stockChartRo.observe(box);
3321
+ }
3322
+
3323
+ const nowTs = Date.now();
3324
+ let rawPts = [];
3325
+ if (range === '1d') {
3326
+ const cut = nowTs - 2 * 86400000;
3327
+ rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
3328
+ if (rawPts.length < 5) rawPts = (d.intraday || []).slice(-95);
3329
+ } else if (range === '1w') {
3330
+ rawPts = (d.intraday || []).filter((b) => b.ts >= nowTs - 8 * 86400000);
3331
+ } else if (range === '1m') {
3332
+ rawPts = d.intraday?.length ? d.intraday : (d.daily || []).slice(-30);
3333
+ } else if (range === '1y') {
3334
+ rawPts = (d.daily || []).filter((b) => b.ts >= nowTs - 370 * 86400000);
3335
+ } else {
3336
+ rawPts = d.daily || [];
3337
+ }
3338
+
3339
+ const seenT = new Set();
3340
+ const seriesData = [];
3341
+ for (const b of rawPts) {
3342
+ const t = chartTime(b.ts);
3343
+ if (seenT.has(t) || !(b.c > 0)) continue;
3344
+ seenT.add(t);
3345
+ seriesData.push({ time: t, value: b.c });
3346
+ }
3347
+ seriesData.sort((a, b) => a.time - b.time);
3348
+
3349
+ const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
3350
+ const mainColor = isUp ? '#3fb950' : '#f85149';
3351
+ const areaSeries = stockModalChart.addAreaSeries({
3352
+ lineColor: mainColor,
3353
+ topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
3354
+ bottomColor: 'rgba(0, 0, 0, 0.0)',
3355
+ lineWidth: 2,
3356
+ priceFormat: {
3357
+ type: 'price',
3358
+ precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
3359
+ minMove: d.specs?.minStep || 0.01,
3360
+ },
3361
+ });
3362
+ areaSeries.setData(seriesData);
3363
+
3364
+ // Маркеры сделок пользователя и дивидендов на графике
3365
+ const markers = [];
3366
+ if (seriesData.length > 0) {
3367
+ const minT = seriesData[0].time;
3368
+ const maxT = seriesData[seriesData.length - 1].time;
3369
+ for (const op of (d.userOperations || [])) {
3370
+ if (op.kind !== 'buy' && op.kind !== 'sell') continue;
3371
+ const t = chartTime(op.date);
3372
+ if (t >= minT && t <= maxT) {
3373
+ markers.push({
3374
+ time: t,
3375
+ position: op.kind === 'buy' ? 'belowBar' : 'aboveBar',
3376
+ color: op.kind === 'buy' ? '#3fb950' : '#f85149',
3377
+ shape: op.kind === 'buy' ? 'arrowUp' : 'arrowDown',
3378
+ text: `${op.kind === 'buy' ? 'Пок.' : 'Прод.'} ${op.qty} шт.`,
3379
+ });
3380
+ }
3381
+ }
3382
+ if (range === '1y' || range === 'all') {
3383
+ for (const dv of (d.dividends || [])) {
3384
+ const dtMs = Date.parse(`${dv.registry}T10:00:00Z`);
3385
+ if (!Number.isFinite(dtMs)) continue;
3386
+ const t = chartTime(dtMs);
3387
+ if (t >= minT && t <= maxT) {
3388
+ markers.push({
3389
+ time: t,
3390
+ position: 'aboveBar',
3391
+ color: '#4f8cff',
3392
+ shape: 'circle',
3393
+ text: `Див. ${dv.value} ₽`,
3394
+ });
3395
+ }
3396
+ }
3397
+ }
3398
+ markers.sort((a, b) => a.time - b.time);
3399
+ if (markers.length) {
3400
+ try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
3401
+ }
3402
+ }
3403
+ stockModalChart.timeScale().fitContent();
3404
+ }
3405
+
3406
+ async function openStockModal(symbol) {
3407
+ const sym = String(symbol || '').toUpperCase().trim();
3408
+ if (!sym) return;
3409
+ const modal = $('#stock-modal');
3410
+ const titleEl = $('#stock-modal-title');
3411
+ const bodyEl = $('#stock-modal-body');
3412
+ if (!modal || !bodyEl) return;
3413
+ modal.hidden = false;
3414
+ titleEl.innerHTML = `<b>${esc(sym)}</b> <span class="muted small">· загрузка досье инструмента…</span>`;
3415
+ bodyEl.innerHTML = `<div class="ld-calc-banner"><div class="ld-calc-spin"></div><div><b>Загружаем котировки, прогноз ИИ, дивиденды и историю сделок по ${esc(sym)}…</b></div></div>`;
3416
+
3417
+ try {
3418
+ const d = await api(`/api/stock/${encodeURIComponent(sym)}`);
3419
+ currentStockData = d;
3420
+ KNOWN_MOEX_TICKERS.add(d.symbol);
3421
+ const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
3422
+ const fc = d.forecast;
3423
+ const sigTag = fc?.signal
3424
+ ? '<span class="tag ok">сигнал: купить</span>'
3425
+ : fc?.shortSignal
3426
+ ? '<span class="tag no">сигнал: шорт</span>'
3427
+ : fc?.shortBlockedByBroker
3428
+ ? '<span class="tag">шорт запрещён брокером</span>'
3429
+ : '<span class="tag">нейтрально (вне порога)</span>';
3430
+ const shortBadge = d.shortAllowedByBroker === true
3431
+ ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
3432
+ : d.shortAllowedByBroker === false
3433
+ ? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
3434
+ : '';
3435
+
3436
+ titleEl.innerHTML = `<b>${esc(d.name)}</b> <span class="tag">${esc(d.symbol)}</span> <span class="tag ${d.tier === 1 ? 'ok' : ''}">${esc(d.tierLabel)}</span> ${sigTag} ${shortBadge}`;
3437
+
3438
+ const capStr = d.specs?.marketCapRub
3439
+ ? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
3440
+ : '—';
3441
+
3442
+ // 1. Верхние KPI
3443
+ const kpiHtml = `<div class="kpis">
3444
+ <div class="kpi"><div class="v">${d.price ? `${fmtNum(d.price, pxDec)} ₽` : '—'}</div><div class="l">текущая цена (1 лот = ${fmtNum(d.specs?.lotSize || 1, 0)} шт. ≈ ${fmtNum(d.specs?.lotCostRub || 0, 1)} ₽)</div></div>
3445
+ <div class="kpi"><div class="v ${cls(d.dayChangePct)}">${fmtPct(d.dayChangePct, 2)}</div><div class="l">за день · за 30 дн.: <b class="${cls(d.monthChangePct)}">${fmtPct(d.monthChangePct, 1)}</b></div></div>
3446
+ <div class="kpi"><div class="v ${cls(fc?.expectedPct || 0)}">${fc ? fmtPct(fc.expectedPct, 2) : '—'}</div><div class="l">прогноз ИИ на ${fc?.horizonHours || 2.5} ч (порог +${fmtNum(d.buyThresholdPct, 2)}%)</div></div>
3447
+ <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
3448
+ <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
3449
+ </div>`;
3450
+
3451
+ // 2. График с выбором периода
3452
+ const chartWrapHtml = `<div class="scard spadded">
3453
+ <div class="stitle bar" style="margin:0 0 8px">
3454
+ <span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
3455
+ <div class="seg" id="stock-chart-range" style="margin:0">
3456
+ <button type="button" data-srange="1d">1Д</button>
3457
+ <button type="button" data-srange="1w">1Н</button>
3458
+ <button type="button" data-srange="1m" class="active">1М</button>
3459
+ <button type="button" data-srange="1y">1Г</button>
3460
+ <button type="button" data-srange="all">Все</button>
3461
+ </div>
3462
+ </div>
3463
+ <div id="stock-chart-box" class="chart"></div>
3464
+ </div>`;
3465
+
3466
+ // 3. Соседи по карте рынка (кто влияет на акцию прямо сейчас)
3467
+ const infHtml = fc?.influences?.length
3468
+ ? `<div class="scard spadded">
3469
+ <div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
3470
+ <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (инерция): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
3471
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
3472
+ ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
3473
+ <div>
3474
+ <span class="sym-link" data-sym="${esc(inf.symbol)}" title="Перейти в досье ${esc(inf.symbol)}"><b>${esc(inf.symbol)}</b> <span class="muted small">${esc(inf.name || '')}</span></span>
3475
+ <div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
3476
+ </div>
3477
+ <div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
3478
+ </div>`).join('')}
3479
+ </div>
3480
+ </div>`
3481
+ : '';
3482
+
3483
+ // 4. Позиция пользователя и история реальных сделок на брокерском счёте по этой бумаге
3484
+ const userPosBanner = d.userPositions?.length
3485
+ ? d.userPositions.map((p) => `<div style="padding:8px 12px;border:1px solid var(--accent);border-radius:8px;background:rgba(59,130,246,0.06);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
3486
+ <div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
3487
+ <div>Стоимость: <b>${fmtNum(p.valueRub, 0)} ₽</b> · Финрезультат: <b class="${cls(p.pnlRub)}">${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽ (${fmtPct(p.pnlPct, 2)})</b></div>
3488
+ </div>`).join('')
3489
+ : '';
3490
+
3491
+ const userOpsHtml = d.userOperations?.length
3492
+ ? `<table><thead><tr><th>Дата и время</th><th>Операция</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${d.userOperations.slice(0, 15).map((op) => `<tr>
3493
+ <td class="muted small">${fmtTs(op.date)}</td>
3494
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
3495
+ <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
3496
+ <td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
3497
+ <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
3498
+ </tr>`).join('')}</tbody></table>`
3499
+ : '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
3500
+
3501
+ const btInfo = d.backtestStat
3502
+ ? `<div class="muted small" style="margin-top:8px">В эталонном Walk-Forward тесте (№${d.backtestStat.runId}): сделок по ${esc(d.symbol)}: <b>${d.backtestStat.tradesCount}</b> · плюсовых: <b>${d.backtestStat.winRate}%</b> · средний результат сделки: <b class="${cls(d.backtestStat.avgPnlPct)}">${fmtPct(d.backtestStat.avgPnlPct, 2)}</b></div>`
3503
+ : '';
3504
+
3505
+ const userBoxHtml = `<div class="scard spadded">
3506
+ <div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
3507
+ ${userPosBanner}
3508
+ ${userOpsHtml}
3509
+ ${btInfo}
3510
+ </div>`;
3511
+
3512
+ // 5. Фундаментальный бизнес-профиль (20 признаков) + Дивиденды (в 2 колонки)
3513
+ const topTraits = [...(d.traits || [])].filter((t) => t.value >= 0.15).sort((a, b) => b.value - a.value).slice(0, 10);
3514
+ const traitsColHtml = topTraits.length
3515
+ ? `<div class="scard spadded">
3516
+ <div style="font-weight:700;margin-bottom:8px">Фундаментальный профиль бизнеса (${esc(d.symbol)})</div>
3517
+ <div style="display:grid;gap:6px">
3518
+ ${topTraits.map((t) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12.5px" title="${esc(t.ask)}">
3519
+ <span>${esc(t.label)}</span>
3520
+ <div style="display:flex;align-items:center;gap:8px">
3521
+ <div class="stock-trait-bar"><div style="width:${Math.round(t.value * 100)}%"></div></div>
3522
+ <b class="num" style="width:34px;text-align:right">${Math.round(t.value * 100)}%</b>
3523
+ </div>
3524
+ </div>`).join('')}
3525
+ </div>
3526
+ </div>`
3527
+ : `<div class="scard spadded"><div style="font-weight:700;margin-bottom:6px">Фундаментальный профиль бизнеса</div><p class="muted small" style="margin:0">Отраслевой профиль компании учитывается через спектральную матрицу совместных движений цен.</p></div>`;
3528
+
3529
+ const divsColHtml = `<div class="scard spadded">
3530
+ <div style="font-weight:700;margin-bottom:8px">Дивидендная история ${esc(d.symbol)} (${d.dividends?.length || 0})</div>
3531
+ ${d.dividends?.length
3532
+ ? `<div style="max-height:220px;overflow-y:auto"><table><thead><tr><th>Реестр (отсечка)</th><th class="num">Выплата на 1 акцию</th><th class="num">К текущей цене</th></tr></thead><tbody>${d.dividends.slice(0, 15).map((dv) => {
3533
+ const yld = d.price > 0 ? (dv.value / d.price) * 100 : null;
3534
+ return `<tr>
3535
+ <td>${esc(dv.registry.split('-').reverse().join('.'))}</td>
3536
+ <td class="num pos"><b>+${fmtNum(dv.value, dv.value < 1 ? 4 : 2)} ₽</b></td>
3537
+ <td class="num muted">${yld ? `${fmtNum(yld, 2)}%` : '—'}</td>
3538
+ </tr>`;
3539
+ }).join('')}</tbody></table></div>`
3540
+ : '<p class="muted small" style="margin:0">Дивидендных выплат в рублях по данной бумаге не зафиксировано.</p>'}
3541
+ </div>`;
3542
+
3543
+ // 6. Новости по акции
3544
+ const newsHtml = `<div class="scard spadded">
3545
+ <div style="font-weight:700;margin-bottom:8px">Новости и раскрытия по ${esc(d.symbol)} (${d.news?.length || 0})</div>
3546
+ ${d.news?.length
3547
+ ? `<div style="display:grid;gap:6px">${d.news.map((n) => {
3548
+ const symTag = (n.symbols || []).find((s) => s.symbol === d.symbol);
3549
+ const tone = symTag?.tone ?? n.tone ?? 0;
3550
+ const toneBadge = tone >= 0.3 ? `<span class="tag ok">позитив +${fmtNum(tone, 1)}</span>` : tone <= -0.3 ? `<span class="tag no">негатив ${fmtNum(tone, 1)}</span>` : `<span class="tag">нейтр.</span>`;
3551
+ return `<div class="news-item">${toneBadge} <a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`;
3552
+ }).join('')}</div>`
3553
+ : '<p class="muted small" style="margin:0">За последние 30 дней размеченных новостей по этому эмитенту не было.</p>'}
3554
+ </div>`;
3555
+
3556
+ bodyEl.innerHTML = `
3557
+ ${kpiHtml}
3558
+ ${chartWrapHtml}
3559
+ ${infHtml}
3560
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">${traitsColHtml}${divsColHtml}</div>
3561
+ ${userBoxHtml}
3562
+ ${newsHtml}
3563
+ `;
3564
+
3565
+ renderStockChart(d, '1m');
3566
+ } catch (err) {
3567
+ bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
3568
+ }
3569
+ }
3570
+
3571
+ $('#stock-close')?.addEventListener('click', () => { $('#stock-modal').hidden = true; });
3572
+ $('#stock-modal')?.addEventListener('click', (e) => {
3573
+ if (e.target.id === 'stock-modal') {
3574
+ $('#stock-modal').hidden = true;
3575
+ return;
3576
+ }
3577
+ const rBtn = e.target.closest('button[data-srange]');
3578
+ if (rBtn && currentStockData) {
3579
+ $('#stock-chart-range')?.querySelectorAll('button[data-srange]').forEach((b) => b.classList.toggle('active', b === rBtn));
3580
+ renderStockChart(currentStockData, rBtn.dataset.srange);
3581
+ }
3582
+ });
3583
+ $('#btn-stock-ask')?.addEventListener('click', () => {
3584
+ if (!currentStockData?.symbol) return;
3585
+ const sym = currentStockData.symbol;
3586
+ $('#stock-modal').hidden = true;
3587
+ sendChat(`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`);
3588
+ });
3589
+
3590
+ // Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
3591
+ document.addEventListener('click', (e) => {
3592
+ const symEl = e.target.closest('[data-sym]');
3593
+ if (!symEl) return;
3594
+ e.preventDefault();
3595
+ e.stopPropagation();
3596
+ openStockModal(symEl.dataset.sym);
3597
+ });
3598
+
3174
3599
  document.addEventListener('keydown', (e) => {
3175
3600
  if (e.key === 'Escape') {
3176
3601
  $('#help').hidden = true;
3602
+ if ($('#stock-modal')) $('#stock-modal').hidden = true;
3177
3603
  closeModelMenu();
3178
3604
  closeModeMenu();
3179
3605
  }
@@ -3204,7 +3630,7 @@ document.addEventListener('keydown', (e) => {
3204
3630
  }
3205
3631
  if (urlParams.get('run') && isOwnerUser()) {
3206
3632
  showMainSec('overview');
3207
- } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'g0', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3633
+ } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3208
3634
  showMainSec(savedNav.mainSec);
3209
3635
  } else {
3210
3636
  showMainSec('brokers');