neuroalpha 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +13 -17
  2. package/README.md +4 -2
  3. package/bin/cli.js +1 -1
  4. package/deploy/install.sh +1 -1
  5. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
  6. 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 +1 -1
  7. package/package.json +2 -2
  8. package/public/admin.html +43 -20
  9. package/public/app.js +780 -226
  10. package/public/index.html +43 -3
  11. package/public/landing.html +328 -115
  12. package/public/legal.html +37 -16
  13. package/public/styles.css +24 -3
  14. package/server/api/routes.js +62 -30
  15. package/server/auth/index.js +109 -20
  16. package/server/billing/yookassa.js +1 -1
  17. package/server/brokers/index.js +256 -53
  18. package/server/config.js +1 -1
  19. package/server/data/moex.js +1 -1
  20. package/server/db.js +17 -3
  21. package/server/hub.js +1 -1
  22. package/server/index.js +4 -4
  23. package/server/llm/client.js +1 -1
  24. package/server/llm/providers.js +1 -1
  25. package/server/model/dataset.js +1 -1
  26. package/server/model/forecast.js +354 -25
  27. package/server/model/linalg.js +1 -1
  28. package/server/model/live.js +36 -25
  29. package/server/model/metrics.js +1 -1
  30. package/server/model/models.js +1 -1
  31. package/server/model/profiles.js +1 -1
  32. package/server/model/research.js +14 -38
  33. package/server/model/vocab.js +1 -1
  34. package/server/news/index.js +1 -1
  35. package/server/notify/index.js +1 -1
  36. package/server/params-meta.js +4 -3
  37. package/server/settings.js +1 -1
  38. package/server/strategist/analytics.js +2 -2
  39. package/server/strategist/index.js +2 -2
  40. package/server/strategist/tools.js +1 -1
  41. package/server/util.js +1 -1
package/public/app.js CHANGED
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  const $ = (s, root = document) => root.querySelector(s);
3
3
  const MSK = 3 * 3600000;
4
4
 
@@ -13,7 +13,34 @@ const post = (url, body = {}) => api(url, { method: 'POST', body });
13
13
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
14
14
  const fmtNum = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v) ? '—' : Number(v).toLocaleString('ru-RU', { maximumFractionDigits: d }));
15
15
  const fmtPct = (v, d = 1) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${fmtNum(v, d)}%`);
16
- const fmtTs = (ts) => { if (!ts) return '—'; const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)} ${d.slice(11, 16)}`; };
16
+ const fmtTs = (ts) => {
17
+ if (!ts) return '—';
18
+ const d = new Date(ts + MSK).toISOString();
19
+ const y = d.slice(0, 4);
20
+ const curY = new Date(Date.now() + MSK).toISOString().slice(0, 4);
21
+ return `${d.slice(8, 10)}.${d.slice(5, 7)}${y !== curY ? `.${y}` : ''} ${d.slice(11, 16)}`;
22
+ };
23
+ const fmtTimeOnly = (ts) => { if (!ts) return '—'; return new Date(ts + MSK).toISOString().slice(11, 16); };
24
+ const MONTHS_GEN = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
25
+ const DOW_SHORT = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
26
+ function fmtDayGroup(ts) {
27
+ if (!ts) return 'Без даты';
28
+ const dt = new Date(ts + MSK);
29
+ const iso = dt.toISOString();
30
+ const dayKey = iso.slice(0, 10);
31
+ const nowIso = new Date(Date.now() + MSK).toISOString();
32
+ const todayKey = nowIso.slice(0, 10);
33
+ const yestKey = new Date(Date.now() + MSK - 86400000).toISOString().slice(0, 10);
34
+ const dNum = Number(iso.slice(8, 10));
35
+ const mName = MONTHS_GEN[Number(iso.slice(5, 7)) - 1] || '';
36
+ const y = iso.slice(0, 4);
37
+ const curY = nowIso.slice(0, 4);
38
+ const dow = DOW_SHORT[dt.getUTCDay()] || '';
39
+ const base = `${dNum} ${mName}${y !== curY ? ` ${y} г.` : ''}`;
40
+ if (dayKey === todayKey) return `Сегодня, ${base}`;
41
+ if (dayKey === yestKey) return `Вчера, ${base}`;
42
+ return `${base} (${dow})`;
43
+ }
17
44
  const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
18
45
  const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
