neuroalpha 0.3.12 → 0.3.13

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 +524 -211
  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 +61 -29
  15. package/server/auth/index.js +109 -20
  16. package/server/billing/yookassa.js +1 -1
  17. package/server/brokers/index.js +179 -34
  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 +188 -17
  27. package/server/model/linalg.js +1 -1
  28. package/server/model/live.js +6 -19
  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 +12 -36
  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,92 @@ $('#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;
892
1023
  function renderLive(st) {
893
1024
  state.live = st;
894
1025
  const accs = st?.accounts || [];
@@ -898,13 +1029,17 @@ function renderLive(st) {
898
1029
  $('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
899
1030
  return;
900
1031
  }
1032
+ if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
1033
+ openLiveId = accs[0].runId;
1034
+ }
901
1035
  $('#live').innerHTML = accs.map((a) => {
1036
+ const isOpen = String(a.runId) === String(openLiveId);
902
1037
  const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
903
1038
  const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
904
1039
  const retPct = a.returnPct ?? 0;
905
1040
  const step = a.recent?.[0];
906
1041
  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' : 'в рост и в падение'}` : ' · только в рост'}`;
1042
+ const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
908
1043
  let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
909
1044
  if (a.corridor) {
910
1045
  const c = a.corridor;
@@ -917,29 +1052,33 @@ function renderLive(st) {
917
1052
  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
1053
  }
919
1054
  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>
1055
+ return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
1056
+ <div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
1057
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
1058
+ <span class="acc-caret">▶</span>
923
1059
  <b>${esc(dispName)}</b>
924
- <span class="tag ${a.running ? 'ok' : ''}" style="margin-left:6px">${a.running ? 'работает' : 'остановлен'}</span>
1060
+ <span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
925
1061
  ${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
1062
  </div>
928
- <div class="row-gap">
1063
+ <div class="row-gap" style="align-items:center">
1064
+ <span class="small muted">На счёте: <b>${fmtNum(s.equity, 0)} ₽</b> (<span class="${cls(pnl)}">${fmtPct(retPct, 2)}</span>) · поз.: ${s.positions.length}</span>
929
1065
  <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
930
1066
  ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
931
1067
  <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
932
1068
  </div>
933
1069
  </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>
1070
+ <div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
1071
+ <div class="muted small" style="margin-bottom:8px">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
1072
+ <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
1073
+ <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">на счёте</div></div>
1074
+ <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>
1075
+ ${corrHtml}
1076
+ <div class="kpi"><div class="v">${a.metrics?.full?.trades ?? 0}</div><div class="l">закрыто сделок</div></div>
1077
+ </div>
1078
+ <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>
1079
+ ${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>`}
1080
+ ${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
939
1081
  </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
1082
  </div>`;
944
1083
  }).join('');
945
1084
  syncUiAvailability();
@@ -981,6 +1120,14 @@ $('#live').addEventListener('click', async (e) => {
981
1120
  const id = Number(askBtn.dataset.liveAsk);
982
1121
  const acc = (state.live?.accounts || []).find((x) => x.runId === id);
983
1122
  sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
1123
+ return;
1124
+ }
1125
+ const foldHead = e.target.closest('[data-live-fold]');
1126
+ if (foldHead) {
1127
+ const id = foldHead.dataset.liveFold;
1128
+ openLiveId = String(openLiveId) === String(id) ? null : id;
1129
+ saveNavPatch({ openLiveId });
1130
+ renderLive(state.live);
984
1131
  }
985
1132
  });
986
1133
 
@@ -1138,6 +1285,7 @@ function drawProfilesMap() {
1138
1285
  }
1139
1286
  async function showMapSec(sec) {
1140
1287
  document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
1288
+ saveNavPatch({ mapSec: sec });
1141
1289
  if (sec === 'journal') {
1142
1290
  $('#map-sec').hidden = true;
1143
1291
  $('#journal-box').hidden = false;
@@ -1215,6 +1363,8 @@ function showSettingsSec(sec) {
1215
1363
  document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
1216
1364
  document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
1217
1365
  document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
1366
+ saveNavPatch({ settingsSec: sec });
1367
+ if (sec === 'cloud') renderCloudPane().catch(() => {});
1218
1368
  }
1219
1369
  document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
1220
1370
  const b = e.target.closest('[data-sec]');
@@ -1961,7 +2111,13 @@ const INST_KIND_LABEL = {
1961
2111
  futures: ['Фьючерс', 'mid'],
1962
2112
  };
1963
2113
  const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
1964
- const brokerTabById = new Map(); // accId -> 'assets' | 'trade' | 'history'
2114
+ const brokerTabById = new Map(Object.entries(loadSavedNav().brokerTabs || {})); // accId -> 'assets' | 'trade' | 'history'
2115
+ function setBrokerSubTab(id, tab) {
2116
+ brokerTabById.set(id, tab);
2117
+ saveNavPatch({ brokerTabs: Object.fromEntries(brokerTabById) });
2118
+ }
2119
+ let openBrokerId = loadSavedNav().openBrokerId ?? null;
2120
+ let openBrokerCfgId = loadSavedNav().openBrokerCfgId ?? null;
1965
2121
  let editingBrokerId = null;
1966
2122
 
1967
2123
  async function loadBrokers() {
@@ -1976,40 +2132,58 @@ function renderBrokers() {
1976
2132
  if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
1977
2133
  updateTabBadges();
1978
2134
 
1979
- // 1) список в Настройках
2135
+ // 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
1980
2136
  const listEl = $('#brokers-list');
1981
2137
  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>
2138
+ if (accs.length && !accs.some((a) => a.id === openBrokerCfgId)) {
2139
+ openBrokerCfgId = accs[0].id;
2140
+ }
2141
+ listEl.innerHTML = accs.length ? accs.map((a) => {
2142
+ const ls = a.lastSync;
2143
+ const isCfgOpen = a.id === openBrokerCfgId;
2144
+ return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2145
+ <div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
2146
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2147
+ <span class="acc-caret">▶</span>
2148
+ <b>${esc(a.name)}</b>
2149
+ <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2150
+ ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2151
+ ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
2152
+ </div>
2153
+ <div class="row-gap" style="align-items:center">
2154
+ ${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
2155
+ <button type="button" data-bact="sync">Проверить связь</button>
2156
+ <button type="button" data-bact="edit" class="icon">Изменить</button>
2157
+ <button type="button" data-bact="del" class="icon">Удалить</button>
2158
+ </div>
1996
2159
  </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>`}` : ''}
2160
+ <div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
2161
+ <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>
2162
+ <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>
2163
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
2164
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2165
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2166
+ <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>
2167
+ ${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
2168
+ ${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
2169
  </div>
2001
- </div>`).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
2170
+ </div>`;
2171
+ }).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
2002
2172
  }
2003
2173
 
2004
- // 2) карточки во вкладке «Торговля → Брокерские счета» (с 3 вкладками: Активы · Торговля · История)
2174
+ // 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
2005
2175
  const tradeEl = $('#brokers-trade');
2006
2176
  if (!tradeEl) return;
2007
2177
  if (!accs.length) {
2008
2178
  tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
2009
2179
  return;
2010
2180
  }
2181
+ if (!accs.some((a) => a.id === openBrokerId)) {
2182
+ openBrokerId = accs[0].id;
2183
+ }
2011
2184
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
2012
2185
  tradeEl.innerHTML = accs.map((a) => {
2186
+ const isOpen = a.id === openBrokerId;
2013
2187
  const ls = a.lastSync;
2014
2188
  const pb = a.pendingBatch;
2015
2189
  const lh = a.lastHistory;
@@ -2100,16 +2274,19 @@ function renderBrokers() {
2100
2274
  const resHtml = pb.results?.length
2101
2275
  ? `<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
2276
  : '';
2277
+ const throttleHtml = pb.planThrottleNote
2278
+ ? `<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>`
2279
+ : '';
2103
2280
  batchHtml = `<div class="stab-box">
2104
2281
  <div class="stab-head">
2105
2282
  <b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
2106
2283
  ${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
2107
2284
  <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>
2285
+ ${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
2286
  </div>
2111
2287
  ${ordRows}
2112
2288
  ${resHtml}
2289
+ ${throttleHtml}
2113
2290
  </div>`;
2114
2291
  } else {
2115
2292
  batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
@@ -2120,6 +2297,104 @@ function renderBrokers() {
2120
2297
  const eqHistHtml = eqHist.length > 1
2121
2298
  ? `<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
2299
  : '';
2300
+ const renderOpsTable = (opsList) => {
2301
+ const byDay = new Map();
2302
+ for (const op of opsList) {
2303
+ const dayKey = op.date ? new Date(op.date + MSK).toISOString().slice(0, 10) : 'none';
2304
+ if (!byDay.has(dayKey)) byDay.set(dayKey, []);
2305
+ byDay.get(dayKey).push(op);
2306
+ }
2307
+ const rows = [];
2308
+ for (const [, dayOps] of byDay) {
2309
+ let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2310
+ const symQty = new Map(), symCash = new Map();
2311
+ for (const op of dayOps) {
2312
+ const pay = Number(op.paymentRub) || 0;
2313
+ if (op.kind === 'buy') {
2314
+ tradesCnt++;
2315
+ buyRub += Math.abs(pay);
2316
+ if (op.symbol) {
2317
+ symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2318
+ symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2319
+ }
2320
+ } else if (op.kind === 'sell') {
2321
+ tradesCnt++;
2322
+ sellRub += Math.abs(pay);
2323
+ if (op.symbol) {
2324
+ symQty.set(op.symbol, (symQty.get(op.symbol) || 0) - (Number(op.qty) || 0));
2325
+ symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2326
+ }
2327
+ } else if (op.kind === 'fee') {
2328
+ if (/дивиденд|купон/i.test(op.title || '')) divTaxRub += pay;
2329
+ else feeRub += pay;
2330
+ } else if (op.kind === 'div') {
2331
+ divTaxRub += pay;
2332
+ } else if (op.kind === 'in' || op.kind === 'out') {
2333
+ inOutRub += pay;
2334
+ }
2335
+ }
2336
+ // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
2337
+ let closedTradePnl = 0, closedSyms = 0;
2338
+ for (const [sym, netQ] of symQty) {
2339
+ if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2340
+ closedTradePnl += symCash.get(sym);
2341
+ closedSyms++;
2342
+ }
2343
+ }
2344
+ if (closedSyms > 0) closedTradePnl += feeRub;
2345
+
2346
+ const summaryParts = [];
2347
+ if (closedSyms > 0) {
2348
+ summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
2349
+ } else if (tradesCnt > 0) {
2350
+ const tParts = [];
2351
+ if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
2352
+ if (sellRub > 0) tParts.push(`продано на ${fmtNum(sellRub, 0)} ₽`);
2353
+ if (feeRub !== 0) tParts.push(`комиссия ${fmtNum(feeRub, 2)} ₽`);
2354
+ summaryParts.push(tParts.join(' · '));
2355
+ } else if (feeRub !== 0) {
2356
+ summaryParts.push(`комиссии и налоги: <span class="neg">${fmtNum(feeRub, 2)} ₽</span>`);
2357
+ }
2358
+ if (divTaxRub !== 0) {
2359
+ summaryParts.push(`дивиденды/купоны (чистыми): <b class="${cls(divTaxRub)}">${divTaxRub > 0 ? '+' : ''}${fmtNum(divTaxRub, 2)} ₽</b>`);
2360
+ }
2361
+ if (inOutRub !== 0) {
2362
+ summaryParts.push(`${inOutRub > 0 ? 'пополнение' : 'вывод'}: <b class="${cls(inOutRub)}">${inOutRub > 0 ? '+' : ''}${fmtNum(inOutRub, 0)} ₽</b>`);
2363
+ }
2364
+
2365
+ 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>`);
2366
+ for (const op of dayOps) {
2367
+ rows.push(`<tr>
2368
+ <td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
2369
+ <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>
2370
+ <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2371
+ <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2372
+ <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
2373
+ <td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
2374
+ </tr>`);
2375
+ }
2376
+ }
2377
+ 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>`;
2378
+ };
2379
+
2380
+ const byYear = new Map();
2381
+ for (const op of lh?.operations || []) {
2382
+ const y = op.date ? new Date(op.date + MSK).toISOString().slice(0, 4) : 'Прочее';
2383
+ if (!byYear.has(y)) byYear.set(y, []);
2384
+ byYear.get(y).push(op);
2385
+ }
2386
+ const years = [...byYear.keys()];
2387
+ const yearsHtml = years.map((y, idx) => {
2388
+ const yOps = byYear.get(y);
2389
+ const yTrades = yOps.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
2390
+ const yDivs = yOps.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
2391
+ const yMeta = `операций: ${yOps.length}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`;
2392
+ return `<details class="hist-year-box"${idx === 0 ? ' open' : ''}>
2393
+ <summary><span>📁 ${esc(y)} год</span><span class="muted small">${yMeta}</span></summary>
2394
+ ${renderOpsTable(yOps)}
2395
+ </details>`;
2396
+ }).join('');
2397
+
2123
2398
  const histHtml = lh?.operations?.length
2124
2399
  ? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2125
2400
  <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
@@ -2127,52 +2402,56 @@ function renderBrokers() {
2127
2402
  <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
2128
2403
  <div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
2129
2404
  </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>
2405
+ ${yearsHtml}`
2406
+ : `${eqHistHtml}<p class="muted small">Нажмите <b>«↻»</b> или переключитесь сюда, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
2407
+
2408
+ return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2409
+ <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
2410
+ <div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2411
+ <span class="acc-caret">▶</span>
2412
+ <b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
2145
2413
  <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
2414
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2150
2415
  ${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
2416
  </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>
2417
+ <div class="row-gap" style="align-items:center">
2418
+ ${ls?.ok ? `<span class="small muted">Всего: <b>${fmtNum(ls.totalRub, 0)} ₽</b> · свободно: <b class="pos">${fmtNum(ls.cashRub, 0)} ₽</b> · поз.: ${ls.positions?.length || 0}</span>` : ''}
2159
2419
  </div>
2160
2420
  </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>
2421
+ <div ${isOpen ? '' : 'hidden'}>
2422
+ <div class="brk-specs">
2423
+ <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>
2424
+ <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>
2425
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2426
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2427
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2428
+ <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>
2429
+ </div>
2430
+ <div class="brk-toolbar">
2431
+ ${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>` : ''}
2432
+ ${a.autoSchedule === false ? `<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}" title="Авто-торговля по расписанию выключена — нажмите для ручного расчёта заявок">⚡ Рассчитать заявки</button>` : ''}
2433
+ <span class="grow"></span>
2434
+ ${a.autoSchedule !== false ? `<button type="button" class="brk-icon-btn" data-btrade="prep" data-bid="${esc(a.id)}" title="Внеочередной пересчёт пакета заявок прямо сейчас (помимо авто-расписания)">⚡</button>` : ''}
2435
+ <button type="button" class="brk-icon-btn" data-btrade="sync" data-bid="${esc(a.id)}" title="Обновить портфель и баланс с брокера">↻</button>
2436
+ <button type="button" class="brk-icon-btn" data-btrade="hist" data-bid="${esc(a.id)}" title="Обновить историю операций по счёту">📜</button>
2437
+ <button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
2438
+ </div>
2439
+ ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2440
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2441
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2442
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2443
+ <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>
2444
+ </div>` : ''}
2445
+ ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2446
+ <div class="brk-subtabs">
2447
+ <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2448
+ <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2449
+ <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2450
+ </div>
2451
+ <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2452
+ <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2453
+ <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2172
2454
  </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
2455
  </div>`;
2177
2456
  }).join('');
2178
2457
  }
@@ -2292,7 +2571,7 @@ $('#btn-broker-discover')?.addEventListener('click', async () => {
2292
2571
  box.hidden = false;
2293
2572
  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
2573
  <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>` : ''}
2574
+ <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
2575
  <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
2576
  </div>
2298
2577
  <button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
@@ -2350,7 +2629,16 @@ brokerForm.addEventListener('submit', async (e) => {
2350
2629
 
2351
2630
  $('#brokers-list').addEventListener('click', async (e) => {
2352
2631
  const b = e.target.closest('button[data-bact]');
2353
- if (!b) return;
2632
+ if (!b) {
2633
+ const fold = e.target.closest('[data-bcfg-fold]');
2634
+ if (fold) {
2635
+ const fid = fold.dataset.bcfgFold;
2636
+ openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
2637
+ saveNavPatch({ openBrokerCfgId });
2638
+ renderBrokers();
2639
+ }
2640
+ return;
2641
+ }
2354
2642
  const id = b.closest('[data-bid]').dataset.bid;
2355
2643
  const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2356
2644
  if (!acc) return;
@@ -2386,7 +2674,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2386
2674
  if (tabBtn) {
2387
2675
  const id = tabBtn.dataset.bid;
2388
2676
  const t = tabBtn.dataset.btab;
2389
- brokerTabById.set(id, t);
2677
+ setBrokerSubTab(id, t);
2390
2678
  renderBrokers();
2391
2679
  if (t === 'history') {
2392
2680
  const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
@@ -2397,7 +2685,16 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2397
2685
  return;
2398
2686
  }
2399
2687
  const b = e.target.closest('button[data-btrade]');
2400
- if (!b) return;
2688
+ if (!b) {
2689
+ const fold = e.target.closest('[data-brk-fold]');
2690
+ if (fold) {
2691
+ const fid = fold.dataset.brkFold;
2692
+ openBrokerId = openBrokerId === fid ? null : fid;
2693
+ saveNavPatch({ openBrokerId });
2694
+ renderBrokers();
2695
+ }
2696
+ return;
2697
+ }
2401
2698
  const id = b.dataset.bid;
2402
2699
  const act = b.dataset.btrade;
2403
2700
  if (act === 'cfg') {
@@ -2414,21 +2711,24 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2414
2711
  const old = b.textContent;
2415
2712
  try {
2416
2713
  if (act === 'sync') {
2417
- b.textContent = 'Обновляем…';
2714
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
2418
2715
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
2419
2716
  toast('Портфель обновлён');
2420
2717
  } else if (act === 'hist') {
2421
- b.textContent = 'Загружаем…';
2422
- brokerTabById.set(id, 'history');
2718
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Загружаем…';
2719
+ setBrokerSubTab(id, 'history');
2423
2720
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
2424
2721
  toast('История операций загружена');
2425
2722
  } else if (act === 'freeze') {
2426
- b.textContent = 'Фиксируем…';
2427
2723
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
2428
- toast('Текущие бумаги счёта зафиксированы как неприкосновенный старый портфель');
2724
+ const updated = (state.brokers?.accounts || []).find((x) => x.id === id);
2725
+ const isIso = updated?.legacyMode === 'isolate';
2726
+ toast(isIso
2727
+ ? '🔒 Защита бумаг включена (режим «Изолированный карман» — старые бумаги не продаются)'
2728
+ : '🔓 Включён режим «Плавный переход» (разрешено плавное замещение старых бумаг под сигналы)');
2429
2729
  } else if (act === 'prep') {
2430
- b.textContent = 'Считаем лоты…';
2431
- brokerTabById.set(id, 'trade');
2730
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Считаем лоты…';
2731
+ setBrokerSubTab(id, 'trade');
2432
2732
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
2433
2733
  toast('Пакет заявок по прогнозу рассчитан');
2434
2734
  } else if (act === 'exec') {
@@ -2611,7 +2911,42 @@ function connectWs() {
2611
2911
  };
2612
2912
  }
2613
2913
 
2614
- // ---------- профиль, тарифы ЮKassa, боты Telegram/MAX и админ-панель ----------
2914
+ // ---------- окно подписания Договора-оферты в терминале ----------
2915
+ let termOfferCallback = null;
2916
+ const termOfferModal = $('#offer-modal');
2917
+ const termChk1 = $('#term-offer-chk1');
2918
+ const termChk2 = $('#term-offer-chk2');
2919
+ const termOfferAccept = $('#term-offer-accept');
2920
+
2921
+ function syncTermOfferBtn() {
2922
+ if (termOfferAccept) termOfferAccept.disabled = !(termChk1?.checked && termChk2?.checked);
2923
+ }
2924
+ termChk1?.addEventListener('change', syncTermOfferBtn);
2925
+ termChk2?.addEventListener('change', syncTermOfferBtn);
2926
+ $('#offer-close')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
2927
+ $('#term-offer-decline')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
2928
+ termOfferAccept?.addEventListener('click', async () => {
2929
+ termOfferAccept.disabled = true;
2930
+ try {
2931
+ await post('/api/auth/accept-offer').catch(() => {});
2932
+ termOfferModal.hidden = true;
2933
+ if (termOfferCallback) await termOfferCallback();
2934
+ } finally {
2935
+ termOfferCallback = null;
2936
+ syncTermOfferBtn();
2937
+ }
2938
+ });
2939
+
2940
+ function requireTermOffer(fn) {
2941
+ termOfferCallback = fn;
2942
+ if (termChk1) termChk1.checked = false;
2943
+ if (termChk2) termChk2.checked = false;
2944
+ syncTermOfferBtn();
2945
+ if (termOfferModal) termOfferModal.hidden = false;
2946
+ else fn();
2947
+ }
2948
+
2949
+ // ---------- профиль, тарифы ЮKassa, VIP-токен и боты Telegram/MAX ----------
2615
2950
  $('#btn-user')?.addEventListener('click', () => {
2616
2951
  switchTab('settings');
2617
2952
  showSettingsSec('cloud');
@@ -2652,7 +2987,7 @@ async function renderCloudPane() {
2652
2987
  adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
2653
2988
  <div class="stxt">
2654
2989
  <div class="slbl">Консоль администратора платформы</div>
2655
- <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, OAuth-провайдерами (Яндекс, Сбер, Т-Банк, VK) и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
2990
+ <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
2656
2991
  </div>
2657
2992
  <div class="row-gap" style="flex-wrap:wrap">
2658
2993
  <a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
@@ -2660,9 +2995,9 @@ async function renderCloudPane() {
2660
2995
  </div>`;
2661
2996
  } else {
2662
2997
  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>
2998
+ <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
2664
2999
  <form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
2665
- <div class="muted small">Если вы запускаете сервер локально и хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
3000
+ <div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
2666
3001
  <div class="row-gap" style="flex-wrap:wrap">
2667
3002
  <input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
2668
3003
  <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 +3007,51 @@ async function renderCloudPane() {
2672
3007
  <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
3008
  <button type="submit" class="primary">Сохранить ботов</button>
2674
3009
  </div>
2675
- <div class="muted small" style="margin-top:4px">Администрирование SaaS-портала (ЮKassa, пользователи, промокоды, OAuth) доступно в отдельном приложении: <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
3010
+ <div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
2676
3011
  </form>
2677
3012
  </details>`;
2678
3013
  }
2679
3014
  }
2680
3015
 
2681
- const apiKey = u.apiKey || '';
2682
- const npxCmd = apiKey ? `npx neuroalpha --key ${apiKey}` : 'npx neuroalpha';
3016
+ const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3017
+ ? 'без ограничений суммы'
3018
+ : u.limits?.maxCapitalPerAccountRub > 0
3019
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
3020
+ : 'только демо-торговля';
3021
+ const retLimStr = u.limits?.maxAnnualReturnPct > 0
3022
+ ? `до ${u.limits.maxAnnualReturnPct}% годовых`
3023
+ : 'без ограничений (полная мощность VIP)';
3024
+ const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
2683
3025
 
2684
3026
  box.innerHTML = `
2685
3027
  <div class="srow sblock">
2686
3028
  <div class="stxt">
2687
3029
  <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>
3030
+ <div class="hint">
3031
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Предел доходности стратегии: <b>${retLimStr}</b> · Виртуальных счетов: до <b>${u.limits?.maxVirtualAccounts ?? 1}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3032
+ </div>
2689
3033
  </div>
2690
3034
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
2691
3035
  <button type="button" id="btn-cloud-verify">Проверить оплату</button>
2692
3036
  <button type="button" id="btn-cloud-logout">Выйти</button>
2693
3037
  </div>` : ''}
2694
3038
  </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">
3039
+ <div class="srow sblock">
2709
3040
  <div class="stxt">
2710
- <div class="slbl">Ваш персональный API-ключ (для запуска на вашем ПК через <code>npx</code>)</div>
3041
+ <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
2711
3042
  <div class="hint">
2712
- Ключ: <b><code id="user-api-key">${esc(apiKey || 'не создан')}</code></b><br>
2713
- Команда для запуска на вашем ПК (ключи брокера остаются локально): <code>${esc(npxCmd)}</code>
3043
+ • <b>«Старт» (0 ₽)</b> — тестирование стратегий любой сложности без реальной торговли;<br>
3044
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — до 2 брокерских счетов (до 1 млн ₽ на каждом), торговля «Только в рост (без шорта)», до 50% годовых;<br>
3045
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов (до 10 млн ₽ на каждом), «В рост и в падение (с шортом)», до 100% годовых;<br>
3046
+ • <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
2714
3047
  </div>
2715
3048
  </div>
2716
3049
  <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>
3050
+ <input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
3051
+ <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
2730
3052
  <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
2731
3053
  <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
3054
+ <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Запросить VIP →</a>
2732
3055
  </div>
2733
3056
  </div>
2734
3057
  <div class="srow sblock">
@@ -2743,39 +3066,13 @@ async function renderCloudPane() {
2743
3066
  ${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
3067
  ${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
3068
  <button type="button" id="btn-notify-test">Тест уведомления</button>
3069
+ <a class="btn" href="/legal" target="_blank" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Договор-оферта →</a>
2746
3070
  </div>
2747
3071
  </div>
2748
3072
  ${adminLinkHtml}
2749
3073
  ${customBotsHtml}
2750
3074
  `;
2751
3075
 
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
3076
  box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
2780
3077
  await post('/api/auth/logout');
2781
3078
  location.href = '/landing';
@@ -2787,11 +3084,14 @@ async function renderCloudPane() {
2787
3084
  renderCloudPane();
2788
3085
  } catch (e) { toast(e.message, 'error'); }
2789
3086
  });
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'); }
3087
+ box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', () => {
3088
+ const plan = b.dataset.checkout;
3089
+ requireTermOffer(async () => {
3090
+ try {
3091
+ const r = await post('/api/billing/checkout', { plan, months: 1, returnOrigin: location.origin });
3092
+ if (r.confirmationUrl) location.href = r.confirmationUrl;
3093
+ } catch (e) { toast(e.message, 'error'); }
3094
+ });
2795
3095
  }));
2796
3096
  box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
2797
3097
  try {
@@ -2799,14 +3099,16 @@ async function renderCloudPane() {
2799
3099
  toast('Тестовое уведомление отправлено в привязанные мессенджеры');
2800
3100
  } catch (e) { toast(e.message, 'error'); }
2801
3101
  });
2802
- box.querySelector('#btn-promo-redeem')?.addEventListener('click', async () => {
3102
+ box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
2803
3103
  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'); }
3104
+ if (!code) return toast('Введите промокод или персональный VIP-токен', 'error');
3105
+ requireTermOffer(async () => {
3106
+ try {
3107
+ const r = await post('/api/auth/redeem-promo', { code });
3108
+ toast(`Код активирован! Ваш тариф: ${r.user?.planName}`);
3109
+ renderCloudPane();
3110
+ } catch (e) { toast(e.message, 'error'); }
3111
+ });
2810
3112
  });
2811
3113
  box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
2812
3114
  e.preventDefault();
@@ -2876,7 +3178,7 @@ document.addEventListener('keydown', (e) => {
2876
3178
  }
2877
3179
  });
2878
3180
 
2879
- // ---------- старт ----------
3181
+ // ---------- старт и восстановление сохранённого места в интерфейсе ----------
2880
3182
  (async function init() {
2881
3183
  state.config = await api('/api/config');
2882
3184
  buildForm();
@@ -2895,6 +3197,17 @@ document.addEventListener('keydown', (e) => {
2895
3197
  renderCloudPane();
2896
3198
  }).catch(() => {});
2897
3199
  }
2898
- const initRunId = Number(urlParams.get('run')) || undefined;
3200
+ const savedNav = loadSavedNav();
3201
+ const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
2899
3202
  await loadRuns(initRunId);
3203
+
3204
+ // Восстанавливаем открытые подразделы и главную вкладку, где находился пользователь
3205
+ if (savedNav.nowSec) showNowSec(savedNav.nowSec);
3206
+ if (savedNav.settingsSec) showSettingsSec(savedNav.settingsSec);
3207
+ if (savedNav.mapSec) showMapSec(savedNav.mapSec).catch(() => {});
3208
+ if (savedNav.tab && ['overview', 'now', 'map', 'settings'].includes(savedNav.tab) && !urlParams.get('run')) {
3209
+ switchTab(savedNav.tab);
3210
+ }
3211
+ // В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
3212
+ setTimeout(() => refreshForecastIfStale(), 600);
2900
3213
  })().catch((e) => toast(e.message, 'error'));