neuroalpha 0.3.17 → 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 (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 +573 -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 +1014 -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,13 @@ 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>
2203
2307
  </div>
2204
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2308
+ ${ls?.ok ? `<div class="kpis">
2205
2309
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2206
2310
  <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2207
2311
  <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>
2312
+ <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2209
2313
  </div>` : ''}
2210
2314
  ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2211
2315
  <div class="brk-subtabs">
@@ -2231,6 +2335,18 @@ function renderBrokers() {
2231
2335
  }
2232
2336
  }
2233
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
+
2234
2350
  $('#btn-broker-add')?.addEventListener('click', () => {
2235
2351
  const formBox = $('#broker-add-box');
2236
2352
  const formSlot = $('#broker-form-slot');
@@ -2267,6 +2383,8 @@ function getBrokerFormSnap() {
2267
2383
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2268
2384
  autoSchedule: Boolean(f.autoSchedule.checked),
2269
2385
  skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
2386
+ brokerStops: Boolean(f.brokerStops?.checked),
2387
+ cashSweep: Boolean(f.cashSweep?.checked),
2270
2388
  sandbox: Boolean(f.sandbox.checked),
2271
2389
  });
2272
2390
  }
@@ -2276,8 +2394,23 @@ function syncBrokerGuideUi() {
2276
2394
  if (!f) return;
2277
2395
  const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2278
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
+ }
2279
2407
  if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2280
- 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
+ }
2281
2414
  if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2282
2415
  if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2283
2416
  if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
@@ -2299,10 +2432,11 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2299
2432
  const accId = f.accountId.value.trim();
2300
2433
  const cap = Number(f.maxCapitalRub.value) || 100000;
2301
2434
  let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
2435
+ // Сам токен в чат не передаётся: он уходил бы во внешнюю модель ИИ и сохранялся в истории переписки
2302
2436
  if (tok && accId) {
2303
- promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
2437
+ promptText += ` Токен я уже вставил в форму (в чат его не пишу), номер счёта "${accId}". Подскажи, что проверить перед сохранением счёта.`;
2304
2438
  } else if (tok) {
2305
- promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
2439
+ promptText += ` Токен я уже вставил в форму (в чат его не пишу). Подскажи, как выбрать подходящий субсчёт кнопкой «🔍 Найти мои счета по токену» и что проверить перед сохранением.`;
2306
2440
  } else if (accId) {
2307
2441
  promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
2308
2442
  } else {
@@ -2406,7 +2540,8 @@ function startBrokerEdit(acc) {
2406
2540
  f.name.value = acc.name;
2407
2541
  f.broker.value = acc.broker;
2408
2542
  f.accountId.value = acc.accountId || '';
2409
- f.token.value = acc.token || '';
2543
+ // токен с сервера не приходит: пустое поле = оставить сохранённый токен без изменений
2544
+ f.token.value = '';
2410
2545
  f.mode.value = acc.mode || 'confirm';
2411
2546
  if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2412
2547
  f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
@@ -2414,6 +2549,8 @@ function startBrokerEdit(acc) {
2414
2549
  f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2415
2550
  f.autoSchedule.checked = acc.autoSchedule !== false;
2416
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);
2417
2554
  f.sandbox.checked = !!acc.sandbox;
2418
2555
  $('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
2419
2556
  $('#broker-save-btn').textContent = 'Сохранить изменения';
@@ -2512,8 +2649,9 @@ brokerForm.addEventListener('submit', async (e) => {
2512
2649
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2513
2650
  autoSchedule: f.autoSchedule.checked,
2514
2651
  skipEdgeMinutes: f.skipEdgeMinutes.checked,
2652
+ brokerStops: Boolean(f.brokerStops?.checked),
2653
+ cashSweep: Boolean(f.cashSweep?.checked),
2515
2654
  sandbox: f.sandbox.checked,
2516
- resetHalt: true,
2517
2655
  };
2518
2656
  try {
2519
2657
  const savedId = editingBrokerId;
@@ -2614,6 +2752,20 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2614
2752
  }
2615
2753
  return;
2616
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
+ }
2617
2769
  b.disabled = true;
2618
2770
  const old = b.textContent;
2619
2771
  try {
@@ -2695,7 +2847,16 @@ function connectWs() {
2695
2847
  loadChat().catch(() => {});
2696
2848
  loadLlm().catch(() => {});
2697
2849
  };
2698
- 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
+ };
2699
2860
  ws.onmessage = (ev) => {
2700
2861
  const m = JSON.parse(ev.data);
2701
2862
  switch (m.type) {
@@ -2897,7 +3058,7 @@ async function renderCloudPane() {
2897
3058
  }
2898
3059
 
2899
3060
  const nt = me.notify || {};
2900
- const code = u.notifyCode || 'NA-OWNER1';
3061
+ const code = u.notifyCode || '';
2901
3062
  const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
2902
3063
  const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
2903
3064
 
@@ -2939,9 +3100,6 @@ async function renderCloudPane() {
2939
3100
  : u.limits?.maxCapitalPerAccountRub > 0
2940
3101
  ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
2941
3102
  : 'подписка не активна';
2942
- const retLimStr = u.limits?.maxAnnualReturnPct > 0
2943
- ? `до ${u.limits.maxAnnualReturnPct}% годовых`
2944
- : (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
2945
3103
  const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
2946
3104
  updateTabBadges();
2947
3105
 
@@ -2950,7 +3108,7 @@ async function renderCloudPane() {
2950
3108
  <div class="stxt">
2951
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>
2952
3110
  <div class="hint">
2953
- Брокерских счетов: до <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)}` : ''}.
2954
3112
  </div>
2955
3113
  </div>
2956
3114
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
@@ -2962,9 +3120,9 @@ async function renderCloudPane() {
2962
3120
  <div class="stxt">
2963
3121
  <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
2964
3122
  <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% годовых.
3123
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
3124
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
3125
+ • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
2968
3126
  </div>
2969
3127
  </div>
2970
3128
  <div class="row-gap" style="flex-wrap:wrap">
@@ -2995,8 +3153,8 @@ async function renderCloudPane() {
2995
3153
  `;
2996
3154
 
2997
3155
  box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
2998
- await post('/api/auth/logout');
2999
- location.href = '/landing';
3156
+ const r = await post('/api/auth/logout').catch(() => ({}));
3157
+ location.href = r?.redirect || '/landing';
3000
3158
  });
3001
3159
  box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
3002
3160
  try {
@@ -3066,6 +3224,38 @@ if (localStorage.getItem('theme') === 'light') applyTheme(true);
3066
3224
 
3067
3225
  // ---------- справка ----------
3068
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
+
3069
3259
  async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
3070
3260
  $('#help').hidden = false;
3071
3261
  document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
@@ -3090,9 +3280,326 @@ $('#help-tabs')?.addEventListener('click', (e) => {
3090
3280
  });
3091
3281
  $('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
3092
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
+
3093
3599
  document.addEventListener('keydown', (e) => {
3094
3600
  if (e.key === 'Escape') {
3095
3601
  $('#help').hidden = true;
3602
+ if ($('#stock-modal')) $('#stock-modal').hidden = true;
3096
3603
  closeModelMenu();
3097
3604
  closeModeMenu();
3098
3605
  }