19
46
  const md = (s) => {
@@ -51,12 +78,47 @@ const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '
51
78
  const labelOf = (k) => state.config?.params.find((p) => p.key === k)?.label || k;
52
79
 
53
80
  // ======================================================================
54
- // СПЛИТТЕР
81
+ // СПЛИТТЕР И ПЕРЕКЛЮЧАТЕЛЬ ЧАТА СО СТРАТЕГОМ + СОХРАНЕНИЕ НАВИГАЦИИ
55
82
  // ======================================================================
83
+ const NAV_STORAGE_KEY = 'na_ui_nav_v1';
84
+ function loadSavedNav() {
85
+ try { return JSON.parse(localStorage.getItem(NAV_STORAGE_KEY) || '{}') || {}; } catch { return {}; }
86
+ }
87
+ function saveNavPatch(patch = {}) {
88
+ try {
89
+ const cur = loadSavedNav();
90
+ localStorage.setItem(NAV_STORAGE_KEY, JSON.stringify({ ...cur, ...patch }));
91
+ } catch { /* ignore */ }
92
+ }
93
+
94
+ function setChatClosed(closed) {
95
+ document.body.classList.toggle('chat-closed', Boolean(closed));
96
+ const btn = $('#btn-chat-toggle');
97
+ if (btn) {
98
+ btn.classList.toggle('active', !closed);
99
+ btn.title = closed ? 'Открыть панель чата с ИИ-Стратегом' : 'Свернуть панель чата с ИИ-Стратегом';
100
+ }
101
+ saveNavPatch({ chatClosed: Boolean(closed) });
102
+ requestAnimationFrame(() => {
103
+ try {
104
+ eqChart.timeScale().fitContent();
105
+ icChart.timeScale().fitContent();
106
+ if (!$('#tab-map')?.hidden) drawMap();
107
+ } catch { /* ignore */ }
108
+ });
109
+ }
110
+
111
+ $('#btn-chat-toggle')?.addEventListener('click', () => {
112
+ setChatClosed(!document.body.classList.contains('chat-closed'));
113
+ });
114
+ $('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
115
+
56
116
  (function splitter() {
57
117
  const sp = $('#splitter');
58
118
  const saved = localStorage.getItem('chatWidth');
59
119
  if (saved) document.documentElement.style.setProperty('--chat-w', saved);
120
+ const initNav = loadSavedNav();
121
+ setChatClosed(Boolean(initNav.chatClosed));
60
122
  let dragging = false;
61
123
  sp.addEventListener('mousedown', (e) => { dragging = true; sp.classList.add('drag'); document.body.classList.add('dragging'); e.preventDefault(); });
62
124
  window.addEventListener('mousemove', (e) => {
@@ -138,6 +200,13 @@ function switchTab(t) {
138
200
  if (t === 'journal') t = 'map';
139
201
  $('#tabs').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.tab === t));
140
202
  document.querySelectorAll('.right > .tab').forEach((el) => { el.hidden = el.id !== `tab-${t}`; });
203
+ saveNavPatch({ tab: t });
204
+ if (t === 'overview') {
205
+ requestAnimationFrame(() => {
206
+ eqChart.timeScale().fitContent();
207
+ icChart.timeScale().fitContent();
208
+ });
209
+ }
141
210
  if (t === 'settings') loadHistory().catch(() => {});
142
211
  if (t === 'now') { refreshForecastIfStale(); loadNews(); }
143
212
  if (t === 'map') {
@@ -158,7 +227,11 @@ $('#tabs').addEventListener('click', (e) => {
158
227
  const opts = () => ({
159
228
  layout: { background: { color: 'transparent' }, textColor: '#8b949e' },
160
229
  grid: { vertLines: { visible: false }, horzLines: { color: '#1b2029' } },
161
- timeScale: { borderVisible: false }, rightPriceScale: { borderVisible: false }, crosshair: { mode: 0 }, autoSize: true, attributionLogo: false,
230
+ timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
231
+ rightPriceScale: { borderVisible: false },
232
+ crosshair: { mode: 0 },
233
+ autoSize: true,
234
+ attributionLogo: false,
162
235
  });
163
236
  const eqChart = LightweightCharts.createChart($('#eq-chart'), opts());
164
237
  const eqLines = {
@@ -169,6 +242,13 @@ const eqLines = {
169
242
  };
170
243
  const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
171
244
  const icSeries = icChart.addHistogramSeries({ priceFormat: { type: 'price', precision: 3, minMove: 0.001 }, priceLineVisible: false, lastValueVisible: false });
245
+ if (window.ResizeObserver) {
246
+ const ro = new ResizeObserver(() => {
247
+ eqChart.timeScale().fitContent();
248
+ icChart.timeScale().fitContent();
249
+ });
250
+ if ($('#eq-chart')) ro.observe($('#eq-chart'));
251
+ }
172
252
 
173
253
  const RUN_PARTS = [
174
254
  ['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : 'Авто (мульти-горизонт 1,5–3,5ч)')],
@@ -177,7 +257,7 @@ const RUN_PARTS = [
177
257
  ['modelExitRatio', (v, p) => (p.modelAutoAdapt === false ? `Выход: ${fmtNum(v, 2)}` : null)],
178
258
  ['modelTopK', (v) => `Акции: ${v}`],
179
259
  ['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : 'Доли поровну')],
180
- ['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : v === 'neutral' ? 'Хедж 50/50 (рост + падение)' : 'Только в рост (без шорта)')],
260
+ ['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : 'Только в рост (без шорта)')],
181
261
  ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
182
262
  ['modelTrainDays', (v) => `Окно: ${v}дн`],
183
263
  ['modelHistoryDays', (v) => `История: ${v}дн`],
@@ -246,6 +326,7 @@ async function loadRuns(selectId) {
246
326
  async function openRun(id) {
247
327
  state.run = await api(`/api/runs/${id}`);
248
328
  $('#run-select').value = String(id);
329
+ saveNavPatch({ runId: Number(id) });
249
330
  syncUiAvailability();
250
331
  renderOverview();
251
332
  setupMapSlider();
@@ -359,8 +440,8 @@ function syncSettingsDependencies() {
359
440
  setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
360
441
  setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
361
442
  setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
362
- setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение» или «Хедж 50/50»)');
363
- setRowEnabled('modelMarketFilter', dir !== 'neutral', 'В режиме «Хедж 50/50 (рост + падение)» портфель уже захеджирован от направления индекса');
443
+ setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
444
+ setRowEnabled('modelMarketFilter', true, '');
364
445
 
365
446
  const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
366
447
  if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
@@ -455,27 +536,27 @@ function calcPeriodStats(period, returnPct, marketPct) {
455
536
  return { calDays, years, months, yrsLabel, dateRange, cagr, avgSimple, mktAnnual };
456
537
  }
457
538
 
458
- function verdictHtml(c, dir, pStats) {
539
+ function verdictHtml(c, oosA) {
459
540
  const a = c.attention, base = c.inertia;
460
541
  if (!a) return '';
461
- const neutral = dir === 'neutral';
462
- const spanInfo = pStats?.dateRange ? ` за <b>${pStats.yrsLabel}</b> (${pStats.dateRange}, <b>${fmtPct(pStats.cagr, 1)} годовых</b> с реинвестированием / ${fmtPct(pStats.avgSimple, 1)} в среднем в год)` : '';
463
- // помогает ли карта рынка — по торговому результату против простейшей модели
542
+ if (a.trades < 30) return `<span class="tag mid">Мало сделок (${a.trades})</span> Слишком мало сделок для статистически надёжных выводов.`;
543
+ const oosPart = oosA?.returnPct !== undefined
544
+ ? ` · Контрольный период (последние 30%): <b class="${cls(oosA.returnPct)}">${fmtPct(oosA.returnPct)}</b> (${oosA.trades ?? 0} сд.)`
545
+ : '';
464
546
  let vs = '';
465
547
  if (base) {
466
548
  const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
467
- if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = `<b class="pos">Карта рынка даёт преимущество:</b> ${fmtPct(a.returnPct)} против ${fmtPct(base.returnPct)} у простейшей стратегии; на 1 ₽ убытка — ${fmtNum(pf)} ₽ против ${fmtNum(bpf)} ₽.`;
468
- else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = `Карта рынка и простейшая стратегия заработали примерно одинаково (${fmtPct(a.returnPct)} против ${fmtPct(base.returnPct)}).`;
469
- else vs = `Простейшая стратегия заработала больше (${fmtPct(base.returnPct)} против ${fmtPct(a.returnPct)}) — карта рынка пока не помогает.`;
470
- vs += ` <span class="muted small">Средняя точность по всем акциям: ${fmtNum(a.ic, 3)} и ${fmtNum(base.ic, 3)} — преимущество проявляется на лучших акциях, которые и покупаются.</span>`;
549
+ if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = ` · Карта рынка даёт преимущество над простой инерцией (<b>${fmtPct(a.returnPct)}</b> против <b>${fmtPct(base.returnPct)}</b>)`;
550
+ else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = ` · Карта рынка и инерция дали близкий результат (${fmtPct(a.returnPct)} и ${fmtPct(base.returnPct)})`;
551
+ else vs = ` · Простая инерция опередила карту рынка (${fmtPct(base.returnPct)} против ${fmtPct(a.returnPct)})`;
552
+ }
553
+ if (a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) {
554
+ return `<span class="tag ok">Стратегия прибыльна</span> Превосходит индекс Мосбиржи на <b class="pos">${fmtPct(a.returnPct - a.marketPct)}</b>${vs}${oosPart}`;
471
555
  }
472
- let unreliable = a.trades < 30 || (base && base.trades < 30) ? ' <span class="muted small">Сделок мало, сравнение ненадёжно.</span>' : '';
473
- if (c.transformer) unreliable += `<div class="muted small">Трансформер: итог ${fmtPct(c.transformer.returnPct)} против ${fmtPct(a.returnPct)} у внимания через карту.</div>`;
474
- if (a.trades < 30) return `<b>Слишком мало сделок для выводов (${a.trades}).</b> <span class="muted">${vs}</span>${unreliable}`;
475
- if (neutral && a.returnPct > 0 && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий${spanInfo}; нейтрально к рынку: рынок почти не влияет. ${vs}${unreliable}`;
476
- if (!neutral && a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий${spanInfo}, рынок за то же время ${fmtPct(a.marketPct)}. ${vs}${unreliable}`;
477
- if ((a.grossReturnPct ?? 0) > 0) return `<b style="color:var(--yellow)">Сигнал есть, но комиссии его съедают:</b> без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}${spanInfo}. Попробуйте увеличить «Порог выгоды». ${vs}${unreliable}`;
478
- return `<b class="neg">Стратегия не заработала даже без комиссий</b> (${fmtPct(a.grossReturnPct)}). ${vs}${unreliable}`;
556
+ if ((a.grossReturnPct ?? 0) > 0) {
557
+ return `<span class="tag mid">Съедено комиссией</span> Без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}${vs}${oosPart}`;
558
+ }
559
+ return `<span class="tag no">Убыток</span> Стратегия не заработала (${fmtPct(a.returnPct)})${vs}${oosPart}`;
479
560
  }
480
561
 
481
562
  const MONTHS = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
@@ -700,17 +781,21 @@ function renderOverview() {
700
781
  const oosA = ex.comparison?.[chosen]?.outOfSample || run.metrics?.outOfSample || {};
701
782
  const pStats = calcPeriodStats(run.metrics?.period, a.returnPct, a.marketPct);
702
783
  const dir = run.params?.modelDirection || 'long';
703
- const adaptNote = ex.adaptation
704
- ? `<div class="muted small">Авто-адаптация: порог выхода ${fmtNum(ex.adaptation.exitRatio, 2)}${ex.adaptation.quarantined?.length ? ` · отсеяны убыточные бумаги (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 10).join(', '))}${ex.adaptation.quarantined.length > 10 ? '…' : ''}` : ''}${oosA.returnPct !== undefined ? ` · контрольный период (последние 30%): <b class="${cls(oosA.returnPct)}">${fmtPct(oosA.returnPct)}</b> (${oosA.trades ?? 0} сделок)` : ''}</div>`
784
+ const quarNote = ex.adaptation?.quarantined?.length
785
+ ? ` · <span class="muted small" title="Временно отсеяны убыточные акции">Карантин (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 8).join(', '))}${ex.adaptation.quarantined.length > 8 ? '…' : ''}</span>`
705
786
  : '';
706
- $('#verdict').innerHTML = verdictHtml(c, dir, pStats) + adaptNote + (run.params?._note ? `<div class="muted small">Гипотеза: ${esc(run.params._note)}</div>` : '');
787
+ const hypNote = run.params?._note ? `<div class="muted small" style="margin-top:2px">Гипотеза: ${esc(run.params._note)}</div>` : '';
788
+ $('#verdict').innerHTML = verdictHtml(c, oosA) + quarNote + hypNote;
707
789
  const kpi = (v, l, sub, h) => `<div class="kpi" title="${esc(h)}"><div class="v">${v}</div><div class="l">${l}</div>${sub ? `<div class="sub">${sub}</div>` : ''}</div>`;
790
+ const sideSub = (dir !== 'long' && a.shortPct !== undefined)
791
+ ? `рост ${fmtPct(a.longPct)} · шорт ${fmtPct(a.shortPct)}`
792
+ : `${pStats.dateRange || 'весь период'} · без ком. ${fmtPct(a.grossReturnPct)}`;
708
793
  $('#kpis').innerHTML = [
709
794
  kpi(
710
795
  `<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`,
711
796
  `итог за ${pStats.yrsLabel}`,
712
- `${pStats.dateRange || 'весь период'} · без ком. ${fmtPct(a.grossReturnPct)}`,
713
- 'Общий чистый доход портфеля после вычета комиссий, проскальзывания и платы за шорт',
797
+ sideSub,
798
+ `Общий чистый доход портфеля за ${pStats.dateRange || 'период'} после вычета комиссий (без комиссий: ${fmtPct(a.grossReturnPct)})`,
714
799
  ),
715
800
  kpi(
716
801
  `<span class="${cls(pStats.cagr)}">${fmtPct(pStats.cagr, 1)}</span>`,
@@ -737,8 +822,7 @@ function renderOverview() {
737
822
  'Максимальная просадка капитала от пика и годовой коэффициент Шарпа (стабильность кривой доходности)',
738
823
  ),
739
824
  ].join('');
740
- $('#sides').hidden = dir === 'long' || a.shortPct === undefined;
741
- $('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
825
+ $('#sides').hidden = true;
742
826
  renderStability(ex.stability);
743
827
 
744
828
  const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
@@ -746,6 +830,7 @@ function renderOverview() {
746
830
  // трансформер — только если он был в прогоне
747
831
  for (const n of MODELS) eqLines[n].applyOptions({ visible: !!ex.curves[n] && (n === chosen || $('#more').open) });
748
832
  eqChart.timeScale().fitContent();
833
+ requestAnimationFrame(() => eqChart.timeScale().fitContent());
749
834
  $('#legend').innerHTML = [chosen, 'market', ...($('#more').open ? MODELS.filter((n) => n !== chosen && ex.curves[n]) : [])]
750
835
  .map((n) => `<span><i style="background:${COLORS[n]}"></i>${n === 'market' ? 'Рынок' : MODEL_LABEL[n]}</span>`).join('') + `<span>· ${fmtDate(run.metrics.period.from)} — ${fmtDate(run.metrics.period.till)}, прогноз на ${run.params.modelHorizon} ч, ${run.metrics.model.tokens} акций</span>`;
751
836
 
@@ -849,46 +934,98 @@ $('#btn-cancel').addEventListener('click', async () => {
849
934
  });
850
935
 
851
936
  // ======================================================================
852
- // СЕЙЧАС: прогноз и живая проверка
937
+ // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
853
938
  // ======================================================================
939
+ let forecastBusy = false;
940
+ const prevClientExp = new Map();
941
+ const prevClientRank = new Map();
942
+
854
943
  async function loadForecast(force, silent = false) {
944
+ if (forecastBusy) return;
945
+ forecastBusy = true;
855
946
  const box = $('#forecast');
856
- if (!silent) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
857
- $('#btn-forecast').disabled = true;
947
+ if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
858
948
  try {
859
- const f = state.forecast = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
949
+ const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
950
+ // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
951
+ f.rows.forEach((r, idx) => {
952
+ const rank = idx + 1;
953
+ if (prevClientExp.has(r.symbol)) {
954
+ const d = Number((r.expectedPct - prevClientExp.get(r.symbol)).toFixed(3));
955
+ if (Math.abs(d) >= 0.001) r.deltaPct = d;
956
+ const rd = prevClientRank.get(r.symbol) - rank;
957
+ if (rd !== 0) r.rankDelta = rd;
958
+ }
959
+ prevClientExp.set(r.symbol, r.expectedPct);
960
+ prevClientRank.set(r.symbol, rank);
961
+ });
962
+ state.forecast = f;
963
+ const liveTimeStr = new Date((f.liveTs || f.at || Date.now()) + MSK).toISOString().slice(11, 19);
860
964
  $('#fc-title').textContent = `Прогноз на ${f.horizon} ч · свеча ${f.bar.slice(8, 10)}.${f.bar.slice(5, 7)} ${f.bar.slice(11)}`;
861
- // ● у акции с новостями: зелёный/красный/серый по тону, подсказка — суть, источник и время
965
+ const badge = $('#fc-live-badge');
966
+ if (badge) badge.textContent = `● живой радар · ${liveTimeStr}`;
967
+
862
968
  const dot = (n) => (n ? ` <span class="${n.tone >= 0.15 ? 'pos' : n.tone <= -0.15 ? 'neg' : 'muted'}" title="${esc(`${n.top}${n.count > 1 ? ` (и ещё ${n.count - 1})` : ''} — ${n.src}, ${fmtTs(n.ts)}`)}">●</span>` : '');
863
- const row = (r) => `<tr><td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${dot(r.news)}</td><td class="num ${cls(r.expectedPct)}">${fmtPct(r.expectedPct, 2)}</td>
864
- <td>${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">продать</span>' : ''}</td><td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td></tr>`;
865
- box.innerHTML = `<table><thead><tr><th>Сильнее рынка</th><th class="num">Ожидание</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
866
- <table style="margin-top:10px"><thead><tr><th>Слабее рынка</th><th class="num">Ожидание</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
867
- <p class="muted small">Ожидание — движение относительно рынка в целом. «Купить» — если ожидание выше порога ${fmtPct(f.thresholdPct, 2)} (окупает комиссии с запасом).${f.direction && f.direction !== 'long' ? ' «Продать» — ожидание ниже −порога: продажа без покупки.' : ''}</p>`;
969
+ const arrowOf = (r) => {
970
+ const d = Number(r.deltaPct || 0);
971
+ if (d > 0.002) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">▲ +${fmtNum(d, 2)}%</span>`;
972
+ if (d < -0.002) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">▼ ${fmtNum(d, 2)}%</span>`;
973
+ return '<span class="muted small" title="Без изменений">—</span>';
974
+ };
975
+ const rankBadge = (r) => {
976
+ const rd = Number(r.rankDelta || 0);
977
+ if (rd > 0) return ` <span class="pos small" title="Поднялась на ${rd} поз. в рейтинге">↑${rd}</span>`;
978
+ if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
979
+ return '';
980
+ };
981
+ const row = (r) => `<tr>
982
+ <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
983
+ <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
984
+ <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
985
+ <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
986
+ <td>${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">шорт</span>' : ''}</td>
987
+ <td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
988
+ </tr>`;
989
+ box.innerHTML = `<table><thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
990
+ <table style="margin-top:10px"><thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
991
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога ${fmtPct(f.thresholdPct, 2)}.${f.direction && f.direction !== 'long' ? ' «Шорт» — ожидание ниже −порога.' : ''}</p>`;
868
992
  setupMapSlider();
993
+ if (state.brokers?.accounts?.length) renderBrokers();
869
994
  } catch (e) {
870
995
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
871
996
  } finally {
872
- $('#btn-forecast').disabled = false;
997
+ forecastBusy = false;
873
998
  syncUiAvailability();
874
999
  }
875
1000
  }
876
- $('#btn-forecast').addEventListener('click', () => loadForecast(true));
877
- const FORECAST_MAX_AGE = 3600000;
1001
+ const FORECAST_TICK_MS = 45000; // живое обновление каждые 45 секунд
878
1002
  function refreshForecastIfStale() {
879
- if (state.forecast?.at && Date.now() - state.forecast.at > FORECAST_MAX_AGE && !$('#btn-forecast').disabled) loadForecast(true, true);
1003
+ if (forecastBusy) return;
1004
+ if (!state.forecast?.at) {
1005
+ loadForecast(false, false);
1006
+ } else if (Date.now() - state.forecast.at >= 20000) {
1007
+ loadForecast(false, true);
1008
+ }
880
1009
  }
881
- setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, 5 * 60000);
1010
+ setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
882
1011
 
883
1012
  function showNowSec(sec) {
884
1013
  document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
885
1014
  document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
1015
+ saveNavPatch({ nowSec: sec });
886
1016
  }
887
1017
  $('#now-nav').addEventListener('click', (e) => {
888
1018
  const b = e.target.closest('[data-nsec]');
889
1019
  if (b) showNowSec(b.dataset.nsec);
890
1020
  });
891
1021
 
1022
+ let openLiveId = loadSavedNav().openLiveId ?? null;
1023
+ const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
1024
+ function setLiveSubTab(id, tab) {
1025
+ liveTabById.set(String(id), tab);
1026
+ saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
1027
+ }
1028
+
892
1029
  function renderLive(st) {
893
1030
  state.live = st;
894
1031
  const accs = st?.accounts || [];
@@ -898,13 +1035,25 @@ function renderLive(st) {
898
1035
  $('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
899
1036
  return;
900
1037
  }
1038
+ if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
1039
+ openLiveId = accs[0].runId;
1040
+ }
901
1041
  $('#live').innerHTML = accs.map((a) => {
902
- const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
1042
+ const isOpen = String(a.runId) === String(openLiveId);
1043
+ const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
1044
+ const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
903
1045
  const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
904
1046
  const retPct = a.returnPct ?? 0;
905
1047
  const step = a.recent?.[0];
906
1048
  const p = a.params || {};
907
- const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ` · ${p.modelDirection === 'neutral' ? 'хедж 50/50' : 'в рост и в падение'}` : ' · только в рост'}`;
1049
+ const mf = a.metrics?.full || {};
1050
+ const trades = Array.isArray(a.trades) ? a.trades : [];
1051
+ const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
1052
+ const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
1053
+ const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
1054
+ const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
1055
+ const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
1056
+ const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
908
1057
  let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
909
1058
  if (a.corridor) {
910
1059
  const c = a.corridor;
@@ -917,29 +1066,99 @@ function renderLive(st) {
917
1066
  corrHtml = `<div class="kpi" title="Ожидание по темпу эксперимента №${c.runId} (история: ${fmtPct(c.histReturnPct)}, макс. просадка ${fmtNum(c.histMaxDdPct, 1)}%)"><div class="v ${cls(c.expectedPct)}">${fmtPct(c.expectedPct, 2)} <span class="muted small">(${fmtPct(c.minPct, 1)}…${fmtPct(c.maxPct, 1)})</span></div><div class="l">ориентир №${c.runId} · <span class="${sCls === 'no' ? 'neg' : 'pos'}">${sLbl}</span></div></div>`;
918
1067
  }
919
1068
  const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
920
- return `<div class="live-card${a.running ? '' : ' stopped'}" data-acc="${a.runId}">
921
- <div class="live-head">
922
- <div>
1069
+
1070
+ // Вкладка 1: Текущие позиции и ожидающие заявки
1071
+ const posPaneHtml = `
1072
+ ${s.pending?.length ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Ожидают исполнения на открытии следующей свечи (${s.pending.length})</b></div><div class="muted small">${s.pending.map((o) => `<span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${{ buy: 'купить', short: 'открыть шорт', cover: 'закрыть шорт' }[o.action] || 'продать'} <b>${esc(o.symbol)}</b></span>`).join(' ')}</div></div>` : ''}
1073
+ ${s.positions.length ? `<table><thead><tr><th>Бумага</th><th>Направление</th><th>Открыта</th><th class="num">Кол-во</th><th class="num">Цена входа → Сейчас</th><th class="num">Текущий доход</th></tr></thead><tbody>${s.positions.map((pos) => {
1074
+ const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
1075
+ return `<tr>
1076
+ <td><b>${esc(pos.symbol)}</b></td>
1077
+ <td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
1078
+ <td class="muted small">${fmtTs(pos.entryTs)}</td>
1079
+ <td class="num">${fmtNum(pos.qty, 0)} шт.</td>
1080
+ <td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
1081
+ <td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
1082
+ </tr>`;
1083
+ }).join('')}</tbody></table>` : `<p class="muted small" style="margin:6px 0">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'открытых позиций нет' : 'ничего не куплено'} (все <b>${fmtNum(s.cash ?? s.equity, 0)} ₽</b> в свободном кэше) — стратегия ждёт сильного сигнала.</p>`}
1084
+ ${step ? `<p class="muted small" style="margin:8px 0 0">Последняя проверка рынка (${fmtTs(step.ts)}): ${step.decisions?.length ? step.decisions.map((d) => `<b>${esc(d.symbol)}</b>: ${esc(d.text)}`).join(' · ') : 'без изменений'}${step.top?.length ? ` · лидеры прогноза: ${step.top.slice(0, 3).map((x) => `${esc(x.symbol)} (${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%)`).join(', ')}` : ''}</p>` : ''}
1085
+ `;
1086
+
1087
+ // Вкладка 2: История закрытых сделок и по-дневной доход
1088
+ let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
1089
+ if (trades.length) {
1090
+ const byDay = new Map();
1091
+ for (const t of trades) {
1092
+ const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
1093
+ if (!byDay.has(dKey)) byDay.set(dKey, []);
1094
+ byDay.get(dKey).push(t);
1095
+ }
1096
+ const rows = [];
1097
+ for (const [, dayTrades] of byDay) {
1098
+ const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
1099
+ const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
1100
+ const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
1101
+ rows.push(`<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTrades[0]?.exitTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">сделок: <b>${dayTrades.length}</b> (в плюс: ${dayWins}) · комиссия: ${fmtNum(dayFee, 0)} ₽ · доход за день: <b class="${cls(dayPnl)}">${dayPnl >= 0 ? '+' : ''}${fmtNum(dayPnl, 0)} ₽</b></span></div></td></tr>`);
1102
+ for (const t of dayTrades) {
1103
+ rows.push(`<tr>
1104
+ <td><b>${esc(t.symbol)}</b></td>
1105
+ <td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
1106
+ <td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
1107
+ <td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
1108
+ <td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
1109
+ <td class="num ${cls(t.pnl)}" style="white-space:nowrap"><b>${t.pnl >= 0 ? '+' : ''}${fmtNum(t.pnl, 0)} ₽</b> <span class="small">(${fmtPct(t.pnlPct, 2)})</span></td>
1110
+ <td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
1111
+ </tr>`);
1112
+ }
1113
+ }
1114
+ tradesPaneHtml = `<table><thead><tr><th>Бумага</th><th>Тип</th><th>Вход → Выход</th><th class="num">Кол-во</th><th class="num">Цена входа → выхода</th><th class="num">Чистый доход</th><th>Причина</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
1115
+ }
1116
+
1117
+ // Вкладка 3: Журнал шагов и решений ИИ по свечам
1118
+ const stepsList = Array.isArray(a.recent) ? a.recent : [];
1119
+ const stepsPaneHtml = stepsList.length
1120
+ ? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
1121
+ <td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
1122
+ <td>${stItem.decisions?.length ? stItem.decisions.map((d) => `<span class="tag ${d.action === 'buy' ? 'ok' : d.action === 'short' || d.action === 'sell' ? 'mid' : ''}">${esc(d.symbol)}</span> <span class="small">${esc(d.text)}</span>`).join('<br>') : '<span class="muted small">Позиции без изменений</span>'}</td>
1123
+ <td class="small muted">${stItem.top?.length ? stItem.top.slice(0, 3).map((x) => `<b>${esc(x.symbol)}</b> <span class="${cls(x.expectedPct)}">${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%</span>`).join(' · ') : '—'}</td>
1124
+ </tr>`).join('')}</tbody></table>`
1125
+ : '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
1126
+
1127
+ return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
1128
+ <div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
1129
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
1130
+ <span class="acc-caret">▶</span>
923
1131
  <b>${esc(dispName)}</b>
924
- <span class="tag ${a.running ? 'ok' : ''}" style="margin-left:6px">${a.running ? 'работает' : 'остановлен'}</span>
1132
+ <span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
925
1133
  ${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
926
- <div class="muted small">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
927
1134
  </div>
928
- <div class="row-gap">
1135
+ <div class="row-gap" style="align-items:center">
929
1136
  <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
930
1137
  ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
931
1138
  <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
932
1139
  </div>
933
1140
  </div>
934
- <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
935
- <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">на счёте</div></div>
936
- <div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">с начала</div></div>
937
- ${corrHtml}
938
- <div class="kpi"><div class="v">${a.metrics?.full?.trades ?? 0}</div><div class="l">закрыто сделок</div></div>
1141
+ <div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
1142
+ <div class="brk-specs" style="margin-bottom:10px">
1143
+ <span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
1144
+ <span class="k">Статус и время работы:</span><span class="v">запущен <b>${fmtTs(a.startedAt)}</b>${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча <b>${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}</b>${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}</span>
1145
+ <span class="k">Структура дохода:</span><span class="v">зафиксировано по сделкам: <b class="${cls(realizedPnl)}">${realizedPnl >= 0 ? '+' : ''}${fmtNum(realizedPnl, 0)} ₽</b> · в открытых позициях: <b class="${cls(openPnl)}">${openPnl >= 0 ? '+' : ''}${fmtNum(openPnl, 0)} ₽</b> · учтено комиссий: <span class="muted">−${fmtNum(totalFee, 0)} ₽</span> · макс. просадка: <b>${fmtNum(mf.maxDrawdownPct || 0, 2)}%</b></span>
1146
+ </div>
1147
+ <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
1148
+ <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
1149
+ <div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">чистый доход с начала</div></div>
1150
+ ${corrHtml}
1151
+ <div class="kpi"><div class="v">${trades.length || (mf.trades ?? 0)} <span class="muted small">(${winRate}% в плюс)</span></div><div class="l">закрыто сделок · профит-фактор ${fmtNum(mf.profitFactor || 0, 2)}</div></div>
1152
+ </div>
1153
+ <div class="brk-subtabs" style="margin-top:10px">
1154
+ <button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
1155
+ <button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
1156
+ <button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
1157
+ </div>
1158
+ <div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
1159
+ <div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
1160
+ <div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
939
1161
  </div>
940
- <p class="muted small">Запущен ${fmtTs(a.startedAt)}${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча ${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}${s.pending.length ? ` · ждут исполнения: ${s.pending.map((o) => `${{ buy: 'купить', short: 'продать без покупки', cover: 'закрыть продажу' }[o.action] || 'продать'} ${o.symbol}`).join(', ')}` : ''}</p>
941
- ${s.positions.length ? `<table><thead><tr><th>Держим</th><th>С</th><th class="num">Цена входа</th><th class="num">Сейчас</th><th class="num">Результат</th></tr></thead><tbody>${s.positions.map((pos) => `<tr><td><b>${pos.symbol}</b>${pos.side === 'short' ? ' <span class="tag no">продажа</span>' : ''}</td><td>${fmtTs(pos.entryTs)}</td><td class="num">${fmtNum(pos.entryPrice, 3)}</td><td class="num">${fmtNum(pos.lastPrice, 3)}</td><td class="num ${cls(pos.pnl)}">${fmtNum(pos.pnl, 0)} ₽</td></tr>`).join('')}</tbody></table>` : `<p class="muted small">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'позиций нет' : 'ничего не куплено'} — стратегия ждёт сильного сигнала.</p>`}
942
- ${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
943
1162
  </div>`;
944
1163
  }).join('');
945
1164
  syncUiAvailability();
@@ -955,6 +1174,12 @@ $('#btn-live').addEventListener('click', async () => {
955
1174
  } catch (e) { toast(e.message, 'error'); }
956
1175
  });
957
1176
  $('#live').addEventListener('click', async (e) => {
1177
+ const tabBtn = e.target.closest('button[data-ltab]');
1178
+ if (tabBtn) {
1179
+ setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
1180
+ renderLive(state.live);
1181
+ return;
1182
+ }
958
1183
  const stopBtn = e.target.closest('[data-live-stop]');
959
1184
  if (stopBtn) {
960
1185
  const id = Number(stopBtn.dataset.liveStop);
@@ -981,6 +1206,14 @@ $('#live').addEventListener('click', async (e) => {
981
1206
  const id = Number(askBtn.dataset.liveAsk);
982
1207
  const acc = (state.live?.accounts || []).find((x) => x.runId === id);
983
1208
  sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
1209
+ return;
1210
+ }
1211
+ const foldHead = e.target.closest('[data-live-fold]');
1212
+ if (foldHead) {
1213
+ const id = foldHead.dataset.liveFold;
1214
+ openLiveId = String(openLiveId) === String(id) ? null : id;
1215
+ saveNavPatch({ openLiveId });
1216
+ renderLive(state.live);
984
1217
  }
985
1218
  });
986
1219
 
@@ -1138,6 +1371,7 @@ function drawProfilesMap() {
1138
1371
  }
1139
1372
  async function showMapSec(sec) {
1140
1373
  document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
1374
+ saveNavPatch({ mapSec: sec });
1141
1375
  if (sec === 'journal') {
1142
1376
  $('#map-sec').hidden = true;
1143
1377
  $('#journal-box').hidden = false;
@@ -1215,6 +1449,8 @@ function showSettingsSec(sec) {
1215
1449
  document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
1216
1450
  document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
1217
1451
  document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
1452
+ saveNavPatch({ settingsSec: sec });
1453
+ if (sec === 'cloud') renderCloudPane().catch(() => {});
1218
1454
  }
1219
1455
  document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
1220
1456
  const b = e.target.closest('[data-sec]');
@@ -1961,8 +2197,70 @@ const INST_KIND_LABEL = {
1961
2197
  futures: ['Фьючерс', 'mid'],
1962
2198
  };
1963
2199
  const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
1964
- const brokerTabById = new Map(); // accId -> 'assets' | 'trade' | 'history'
2200
+ const HIST_ALL_KINDS = ['buy', 'sell', 'fee', 'div', 'other'];
2201
+ const brokerTabById = new Map(Object.entries(loadSavedNav().brokerTabs || {})); // accId -> 'assets' | 'trade' | 'history'
2202
+ const brokerHistFilterById = new Map(Object.entries(loadSavedNav().brokerHistFilters || {})); // accId -> Array of kinds
2203
+ const openHistYears = new Set(); // `${accId}:${year}`
2204
+ const loadingHistYears = new Set(); // `${accId}:${year}`
2205
+ const preparingBrokerIds = new Set(); // accId
2206
+ function setBrokerSubTab(id, tab) {
2207
+ brokerTabById.set(id, tab);
2208
+ saveNavPatch({ brokerTabs: Object.fromEntries(brokerTabById) });
2209
+ }
2210
+ function getBrokerHistFilterSet(id) {
2211
+ const saved = brokerHistFilterById.get(id);
2212
+ if (Array.isArray(saved)) return new Set(saved);
2213
+ return new Set(HIST_ALL_KINDS);
2214
+ }
2215
+ function toggleBrokerHistFilter(id, flt) {
2216
+ const cur = getBrokerHistFilterSet(id);
2217
+ if (flt === 'all') {
2218
+ brokerHistFilterById.set(id, [...HIST_ALL_KINDS]);
2219
+ } else {
2220
+ if (cur.has(flt)) cur.delete(flt);
2221
+ else cur.add(flt);
2222
+ brokerHistFilterById.set(id, [...cur]);
2223
+ }
2224
+ saveNavPatch({ brokerHistFilters: Object.fromEntries(brokerHistFilterById) });
2225
+ }
2226
+ let openBrokerId = loadSavedNav().openBrokerId ?? null;
2227
+ let openBrokerCfgId = loadSavedNav().openBrokerCfgId ?? null;
1965
2228
  let editingBrokerId = null;
2229
+ let brokerPollBusy = false;
2230
+ let lastBrokerPollTs = 0;
2231
+
2232
+ async function pollActiveBrokerAccount(force = false) {
2233
+ if (brokerPollBusy || document.hidden) return;
2234
+ const nowPageHidden = $('#tab-now')?.hidden;
2235
+ const brokersSecHidden = $('#nsec-brokers')?.hidden;
2236
+ if (nowPageHidden || brokersSecHidden) return;
2237
+ const accs = state.brokers?.accounts || [];
2238
+ if (!accs.length) return;
2239
+ const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
2240
+ const activeAcc = accs.find((a) => a.id === activeId);
2241
+ if (!activeAcc?.hasToken) return;
2242
+ const now = Date.now();
2243
+ if (!force && now - lastBrokerPollTs < 25000) return;
2244
+ brokerPollBusy = true;
2245
+ lastBrokerPollTs = now;
2246
+ try {
2247
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/sync`);
2248
+ const curSubTab = brokerTabById.get(activeId) || 'assets';
2249
+ if (curSubTab === 'history') {
2250
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
2251
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/history`, { year: curYear });
2252
+ }
2253
+ renderBrokers();
2254
+ } catch { /* ignore background poll error */ } finally {
2255
+ brokerPollBusy = false;
2256
+ }
2257
+ }
2258
+
2259
+ setInterval(() => pollActiveBrokerAccount(false), 30000);
2260
+ window.addEventListener('focus', () => pollActiveBrokerAccount(true));
2261
+ document.addEventListener('visibilitychange', () => {
2262
+ if (!document.hidden) pollActiveBrokerAccount(true);
2263
+ });
1966
2264
 
1967
2265
  async function loadBrokers() {
1968
2266
  state.brokers = await api('/api/brokers');
@@ -1976,40 +2274,58 @@ function renderBrokers() {
1976
2274
  if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
1977
2275
  updateTabBadges();
1978
2276
 
1979
- // 1) список в Настройках
2277
+ // 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
1980
2278
  const listEl = $('#brokers-list');
1981
2279
  if (listEl) {
1982
- listEl.innerHTML = accs.length ? accs.map((a) => `<div class="llm-prov" data-bid="${esc(a.id)}">
1983
- <div class="llm-head">
1984
- <b>${esc(a.name)}</b>
1985
- <span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
1986
- <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
1987
- <span class="tag" title="Режим работы с начальным портфелем на счёте">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
1988
- ${a.lastSync?.marginEnabled === false ? '<span class="tag" title="На этом счёте у брокера отключена маржинальная торговля — стратегия автоматически работает в режиме «Только в рост (без шорта)»">только в рост (маржиналка выкл.)</span>' : a.lastSync?.marginEnabled === true ? '<span class="tag ok" title="Маржинальная торговля включена — доступны сделки в рост и в падение (с шортом)">рост + шорт доступны</span>' : ''}
1989
- ${a.autoSchedule !== false ? '<span class="tag ok" title="Заявки рассчитываются автоматически раз в горизонт прогноза и приходят в Telegram / MAX">авто-расписание</span>' : ''}
1990
- ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
1991
- ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
1992
- <span class="grow"></span>
1993
- <button type="button" data-bact="sync">Проверить связь</button>
1994
- <button type="button" data-bact="edit" class="icon">Изменить</button>
1995
- <button type="button" data-bact="del" class="icon">Удалить</button>
2280
+ if (accs.length && !accs.some((a) => a.id === openBrokerCfgId)) {
2281
+ openBrokerCfgId = accs[0].id;
2282
+ }
2283
+ listEl.innerHTML = accs.length ? accs.map((a) => {
2284
+ const ls = a.lastSync;
2285
+ const isCfgOpen = a.id === openBrokerCfgId;
2286
+ return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2287
+ <div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
2288
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2289
+ <span class="acc-caret">▶</span>
2290
+ <b>${esc(a.name)}</b>
2291
+ <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2292
+ ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2293
+ ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
2294
+ </div>
2295
+ <div class="row-gap" style="align-items:center">
2296
+ ${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
2297
+ <button type="button" data-bact="sync">Проверить связь</button>
2298
+ <button type="button" data-bact="edit" class="icon">Изменить</button>
2299
+ <button type="button" data-bact="del" class="icon">Удалить</button>
2300
+ </div>
1996
2301
  </div>
1997
- <div class="llm-meta">
1998
- Счёт: ${esc(a.accountId || 'авто')} · Токен: ${a.hasToken ? '<span class="pos">задан</span>' : '<span class="nokey">нет токена</span>'} · Лимит стратегии: ${fmtNum(a.maxCapitalRub, 0)} ₽ · Дневной стоп: −${fmtNum(a.maxDailyDrawdownPct, 1)}% · Отступ лимитки: ${fmtNum(a.limitOffsetPct, 2)}%${a.baseRunLabel ? ` · Настройки из ${esc(a.baseRunLabel)}` : ''}
1999
- ${a.lastSync ? `<br>Синхронизация ${fmtTs(a.lastSync.at)}: ${a.lastSync.ok ? `<span class="pos">на счёте ${fmtNum(a.lastSync.totalRub, 0)} ₽ (свободно ${fmtNum(a.lastSync.cashRub, 0)} ₽, бумаг ${a.lastSync.positions?.length || 0})</span>` : `<span class="neg">${esc(a.lastSync.error)}</span>`}` : ''}
2302
+ <div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
2303
+ <span class="k">Брокер и субсчёт:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · ID <code>${esc(a.accountId || ls?.accountId || 'авто')}</code> (токен: ${a.hasToken ? '<span class="pos">задан ✓</span>' : '<span class="nokey">не задан</span>'})</span>
2304
+ <span class="k">Режим управления:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-расписание: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
2305
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
2306
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2307
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2308
+ <span class="k">Предохранители:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b></span>
2309
+ ${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
2310
+ ${ls ? `<span class="k">Синхронизация (${fmtTs(ls.at)}):</span><span class="v">${ls.ok ? `<span class="pos">на счёте <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽, бумаг ${ls.positions?.length || 0})</span>` : `<span class="neg">${esc(ls.error)}</span>`}</span>` : ''}
2000
2311
  </div>
2001
- </div>`).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
2312
+ </div>`;
2313
+ }).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
2002
2314
  }
2003
2315
 
2004
- // 2) карточки во вкладке «Торговля → Брокерские счета» (с 3 вкладками: Активы · Торговля · История)
2316
+ // 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
2005
2317
  const tradeEl = $('#brokers-trade');
2006
2318
  if (!tradeEl) return;
2007
2319
  if (!accs.length) {
2008
2320
  tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
2009
2321
  return;
2010
2322
  }
2323
+ if (!accs.some((a) => a.id === openBrokerId)) {
2324
+ openBrokerId = accs[0].id;
2325
+ }
2011
2326
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
2012
2327
  tradeEl.innerHTML = accs.map((a) => {
2328
+ const isOpen = a.id === openBrokerId;
2013
2329
  const ls = a.lastSync;
2014
2330
  const pb = a.pendingBatch;
2015
2331
  const lh = a.lastHistory;
@@ -2100,79 +2416,232 @@ function renderBrokers() {
2100
2416
  const resHtml = pb.results?.length
2101
2417
  ? `<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>`
2102
2418
  : '';
2419
+ const throttleHtml = pb.planThrottleNote
2420
+ ? `<div class="muted small" style="margin-top:6px;padding:6px 10px;border-left:2px solid var(--accent);background:rgba(59,130,246,0.06)">ℹ ${esc(pb.planThrottleNote)}</div>`
2421
+ : '';
2103
2422
  batchHtml = `<div class="stab-box">
2104
2423
  <div class="stab-head">
2105
2424
  <b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
2425
+ ${preparingBrokerIds.has(a.id) ? '<span class="muted small">· обновляем расчёт…</span>' : ''}
2106
2426
  ${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
2107
2427
  <span class="grow"></span>
2108
- ${pb.status === 'pending' && pb.orders?.length ? `<button type="button" class="primary" data-btrade="exec" data-bid="${esc(a.id)}"${a.mode === 'readonly' ? ' disabled title="В режиме «Только чтение» отправка отключена"' : ''}>Исполнить заявки (${pb.orders.length})</button>` : ''}
2109
- <button type="button" data-btrade="rej" data-bid="${esc(a.id)}">${pb.status === 'pending' ? 'Отклонить' : 'Скрыть'}</button>
2428
+ ${pb.status === 'pending' && pb.orders?.length ? `<button type="button" class="primary" data-btrade="exec" data-bid="${esc(a.id)}"${a.mode === 'readonly' ? ' disabled title="В режиме «Только чтение» отправка отключена"' : ''}>Исполнить заявки (${pb.orders.length})</button><button type="button" data-btrade="rej" data-bid="${esc(a.id)}">Отклонить заявки</button>` : `<button type="button" class="icon small" data-btrade="rej" data-bid="${esc(a.id)}" title="Скрыть сводку">Скрыть</button>`}
2110
2429
  </div>
2111
2430
  ${ordRows}
2112
2431
  ${resHtml}
2432
+ ${throttleHtml}
2113
2433
  </div>`;
2114
2434
  } else {
2115
- batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
2435
+ batchHtml = `<p class="muted small">${preparingBrokerIds.has(a.id) ? 'Считаем пакет лимитных заявок по текущему прогнозу…' : 'Переключитесь на вкладку <b>«⚡ Торговля и заявки»</b> для внеочередного пересчёта заявок (или дождитесь закрытия свечи при включённом авто-расписании).'}</p>`;
2116
2436
  }
2117
2437
 
2118
- // Вкладка 3: История операций и динамика счёта
2119
- const eqHist = Array.isArray(a.equityHistory) ? a.equityHistory : [];
2120
- const eqHistHtml = eqHist.length > 1
2121
- ? `<div class="muted small" style="margin-bottom:8px">Точки синхронизации баланса (переживают перезапуск сервера): ${eqHist.slice(-6).map((pt) => `${fmtTs(pt.ts)} — <b>${fmtNum(pt.totalRub, 0)} ₽</b>`).join(' · ')}</div>`
2122
- : '';
2123
- const histHtml = lh?.operations?.length
2124
- ? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2125
- <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
2438
+ // Вкладка 3: История операций и динамика счёта (по умолчанию текущий год, прошлые года — по клику)
2439
+ const renderOpsTable = (opsList) => {
2440
+ const byDay = new Map();
2441
+ for (const op of opsList) {
2442
+ const dayKey = op.date ? new Date(op.date + MSK).toISOString().slice(0, 10) : 'none';
2443
+ if (!byDay.has(dayKey)) byDay.set(dayKey, []);
2444
+ byDay.get(dayKey).push(op);
2445
+ }
2446
+ const rows = [];
2447
+ for (const [, dayOps] of byDay) {
2448
+ let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2449
+ const symQty = new Map(), symCash = new Map();
2450
+ for (const op of dayOps) {
2451
+ const pay = Number(op.paymentRub) || 0;
2452
+ if (op.kind === 'buy') {
2453
+ tradesCnt++;
2454
+ buyRub += Math.abs(pay);
2455
+ if (op.symbol) {
2456
+ symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2457
+ symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2458
+ }
2459
+ } else if (op.kind === 'sell') {
2460
+ tradesCnt++;
2461
+ sellRub += Math.abs(pay);
2462
+ if (op.symbol) {
2463
+ symQty.set(op.symbol, (symQty.get(op.symbol) || 0) - (Number(op.qty) || 0));
2464
+ symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2465
+ }
2466
+ } else if (op.kind === 'fee') {
2467
+ if (/дивиденд|купон/i.test(op.title || '')) divTaxRub += pay;
2468
+ else feeRub += pay;
2469
+ } else if (op.kind === 'div') {
2470
+ divTaxRub += pay;
2471
+ } else if (op.kind === 'in' || op.kind === 'out') {
2472
+ inOutRub += pay;
2473
+ }
2474
+ }
2475
+ // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
2476
+ let closedTradePnl = 0, closedSyms = 0;
2477
+ for (const [sym, netQ] of symQty) {
2478
+ if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2479
+ closedTradePnl += symCash.get(sym);
2480
+ closedSyms++;
2481
+ }
2482
+ }
2483
+ if (closedSyms > 0) closedTradePnl += feeRub;
2484
+
2485
+ const summaryParts = [];
2486
+ if (closedSyms > 0) {
2487
+ summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
2488
+ } else if (tradesCnt > 0) {
2489
+ const tParts = [];
2490
+ if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
2491
+ if (sellRub > 0) tParts.push(`продано на ${fmtNum(sellRub, 0)} ₽`);
2492
+ if (feeRub !== 0) tParts.push(`комиссия ${fmtNum(feeRub, 2)} ₽`);
2493
+ summaryParts.push(tParts.join(' · '));
2494
+ } else if (feeRub !== 0) {
2495
+ summaryParts.push(`комиссии и налоги: <span class="neg">${fmtNum(feeRub, 2)} ₽</span>`);
2496
+ }
2497
+ if (divTaxRub !== 0) {
2498
+ summaryParts.push(`дивиденды/купоны (чистыми): <b class="${cls(divTaxRub)}">${divTaxRub > 0 ? '+' : ''}${fmtNum(divTaxRub, 2)} ₽</b>`);
2499
+ }
2500
+ if (inOutRub !== 0) {
2501
+ summaryParts.push(`${inOutRub > 0 ? 'пополнение' : 'вывод'}: <b class="${cls(inOutRub)}">${inOutRub > 0 ? '+' : ''}${fmtNum(inOutRub, 0)} ₽</b>`);
2502
+ }
2503
+
2504
+ rows.push(`<tr class="day-group-row"><td colspan="6"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayOps[0]?.date))}</span><span class="small" style="font-weight:500;color:var(--muted)">${summaryParts.join(' · ')}</span></div></td></tr>`);
2505
+ for (const op of dayOps) {
2506
+ rows.push(`<tr>
2507
+ <td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
2508
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span> <span class="muted small">${esc(op.title !== op.kindLabel ? op.title : '')}</span></td>
2509
+ <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2510
+ <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2511
+ <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
2512
+ <td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
2513
+ </tr>`);
2514
+ }
2515
+ }
2516
+ return `<table><thead><tr><th style="width:64px">Время</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
2517
+ };
2518
+
2519
+ const activeHistKinds = getBrokerHistFilterSet(a.id);
2520
+ const isAllHistActive = HIST_ALL_KINDS.every((k) => activeHistKinds.has(k));
2521
+ const opBucketOf = (op) => (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee' || op.kind === 'div' ? op.kind : 'other');
2522
+ const matchHistFilter = (op) => activeHistKinds.has(opBucketOf(op));
2523
+
2524
+ const byYear = new Map();
2525
+ const byYearFiltered = new Map();
2526
+ const filterCounts = { all: 0, buy: 0, sell: 0, fee: 0, div: 0, other: 0 };
2527
+ for (const op of lh?.operations || []) {
2528
+ filterCounts.all++;
2529
+ const b = opBucketOf(op);
2530
+ filterCounts[b] = (filterCounts[b] || 0) + 1;
2531
+
2532
+ const y = op.date ? new Date(op.date + MSK).toISOString().slice(0, 4) : 'Прочее';
2533
+ if (!byYear.has(y)) byYear.set(y, []);
2534
+ byYear.get(y).push(op);
2535
+ if (matchHistFilter(op)) {
2536
+ if (!byYearFiltered.has(y)) byYearFiltered.set(y, []);
2537
+ byYearFiltered.get(y).push(op);
2538
+ }
2539
+ }
2540
+
2541
+ const histFilterBarHtml = `<div class="row-gap" style="margin-bottom:10px;align-items:center;flex-wrap:wrap">
2542
+ <span class="muted small" style="margin-right:4px">Фильтры:</span>
2543
+ ${[
2544
+ ['buy', 'Покупка', filterCounts.buy],
2545
+ ['sell', 'Продажа', filterCounts.sell],
2546
+ ['fee', 'Комиссия', filterCounts.fee],
2547
+ ['div', 'Дивиденды', filterCounts.div],
2548
+ ['other', 'Операция', filterCounts.other],
2549
+ ].map(([fKey, fLbl, fCnt]) => {
2550
+ const on = activeHistKinds.has(fKey);
2551
+ return `<button type="button" class="brk-toggle small${on ? ' on' : ''}" data-hfilter="${fKey}" data-bid="${esc(a.id)}" title="${on ? 'Включён (нажмите, чтобы скрыть)' : 'Выключен (нажмите, чтобы показать)'}">${on ? '✓ ' : ''}${fLbl} (${fCnt})</button>`;
2552
+ }).join('')}
2553
+ ${!isAllHistActive ? `<button type="button" class="icon small" data-hfilter="all" data-bid="${esc(a.id)}" title="Включить обратно все типы операций">Показать все</button>` : ''}
2554
+ </div>`;
2555
+
2556
+ const curYearNum = new Date(Date.now() + MSK).getUTCFullYear();
2557
+ const curYearStr = String(curYearNum);
2558
+ openHistYears.add(`${a.id}:${curYearStr}`);
2559
+ const loadedYearsSet = new Set(Array.isArray(lh?.loadedYears) ? lh.loadedYears.map(String) : [...byYear.keys()]);
2560
+ const allYears = [];
2561
+ for (let y = curYearNum; y >= curYearNum - 4; y--) allYears.push(String(y));
2562
+ for (const y of byYear.keys()) if (!allYears.includes(y)) allYears.push(y);
2563
+
2564
+ const yearsHtml = allYears.map((y) => {
2565
+ const yKey = `${a.id}:${y}`;
2566
+ const isYearOpen = openHistYears.has(yKey);
2567
+ const isLoaded = loadedYearsSet.has(y) || byYear.has(y);
2568
+ const isLoading = loadingHistYears.has(yKey);
2569
+ const yOpsAll = byYear.get(y) || [];
2570
+ const yOps = byYearFiltered.get(y) || [];
2571
+ let yMeta = '';
2572
+ if (isLoading) {
2573
+ yMeta = 'загружаем операции с брокера…';
2574
+ } else if (isLoaded) {
2575
+ const yTrades = yOpsAll.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
2576
+ const yDivs = yOpsAll.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
2577
+ const fltSuffix = !isAllHistActive ? ` (показано: ${yOps.length})` : '';
2578
+ yMeta = yOpsAll.length
2579
+ ? `операций: ${yOpsAll.length}${fltSuffix}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`
2580
+ : 'операций за этот год нет';
2581
+ } else {
2582
+ yMeta = 'нажмите, чтобы подгрузить операции за этот год';
2583
+ }
2584
+ const bodyContent = isLoading
2585
+ ? '<p class="muted small" style="padding:10px 12px;margin:0">Запрашиваем выписку у брокера…</p>'
2586
+ : yOps.length
2587
+ ? renderOpsTable(yOps)
2588
+ : `<p class="muted small" style="padding:10px 12px;margin:0">${!isAllHistActive && yOpsAll.length ? 'Все операции за этот год скрыты фильтрами выше.' : 'За выбранный год операций по счёту не найдено.'}</p>`;
2589
+ return `<details class="hist-year-box" data-hyear="${esc(y)}" data-bid="${esc(a.id)}"${isYearOpen ? ' open' : ''}>
2590
+ <summary><span>📁 ${esc(y)} год</span><span class="muted small">${yMeta}</span></summary>
2591
+ ${bodyContent}
2592
+ </details>`;
2593
+ }).join('');
2594
+
2595
+ const histHtml = lh?.operations?.length || lh?.loadedYears?.length
2596
+ ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2597
+ <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
2126
2598
  <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2127
2599
  <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
2128
2600
  <div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
2129
2601
  </div>
2130
- <table><thead><tr><th>Дата</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${lh.operations.map((op) => `<tr>
2131
- <td class="muted small">${op.date ? fmtTs(op.date) : '—'}</td>
2132
- <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span> <span class="muted small">${esc(op.title !== op.kindLabel ? op.title : '')}</span></td>
2133
- <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2134
- <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2135
- <td class="num">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
2136
- <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
2137
- </tr>`).join('')}</tbody></table>`
2138
- : `${eqHistHtml}<p class="muted small">Нажмите <b>«Загрузить историю операций»</b>, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
2139
-
2140
- return `<div class="live-card" data-bid="${esc(a.id)}">
2141
- <div class="live-head">
2142
- <div>
2143
- <b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? ` <span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
2144
- <span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
2602
+ ${histFilterBarHtml}
2603
+ ${yearsHtml}`
2604
+ : '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
2605
+
2606
+ return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2607
+ <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
2608
+ <div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2609
+ <span class="acc-caret">▶</span>
2610
+ <b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
2145
2611
  <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2146
- <span class="tag">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2147
- ${ls?.marginEnabled === false ? '<span class="tag" title="На этом счёте у брокера отключена маржинальная торговля — стратегия автоматически работает в режиме «Только в рост (без шорта)»">только в рост (маржиналка выкл.)</span>' : ls?.marginEnabled === true ? '<span class="tag ok" title="Маржинальная торговля на счёте включена — доступны сделки в рост и в падение (с шортом)">рост + шорт доступны</span>' : ''}
2148
- ${a.autoSchedule !== false ? '<span class="tag ok" title="Активно и автоматически восстанавливается после перезапуска сервера">авто-торговля вкл.</span>' : ''}
2149
2612
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2150
2613
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2151
- <div class="muted small">Счёт: <b>${esc(a.accountId || ls?.accountId || 'авто')}</b> · Лимит стратегии: ${fmtNum(a.maxCapitalRub, 0)} ₽ · Дневной стоп: −${fmtNum(a.maxDailyDrawdownPct, 1)}% · Отступ лимитки: ${fmtNum(a.limitOffsetPct, 2)}%${a.baseRunLabel ? ` · Настройки ${esc(a.baseRunLabel)}` : ''}${ls?.at ? ` · Обновлён ${fmtTs(ls.at)}` : ''}</div>
2152
2614
  </div>
2153
- <div class="row-gap" style="flex-wrap:wrap">
2154
- <button type="button" data-btrade="sync" data-bid="${esc(a.id)}">Обновить портфель</button>
2155
- <button type="button" data-btrade="hist" data-bid="${esc(a.id)}">История операций</button>
2156
- ${lMode !== 'takeover' && ls?.positions?.length ? `<button type="button" data-btrade="freeze" data-bid="${esc(a.id)}" title="Запомнить все текущие бумаги на счёте как ваш личный старый портфель, который стратегия не будет продавать в режиме «Изолированный карман»">Защитить текущие бумаги</button>` : ''}
2157
- <button type="button" data-btrade="cfg" data-bid="${esc(a.id)}" title="Открыть настройки и предохранители этого брокерского счёта">⚙ Настроить счёт</button>
2158
- <button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}">Рассчитать заявки (в лотах)</button>
2615
+ <div class="row-gap" style="align-items:center">
2616
+ ${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>` : ''}
2617
+ <button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
2159
2618
  </div>
2160
2619
  </div>
2161
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2162
- <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2163
- <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2164
- <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2165
- <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2166
- </div>` : ''}
2167
- ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2168
- <div class="brk-subtabs">
2169
- <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2170
- <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2171
- <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2620
+ <div ${isOpen ? '' : 'hidden'}>
2621
+ <div class="brk-specs">
2622
+ <span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
2623
+ <span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
2624
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2625
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2626
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2627
+ <span class="k">Предохранители и настройки:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2628
+ </div>
2629
+ ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2630
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2631
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2632
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2633
+ <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>
2634
+ </div>` : ''}
2635
+ ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2636
+ <div class="brk-subtabs">
2637
+ <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2638
+ <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2639
+ <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2640
+ </div>
2641
+ <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2642
+ <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2643
+ <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2172
2644
  </div>
2173
- <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2174
- <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2175
- <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2176
2645
  </div>`;
2177
2646
  }).join('');
2178
2647
  }
@@ -2292,7 +2761,7 @@ $('#btn-broker-discover')?.addEventListener('click', async () => {
2292
2761
  box.hidden = false;
2293
2762
  box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg1)">
2294
2763
  <div>
2295
- <b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">шорт доступен</span>' : '<span class="tag">только в рост (без шорта)</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
2764
+ <b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
2296
2765
  <div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
2297
2766
  </div>
2298
2767
  <button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
@@ -2339,18 +2808,35 @@ brokerForm.addEventListener('submit', async (e) => {
2339
2808
  resetHalt: true,
2340
2809
  };
2341
2810
  try {
2811
+ const savedId = editingBrokerId;
2342
2812
  state.brokers = editingBrokerId
2343
2813
  ? await api(`/api/brokers/${encodeURIComponent(editingBrokerId)}`, { method: 'PUT', body })
2344
2814
  : await post('/api/brokers', body);
2815
+ const targetId = savedId || state.brokers?.accounts?.[state.brokers.accounts.length - 1]?.id;
2345
2816
  stopBrokerEdit();
2346
2817
  renderBrokers();
2347
- toast('Брокерский счёт сохранён');
2818
+ toast('Настройки счёта сохранены — пересчитываем портфель и заявки…');
2819
+ if (targetId) {
2820
+ post(`/api/brokers/${encodeURIComponent(targetId)}/sync`)
2821
+ .then(() => post(`/api/brokers/${encodeURIComponent(targetId)}/prepare`))
2822
+ .then((st) => { state.brokers = st; renderBrokers(); })
2823
+ .catch(() => {});
2824
+ }
2348
2825
  } catch (err) { toast(err.message, 'error'); }
2349
2826
  });
2350
2827
 
2351
2828
  $('#brokers-list').addEventListener('click', async (e) => {
2352
2829
  const b = e.target.closest('button[data-bact]');
2353
- if (!b) return;
2830
+ if (!b) {
2831
+ const fold = e.target.closest('[data-bcfg-fold]');
2832
+ if (fold) {
2833
+ const fid = fold.dataset.bcfgFold;
2834
+ openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
2835
+ saveNavPatch({ openBrokerCfgId });
2836
+ renderBrokers();
2837
+ }
2838
+ return;
2839
+ }
2354
2840
  const id = b.closest('[data-bid]').dataset.bid;
2355
2841
  const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2356
2842
  if (!acc) return;
@@ -2382,22 +2868,74 @@ $('#brokers-list').addEventListener('click', async (e) => {
2382
2868
  });
2383
2869
 
2384
2870
  $('#brokers-trade').addEventListener('click', async (e) => {
2871
+ const fltBtn = e.target.closest('button[data-hfilter]');
2872
+ if (fltBtn) {
2873
+ toggleBrokerHistFilter(fltBtn.dataset.bid, fltBtn.dataset.hfilter);
2874
+ renderBrokers();
2875
+ return;
2876
+ }
2877
+ const yearSummary = e.target.closest('.hist-year-box > summary');
2878
+ if (yearSummary) {
2879
+ const det = yearSummary.parentElement;
2880
+ const id = det?.dataset?.bid;
2881
+ const y = det?.dataset?.hyear;
2882
+ if (id && y) {
2883
+ setTimeout(() => {
2884
+ const yKey = `${id}:${y}`;
2885
+ if (det.open) {
2886
+ openHistYears.add(yKey);
2887
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2888
+ const loaded = Array.isArray(acc?.lastHistory?.loadedYears) && acc.lastHistory.loadedYears.map(String).includes(String(y));
2889
+ const hasOps = (acc?.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).toISOString().slice(0, 4) === String(y));
2890
+ if (!loaded && !hasOps && !loadingHistYears.has(yKey)) {
2891
+ loadingHistYears.add(yKey);
2892
+ renderBrokers();
2893
+ post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: Number(y) })
2894
+ .then((st) => { state.brokers = st; })
2895
+ .catch((err) => toast(err.message, 'error'))
2896
+ .finally(() => { loadingHistYears.delete(yKey); renderBrokers(); });
2897
+ }
2898
+ } else {
2899
+ openHistYears.delete(yKey);
2900
+ }
2901
+ }, 0);
2902
+ }
2903
+ return;
2904
+ }
2385
2905
  const tabBtn = e.target.closest('button[data-btab]');
2386
2906
  if (tabBtn) {
2387
2907
  const id = tabBtn.dataset.bid;
2388
2908
  const t = tabBtn.dataset.btab;
2389
- brokerTabById.set(id, t);
2909
+ setBrokerSubTab(id, t);
2910
+ if (t === 'trade') {
2911
+ preparingBrokerIds.add(id);
2912
+ renderBrokers();
2913
+ post(`/api/brokers/${encodeURIComponent(id)}/prepare`)
2914
+ .then((st) => { state.brokers = st; })
2915
+ .catch((err) => toast(err.message, 'error'))
2916
+ .finally(() => { preparingBrokerIds.delete(id); renderBrokers(); });
2917
+ return;
2918
+ }
2390
2919
  renderBrokers();
2391
2920
  if (t === 'history') {
2392
- const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2393
- if (acc && !acc.lastHistory) {
2394
- post(`/api/brokers/${encodeURIComponent(id)}/history`).then((st) => { state.brokers = st; renderBrokers(); }).catch((err) => toast(err.message, 'error'));
2395
- }
2921
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
2922
+ post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: curYear })
2923
+ .then((st) => { state.brokers = st; renderBrokers(); })
2924
+ .catch((err) => toast(err.message, 'error'));
2396
2925
  }
2397
2926
  return;
2398
2927
  }
2399
2928
  const b = e.target.closest('button[data-btrade]');
2400
- if (!b) return;
2929
+ if (!b) {
2930
+ const fold = e.target.closest('[data-brk-fold]');
2931
+ if (fold) {
2932
+ const fid = fold.dataset.brkFold;
2933
+ openBrokerId = openBrokerId === fid ? null : fid;
2934
+ saveNavPatch({ openBrokerId });
2935
+ renderBrokers();
2936
+ }
2937
+ return;
2938
+ }
2401
2939
  const id = b.dataset.bid;
2402
2940
  const act = b.dataset.btrade;
2403
2941
  if (act === 'cfg') {
@@ -2414,21 +2952,24 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2414
2952
  const old = b.textContent;
2415
2953
  try {
2416
2954
  if (act === 'sync') {
2417
- b.textContent = 'Обновляем…';
2955
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
2418
2956
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
2419
2957
  toast('Портфель обновлён');
2420
2958
  } else if (act === 'hist') {
2421
- b.textContent = 'Загружаем…';
2422
- brokerTabById.set(id, 'history');
2959
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Загружаем…';
2960
+ setBrokerSubTab(id, 'history');
2423
2961
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
2424
2962
  toast('История операций загружена');
2425
2963
  } else if (act === 'freeze') {
2426
- b.textContent = 'Фиксируем…';
2427
2964
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
2428
- toast('Текущие бумаги счёта зафиксированы как неприкосновенный старый портфель');
2965
+ const updated = (state.brokers?.accounts || []).find((x) => x.id === id);
2966
+ const isIso = updated?.legacyMode === 'isolate';
2967
+ toast(isIso
2968
+ ? '🔒 Защита бумаг включена (режим «Изолированный карман» — старые бумаги не продаются)'
2969
+ : '🔓 Включён режим «Плавный переход» (разрешено плавное замещение старых бумаг под сигналы)');
2429
2970
  } else if (act === 'prep') {
2430
- b.textContent = 'Считаем лоты…';
2431
- brokerTabById.set(id, 'trade');
2971
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Считаем лоты…';
2972
+ setBrokerSubTab(id, 'trade');
2432
2973
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
2433
2974
  toast('Пакет заявок по прогнозу рассчитан');
2434
2975
  } else if (act === 'exec') {
@@ -2611,7 +3152,42 @@ function connectWs() {
2611
3152
  };
2612
3153
  }
2613
3154
 
2614
- // ---------- профиль, тарифы ЮKassa, боты Telegram/MAX и админ-панель ----------
3155
+ // ---------- окно подписания Договора-оферты в терминале ----------
3156
+ let termOfferCallback = null;
3157
+ const termOfferModal = $('#offer-modal');
3158
+ const termChk1 = $('#term-offer-chk1');
3159
+ const termChk2 = $('#term-offer-chk2');
3160
+ const termOfferAccept = $('#term-offer-accept');
3161
+
3162
+ function syncTermOfferBtn() {
3163
+ if (termOfferAccept) termOfferAccept.disabled = !(termChk1?.checked && termChk2?.checked);
3164
+ }
3165
+ termChk1?.addEventListener('change', syncTermOfferBtn);
3166
+ termChk2?.addEventListener('change', syncTermOfferBtn);
3167
+ $('#offer-close')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
3168
+ $('#term-offer-decline')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
3169
+ termOfferAccept?.addEventListener('click', async () => {
3170
+ termOfferAccept.disabled = true;
3171
+ try {
3172
+ await post('/api/auth/accept-offer').catch(() => {});
3173
+ termOfferModal.hidden = true;
3174
+ if (termOfferCallback) await termOfferCallback();
3175
+ } finally {
3176
+ termOfferCallback = null;
3177
+ syncTermOfferBtn();
3178
+ }
3179
+ });
3180
+
3181
+ function requireTermOffer(fn) {
3182
+ termOfferCallback = fn;
3183
+ if (termChk1) termChk1.checked = false;
3184
+ if (termChk2) termChk2.checked = false;
3185
+ syncTermOfferBtn();
3186
+ if (termOfferModal) termOfferModal.hidden = false;
3187
+ else fn();
3188
+ }
3189
+
3190
+ // ---------- профиль, тарифы ЮKassa, VIP-токен и боты Telegram/MAX ----------
2615
3191
  $('#btn-user')?.addEventListener('click', () => {
2616
3192
  switchTab('settings');
2617
3193
  showSettingsSec('cloud');
@@ -2652,7 +3228,7 @@ async function renderCloudPane() {
2652
3228
  adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
2653
3229
  <div class="stxt">
2654
3230
  <div class="slbl">Консоль администратора платформы</div>
2655
- <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, OAuth-провайдерами (Яндекс, Сбер, Т-Банк, VK) и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
3231
+ <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
2656
3232
  </div>
2657
3233
  <div class="row-gap" style="flex-wrap:wrap">
2658
3234
  <a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
@@ -2660,9 +3236,9 @@ async function renderCloudPane() {
2660
3236
  </div>`;
2661
3237
  } else {
2662
3238
  customBotsHtml = `<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
2663
- <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX (необязательно — для автономного локального сервера) и консоль админа</summary>
3239
+ <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
2664
3240
  <form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
2665
- <div class="muted small">Если вы запускаете сервер локально и хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
3241
+ <div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
2666
3242
  <div class="row-gap" style="flex-wrap:wrap">
2667
3243
  <input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
2668
3244
  <input name="tgToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="${nt.hasTgToken ? 'Токен Telegram задан (оставьте пустым)' : 'Токен Telegram Bot API'}" style="flex:1;min-width:220px">
@@ -2672,63 +3248,51 @@ async function renderCloudPane() {
2672
3248
  <input name="maxToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="${nt.hasMaxToken ? 'Токен MAX задан (оставьте пустым)' : 'Токен MAX Bot API'}" style="flex:1;min-width:220px">
2673
3249
  <button type="submit" class="primary">Сохранить ботов</button>
2674
3250
  </div>
2675
- <div class="muted small" style="margin-top:4px">Администрирование SaaS-портала (ЮKassa, пользователи, промокоды, OAuth) доступно в отдельном приложении: <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
3251
+ <div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
2676
3252
  </form>
2677
3253
  </details>`;
2678
3254
  }
2679
3255
  }
2680
3256
 
2681
- const apiKey = u.apiKey || '';
2682
- const npxCmd = apiKey ? `npx neuroalpha --key ${apiKey}` : 'npx neuroalpha';
3257
+ const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3258
+ ? 'без ограничений суммы'
3259
+ : u.limits?.maxCapitalPerAccountRub > 0
3260
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
3261
+ : 'только демо-торговля';
3262
+ const retLimStr = u.limits?.maxAnnualReturnPct > 0
3263
+ ? `до ${u.limits.maxAnnualReturnPct}% годовых`
3264
+ : 'без ограничений (полная мощность VIP)';
3265
+ const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
2683
3266
 
2684
3267
  box.innerHTML = `
2685
3268
  <div class="srow sblock">
2686
3269
  <div class="stxt">
2687
3270
  <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>
2688
- <div class="hint">Лимиты тарифа: до ${u.limits?.maxRunsPerDay ?? 2} экспериментов в сутки · виртуальных счетов: до ${u.limits?.maxVirtualAccounts ?? 1} · брокерских счетов: до ${u.limits?.maxBrokerAccounts ?? 0}${u.planUntil ? ` · оплачен до ${fmtDate(u.planUntil)}` : ''}.</div>
3271
+ <div class="hint">
3272
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Предел доходности стратегии: <b>${retLimStr}</b> · Виртуальных счетов: до <b>${u.limits?.maxVirtualAccounts ?? 1}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3273
+ </div>
2689
3274
  </div>
2690
3275
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
2691
3276
  <button type="button" id="btn-cloud-verify">Проверить оплату</button>
2692
3277
  <button type="button" id="btn-cloud-logout">Выйти</button>
2693
3278
  </div>` : ''}
2694
3279
  </div>
2695
- ${!me.cloudMode ? `<div class="srow sblock">
2696
- <div class="stxt">
2697
- <div class="slbl">Лицензионный ключ подписки с <a href="https://neuroalpha.odant.org" target="_blank" rel="noopener" style="color:var(--accent)">neuroalpha.odant.org</a></div>
2698
- <div class="hint">
2699
- Вставьте персональный ключ <code>na_live_…</code> из личного кабинета на сайте для активации вашего тарифа и облачного ИИ на этом компьютере.
2700
- ${lic ? `<br><span class="pos">✓ Активирована лицензия: <b>${esc(lic.planName)}</b>${dispEmail ? ` (${esc(dispEmail)})` : ''}</span>` : ''}
2701
- </div>
2702
- </div>
2703
- <div class="row-gap" style="flex-wrap:wrap">
2704
- <input id="lic-key-in" value="${esc(lic?.apiKey || '')}" placeholder="na_live_…" style="width:280px">
2705
- <button type="button" id="btn-lic-activate" class="primary">Активировать ключ</button>
2706
- <a class="btn" href="https://neuroalpha.odant.org/#pricing" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Получить ключ →</a>
2707
- </div>
2708
- </div>` : `<div class="srow sblock">
3280
+ <div class="srow sblock">
2709
3281
  <div class="stxt">
2710
- <div class="slbl">Ваш персональный API-ключ (для запуска на вашем ПК через <code>npx</code>)</div>
3282
+ <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
2711
3283
  <div class="hint">
2712
- Ключ: <b><code id="user-api-key">${esc(apiKey || 'не создан')}</code></b><br>
2713
- Команда для запуска на вашем ПК (ключи брокера остаются локально): <code>${esc(npxCmd)}</code>
3284
+ • <b>«Старт» (0 ₽)</b> — тестирование стратегий любой сложности без реальной торговли;<br>
3285
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — до 2 брокерских счетов (до 1 млн ₽ на каждом), торговля «Только в рост (без шорта)», до 50% годовых;<br>
3286
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов (до 10 млн ₽ на каждом), «В рост и в падение (с шортом)», до 100% годовых;<br>
3287
+ • <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
2714
3288
  </div>
2715
3289
  </div>
2716
3290
  <div class="row-gap" style="flex-wrap:wrap">
2717
- <button type="button" id="btn-copy-apikey" class="primary">Скопировать ключ</button>
2718
- <button type="button" id="btn-copy-npx">Скопировать npx-команду</button>
2719
- <button type="button" id="btn-regen-apikey" title="Перевыпустить API-ключ (старый перестанет работать)">Перевыпустить</button>
2720
- </div>
2721
- </div>`}
2722
- <div class="srow sblock">
2723
- <div class="stxt">
2724
- <div class="slbl">Тариф и промокод</div>
2725
- <div class="hint">Подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram / MAX и расширенных лимитов ИИ.</div>
2726
- </div>
2727
- <div class="row-gap" style="flex-wrap:wrap">
2728
- <input id="promo-code-in" placeholder="Промокод" style="width:150px">
2729
- <button type="button" id="btn-promo-redeem">Применить код</button>
3291
+ <input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
3292
+ <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
2730
3293
  <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
2731
3294
  <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
3295
+ <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Запросить VIP →</a>
2732
3296
  </div>
2733
3297
  </div>
2734
3298
  <div class="srow sblock">
@@ -2743,39 +3307,13 @@ async function renderCloudPane() {
2743
3307
  ${tgUrl ? `<a class="btn" href="${esc(tgUrl)}" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px">Открыть Telegram</a>` : ''}
2744
3308
  ${maxUrl ? `<a class="btn" href="${esc(maxUrl)}" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px">Открыть MAX</a>` : ''}
2745
3309
  <button type="button" id="btn-notify-test">Тест уведомления</button>
3310
+ <a class="btn" href="/legal" target="_blank" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Договор-оферта →</a>
2746
3311
  </div>
2747
3312
  </div>
2748
3313
  ${adminLinkHtml}
2749
3314
  ${customBotsHtml}
2750
3315
  `;
2751
3316
 
2752
- box.querySelector('#btn-copy-apikey')?.addEventListener('click', () => {
2753
- if (!apiKey) return;
2754
- navigator.clipboard?.writeText(apiKey);
2755
- toast('API-ключ скопирован в буфер обмена');
2756
- });
2757
- box.querySelector('#btn-copy-npx')?.addEventListener('click', () => {
2758
- navigator.clipboard?.writeText(npxCmd);
2759
- toast(`Команда «${npxCmd}» скопирована`);
2760
- });
2761
- box.querySelector('#btn-regen-apikey')?.addEventListener('click', async () => {
2762
- if (!confirm('Перевыпустить API-ключ? Прежний ключ перестанет работать.')) return;
2763
- try {
2764
- await post('/api/auth/api-key/regenerate');
2765
- toast('Новый API-ключ выпущен');
2766
- renderCloudPane();
2767
- } catch (e) { toast(e.message, 'error'); }
2768
- });
2769
- box.querySelector('#btn-lic-activate')?.addEventListener('click', async () => {
2770
- const k = box.querySelector('#lic-key-in')?.value.trim();
2771
- if (!k) return toast('Введите API-ключ (na_live_…)', 'error');
2772
- try {
2773
- const r = await post('/api/license/activate', { apiKey: k });
2774
- toast(`Ключ активирован! Тариф: ${r.license?.planName}`);
2775
- renderCloudPane();
2776
- } catch (e) { toast(e.message, 'error'); }
2777
- });
2778
-
2779
3317
  box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
2780
3318
  await post('/api/auth/logout');
2781
3319
  location.href = '/landing';
@@ -2787,11 +3325,14 @@ async function renderCloudPane() {
2787
3325
  renderCloudPane();
2788
3326
  } catch (e) { toast(e.message, 'error'); }
2789
3327
  });
2790
- box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', async () => {
2791
- try {
2792
- const r = await post('/api/billing/checkout', { plan: b.dataset.checkout, months: 1, returnOrigin: location.origin });
2793
- if (r.confirmationUrl) location.href = r.confirmationUrl;
2794
- } catch (e) { toast(e.message, 'error'); }
3328
+ box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', () => {
3329
+ const plan = b.dataset.checkout;
3330
+ requireTermOffer(async () => {
3331
+ try {
3332
+ const r = await post('/api/billing/checkout', { plan, months: 1, returnOrigin: location.origin });
3333
+ if (r.confirmationUrl) location.href = r.confirmationUrl;
3334
+ } catch (e) { toast(e.message, 'error'); }
3335
+ });
2795
3336
  }));
2796
3337
  box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
2797
3338
  try {
@@ -2799,14 +3340,16 @@ async function renderCloudPane() {
2799
3340
  toast('Тестовое уведомление отправлено в привязанные мессенджеры');
2800
3341
  } catch (e) { toast(e.message, 'error'); }
2801
3342
  });
2802
- box.querySelector('#btn-promo-redeem')?.addEventListener('click', async () => {
3343
+ box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
2803
3344
  const code = box.querySelector('#promo-code-in')?.value.trim();
2804
- if (!code) return;
2805
- try {
2806
- const r = await post('/api/auth/redeem-promo', { code });
2807
- toast(`Промокод активирован! Ваш тариф: ${r.user?.planName}`);
2808
- renderCloudPane();
2809
- } catch (e) { toast(e.message, 'error'); }
3345
+ if (!code) return toast('Введите промокод или персональный VIP-токен', 'error');
3346
+ requireTermOffer(async () => {
3347
+ try {
3348
+ const r = await post('/api/auth/redeem-promo', { code });
3349
+ toast(`Код активирован! Ваш тариф: ${r.user?.planName}`);
3350
+ renderCloudPane();
3351
+ } catch (e) { toast(e.message, 'error'); }
3352
+ });
2810
3353
  });
2811
3354
  box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
2812
3355
  e.preventDefault();
@@ -2876,7 +3419,7 @@ document.addEventListener('keydown', (e) => {
2876
3419
  }
2877
3420
  });
2878
3421
 
2879
- // ---------- старт ----------
3422
+ // ---------- старт и восстановление сохранённого места в интерфейсе ----------
2880
3423
  (async function init() {
2881
3424
  state.config = await api('/api/config');
2882
3425
  buildForm();
@@ -2895,6 +3438,17 @@ document.addEventListener('keydown', (e) => {
2895
3438
  renderCloudPane();
2896
3439
  }).catch(() => {});
2897
3440
  }
2898
- const initRunId = Number(urlParams.get('run')) || undefined;
3441
+ const savedNav = loadSavedNav();
3442
+ const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
2899
3443
  await loadRuns(initRunId);
3444
+
3445
+ // Восстанавливаем открытые подразделы и главную вкладку, где находился пользователь
3446
+ if (savedNav.nowSec) showNowSec(savedNav.nowSec);
3447
+ if (savedNav.settingsSec) showSettingsSec(savedNav.settingsSec);
3448
+ if (savedNav.mapSec) showMapSec(savedNav.mapSec).catch(() => {});
3449
+ if (savedNav.tab && ['overview', 'now', 'map', 'settings'].includes(savedNav.tab) && !urlParams.get('run')) {
3450
+ switchTab(savedNav.tab);
3451
+ }
3452
+ // В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
3453
+ setTimeout(() => refreshForecastIfStale(), 600);
2900
3454
  })().catch((e) => toast(e.message, 'error'));