neuroalpha 0.3.17 → 0.5.2

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 (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. 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
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +589 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1095 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. 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); }; };
@@ -371,7 +398,11 @@ function syncUiAvailability() {
371
398
  if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
372
399
  if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
373
400
 
374
- // 3. Кнопки обращения к Стратегу в прогнозе и виртуальных счетах
401
+ // 3. Кнопки обновления и обращения к Стратегу в прогнозе
402
+ if ($('#btn-refresh-forecast')) {
403
+ $('#btn-refresh-forecast').disabled = Boolean(forecastBusy);
404
+ $('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
405
+ }
375
406
  if ($('#btn-ask-forecast')) {
376
407
  $('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
377
408
  $('#btn-ask-forecast').title = !state.forecast
@@ -447,10 +478,12 @@ function verdictHtml(c, oosA) {
447
478
  : '';
448
479
  let vs = '';
449
480
  if (base) {
450
- const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
451
- if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = ` · Карта рынка даёт преимущество над простой инерцией (<b>${fmtPct(a.returnPct)}</b> против <b>${fmtPct(base.returnPct)}</b>)`;
452
- else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = ` · Карта рынка и инерция дали близкий результат (${fmtPct(a.returnPct)} и ${fmtPct(base.returnPct)})`;
453
- 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)})`;
454
487
  }
455
488
  if (a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) {
456
489
  return `<span class="tag ok">Стратегия прибыльна</span> Превосходит индекс Мосбиржи на <b class="pos">${fmtPct(a.returnPct - a.marketPct)}</b>${vs}${oosPart}`;
@@ -517,6 +550,31 @@ function renderStability(st) {
517
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>`;
518
551
  }
519
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
+
520
578
  // Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
521
579
  const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
522
580
 
@@ -529,6 +587,16 @@ function resetLiveTrain(runId) {
529
587
  liveTrainState.text = '';
530
588
  }
531
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
+
532
600
  function drawLiveTrainMap(viz) {
533
601
  const cv = $('#ld-map-cv');
534
602
  if (!cv || !viz?.pts?.length) return;
@@ -537,7 +605,7 @@ function drawLiveTrainMap(viz) {
537
605
  cv.width = W * dpr; cv.height = H * dpr;
538
606
  const ctx = cv.getContext('2d');
539
607
  ctx.scale(dpr, dpr);
540
- 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}`);
541
609
  const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
542
610
  const xy = layout(f, W, H);
543
611
  const light = document.body.classList.contains('light');
@@ -628,7 +696,7 @@ function renderLiveTraining() {
628
696
 
629
697
  empty.innerHTML = `
630
698
  ${calcBanner}
631
- <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
699
+ <div class="kpis" style="margin-bottom:12px">
632
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>
633
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>
634
702
  <div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
@@ -736,6 +804,7 @@ function renderOverview() {
736
804
  ].join('');
737
805
  $('#sides').hidden = true;
738
806
  renderStability(ex.stability);
807
+ renderStress(ex.stress);
739
808
 
740
809
  const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
741
810
  for (const n of ['market', ...MODELS]) eqLines[n].setData(uniq(ex.curves[n]).map((p) => ({ time: chartTime(p.ts), value: p.equity })));
@@ -748,11 +817,18 @@ function renderOverview() {
748
817
 
749
818
  renderPeriods();
750
819
 
751
- 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);
752
828
  const th = (t, h, num = true) => `<th class="${num ? 'num' : ''}" title="${esc(h)}">${t}</th>`;
753
- $('#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>${
754
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>
755
- <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>
756
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>`;
757
833
 
758
834
  const w = uniq(ex.weeklyIc?.[chosen]);
@@ -770,7 +846,7 @@ function renderOverview() {
770
846
  $('#trades-sum').textContent = `Сделки торгующей стратегии (${trades.length})`;
771
847
  const shorts = trades.some((t) => t.side === 'short');
772
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>${
773
- 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>';
774
850
 
775
851
  }
776
852
 
@@ -793,6 +869,10 @@ $('#run-bar').addEventListener('click', async (e) => {
793
869
  sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
794
870
  }
795
871
  });
872
+ $('#btn-refresh-forecast')?.addEventListener('click', () => {
873
+ forecastBusy = false;
874
+ loadForecast(false, false);
875
+ });
796
876
  $('#btn-ask-forecast').addEventListener('click', () => {
797
877
  sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
798
878
  });
@@ -849,6 +929,7 @@ $('#btn-cancel').addEventListener('click', async () => {
849
929
  // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
850
930
  // ======================================================================
851
931
  let forecastBusy = false;
932
+ let forecastBusyAt = 0;
852
933
  let pulseClearTimer = null;
853
934
  const prevClientExp = new Map();
854
935
  const prevClientRank = new Map();
@@ -879,8 +960,10 @@ function triggerTradingPulse(text = 'Обновление котировок и
879
960
  }
880
961
 
881
962
  async function loadForecast(force, silent = false) {
882
- if (forecastBusy) return;
963
+ if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
883
964
  forecastBusy = true;
965
+ forecastBusyAt = Date.now();
966
+ syncUiAvailability();
884
967
  triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
885
968
  const box = $('#forecast');
886
969
  if (!silent && !state.forecast) {
@@ -893,7 +976,7 @@ async function loadForecast(force, silent = false) {
893
976
  </div>`;
894
977
  }
895
978
  try {
896
- 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 });
897
980
  // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
898
981
  f.rows.forEach((r, idx) => {
899
982
  const rank = idx + 1;
@@ -933,12 +1016,12 @@ async function loadForecast(force, silent = false) {
933
1016
  return '<span class="muted small">—</span>';
934
1017
  };
935
1018
  const row = (r) => `<tr>
936
- <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</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>
937
1020
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
938
1021
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
939
1022
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
940
1023
  <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
941
- <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>
942
1025
  </tr>`;
943
1026
  const buyThr = f.buyThresholdPct ?? f.thresholdPct;
944
1027
  const shortThr = f.shortThresholdPct ?? f.thresholdPct;
@@ -946,9 +1029,9 @@ async function loadForecast(force, silent = false) {
946
1029
  ...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
947
1030
  ...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
948
1031
  ].slice(0, 4);
949
- 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>
950
- <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>
951
- <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>`;
952
1035
  if (state.brokers?.accounts?.length) renderBrokers();
953
1036
  } catch (e) {
954
1037
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -957,12 +1040,12 @@ async function loadForecast(force, silent = false) {
957
1040
  syncUiAvailability();
958
1041
  }
959
1042
  }
960
- const FORECAST_TICK_MS = 45000; // живое обновление каждые 45 секунд
1043
+ const FORECAST_TICK_MS = 15000; // живое обновление каждые 15 секунд
961
1044
  function refreshForecastIfStale() {
962
- if (forecastBusy) return;
1045
+ if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
963
1046
  if (!state.forecast?.at) {
964
1047
  loadForecast(false, false);
965
- } else if (Date.now() - state.forecast.at >= 20000) {
1048
+ } else if (Date.now() - state.forecast.at >= 12000) {
966
1049
  loadForecast(false, true);
967
1050
  }
968
1051
  }
@@ -975,7 +1058,7 @@ async function loadNews() {
975
1058
  if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
976
1059
  if ($('#news-list')) {
977
1060
  $('#news-list').innerHTML = items.length
978
- ? 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('')
979
1062
  : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
980
1063
  }
981
1064
  } catch { /* ignore */ }
@@ -1859,7 +1942,11 @@ function renderBrokers() {
1859
1942
  openBrokerId = accs[0].id;
1860
1943
  }
1861
1944
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
1862
- 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) => {
1863
1950
  const isOpen = a.id === openBrokerId;
1864
1951
  const ls = a.lastSync;
1865
1952
  const pb = a.pendingBatch;
@@ -1941,11 +2028,11 @@ function renderBrokers() {
1941
2028
  const fcRow = fcRowMap?.get(p.symbol);
1942
2029
  const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
1943
2030
  return `<tr>
1944
- <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>
1945
2032
  <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
1946
2033
  <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
1947
2034
  <td class="num">${fmtOwnership(p)}</td>
1948
- <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>
1949
2036
  <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
1950
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>
1951
2038
  <td>${posStatus(p)}${fcBadge}</td>
@@ -1961,7 +2048,7 @@ function renderBrokers() {
1961
2048
  <span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
1962
2049
  </div>
1963
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>
1964
- <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>
1965
2052
  <td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
1966
2053
  <td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
1967
2054
  <td class="num">${fmtNum(o.price, 4)} ₽</td>
@@ -1972,7 +2059,7 @@ function renderBrokers() {
1972
2059
  let batchHtml = '';
1973
2060
  if (pb) {
1974
2061
  const ordRows = pb.orders?.length
1975
- ? `<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>`
1976
2063
  : `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
1977
2064
  const resHtml = pb.results?.length
1978
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>`
@@ -2007,43 +2094,58 @@ function renderBrokers() {
2007
2094
  const rows = [];
2008
2095
  for (const [, dayOps] of byDay) {
2009
2096
  let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2010
- 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;
2011
2099
  for (const op of dayOps) {
2012
2100
  const pay = Number(op.paymentRub) || 0;
2013
2101
  if (op.kind === 'buy') {
2014
2102
  tradesCnt++;
2015
2103
  buyRub += Math.abs(pay);
2016
2104
  if (op.symbol) {
2017
- symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2018
- symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2105
+ symBuyQty.set(op.symbol, (symBuyQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2019
2106
  symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
2020
2107
  }
2021
2108
  } else if (op.kind === 'sell') {
2022
2109
  tradesCnt++;
2023
2110
  sellRub += Math.abs(pay);
2024
2111
  if (op.symbol) {
2025
- symQty.set(op.symbol, (symQty.get(op.symbol) || 0) - (Number(op.qty) || 0));
2026
- 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));
2027
2114
  }
2028
2115
  } else if (op.kind === 'fee') {
2029
- if (/дивиденд|купон/i.test(op.title || '')) divTaxRub += pay;
2030
- 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
+ }
2031
2123
  } else if (op.kind === 'div') {
2032
2124
  divTaxRub += pay;
2033
2125
  } else if (op.kind === 'in' || op.kind === 'out') {
2034
2126
  inOutRub += pay;
2035
2127
  }
2036
2128
  }
2037
- // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
2129
+ // Считаем точный чистый результат закрытого объёма внутри дня по каждой бумаге (включая частичные закрытия и комиссии закрытых сделок)
2038
2130
  let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
2039
- for (const [sym, netQ] of symQty) {
2040
- if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2041
- closedTradePnl += symCash.get(sym);
2042
- 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;
2043
2145
  closedSyms++;
2044
2146
  }
2045
2147
  }
2046
- if (closedSyms > 0) closedTradePnl += feeRub;
2148
+ if (closedSyms > 0) closedTradePnl += unlinkedFeeRub;
2047
2149
  const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
2048
2150
 
2049
2151
  const summaryParts = [];
@@ -2071,9 +2173,9 @@ function renderBrokers() {
2071
2173
  rows.push(`<tr>
2072
2174
  <td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
2073
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>
2074
- <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>
2075
2177
  <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2076
- <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>
2077
2179
  <td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
2078
2180
  </tr>`);
2079
2181
  }
@@ -2158,7 +2260,7 @@ function renderBrokers() {
2158
2260
  }).join('');
2159
2261
 
2160
2262
  const histHtml = lh?.operations?.length || lh?.loadedYears?.length
2161
- ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2263
+ ? `<div class="kpis" style="margin-bottom:10px">
2162
2264
  <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
2163
2265
  <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2164
2266
  <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
@@ -2182,11 +2284,13 @@ function renderBrokers() {
2182
2284
  <span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
2183
2285
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2184
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>` : ''}
2185
2288
  ${collapsedMetricsHtml}
2186
2289
  </div>
2187
2290
  <div class="row-gap" style="align-items:center">
2188
- <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>
2189
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>` : ''}
2190
2294
  ${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
2191
2295
  </div>
2192
2296
  </div>
@@ -2199,13 +2303,14 @@ function renderBrokers() {
2199
2303
  <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2200
2304
  <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2201
2305
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2202
- <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>
2307
+ ${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
2203
2308
  </div>
2204
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2309
+ ${ls?.ok ? `<div class="kpis">
2205
2310
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2206
2311
  <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2207
2312
  <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2208
- <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>
2313
+ <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2209
2314
  </div>` : ''}
2210
2315
  ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2211
2316
  <div class="brk-subtabs">
@@ -2231,6 +2336,18 @@ function renderBrokers() {
2231
2336
  }
2232
2337
  }
2233
2338
 
2339
+ $('#btn-broker-stop-all')?.addEventListener('click', async () => {
2340
+ const n = (state.brokers?.accounts || []).length;
2341
+ if (!n) return toast('Брокерских счетов нет');
2342
+ if (!confirm(`Аварийная остановка ${n} счёт(ов):\n\n• авто-торговля на всех счетах встанет на паузу;\n• все активные заявки у брокера будут сняты;\n• открытые позиции НЕ закрываются, защитные стопы продолжают работать.\n\nВозобновить торговлю можно кнопкой «▶ Запустить» на каждом счёте.`)) return;
2343
+ try {
2344
+ const r = await post('/api/brokers-stop-all');
2345
+ state.brokers = r;
2346
+ renderBrokers();
2347
+ toast(r.stopErrors?.length ? `Остановлено, но не все заявки сняты: ${r.stopErrors.join('; ')}` : 'Авто-торговля остановлена, активные заявки сняты', r.stopErrors?.length ? 'error' : 'info');
2348
+ } catch (err) { toast(err.message, 'error'); }
2349
+ });
2350
+
2234
2351
  $('#btn-broker-add')?.addEventListener('click', () => {
2235
2352
  const formBox = $('#broker-add-box');
2236
2353
  const formSlot = $('#broker-form-slot');
@@ -2267,6 +2384,8 @@ function getBrokerFormSnap() {
2267
2384
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2268
2385
  autoSchedule: Boolean(f.autoSchedule.checked),
2269
2386
  skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
2387
+ brokerStops: Boolean(f.brokerStops?.checked),
2388
+ cashSweep: Boolean(f.cashSweep?.checked),
2270
2389
  sandbox: Boolean(f.sandbox.checked),
2271
2390
  });
2272
2391
  }
@@ -2276,8 +2395,23 @@ function syncBrokerGuideUi() {
2276
2395
  if (!f) return;
2277
2396
  const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2278
2397
  const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
2398
+ if ($('#broker-stops-row')) $('#broker-stops-row').hidden = f.broker.value !== 'tbank';
2399
+ if ($('#broker-cash-sweep-row')) $('#broker-cash-sweep-row').hidden = f.broker.value !== 'tbank';
2400
+ // «Автомат» — только для Т-Банка (у остальных нет средней цены для защитных стопов, отмены заявок и истории)
2401
+ const autoOpt = f.mode?.querySelector('option[value="auto"]');
2402
+ if (autoOpt) {
2403
+ const autoAllowed = f.broker.value === 'tbank';
2404
+ autoOpt.disabled = !autoAllowed;
2405
+ autoOpt.title = autoAllowed ? '' : 'Режим «Автомат» пока доступен только для Т-Банка';
2406
+ if (!autoAllowed && f.mode.value === 'auto') f.mode.value = 'confirm';
2407
+ }
2279
2408
  if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2280
- if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
2409
+ if ($('#broker-token-input')) {
2410
+ const editAcc = editingBrokerId ? (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId) : null;
2411
+ $('#broker-token-input').placeholder = editAcc?.hasToken && editAcc.broker === f.broker.value
2412
+ ? `Токен сохранён (${editAcc.tokenMask || '…'}) — оставьте пустым или вставьте новый`
2413
+ : g.tokenPh;
2414
+ }
2281
2415
  if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2282
2416
  if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2283
2417
  if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
@@ -2299,10 +2433,11 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2299
2433
  const accId = f.accountId.value.trim();
2300
2434
  const cap = Number(f.maxCapitalRub.value) || 100000;
2301
2435
  let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
2436
+ // Сам токен в чат не передаётся: он уходил бы во внешнюю модель ИИ и сохранялся в истории переписки
2302
2437
  if (tok && accId) {
2303
- promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
2438
+ promptText += ` Токен я уже вставил в форму (в чат его не пишу), номер счёта "${accId}". Подскажи, что проверить перед сохранением счёта.`;
2304
2439
  } else if (tok) {
2305
- promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
2440
+ promptText += ` Токен я уже вставил в форму (в чат его не пишу). Подскажи, как выбрать подходящий субсчёт кнопкой «🔍 Найти мои счета по токену» и что проверить перед сохранением.`;
2306
2441
  } else if (accId) {
2307
2442
  promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
2308
2443
  } else {
@@ -2406,7 +2541,8 @@ function startBrokerEdit(acc) {
2406
2541
  f.name.value = acc.name;
2407
2542
  f.broker.value = acc.broker;
2408
2543
  f.accountId.value = acc.accountId || '';
2409
- f.token.value = acc.token || '';
2544
+ // токен с сервера не приходит: пустое поле = оставить сохранённый токен без изменений
2545
+ f.token.value = '';
2410
2546
  f.mode.value = acc.mode || 'confirm';
2411
2547
  if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2412
2548
  f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
@@ -2414,6 +2550,8 @@ function startBrokerEdit(acc) {
2414
2550
  f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2415
2551
  f.autoSchedule.checked = acc.autoSchedule !== false;
2416
2552
  f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2553
+ if (f.brokerStops) f.brokerStops.checked = Boolean(acc.brokerStops);
2554
+ if (f.cashSweep) f.cashSweep.checked = Boolean(acc.cashSweep);
2417
2555
  f.sandbox.checked = !!acc.sandbox;
2418
2556
  $('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
2419
2557
  $('#broker-save-btn').textContent = 'Сохранить изменения';
@@ -2512,8 +2650,9 @@ brokerForm.addEventListener('submit', async (e) => {
2512
2650
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2513
2651
  autoSchedule: f.autoSchedule.checked,
2514
2652
  skipEdgeMinutes: f.skipEdgeMinutes.checked,
2653
+ brokerStops: Boolean(f.brokerStops?.checked),
2654
+ cashSweep: Boolean(f.cashSweep?.checked),
2515
2655
  sandbox: f.sandbox.checked,
2516
- resetHalt: true,
2517
2656
  };
2518
2657
  try {
2519
2658
  const savedId = editingBrokerId;
@@ -2614,6 +2753,20 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2614
2753
  }
2615
2754
  return;
2616
2755
  }
2756
+ if (act === 'events') {
2757
+ openBrokerEvents(id);
2758
+ return;
2759
+ }
2760
+ if (act === 'resetstop') {
2761
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2762
+ if (!confirm(`Снять дневной стоп на счёте «${acc?.name || id}»?\n\n${acc?.haltedReason || ''}\n\nДо конца сегодняшнего дня стоп по просадке на этом счёте больше не сработает, и авто-торговля продолжится с текущими убытками.`)) return;
2763
+ try {
2764
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reset-daily-stop`);
2765
+ renderBrokers();
2766
+ toast('Дневной стоп снят до конца дня');
2767
+ } catch (err) { toast(err.message, 'error'); }
2768
+ return;
2769
+ }
2617
2770
  b.disabled = true;
2618
2771
  const old = b.textContent;
2619
2772
  try {
@@ -2695,7 +2848,16 @@ function connectWs() {
2695
2848
  loadChat().catch(() => {});
2696
2849
  loadLlm().catch(() => {});
2697
2850
  };
2698
- ws.onclose = () => { connected = false; setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)'); setTimeout(connectWs, 2000); };
2851
+ ws.onclose = (ev) => {
2852
+ connected = false;
2853
+ if (ev.code === 4401) {
2854
+ setStatus('bad', 'Сессия не авторизована — войдите в аккаунт');
2855
+ location.href = '/landing';
2856
+ return;
2857
+ }
2858
+ setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)');
2859
+ setTimeout(connectWs, 2000);
2860
+ };
2699
2861
  ws.onmessage = (ev) => {
2700
2862
  const m = JSON.parse(ev.data);
2701
2863
  switch (m.type) {
@@ -2897,7 +3059,7 @@ async function renderCloudPane() {
2897
3059
  }
2898
3060
 
2899
3061
  const nt = me.notify || {};
2900
- const code = u.notifyCode || 'NA-OWNER1';
3062
+ const code = u.notifyCode || '';
2901
3063
  const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
2902
3064
  const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
2903
3065
 
@@ -2939,9 +3101,6 @@ async function renderCloudPane() {
2939
3101
  : u.limits?.maxCapitalPerAccountRub > 0
2940
3102
  ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
2941
3103
  : 'подписка не активна';
2942
- const retLimStr = u.limits?.maxAnnualReturnPct > 0
2943
- ? `до ${u.limits.maxAnnualReturnPct}% годовых`
2944
- : (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
2945
3104
  const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
2946
3105
  updateTabBadges();
2947
3106
 
@@ -2950,7 +3109,7 @@ async function renderCloudPane() {
2950
3109
  <div class="stxt">
2951
3110
  <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>
2952
3111
  <div class="hint">
2953
- Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Целевая доходность: <b>${retLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3112
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
2954
3113
  </div>
2955
3114
  </div>
2956
3115
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
@@ -2962,9 +3121,9 @@ async function renderCloudPane() {
2962
3121
  <div class="stxt">
2963
3122
  <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
2964
3123
  <div class="hint">
2965
- • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)», задавленная доходность до 60% годовых;<br>
2966
- • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)», задавленная доходность до 120% годовых;<br>
2967
- • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами, задавленная доходность до 240% годовых.
3124
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
3125
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
3126
+ • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
2968
3127
  </div>
2969
3128
  </div>
2970
3129
  <div class="row-gap" style="flex-wrap:wrap">
@@ -2995,8 +3154,8 @@ async function renderCloudPane() {
2995
3154
  `;
2996
3155
 
2997
3156
  box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
2998
- await post('/api/auth/logout');
2999
- location.href = '/landing';
3157
+ const r = await post('/api/auth/logout').catch(() => ({}));
3158
+ location.href = r?.redirect || '/landing';
3000
3159
  });
3001
3160
  box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
3002
3161
  try {
@@ -3066,6 +3225,53 @@ if (localStorage.getItem('theme') === 'light') applyTheme(true);
3066
3225
 
3067
3226
  // ---------- справка ----------
3068
3227
  const helpCache = new Map();
3228
+ // ---------- журнал исполнения по счёту ----------
3229
+ const EVENT_LABELS = {
3230
+ order_sent: ['заявка отправлена', ''],
3231
+ order_expired: ['заявка снята (>12 мин)', 'muted'],
3232
+ spread_skip: ['пропуск входа (широкий спред)', 'mid'],
3233
+ order_error: ['ошибка заявки', 'neg'],
3234
+ protective_stop: ['защитный стоп', 'neg'],
3235
+ protective_stop_error: ['ошибка защитного стопа', 'neg'],
3236
+ daily_stop: ['дневной стоп', 'neg'],
3237
+ daily_stop_reset: ['стоп снят вручную', ''],
3238
+ stop_all: ['аварийная остановка', 'neg'],
3239
+ broker_stop_set: ['стоп-заявка у брокера выставлена', 'pos'],
3240
+ broker_stop_cancel: ['стоп-заявка у брокера снята', ''],
3241
+ broker_stop_gone: ['стоп-заявка исчезла у брокера', 'neg'],
3242
+ broker_stop_error: ['ошибка стоп-заявки', 'neg'],
3243
+ };
3244
+ async function openBrokerEvents(id) {
3245
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
3246
+ $('#events-title').textContent = `Журнал исполнения · ${acc?.name || id}`;
3247
+ $('#events-body').textContent = 'Загрузка…';
3248
+ $('#events-modal').hidden = false;
3249
+ try {
3250
+ const { events } = await api(`/api/brokers/${encodeURIComponent(id)}/events?limit=300`);
3251
+ const sentCnt = events.filter((e) => e.kind === 'order_sent').length;
3252
+ const expCnt = events.filter((e) => e.kind === 'order_expired').length;
3253
+ const skipCnt = events.filter((e) => e.kind === 'spread_skip').length;
3254
+ const errCnt = events.filter((e) => e.kind === 'order_error').length;
3255
+ const fillPct = sentCnt > 0 ? Math.max(0, Math.round(((sentCnt - expCnt) / sentCnt) * 100)) : null;
3256
+ const ea = acc?.executionAudit;
3257
+ const summaryHtml = (ea || sentCnt > 0)
3258
+ ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head" style="flex-wrap:wrap;gap:10px">
3259
+ <b>Сверка исполнения со стресс-тестом</b>
3260
+ ${ea ? `<span class="small">Сделок: <b>${ea.tradesCount}</b> (закрыто <b>${ea.closedCount}</b> поз.) · Комиссия: <b>${fmtNum(ea.commPct, 3)}%</b> · Отклонение от сигнала: <b>${ea.slipAllPct !== null ? `${ea.slipAllPct > 0 ? '+' : ''}${fmtNum(ea.slipAllPct, 3)}%` : '—'}</b> · Итого издержки: <b class="${ea.totalCostPerSidePct <= ea.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(ea.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности ≤ ${fmtNum(ea.breakEvenTargetPct, 3)}%)</span>` : ''}
3261
+ ${sentCnt > 0 ? `<span class="small muted">По журналу: отправлено ${sentCnt}, исполнено ~${fillPct}% (снято >12 мин: ${expCnt}, пропущено по спреду: ${skipCnt}, ошибок: ${errCnt})</span>` : ''}
3262
+ </div></div>`
3263
+ : '';
3264
+ $('#events-body').innerHTML = summaryHtml + (events.length
3265
+ ? `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Событие</th><th>Подробности</th></tr></thead><tbody>${events.map((ev) => {
3266
+ const [label, cls] = EVENT_LABELS[ev.kind] || [ev.kind, ''];
3267
+ 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>`;
3268
+ }).join('')}</tbody></table></div>`
3269
+ : '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>');
3270
+ } catch (e) { $('#events-body').textContent = e.message; }
3271
+ }
3272
+ $('#events-close')?.addEventListener('click', () => { $('#events-modal').hidden = true; });
3273
+ $('#events-modal')?.addEventListener('click', (e) => { if (e.target.id === 'events-modal') $('#events-modal').hidden = true; });
3274
+
3069
3275
  async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
3070
3276
  $('#help').hidden = false;
3071
3277
  document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
@@ -3090,9 +3296,326 @@ $('#help-tabs')?.addEventListener('click', (e) => {
3090
3296
  });
3091
3297
  $('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
3092
3298
  $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
3299
+
3300
+ // ---------- интерактивное досье акции (#stock-modal) ----------
3301
+ let stockModalChart = null;
3302
+ let stockChartRo = null;
3303
+ let currentStockData = null;
3304
+ let currentStockRange = '1m';
3305
+
3306
+ function renderStockChart(d, range = '1m') {
3307
+ currentStockRange = range;
3308
+ const box = $('#stock-chart-box');
3309
+ if (!box) return;
3310
+ if (stockChartRo) {
3311
+ try { stockChartRo.disconnect(); } catch { /* ignore */ }
3312
+ stockChartRo = null;
3313
+ }
3314
+ if (stockModalChart) {
3315
+ try { stockModalChart.remove(); } catch { /* ignore */ }
3316
+ stockModalChart = null;
3317
+ }
3318
+ box.innerHTML = '';
3319
+ const light = document.body.classList.contains('light');
3320
+ const initW = Math.max(260, box.clientWidth || 720);
3321
+ stockModalChart = LightweightCharts.createChart(box, {
3322
+ width: initW,
3323
+ height: 260,
3324
+ layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
3325
+ grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
3326
+ rightPriceScale: { borderVisible: false },
3327
+ timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
3328
+ crosshair: { mode: 0 },
3329
+ });
3330
+ if (window.ResizeObserver) {
3331
+ stockChartRo = new ResizeObserver(() => {
3332
+ if (stockModalChart && box.clientWidth > 100) {
3333
+ stockModalChart.applyOptions({ width: box.clientWidth });
3334
+ }
3335
+ });
3336
+ stockChartRo.observe(box);
3337
+ }
3338
+
3339
+ const nowTs = Date.now();
3340
+ let rawPts = [];
3341
+ if (range === '1d') {
3342
+ const cut = nowTs - 2 * 86400000;
3343
+ rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
3344
+ if (rawPts.length < 5) rawPts = (d.intraday || []).slice(-95);
3345
+ } else if (range === '1w') {
3346
+ rawPts = (d.intraday || []).filter((b) => b.ts >= nowTs - 8 * 86400000);
3347
+ } else if (range === '1m') {
3348
+ rawPts = d.intraday?.length ? d.intraday : (d.daily || []).slice(-30);
3349
+ } else if (range === '1y') {
3350
+ rawPts = (d.daily || []).filter((b) => b.ts >= nowTs - 370 * 86400000);
3351
+ } else {
3352
+ rawPts = d.daily || [];
3353
+ }
3354
+
3355
+ const seenT = new Set();
3356
+ const seriesData = [];
3357
+ for (const b of rawPts) {
3358
+ const t = chartTime(b.ts);
3359
+ if (seenT.has(t) || !(b.c > 0)) continue;
3360
+ seenT.add(t);
3361
+ seriesData.push({ time: t, value: b.c });
3362
+ }
3363
+ seriesData.sort((a, b) => a.time - b.time);
3364
+
3365
+ const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
3366
+ const mainColor = isUp ? '#3fb950' : '#f85149';
3367
+ const areaSeries = stockModalChart.addAreaSeries({
3368
+ lineColor: mainColor,
3369
+ topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
3370
+ bottomColor: 'rgba(0, 0, 0, 0.0)',
3371
+ lineWidth: 2,
3372
+ priceFormat: {
3373
+ type: 'price',
3374
+ precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
3375
+ minMove: d.specs?.minStep || 0.01,
3376
+ },
3377
+ });
3378
+ areaSeries.setData(seriesData);
3379
+
3380
+ // Маркеры сделок пользователя и дивидендов на графике
3381
+ const markers = [];
3382
+ if (seriesData.length > 0) {
3383
+ const minT = seriesData[0].time;
3384
+ const maxT = seriesData[seriesData.length - 1].time;
3385
+ for (const op of (d.userOperations || [])) {
3386
+ if (op.kind !== 'buy' && op.kind !== 'sell') continue;
3387
+ const t = chartTime(op.date);
3388
+ if (t >= minT && t <= maxT) {
3389
+ markers.push({
3390
+ time: t,
3391
+ position: op.kind === 'buy' ? 'belowBar' : 'aboveBar',
3392
+ color: op.kind === 'buy' ? '#3fb950' : '#f85149',
3393
+ shape: op.kind === 'buy' ? 'arrowUp' : 'arrowDown',
3394
+ text: `${op.kind === 'buy' ? 'Пок.' : 'Прод.'} ${op.qty} шт.`,
3395
+ });
3396
+ }
3397
+ }
3398
+ if (range === '1y' || range === 'all') {
3399
+ for (const dv of (d.dividends || [])) {
3400
+ const dtMs = Date.parse(`${dv.registry}T10:00:00Z`);
3401
+ if (!Number.isFinite(dtMs)) continue;
3402
+ const t = chartTime(dtMs);
3403
+ if (t >= minT && t <= maxT) {
3404
+ markers.push({
3405
+ time: t,
3406
+ position: 'aboveBar',
3407
+ color: '#4f8cff',
3408
+ shape: 'circle',
3409
+ text: `Див. ${dv.value} ₽`,
3410
+ });
3411
+ }
3412
+ }
3413
+ }
3414
+ markers.sort((a, b) => a.time - b.time);
3415
+ if (markers.length) {
3416
+ try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
3417
+ }
3418
+ }
3419
+ stockModalChart.timeScale().fitContent();
3420
+ }
3421
+
3422
+ async function openStockModal(symbol) {
3423
+ const sym = String(symbol || '').toUpperCase().trim();
3424
+ if (!sym) return;
3425
+ const modal = $('#stock-modal');
3426
+ const titleEl = $('#stock-modal-title');
3427
+ const bodyEl = $('#stock-modal-body');
3428
+ if (!modal || !bodyEl) return;
3429
+ modal.hidden = false;
3430
+ titleEl.innerHTML = `<b>${esc(sym)}</b> <span class="muted small">· загрузка досье инструмента…</span>`;
3431
+ bodyEl.innerHTML = `<div class="ld-calc-banner"><div class="ld-calc-spin"></div><div><b>Загружаем котировки, прогноз ИИ, дивиденды и историю сделок по ${esc(sym)}…</b></div></div>`;
3432
+
3433
+ try {
3434
+ const d = await api(`/api/stock/${encodeURIComponent(sym)}`);
3435
+ currentStockData = d;
3436
+ KNOWN_MOEX_TICKERS.add(d.symbol);
3437
+ const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
3438
+ const fc = d.forecast;
3439
+ const sigTag = fc?.signal
3440
+ ? '<span class="tag ok">сигнал: купить</span>'
3441
+ : fc?.shortSignal
3442
+ ? '<span class="tag no">сигнал: шорт</span>'
3443
+ : fc?.shortBlockedByBroker
3444
+ ? '<span class="tag">шорт запрещён брокером</span>'
3445
+ : '<span class="tag">нейтрально (вне порога)</span>';
3446
+ const shortBadge = d.shortAllowedByBroker === true
3447
+ ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
3448
+ : d.shortAllowedByBroker === false
3449
+ ? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
3450
+ : '';
3451
+
3452
+ 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}`;
3453
+
3454
+ const capStr = d.specs?.marketCapRub
3455
+ ? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
3456
+ : '—';
3457
+
3458
+ // 1. Верхние KPI
3459
+ const kpiHtml = `<div class="kpis">
3460
+ <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>
3461
+ <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>
3462
+ <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>
3463
+ <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
3464
+ <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
3465
+ </div>`;
3466
+
3467
+ // 2. График с выбором периода
3468
+ const chartWrapHtml = `<div class="scard spadded">
3469
+ <div class="stitle bar" style="margin:0 0 8px">
3470
+ <span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
3471
+ <div class="seg" id="stock-chart-range" style="margin:0">
3472
+ <button type="button" data-srange="1d">1Д</button>
3473
+ <button type="button" data-srange="1w">1Н</button>
3474
+ <button type="button" data-srange="1m" class="active">1М</button>
3475
+ <button type="button" data-srange="1y">1Г</button>
3476
+ <button type="button" data-srange="all">Все</button>
3477
+ </div>
3478
+ </div>
3479
+ <div id="stock-chart-box" class="chart"></div>
3480
+ </div>`;
3481
+
3482
+ // 3. Соседи по карте рынка (кто влияет на акцию прямо сейчас)
3483
+ const infHtml = fc?.influences?.length
3484
+ ? `<div class="scard spadded">
3485
+ <div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
3486
+ <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (инерция): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
3487
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
3488
+ ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
3489
+ <div>
3490
+ <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>
3491
+ <div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
3492
+ </div>
3493
+ <div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
3494
+ </div>`).join('')}
3495
+ </div>
3496
+ </div>`
3497
+ : '';
3498
+
3499
+ // 4. Позиция пользователя и история реальных сделок на брокерском счёте по этой бумаге
3500
+ const userPosBanner = d.userPositions?.length
3501
+ ? 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">
3502
+ <div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
3503
+ <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>
3504
+ </div>`).join('')
3505
+ : '';
3506
+
3507
+ const userOpsHtml = d.userOperations?.length
3508
+ ? `<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>
3509
+ <td class="muted small">${fmtTs(op.date)}</td>
3510
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
3511
+ <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
3512
+ <td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
3513
+ <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
3514
+ </tr>`).join('')}</tbody></table>`
3515
+ : '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
3516
+
3517
+ const btInfo = d.backtestStat
3518
+ ? `<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>`
3519
+ : '';
3520
+
3521
+ const userBoxHtml = `<div class="scard spadded">
3522
+ <div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
3523
+ ${userPosBanner}
3524
+ ${userOpsHtml}
3525
+ ${btInfo}
3526
+ </div>`;
3527
+
3528
+ // 5. Фундаментальный бизнес-профиль (20 признаков) + Дивиденды (в 2 колонки)
3529
+ const topTraits = [...(d.traits || [])].filter((t) => t.value >= 0.15).sort((a, b) => b.value - a.value).slice(0, 10);
3530
+ const traitsColHtml = topTraits.length
3531
+ ? `<div class="scard spadded">
3532
+ <div style="font-weight:700;margin-bottom:8px">Фундаментальный профиль бизнеса (${esc(d.symbol)})</div>
3533
+ <div style="display:grid;gap:6px">
3534
+ ${topTraits.map((t) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12.5px" title="${esc(t.ask)}">
3535
+ <span>${esc(t.label)}</span>
3536
+ <div style="display:flex;align-items:center;gap:8px">
3537
+ <div class="stock-trait-bar"><div style="width:${Math.round(t.value * 100)}%"></div></div>
3538
+ <b class="num" style="width:34px;text-align:right">${Math.round(t.value * 100)}%</b>
3539
+ </div>
3540
+ </div>`).join('')}
3541
+ </div>
3542
+ </div>`
3543
+ : `<div class="scard spadded"><div style="font-weight:700;margin-bottom:6px">Фундаментальный профиль бизнеса</div><p class="muted small" style="margin:0">Отраслевой профиль компании учитывается через спектральную матрицу совместных движений цен.</p></div>`;
3544
+
3545
+ const divsColHtml = `<div class="scard spadded">
3546
+ <div style="font-weight:700;margin-bottom:8px">Дивидендная история ${esc(d.symbol)} (${d.dividends?.length || 0})</div>
3547
+ ${d.dividends?.length
3548
+ ? `<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) => {
3549
+ const yld = d.price > 0 ? (dv.value / d.price) * 100 : null;
3550
+ return `<tr>
3551
+ <td>${esc(dv.registry.split('-').reverse().join('.'))}</td>
3552
+ <td class="num pos"><b>+${fmtNum(dv.value, dv.value < 1 ? 4 : 2)} ₽</b></td>
3553
+ <td class="num muted">${yld ? `${fmtNum(yld, 2)}%` : '—'}</td>
3554
+ </tr>`;
3555
+ }).join('')}</tbody></table></div>`
3556
+ : '<p class="muted small" style="margin:0">Дивидендных выплат в рублях по данной бумаге не зафиксировано.</p>'}
3557
+ </div>`;
3558
+
3559
+ // 6. Новости по акции
3560
+ const newsHtml = `<div class="scard spadded">
3561
+ <div style="font-weight:700;margin-bottom:8px">Новости и раскрытия по ${esc(d.symbol)} (${d.news?.length || 0})</div>
3562
+ ${d.news?.length
3563
+ ? `<div style="display:grid;gap:6px">${d.news.map((n) => {
3564
+ const symTag = (n.symbols || []).find((s) => s.symbol === d.symbol);
3565
+ const tone = symTag?.tone ?? n.tone ?? 0;
3566
+ 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>`;
3567
+ 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>`;
3568
+ }).join('')}</div>`
3569
+ : '<p class="muted small" style="margin:0">За последние 30 дней размеченных новостей по этому эмитенту не было.</p>'}
3570
+ </div>`;
3571
+
3572
+ bodyEl.innerHTML = `
3573
+ ${kpiHtml}
3574
+ ${chartWrapHtml}
3575
+ ${infHtml}
3576
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">${traitsColHtml}${divsColHtml}</div>
3577
+ ${userBoxHtml}
3578
+ ${newsHtml}
3579
+ `;
3580
+
3581
+ renderStockChart(d, '1m');
3582
+ } catch (err) {
3583
+ bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
3584
+ }
3585
+ }
3586
+
3587
+ $('#stock-close')?.addEventListener('click', () => { $('#stock-modal').hidden = true; });
3588
+ $('#stock-modal')?.addEventListener('click', (e) => {
3589
+ if (e.target.id === 'stock-modal') {
3590
+ $('#stock-modal').hidden = true;
3591
+ return;
3592
+ }
3593
+ const rBtn = e.target.closest('button[data-srange]');
3594
+ if (rBtn && currentStockData) {
3595
+ $('#stock-chart-range')?.querySelectorAll('button[data-srange]').forEach((b) => b.classList.toggle('active', b === rBtn));
3596
+ renderStockChart(currentStockData, rBtn.dataset.srange);
3597
+ }
3598
+ });
3599
+ $('#btn-stock-ask')?.addEventListener('click', () => {
3600
+ if (!currentStockData?.symbol) return;
3601
+ const sym = currentStockData.symbol;
3602
+ $('#stock-modal').hidden = true;
3603
+ sendChat(`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`);
3604
+ });
3605
+
3606
+ // Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
3607
+ document.addEventListener('click', (e) => {
3608
+ const symEl = e.target.closest('[data-sym]');
3609
+ if (!symEl) return;
3610
+ e.preventDefault();
3611
+ e.stopPropagation();
3612
+ openStockModal(symEl.dataset.sym);
3613
+ });
3614
+
3093
3615
  document.addEventListener('keydown', (e) => {
3094
3616
  if (e.key === 'Escape') {
3095
3617
  $('#help').hidden = true;
3618
+ if ($('#stock-modal')) $('#stock-modal').hidden = true;
3096
3619
  closeModelMenu();
3097
3620
  closeModeMenu();
3098
3621
  }