neuroalpha 0.2.0 → 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 (52) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +9 -3
  3. package/bin/cli.js +69 -69
  4. package/config/seed-run.json +1 -0
  5. package/config/strategy.json +4 -10
  6. package/deploy/install.sh +128 -0
  7. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
  8. 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
  9. 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 +171 -0
  10. package/package.json +15 -5
  11. package/public/admin.html +449 -0
  12. package/public/app.js +1182 -379
  13. package/public/index.html +87 -22
  14. package/public/landing.html +1217 -870
  15. package/public/legal.html +51 -8
  16. package/public/styles.css +71 -16
  17. package/server/api/routes.js +220 -35
  18. package/server/auth/index.js +633 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1369 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +69 -4
  24. package/server/hub.js +1 -0
  25. package/server/index.js +10 -2
  26. package/server/llm/client.js +1 -0
  27. package/server/llm/providers.js +50 -3
  28. package/server/model/dataset.js +362 -352
  29. package/server/model/forecast.js +202 -37
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +476 -486
  32. package/server/model/metrics.js +1 -0
  33. package/server/model/models.js +31 -21
  34. package/server/model/profiles.js +1 -0
  35. package/server/model/research.js +128 -205
  36. package/server/model/vocab.js +1 -0
  37. package/server/news/index.js +1 -0
  38. package/server/notify/index.js +304 -168
  39. package/server/params-meta.js +22 -26
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +2 -1
  42. package/server/strategist/index.js +7 -7
  43. package/server/strategist/tools.js +2 -1
  44. package/server/util.js +1 -0
  45. package/server/model/gpu-worker.html +0 -11
  46. package/server/model/gpu-worker.js +0 -60
  47. package/server/model/tf-node.js +0 -26
  48. package/server/model/transformer-core.js +0 -330
  49. package/server/model/transformer-gpu.js +0 -221
  50. package/server/model/transformer-pool.js +0 -200
  51. package/server/model/transformer-worker.js +0 -28
  52. package/server/model/transformer.js +0 -57
package/public/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  const $ = (s, root = document) => root.querySelector(s);
2
3
  const MSK = 3 * 3600000;
3
4
 
@@ -12,7 +13,34 @@ const post = (url, body = {}) => api(url, { method: 'POST', body });
12
13
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
13
14
  const fmtNum = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v) ? '—' : Number(v).toLocaleString('ru-RU', { maximumFractionDigits: d }));
14
15
  const fmtPct = (v, d = 1) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${fmtNum(v, d)}%`);
15
- 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
+ }
16
44
  const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
17
45
  const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
18
46
  const md = (s) => {
@@ -38,25 +66,59 @@ function toast(text, type = 'info', action = null) {
38
66
  }
39
67
 
40
68
  const state = { config: null, settings: null, runs: [], run: null, active: null, forecast: null, live: null, chat: { state: null, msgs: new Map(), stream: new Map() }, mapSel: null, mapMode: 'behav', profiles: null, profilesBusy: false };
41
- const MODELS = ['inertia', 'leadlag', 'attention', 'transformer'];
42
- const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту', transformer: 'Трансформер' };
69
+ const MODELS = ['inertia', 'leadlag', 'attention'];
70
+ const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту' };
43
71
  const MODEL_HINT = {
44
72
  inertia: 'Простейшая планка: акция предсказывается только по своему недавнему движению.',
45
73
  leadlag: 'Каждая акция предсказывается по недавним движениям всех остальных, без карты.',
46
74
  attention: 'Влияние соседей по карте рынка через 12 «голов внимания».',
47
- transformer: 'Нейросеть с вниманием между акциями; учится заново в каждом окне.',
48
75
  };
49
- const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7', transformer: '#39c5cf' };
76
+ const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7' };
50
77
  const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '#0891b2', '#db2777', '#64748b'];
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 = {
@@ -166,40 +239,48 @@ const eqLines = {
166
239
  inertia: eqChart.addLineSeries({ color: COLORS.inertia, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
167
240
  leadlag: eqChart.addLineSeries({ color: COLORS.leadlag, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
168
241
  attention: eqChart.addLineSeries({ color: COLORS.attention, lineWidth: 2, priceLineVisible: false, lastValueVisible: false }),
169
- transformer: eqChart.addLineSeries({ color: COLORS.transformer, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
170
242
  };
171
243
  const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
172
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
+ }
173
252
 
174
253
  const RUN_PARTS = [
175
- ['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : null)],
176
- ['modelHorizon', (v) => `Прогноз: ${fmtNum(v, 3)}ч`],
177
- ['modelMinEdge', (v) => `Порог: ${fmtNum(v, 3)}`],
178
- ['modelExitRatio', (v) => (v !== 0.5 ? `Выход: ${fmtNum(v, 2)}` : null)],
179
- ['modelMinHoldHours', (v) => (v > 0 ? `Удерж.: ${fmtNum(v, 2)}ч` : null)],
254
+ ['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : 'Авто (мульти-горизонт 1,5–3,5ч)')],
255
+ ['modelHorizon', (v, p) => (p.modelAutoAdapt === false ? `Прогноз: ${fmtNum(v, 3)}ч` : null)],
256
+ ['modelMinEdge', (v, p) => (p.modelAutoAdapt === false ? `Порог: ${fmtNum(v, 3)}` : null)],
257
+ ['modelExitRatio', (v, p) => (p.modelAutoAdapt === false ? `Выход: ${fmtNum(v, 2)}` : null)],
180
258
  ['modelTopK', (v) => `Акции: ${v}`],
181
- ['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : null)],
259
+ ['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : 'Доли поровну')],
260
+ ['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : 'Только в рост (без шорта)')],
261
+ ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
182
262
  ['modelTrainDays', (v) => `Окно: ${v}дн`],
263
+ ['modelHistoryDays', (v) => `История: ${v}дн`],
183
264
  ['modelEmbedDim', (v) => `Эмбд.: ${v}`],
184
- ['modelUseMacro', (v) => (v ? 'Макро' : null)],
185
265
  ['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
186
- ['modelDirection', (v) => (v === 'longshort' ? 'Продажи' : v === 'neutral' ? 'Нейтрально' : null)],
187
- ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : null)],
188
- ['modelUseTransformer', (v, p) => (v || p.modelType === 'transformer' ? 'Трансформер' : null)],
189
- ['transformerEpochs', (v, p) => (usesTf(p) && v !== state.config?.defaults?.transformerEpochs ? `Эпох: ${v}` : null)],
190
- ['transformerGpu', (v, p) => (usesTf(p) && v ? 'Видеокарта' : null)],
266
+ ['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
191
267
  ];
192
- function usesTf(p) { return p.modelUseTransformer || p.modelType === 'transformer'; }
193
268
  function runTitle(r, html = false) {
194
269
  const p = r.params || {}, d = state.config?.defaults || {};
195
- const parts = RUN_PARTS.map(([k, f]) => {
196
- if (p[k] == null) return null;
270
+ const m = r.metrics?.full || {};
271
+ const ps = r.metrics?.period ? calcPeriodStats(r.metrics.period, m.returnPct, 0) : null;
272
+ const status = r.status === 'done'
273
+ ? `${fmtPct(m.returnPct)}${ps ? ` (~${fmtPct(ps.cagr, 0)}/год)` : ''}${m.maxDrawdownPct ? `, просадка ${fmtNum(m.maxDrawdownPct, 1)}%` : ''}`
274
+ : r.status === 'running' ? 'идёт…' : 'ошибка';
275
+ const diffs = RUN_PARTS.map(([k, f]) => {
276
+ if (p[k] == null || JSON.stringify(p[k]) === JSON.stringify(d[k])) return null;
197
277
  const t = f(p[k], p);
198
- return html && JSON.stringify(p[k]) !== JSON.stringify(d[k]) ? `<span style="color:var(--yellow)">${t}</span>` : t;
278
+ if (!t) return null;
279
+ return html ? `<span style="color:var(--yellow)">${t}</span>` : t;
199
280
  }).filter(Boolean);
200
- const status = r.status === 'done' ? fmtPct(r.metrics?.full?.returnPct) : r.status === 'running' ? 'идёт…' : 'ошибка';
201
- const head = p._title ? `№${r.id} (${p._title})` : `№${r.id}`;
202
- return [head, ...parts, ...(p._source === 'ai' ? ['Стратег'] : []), status].join(' — ');
281
+ const head = p._title ? `№${r.id} (${html ? esc(p._title) : p._title})` : `№${r.id}`;
282
+ const diffPart = diffs.length ? diffs : ['базовые настройки'];
283
+ return [head, ...diffPart, ...(p._source === 'ai' ? ['Стратег'] : []), status].join(' — ');
203
284
  }
204
285
  const runTip = (r) => {
205
286
  const p = r.params || {};
@@ -222,31 +303,31 @@ async function loadRuns(selectId) {
222
303
  ? state.runs.map((r) => `<option value="${r.id}" title="${runTip(r)}">${runTitle(r)}</option>`).join('')
223
304
  : '<option value="">Исследований пока нет</option>';
224
305
  sel.innerHTML = optHtml;
306
+ const brkSel = $('#broker-run-select');
307
+ if (brkSel) {
308
+ const curVal = brkSel.value;
309
+ brkSel.innerHTML = '<option value="">Текущие настройки</option>' + state.runs.filter((r) => r.status === 'done').map((r) => `<option value="${r.id}">${runTitle(r)}</option>`).join('');
310
+ brkSel.value = curVal;
311
+ }
225
312
  for (const m of state.chat.msgs.values()) if (m.kind === 'event' && m.data?.eventType === 'model') upsertMsg(m);
226
- if ($('#btn-clean')) $('#btn-clean').disabled = state.runs.length < 1;
227
- $('#run-bar').hidden = !state.run;
228
- const doneRun = state.run?.status === 'done';
229
- $('#btn-stability').hidden = !doneRun;
230
- $('#btn-to-live').hidden = !doneRun;
231
- $('#btn-cmp-run').hidden = state.runs.length < 2;
232
- syncApplyBtn();
233
313
  const id = selectId ?? state.run?.id ?? state.runs.find((r) => r.status === 'done')?.id;
234
314
  updateTabBadges();
235
315
  if (id && state.runs.some((r) => r.id === id)) {
236
316
  sel.value = String(id);
237
317
  if (state.run?.id !== id || selectId) await openRun(id);
238
- } else renderOverview();
318
+ else syncUiAvailability();
319
+ } else {
320
+ state.run = null;
321
+ syncUiAvailability();
322
+ renderOverview();
323
+ }
239
324
  }
240
325
 
241
326
  async function openRun(id) {
242
327
  state.run = await api(`/api/runs/${id}`);
243
328
  $('#run-select').value = String(id);
244
- $('#run-bar').hidden = false;
245
- const doneRun = state.run?.status === 'done';
246
- $('#btn-stability').hidden = !doneRun;
247
- $('#btn-to-live').hidden = !doneRun;
248
- $('#btn-cmp-run').hidden = state.runs.length < 2;
249
- syncApplyBtn();
329
+ saveNavPatch({ runId: Number(id) });
330
+ syncUiAvailability();
250
331
  renderOverview();
251
332
  setupMapSlider();
252
333
  if (!$('#tab-map').hidden) drawMap();
@@ -283,29 +364,88 @@ $('#btn-del').addEventListener('click', async () => {
283
364
  toast(`Эксперимент №${r.id} удалён`);
284
365
  } catch (e) { toast(e.message, 'error'); }
285
366
  });
286
- // Проверить устойчивость открытого прогона (5 сдвигов периода −8…+8 дней)
287
367
  function stabilityUi(runningId) {
288
368
  state.stabilityActive = runningId || null;
289
369
  const busy = Boolean(runningId);
290
- $('#btn-stability').disabled = busy;
291
- $('#btn-stability').textContent = busy ? 'Идёт проверка устойчивости…' : 'Проверить устойчивость';
292
- $('#btn-cancel').hidden = !busy && !state.active;
293
370
  if (!busy && !state.active) setProgress(null);
294
371
  updateTabBadges();
372
+ syncUiAvailability();
295
373
  }
296
- $('#btn-stability').addEventListener('click', async () => {
374
+ // Единая синхронизация видимости и доступности элементов управления от текущего состояния системы
375
+ function syncUiAvailability() {
297
376
  const r = state.run;
298
- if (!r || r.status !== 'done') return;
299
- try {
300
- stabilityUi(r.id);
301
- setProgress(1, 'Запускаем проверку устойчивости по 5 сдвигам периода…');
302
- await post(`/api/runs/${r.id}/stability`);
303
- } catch (e) {
304
- stabilityUi(null);
305
- toast(e.message, 'error');
377
+ const isRunning = r?.status === 'running' || (r && r.id === state.active);
378
+ const isDone = r?.status === 'done' && !isRunning;
379
+ const aiReady = Boolean(state.chat.state?.llm && !state.chat.state?.busy);
380
+ const aiBusy = Boolean(state.chat.state?.busy);
381
+
382
+ // 1. Панель действий эксперимента (#run-bar): скрыта во время расчёта
383
+ if ($('#run-bar')) $('#run-bar').hidden = !r || isRunning;
384
+ if ($('#btn-to-live')) $('#btn-to-live').hidden = !isDone;
385
+ if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
386
+ if ($('#btn-ask-run')) {
387
+ $('#btn-ask-run').hidden = !isDone;
388
+ $('#btn-ask-run').disabled = !aiReady;
389
+ $('#btn-ask-run').title = !state.chat.state?.llm
390
+ ? 'ИИ не подключён (Настройки → Модели ИИ)'
391
+ : aiBusy
392
+ ? 'Дождитесь завершения текущего ответа Стратега'
393
+ : 'Попросить Стратега разобрать этот эксперимент';
306
394
  }
307
- });
308
- // Запустить виртуальный счёт прямо из открытого эксперимента (с заморозкой его настроек и ориентиром истории)
395
+ if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
396
+ syncApplyBtn();
397
+
398
+ // 2. Выпадающий список экспериментов и кнопка «Исследовать»
399
+ const anyJobRunning = Boolean(state.active || state.stabilityActive);
400
+ if ($('#btn-model')) $('#btn-model').hidden = anyJobRunning;
401
+ if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
402
+ if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
403
+
404
+ // 3. Кнопки обращения к Стратегу в прогнозе и виртуальных счетах
405
+ if ($('#btn-ask-forecast')) {
406
+ $('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
407
+ $('#btn-ask-forecast').title = !state.forecast
408
+ ? 'Сначала дождитесь расчёта прогноза'
409
+ : !aiReady
410
+ ? 'Стратег сейчас занят или ИИ не подключён'
411
+ : 'Попросить Стратега разобрать текущий прогноз';
412
+ }
413
+ document.querySelectorAll('[data-live-ask]').forEach((b) => {
414
+ b.disabled = !aiReady;
415
+ b.title = !aiReady ? 'Стратег сейчас занят или ИИ не подключён' : 'Попросить Стратега разобрать этот виртуальный счёт';
416
+ });
417
+
418
+ // 4. Зависимые настройки в форме (при включённом Авто-режиме или выключенном Трансформере)
419
+ syncSettingsDependencies();
420
+ }
421
+
422
+ function syncSettingsDependencies() {
423
+ const form = $('#params');
424
+ if (!form || !state.settings) return;
425
+ const s = state.settings;
426
+ const autoOn = s.modelAutoAdapt !== false;
427
+ const dir = s.modelDirection || 'long';
428
+
429
+ const setRowEnabled = (key, enabled, whyDisabled = '') => {
430
+ const row = form.querySelector(`.srow[data-key="${key}"]`);
431
+ if (!row) return;
432
+ row.style.opacity = enabled ? '1' : '0.45';
433
+ const ctrl = form.elements[key];
434
+ if (ctrl) {
435
+ ctrl.disabled = !enabled;
436
+ ctrl.title = enabled ? '' : whyDisabled;
437
+ }
438
+ };
439
+
440
+ setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
441
+ setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
442
+ setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
443
+ setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
444
+ setRowEnabled('modelMarketFilter', true, '');
445
+
446
+ const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
447
+ if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
448
+ }
309
449
  $('#btn-to-live').addEventListener('click', async () => {
310
450
  const r = state.run;
311
451
  if (!r || r.status !== 'done') return;
@@ -322,7 +462,7 @@ $('#btn-to-live').addEventListener('click', async () => {
322
462
  } catch (e) { toast(e.message, 'error'); }
323
463
  });
324
464
  // «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
325
- const APPLY_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelMinHoldHours', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelTextWeight', 'modelUseTransformer', 'transformerEpochs', 'transformerGpu', 'commissionPct', 'slippagePct'];
465
+ const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
326
466
  function runSettingsDiff(r = state.run) {
327
467
  if (!r?.params || !state.settings) return { entries: [], lines: [] };
328
468
  const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
@@ -359,8 +499,9 @@ function syncApplyBtn() {
359
499
  syncShowcaseBtn();
360
500
  const btn = $('#btn-apply');
361
501
  if (!btn) return;
502
+ const isDone = state.run?.status === 'done' && state.run.id !== state.active;
362
503
  const { lines } = runSettingsDiff();
363
- btn.hidden = !state.run || !lines.length;
504
+ btn.hidden = !isDone || !lines.length;
364
505
  if (lines.length) {
365
506
  btn.textContent = `✓ Применить эти настройки (${lines.length})`;
366
507
  btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
@@ -378,26 +519,44 @@ $('#btn-apply').addEventListener('click', async () => {
378
519
  toast(`Настройки из эксперимента №${r.id} применены`);
379
520
  });
380
521
 
381
- function verdictHtml(c, dir) {
522
+ // Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
523
+ function calcPeriodStats(period, returnPct, marketPct) {
524
+ const from = period?.from || 0, till = period?.till || 0;
525
+ const calDays = Math.max(1, Math.round((till - from) / 86400000));
526
+ const years = calDays / 365.25;
527
+ const months = Math.max(1, Math.round(calDays / 30.44));
528
+ const yrsLabel = years >= 1 ? `${fmtNum(years, 1)} г. (${months} мес.)` : `${months} мес.`;
529
+ const dateRange = from && till ? `${fmtDate(from)} — ${fmtDate(till)}` : '';
530
+ const r = Number(returnPct ?? 0);
531
+ const m = Number(marketPct ?? 0);
532
+ // CAGR (среднегодовой темп роста с реинвестированием) и простое среднее в год
533
+ const cagr = years >= 0.25 && r > -99 ? ((Math.pow(1 + r / 100, 1 / years) - 1) * 100) : (r / Math.max(years, 0.1));
534
+ const avgSimple = r / Math.max(years, 0.1);
535
+ const mktAnnual = years >= 0.25 && m > -99 ? ((Math.pow(1 + m / 100, 1 / years) - 1) * 100) : (m / Math.max(years, 0.1));
536
+ return { calDays, years, months, yrsLabel, dateRange, cagr, avgSimple, mktAnnual };
537
+ }
538
+
539
+ function verdictHtml(c, oosA) {
382
540
  const a = c.attention, base = c.inertia;
383
541
  if (!a) return '';
384
- const neutral = dir === 'neutral';
385
- // помогает ли карта рынка — по торговому результату против простейшей модели
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
+ : '';
386
546
  let vs = '';
387
547
  if (base) {
388
548
  const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
389
- if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = `<b class="pos">Карта рынка даёт преимущество:</b> ${fmtPct(a.returnPct)} против ${fmtPct(base.returnPct)} у простейшей стратегии; на 1 ₽ убытка — ${fmtNum(pf)} ₽ против ${fmtNum(bpf)} ₽.`;
390
- else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = `Карта рынка и простейшая стратегия заработали примерно одинаково (${fmtPct(a.returnPct)} против ${fmtPct(base.returnPct)}).`;
391
- else vs = `Простейшая стратегия заработала больше (${fmtPct(base.returnPct)} против ${fmtPct(a.returnPct)}) — карта рынка пока не помогает.`;
392
- 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}`;
393
555
  }
394
- let unreliable = a.trades < 30 || (base && base.trades < 30) ? ' <span class="muted small">Сделок мало, сравнение ненадёжно.</span>' : '';
395
- if (c.transformer) unreliable += `<div class="muted small">Трансформер: итог ${fmtPct(c.transformer.returnPct)} против ${fmtPct(a.returnPct)} у внимания через карту.</div>`;
396
- if (a.trades < 30) return `<b>Слишком мало сделок для выводов (${a.trades}).</b> <span class="muted">${vs}</span>${unreliable}`;
397
- if (neutral && a.returnPct > 0 && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий; нейтрально к рынку: рынок почти не влияет. ${vs}${unreliable}`;
398
- if (!neutral && a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий, рынок за то же время ${fmtPct(a.marketPct)}. ${vs}${unreliable}`;
399
- if ((a.grossReturnPct ?? 0) > 0) return `<b style="color:var(--yellow)">Сигнал есть, но комиссии его съедают:</b> без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}. Попробуйте увеличить «Порог выгоды». ${vs}${unreliable}`;
400
- 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}`;
401
560
  }
402
561
 
403
562
  const MONTHS = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
@@ -456,40 +615,214 @@ function renderStability(st) {
456
615
  box.innerHTML = `<div class="stab-head"><span class="tag ${vCls}">${vLbl}</span><span><b>Медиана ${fmtPct(st.medianPct)}</b> · разброс от <span class="${cls(st.minPct)}">${fmtPct(st.minPct)}</span> до <span class="${cls(st.maxPct)}">${fmtPct(st.maxPct)}</span> · в плюсе ${st.positive} из ${st.count} · худшая просадка ${fmtNum(st.maxDrawdownPct, 1)}%</span><span class="muted small">(${kindLbl})</span></div><div class="stab-chips">${chips}</div>`;
457
616
  }
458
617
 
618
+ // Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
619
+ const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
620
+
621
+ function resetLiveTrain(runId) {
622
+ liveTrainState.runId = runId;
623
+ liveTrainState.stage = 'data';
624
+ liveTrainState.loadedSymbols = [];
625
+ liveTrainState.foldIcs = [];
626
+ liveTrainState.lastViz = null;
627
+ liveTrainState.text = '';
628
+ }
629
+
630
+ function drawLiveTrainMap(viz) {
631
+ const cv = $('#ld-map-cv');
632
+ if (!cv || !viz?.pts?.length) return;
633
+ const W = cv.clientWidth || 440, H = 250;
634
+ const dpr = window.devicePixelRatio || 1;
635
+ cv.width = W * dpr; cv.height = H * dpr;
636
+ const ctx = cv.getContext('2d');
637
+ ctx.scale(dpr, dpr);
638
+ const syms = liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
639
+ const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
640
+ const xy = layout(f, W, H);
641
+ const light = document.body.classList.contains('light');
642
+ // лёгкие соединительные линии внутри одного кластера к ближайшему соседу
643
+ ctx.lineWidth = 1;
644
+ for (let i = 0; i < xy.length; i++) {
645
+ let bestJ = -1, bestD = 68;
646
+ for (let j = i + 1; j < xy.length; j++) {
647
+ if (f.cl[i] !== f.cl[j]) continue;
648
+ const d = Math.hypot(xy[i][0] - xy[j][0], xy[i][1] - xy[j][1]);
649
+ if (d < bestD) { bestD = d; bestJ = j; }
650
+ }
651
+ if (bestJ >= 0) {
652
+ const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
653
+ ctx.strokeStyle = `${col}33`;
654
+ ctx.beginPath(); ctx.moveTo(...xy[i]); ctx.lineTo(...xy[bestJ]); ctx.stroke();
655
+ }
656
+ }
657
+ f.symbols.forEach((s, i) => {
658
+ const [x, y] = xy[i];
659
+ const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
660
+ ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2);
661
+ ctx.fillStyle = col;
662
+ ctx.fill();
663
+ ctx.fillStyle = light ? '#57606a' : '#8b949e';
664
+ ctx.font = '10px system-ui';
665
+ ctx.fillText(s, x + 6, y + 3);
666
+ });
667
+ }
668
+
669
+ function renderLiveTraining() {
670
+ const empty = $('#overview-empty');
671
+ if (!empty || empty.hidden) return;
672
+ const st = liveTrainState;
673
+ empty.classList.add('live-dash');
674
+ const v = st.lastViz;
675
+ if (!v) {
676
+ // Этап 1: загрузка котировок и дивидендов с Мосбиржи
677
+ const chips = st.loadedSymbols.slice(-36).map((s) => `<span class="ld-sym ok">✓ ${esc(s)}</span>`).join('');
678
+ empty.innerHTML = `<div class="ld-card">
679
+ <div class="ld-title"><span>Этап 1 из 2 · Загрузка и сверка исторических свечей и дивидендов МосБиржи</span><span class="muted small">${esc(st.text || 'Подготовка датасета…')}</span></div>
680
+ <div class="muted small">Для каждой из ~100 акций индекса широкого рынка загружаются 10-минутные свечи, сверяются дивидендные отсечки по фактическим утренним разрывам цены и рассчитывается почасовой суточный профиль объёма.</div>
681
+ ${chips ? `<div class="ld-syms">${chips}</div>` : ''}
682
+ </div>`;
683
+ return;
684
+ }
685
+
686
+ // Этап 2: пошаговое скользящее обучение (Walk-Forward)
687
+ const span = Math.max(1, (v.periodTill || 1) - (v.periodFrom || 0));
688
+ const pctPos = (ts) => Math.max(0, Math.min(100, ((ts - v.periodFrom) / span) * 100));
689
+ const trLeft = pctPos(v.trainFrom), trRight = pctPos(v.testFrom), teRight = pctPos(v.testTill);
690
+ const firstTestLeft = st.firstTestLeft ?? trRight;
691
+ if (st.firstTestLeft === undefined) st.firstTestLeft = trRight;
692
+
693
+ const ics = st.foldIcs;
694
+ const posFolds = ics.filter((x) => x > 0).length;
695
+ const meanIc = ics.length ? ics.reduce((a, b) => a + b, 0) / ics.length : 0;
696
+ const maxIc = Math.max(0.05, ...ics.map(Math.abs));
697
+ const icBars = Array.from({ length: v.folds }, (_, idx) => {
698
+ const val = ics[idx];
699
+ if (val === undefined) return `<div class="ld-ic-bar" style="height:3px;background:var(--line)" title="Шаг ${idx + 1}: ещё впереди"></div>`;
700
+ const h = Math.max(5, Math.round((Math.abs(val) / maxIc) * 54));
701
+ const col = val >= 0 ? 'var(--green)' : 'var(--red)';
702
+ return `<div class="ld-ic-bar" style="height:${h}px;background:${col}" title="Шаг ${idx + 1}: совпадение прогноза ${fmtNum(val, 3)}"></div>`;
703
+ }).join('');
704
+
705
+ let headsHtml = '';
706
+ if (v.heads?.length) {
707
+ const maxC = Math.max(...v.heads.map((h) => Math.abs(h.coef)), ...(v.self || []).map(Math.abs), 1e-6);
708
+ const bar = (label, val) => {
709
+ const wd = (Math.abs(val) / maxC) * 50;
710
+ return `<div class="headbar"><span>${esc(label)}</span><div class="b"><div style="left:${val >= 0 ? 50 : 50 - wd}%;width:${wd}%;background:${val >= 0 ? 'var(--green)' : 'var(--red)'}"></div></div><span class="num ${cls(val)}">${val > 0 ? '+' : ''}${fmtNum(val, 3)}</span></div>`;
711
+ };
712
+ headsHtml = [
713
+ ...[...v.heads].sort((x, y) => Math.abs(y.coef) - Math.abs(x.coef)).slice(0, 7).map((h) => bar(h.label, h.coef)),
714
+ ...(v.self?.length ? [bar('своё движение за последний час', v.self[0])] : []),
715
+ ].join('');
716
+ }
717
+
718
+ empty.innerHTML = `
719
+ <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
720
+ <div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
721
+ <div class="kpi"><div class="v ${cls(meanIc)}">${meanIc > 0 ? '+' : ''}${fmtNum(meanIc, 3)}</div><div class="l">среднее совпадение (IC)</div><div class="sub">шагов в плюсе: ${posFolds} из ${ics.length} (${ics.length ? Math.round((posFolds / ics.length) * 100) : 0}%)</div></div>
722
+ <div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
723
+ <div class="kpi"><div class="v ${cls(v.foldIc ?? 0)}">${v.foldIc !== null ? `${v.foldIc > 0 ? '+' : ''}${fmtNum(v.foldIc, 3)}` : '—'}</div><div class="l">точность текущего месяца</div><div class="sub">окно обучения до ${fmtDate(v.testFrom)}</div></div>
724
+ </div>
725
+
726
+ <div class="ld-card">
727
+ <div class="ld-title">
728
+ <span>Движение скользящего окна по истории (без заглядывания в будущее)</span>
729
+ <span class="muted small">Обучение: ${fmtDate(v.trainFrom)} — ${fmtDate(v.testFrom)} → Прогноз: до ${fmtDate(v.testTill)}</span>
730
+ </div>
731
+ <div class="ld-tl">
732
+ <div class="ld-tl-done" style="left:${firstTestLeft.toFixed(1)}%;width:${Math.max(0, trRight - firstTestLeft).toFixed(1)}%"></div>
733
+ <div class="ld-tl-train" style="left:${trLeft.toFixed(1)}%;width:${Math.max(3, trRight - trLeft).toFixed(1)}%">Обучение</div>
734
+ <div class="ld-tl-test" style="left:${trRight.toFixed(1)}%;width:${Math.max(1.5, teRight - trRight).toFixed(1)}%" title="Прогнозируемый месяц"></div>
735
+ </div>
736
+ <div class="ld-title" style="margin-top:10px;margin-bottom:2px"><span class="muted small">Точность угадывания порядка акций (IC) по каждому пройденному месяцу</span><span class="muted small">выше нуля — лучше случайного</span></div>
737
+ <div class="ld-ics">${icBars}</div>
738
+ </div>
739
+
740
+ <div class="ld-grid">
741
+ <div class="ld-card">
742
+ <div class="ld-title"><span>Живая карта рынка на ${fmtDate(v.testFrom)}</span><span class="muted small">кластеры совместного движения</span></div>
743
+ <canvas id="ld-map-cv" style="width:100%;height:250px;display:block"></canvas>
744
+ </div>
745
+ <div class="ld-card">
746
+ <div class="ld-title"><span>Веса голов внимания (текущее окно)</span><span class="muted small"><span class="pos">переток</span> / <span class="neg">возврат</span></span></div>
747
+ ${headsHtml || '<div class="muted small">Расчёт весов…</div>'}
748
+ </div>
749
+ </div>
750
+ `;
751
+ drawLiveTrainMap(v);
752
+ }
753
+
459
754
  function renderOverview() {
460
755
  const run = state.run;
461
756
  const empty = $('#overview-empty');
462
757
  if (!run?.extra || run.status !== 'done') {
463
758
  $('#overview').hidden = true;
464
759
  empty.hidden = false;
760
+ if (run?.status === 'running' || state.active) {
761
+ renderLiveTraining();
762
+ return;
763
+ }
764
+ empty.classList.remove('live-dash');
465
765
  empty.innerHTML = run && run.status !== 'done'
466
- ? (run.status === 'running' ? 'Исследование идёт…' : `Исследование не завершилось: ${esc(run.error || run.status)}`)
766
+ ? `Исследование не завершилось: ${esc(run.error || run.status)}`
467
767
  : `<b>Идея.</b> Каждая акция — как слово со своим местом на карте рынка. Недавние движения всех акций — контекст.
468
768
  Стратегия, как языковая модель, предсказывает «следующее слово» — какие акции сдвинутся в ближайшие часы.<br><br>
469
769
  Нажмите <b>«Исследовать»</b>: стратегия пошагово проверится на двух годах истории (около минуты; первый раз — несколько минут на загрузку цен).
470
770
  Или попросите Стратега слева.`;
471
771
  return;
472
772
  }
773
+ empty.classList.remove('live-dash');
473
774
  empty.hidden = true;
474
775
  $('#overview').hidden = false;
475
776
  const ex = run.extra;
476
777
  const c = Object.fromEntries(MODELS.map((n) => [n, ex.comparison[n]?.full]));
477
778
  const chosen = run.metrics?.model?.chosen || 'attention';
478
779
  const a = c[chosen] || {};
780
+ const fullM = run.metrics?.full || {};
781
+ const oosA = ex.comparison?.[chosen]?.outOfSample || run.metrics?.outOfSample || {};
782
+ const pStats = calcPeriodStats(run.metrics?.period, a.returnPct, a.marketPct);
479
783
  const dir = run.params?.modelDirection || 'long';
480
- const adaptNote = ex.adaptation
481
- ? `<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 ? '…' : ''}` : ''}</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>`
482
786
  : '';
483
- $('#verdict').innerHTML = verdictHtml(c, dir) + adaptNote + (run.params?._note ? `<div class="muted small">Гипотеза: ${esc(run.params._note)}</div>` : '');
484
- const kpi = (v, l, h) => `<div class="kpi" title="${esc(h)}"><div class="v">${v}</div><div class="l">${l}</div></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;
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)}`;
485
793
  $('#kpis').innerHTML = [
486
- kpi(`<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`, 'итог стратегии', 'Доход портфеля из лучших прогнозов после комиссий'),
487
- kpi(`<span class="${cls(a.marketPct)}">${fmtPct(a.marketPct)}</span>`, 'рынок', 'Индекс МосБиржи за то же время'),
488
- kpi(fmtNum(a.trades, 0), 'сделок', 'Чем больше сделок, тем надёжнее вывод'),
489
- kpi(`${fmtNum(a.maxDrawdownPct, 1)}%`, 'худшее падение', 'Насколько счёт проседал от своего максимума'),
794
+ kpi(
795
+ `<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`,
796
+ `итог за ${pStats.yrsLabel}`,
797
+ sideSub,
798
+ `Общий чистый доход портфеля за ${pStats.dateRange || 'период'} после вычета комиссий (без комиссий: ${fmtPct(a.grossReturnPct)})`,
799
+ ),
800
+ kpi(
801
+ `<span class="${cls(pStats.cagr)}">${fmtPct(pStats.cagr, 1)}</span>`,
802
+ 'средний годовой (CAGR)',
803
+ `простое среднее: ${fmtPct(pStats.avgSimple, 1)}/год`,
804
+ 'Среднегодовая доходность с учётом реинвестирования прибыли (CAGR) и простое арифметическое среднее в год',
805
+ ),
806
+ kpi(
807
+ `<span class="${cls(a.marketPct)}">${fmtPct(a.marketPct)}</span>`,
808
+ 'индекс МосБиржи',
809
+ `${fmtPct(pStats.mktAnnual, 1)}/год · альфа ${fmtPct((a.returnPct ?? 0) - (a.marketPct ?? 0))}`,
810
+ 'Изменение индекса МосБиржи (IMOEX) за точно такой же период и преимущество стратегии над рынком',
811
+ ),
812
+ kpi(
813
+ fmtNum(a.trades, 0),
814
+ 'сделок за период',
815
+ `плюсовых ${fmtNum(a.winRate, 1)}% · на 1 ₽ уб.: ${fmtNum(a.profitFactor)} ₽`,
816
+ 'Общее количество сделок, доля прибыльных сделок (Win Rate) и отношение общей прибыли к общему убытку (Profit Factor)',
817
+ ),
818
+ kpi(
819
+ `${fmtNum(a.maxDrawdownPct, 1)}%`,
820
+ 'худшее падение (просадка)',
821
+ `коэф. Шарпа: ${fmtNum(fullM.sharpe, 2)}`,
822
+ 'Максимальная просадка капитала от пика и годовой коэффициент Шарпа (стабильность кривой доходности)',
823
+ ),
490
824
  ].join('');
491
- $('#sides').hidden = dir === 'long' || a.shortPct === undefined;
492
- $('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
825
+ $('#sides').hidden = true;
493
826
  renderStability(ex.stability);
494
827
 
495
828
  const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
@@ -497,6 +830,7 @@ function renderOverview() {
497
830
  // трансформер — только если он был в прогоне
498
831
  for (const n of MODELS) eqLines[n].applyOptions({ visible: !!ex.curves[n] && (n === chosen || $('#more').open) });
499
832
  eqChart.timeScale().fitContent();
833
+ requestAnimationFrame(() => eqChart.timeScale().fitContent());
500
834
  $('#legend').innerHTML = [chosen, 'market', ...($('#more').open ? MODELS.filter((n) => n !== chosen && ex.curves[n]) : [])]
501
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>`;
502
836
 
@@ -545,16 +879,6 @@ $('#run-bar').addEventListener('click', async (e) => {
545
879
  }
546
880
  if (e.target.closest('#btn-ask-run')) {
547
881
  sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
548
- return;
549
- }
550
- if (e.target.closest('#btn-cmp-run')) {
551
- const others = state.runs.filter((x) => x.id !== r.id && x.status === 'done');
552
- const defId = others[0]?.id || '';
553
- const raw = prompt(`С каким номером эксперимента сравнить №${r.id}? (доступны: ${others.slice(0, 8).map((x) => `№${x.id}`).join(', ')})`, String(defId));
554
- if (!raw) return;
555
- const otherId = Number(String(raw).replace(/\D/g, ''));
556
- if (!otherId) return;
557
- sendChat(`Сравни исследования №${r.id} и №${otherId} через compare_runs и объясни простыми словами, почему отличается результат и какой вариант надёжнее.`);
558
882
  }
559
883
  });
560
884
  $('#btn-ask-forecast').addEventListener('click', () => {
@@ -568,10 +892,9 @@ function setProgress(pct, text) {
568
892
  $('#progress span').textContent = text || '';
569
893
  }
570
894
  function researchUi(running) {
571
- $('#btn-model').disabled = running;
572
- $('#btn-cancel').hidden = !running && !state.stabilityActive;
573
895
  if (!running && !state.stabilityActive) setProgress(null);
574
896
  updateTabBadges();
897
+ syncUiAvailability();
575
898
  }
576
899
  async function startResearch(force = false) {
577
900
  try {
@@ -611,45 +934,92 @@ $('#btn-cancel').addEventListener('click', async () => {
611
934
  });
612
935
 
613
936
  // ======================================================================
614
- // СЕЙЧАС: прогноз и живая проверка
937
+ // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
615
938
  // ======================================================================
939
+ let forecastBusy = false;
940
+ const prevClientExp = new Map();
941
+ const prevClientRank = new Map();
942
+
616
943
  async function loadForecast(force, silent = false) {
944
+ if (forecastBusy) return;
945
+ forecastBusy = true;
617
946
  const box = $('#forecast');
618
- if (!silent) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
619
- $('#btn-forecast').disabled = true;
947
+ if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
620
948
  try {
621
- 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);
622
964
  $('#fc-title').textContent = `Прогноз на ${f.horizon} ч · свеча ${f.bar.slice(8, 10)}.${f.bar.slice(5, 7)} ${f.bar.slice(11)}`;
623
- // ● у акции с новостями: зелёный/красный/серый по тону, подсказка — суть, источник и время
965
+ const badge = $('#fc-live-badge');
966
+ if (badge) badge.textContent = `● живой радар · ${liveTimeStr}`;
967
+
624
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>` : '');
625
- 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>
626
- <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>`;
627
- 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>
628
- <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>
629
- <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>`;
630
992
  setupMapSlider();
993
+ if (state.brokers?.accounts?.length) renderBrokers();
631
994
  } catch (e) {
632
995
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
633
996
  } finally {
634
- $('#btn-forecast').disabled = false;
997
+ forecastBusy = false;
998
+ syncUiAvailability();
635
999
  }
636
1000
  }
637
- $('#btn-forecast').addEventListener('click', () => loadForecast(true));
638
- const FORECAST_MAX_AGE = 3600000;
1001
+ const FORECAST_TICK_MS = 45000; // живое обновление каждые 45 секунд
639
1002
  function refreshForecastIfStale() {
640
- 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
+ }
641
1009
  }
642
- setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, 5 * 60000);
1010
+ setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
643
1011
 
644
1012
  function showNowSec(sec) {
645
1013
  document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
646
1014
  document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
1015
+ saveNavPatch({ nowSec: sec });
647
1016
  }
648
1017
  $('#now-nav').addEventListener('click', (e) => {
649
1018
  const b = e.target.closest('[data-nsec]');
650
1019
  if (b) showNowSec(b.dataset.nsec);
651
1020
  });
652
1021
 
1022
+ let openLiveId = loadSavedNav().openLiveId ?? null;
653
1023
  function renderLive(st) {
654
1024
  state.live = st;
655
1025
  const accs = st?.accounts || [];
@@ -659,13 +1029,17 @@ function renderLive(st) {
659
1029
  $('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
660
1030
  return;
661
1031
  }
1032
+ if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
1033
+ openLiveId = accs[0].runId;
1034
+ }
662
1035
  $('#live').innerHTML = accs.map((a) => {
1036
+ const isOpen = String(a.runId) === String(openLiveId);
663
1037
  const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
664
1038
  const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
665
1039
  const retPct = a.returnPct ?? 0;
666
1040
  const step = a.recent?.[0];
667
1041
  const p = a.params || {};
668
- const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ` · ${p.modelDirection === 'neutral' ? 'нейтрально' : 'покупки+продажи'}` : ''}`;
1042
+ const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
669
1043
  let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
670
1044
  if (a.corridor) {
671
1045
  const c = a.corridor;
@@ -678,31 +1052,36 @@ function renderLive(st) {
678
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>`;
679
1053
  }
680
1054
  const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
681
- return `<div class="live-card${a.running ? '' : ' stopped'}" data-acc="${a.runId}">
682
- <div class="live-head">
683
- <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>
684
1059
  <b>${esc(dispName)}</b>
685
- <span class="tag ${a.running ? 'ok' : ''}" style="margin-left:6px">${a.running ? 'работает' : 'остановлен'}</span>
1060
+ <span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
686
1061
  ${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
687
- <div class="muted small">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
688
1062
  </div>
689
- <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>
690
1065
  <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
691
1066
  ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
692
1067
  <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
693
1068
  </div>
694
1069
  </div>
695
- <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
696
- <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">на счёте</div></div>
697
- <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>
698
- ${corrHtml}
699
- <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>` : ''}
700
1081
  </div>
701
- <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>
702
- ${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>`}
703
- ${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
704
1082
  </div>`;
705
1083
  }).join('');
1084
+ syncUiAvailability();
706
1085
  }
707
1086
  $('#btn-live').addEventListener('click', async () => {
708
1087
  const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
@@ -741,6 +1120,14 @@ $('#live').addEventListener('click', async (e) => {
741
1120
  const id = Number(askBtn.dataset.liveAsk);
742
1121
  const acc = (state.live?.accounts || []).find((x) => x.runId === id);
743
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);
744
1131
  }
745
1132
  });
746
1133
 
@@ -898,6 +1285,7 @@ function drawProfilesMap() {
898
1285
  }
899
1286
  async function showMapSec(sec) {
900
1287
  document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
1288
+ saveNavPatch({ mapSec: sec });
901
1289
  if (sec === 'journal') {
902
1290
  $('#map-sec').hidden = true;
903
1291
  $('#journal-box').hidden = false;
@@ -945,12 +1333,12 @@ async function loadJournal() {
945
1333
  }));
946
1334
  }
947
1335
 
948
- // Группы настроек в форме; не перечисленные сюда попадают в «Дополнительно»
1336
+ // Логические группы настроек по смыслу:
1337
+ // g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
949
1338
  const GROUPS = [
950
- ['Общие', ['modelAutoAdapt', 'modelHorizon', 'modelTopK', 'modelDirection', 'modelType', 'commissionPct', 'autopilotBudget']],
951
- ['Для карты', ['modelEmbedDim', 'modelTextWeight', 'modelUseMacro']],
952
- ['Для Трансформера', ['modelUseTransformer', 'transformerEpochs', 'transformerGpu']],
953
- ['Дополнительно', []],
1339
+ ['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
1340
+ ['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
1341
+ ['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
954
1342
  ];
955
1343
  function buildForm() {
956
1344
  const field = (p) => {
@@ -965,7 +1353,7 @@ function buildForm() {
965
1353
  const cleanRow = `<div class="srow" data-key="_cleanup"><div class="stxt"><div class="slbl">Очистка истории экспериментов</div><div class="hint">Удалить старые эксперименты, оставив ровно указанное число лучших по итогу (0 — удалить все завершённые эксперименты).</div></div><div class="row-gap" style="align-items:center"><span class="muted small">оставить</span><input id="keep-n" type="number" value="0" min="0" max="50" class="sctrl" style="width:68px"><button id="btn-clean" type="button">Очистить историю</button></div></div>`;
966
1354
  $('#params').innerHTML = GROUPS.map(([name, keys], i) => {
967
1355
  const items = keys.length ? keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean) : shown.filter((p) => !listed.has(p.key));
968
- const extra = i === 3 ? cleanRow : '';
1356
+ const extra = i === 2 ? cleanRow : '';
969
1357
  return items.length || extra ? `<section class="ssec" data-sec="g${i}"${i ? ' hidden' : ''}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}${extra}</div></section>` : '';
970
1358
  }).join('');
971
1359
  $('#btn-clean')?.addEventListener('click', cleanRunsHistory);
@@ -975,6 +1363,8 @@ function showSettingsSec(sec) {
975
1363
  document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
976
1364
  document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
977
1365
  document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
1366
+ saveNavPatch({ settingsSec: sec });
1367
+ if (sec === 'cloud') renderCloudPane().catch(() => {});
978
1368
  }
979
1369
  document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
980
1370
  const b = e.target.closest('[data-sec]');
@@ -1013,6 +1403,7 @@ function markDefaults(s) {
1013
1403
  b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
1014
1404
  });
1015
1405
  syncApplyBtn();
1406
+ syncSettingsDependencies();
1016
1407
  updateTabBadges();
1017
1408
  }
1018
1409
  const saveSettings = async (body) => {
@@ -1222,27 +1613,21 @@ function syncActionBtn() {
1222
1613
  btn.hidden = Boolean(s?.busy);
1223
1614
  if (speechActive) {
1224
1615
  // 1. Микрофон включён -> в любом случае Стоп
1225
- $('#ico-mic').hidden = true;
1226
- $('#ico-send').hidden = true;
1227
- $('#ico-stop').hidden = false;
1616
+ btn.dataset.icon = 'stop';
1228
1617
  btn.classList.remove('send-mode');
1229
1618
  btn.classList.add('rec');
1230
1619
  btn.disabled = false;
1231
1620
  btn.title = 'Остановить запись голоса';
1232
1621
  } else if (hasText) {
1233
1622
  // 2. Микрофон выключен, есть текст -> Стрелка
1234
- $('#ico-mic').hidden = true;
1235
- $('#ico-send').hidden = false;
1236
- $('#ico-stop').hidden = true;
1623
+ btn.dataset.icon = 'send';
1237
1624
  btn.classList.add('send-mode');
1238
1625
  btn.classList.remove('rec');
1239
1626
  btn.disabled = !s?.llm;
1240
1627
  btn.title = 'Отправить (Enter)';
1241
1628
  } else {
1242
1629
  // 3. Микрофон выключен, нет текста -> Микрофон
1243
- $('#ico-mic').hidden = false;
1244
- $('#ico-send').hidden = true;
1245
- $('#ico-stop').hidden = true;
1630
+ btn.dataset.icon = 'mic';
1246
1631
  btn.classList.remove('send-mode', 'rec');
1247
1632
  btn.disabled = false;
1248
1633
  btn.title = 'Голосовой ввод (русский язык)';
@@ -1277,6 +1662,7 @@ function renderChatState() {
1277
1662
  $('#chat-status').textContent = st;
1278
1663
  for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
1279
1664
  renderSuggest();
1665
+ syncUiAvailability();
1280
1666
  }
1281
1667
 
1282
1668
  const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
@@ -1711,6 +2097,27 @@ addForm.addEventListener('submit', async (e) => {
1711
2097
  // ======================================================================
1712
2098
  const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
1713
2099
  const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
2100
+ const LEGACY_MODES = {
2101
+ isolate: 'Изолированный карман (старые бумаги не трогаются)',
2102
+ smooth: 'Плавный переход (продажа старых только под новые сигналы)',
2103
+ takeover: 'Полное управление (все акции под стратегией)',
2104
+ };
2105
+ const INST_KIND_LABEL = {
2106
+ share: ['Акция', 'ok'],
2107
+ metal: ['Драгметалл', 'mid'],
2108
+ currency: ['Валюта', 'mid'],
2109
+ bond: ['Облигация', ''],
2110
+ etf: ['Фонд', ''],
2111
+ futures: ['Фьючерс', 'mid'],
2112
+ };
2113
+ const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
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;
1714
2121
  let editingBrokerId = null;
1715
2122
 
1716
2123
  async function loadBrokers() {
@@ -1725,83 +2132,326 @@ function renderBrokers() {
1725
2132
  if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
1726
2133
  updateTabBadges();
1727
2134
 
1728
- // 1) список в Настройках
2135
+ // 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
1729
2136
  const listEl = $('#brokers-list');
1730
2137
  if (listEl) {
1731
- listEl.innerHTML = accs.length ? accs.map((a) => `<div class="llm-prov" data-bid="${esc(a.id)}">
1732
- <div class="llm-head">
1733
- <b>${esc(a.name)}</b>
1734
- <span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
1735
- <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
1736
- ${a.autoSchedule !== false ? '<span class="tag ok" title="Заявки рассчитываются автоматически раз в горизонт прогноза и приходят в Telegram / MAX">авто-расписание</span>' : ''}
1737
- ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
1738
- ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
1739
- <span class="grow"></span>
1740
- <button type="button" data-bact="sync">Проверить связь</button>
1741
- <button type="button" data-bact="edit" class="icon">Изменить</button>
1742
- <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>
1743
2159
  </div>
1744
- <div class="llm-meta">
1745
- Счёт: ${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)}` : ''}
1746
- ${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>` : ''}
1747
2169
  </div>
1748
- </div>`).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
2170
+ </div>`;
2171
+ }).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
1749
2172
  }
1750
2173
 
1751
- // 2) карточки во вкладке «Торговля»
2174
+ // 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
1752
2175
  const tradeEl = $('#brokers-trade');
1753
2176
  if (!tradeEl) return;
1754
2177
  if (!accs.length) {
1755
2178
  tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
1756
2179
  return;
1757
2180
  }
2181
+ if (!accs.some((a) => a.id === openBrokerId)) {
2182
+ openBrokerId = accs[0].id;
2183
+ }
2184
+ const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
1758
2185
  tradeEl.innerHTML = accs.map((a) => {
2186
+ const isOpen = a.id === openBrokerId;
1759
2187
  const ls = a.lastSync;
1760
2188
  const pb = a.pendingBatch;
1761
- const posHtml = ls?.positions?.length
1762
- ? `<table><thead><tr><th>Бумага</th><th class="num">Штук</th><th class="num">Цена</th><th class="num">Стоимость</th><th class="num">Финрезультат</th></tr></thead><tbody>${ls.positions.map((p) => `<tr><td><b>${esc(p.symbol)}</b></td><td class="num">${fmtNum(p.qty, 0)}</td><td class="num">${fmtNum(p.price, 3)}</td><td class="num">${fmtNum(p.valueRub, 0)} ₽</td><td class="num ${cls(p.pnlRub)}">${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</td></tr>`).join('')}</tbody></table>`
1763
- : `<p class="muted small">${ls?.ok ? 'Открытых позиций по акциям нет.' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и позиции с брокера.'}</p>`;
2189
+ const lh = a.lastHistory;
2190
+ const lMode = a.legacyMode || 'isolate';
2191
+ const baseMap = a.baselineQty || {};
2192
+ const curSubTab = brokerTabById.get(a.id) || 'assets';
2193
+
2194
+ const algoQtyOf = (p) => {
2195
+ if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
2196
+ if (lMode === 'takeover') return p.qty;
2197
+ const bq = Number(baseMap[p.symbol] || 0);
2198
+ if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
2199
+ if (p.qty < 0 && bq < 0) return Math.min(0, p.qty - bq);
2200
+ return p.qty;
2201
+ };
2202
+
2203
+ const posStatus = (p) => {
2204
+ if (p.instrumentType && p.instrumentType !== 'share') {
2205
+ const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
2206
+ return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
2207
+ }
2208
+ if (fcRowMap && !fcRowMap.has(p.symbol)) return '<span class="tag" title="Вне индекса широкого рынка — стратегия не трогает">вне словаря (защищена)</span>';
2209
+ if (lMode === 'takeover') return '<span class="tag ok">под управлением стратегии</span>';
2210
+ const bq = Number(baseMap[p.symbol] || 0);
2211
+ const aq = algoQtyOf(p);
2212
+ if (lMode === 'isolate') {
2213
+ if (aq === 0) return `<span class="tag" title="Начальная позиция счёта — робот её не продаёт">защищена (старый портфель: ${fmtNum(bq, 0)} шт.)</span>`;
2214
+ if (bq !== 0) return `<span class="tag ok">старый: ${fmtNum(bq, 0)} шт. · стратегия: ${fmtNum(aq, 0)} шт.</span>`;
2215
+ return '<span class="tag ok">открыта стратегией</span>';
2216
+ }
2217
+ if (aq === 0) return `<span class="tag mid" title="Будет плавно замещена при появлении новых сигналов покупки">плавный переход (${fmtNum(bq, 0)} шт.)</span>`;
2218
+ return '<span class="tag ok">под управлением стратегии</span>';
2219
+ };
2220
+
2221
+ // Полоса структуры активов (Allocation Bar)
2222
+ const totalVal = Math.max(1, Number(ls?.totalRub || 0));
2223
+ const allocSegments = [];
2224
+ (ls?.positions || []).forEach((p, idx) => {
2225
+ const pct = Math.max(0, Math.min(100, (Math.abs(p.valueRub || 0) / totalVal) * 100));
2226
+ if (pct >= 0.3) allocSegments.push({ label: `${p.name || p.symbol} (${p.symbol})`, valRub: p.valueRub, pct: Number(pct.toFixed(1)), color: ALLOC_PALETTE[idx % ALLOC_PALETTE.length] });
2227
+ });
2228
+ if ((ls?.cashRub || 0) > 10) {
2229
+ const cPct = Math.max(0, Math.min(100, (ls.cashRub / totalVal) * 100));
2230
+ allocSegments.push({ label: 'Свободные рубли (RUB)', valRub: ls.cashRub, pct: Number(cPct.toFixed(1)), color: '#16a34a' });
2231
+ }
2232
+ const allocBarHtml = allocSegments.length ? `<div class="brk-alloc-wrap">
2233
+ <div class="brk-alloc-bar">${allocSegments.map((s) => `<div style="width:${s.pct}%;background:${s.color}" title="${esc(s.label)}: ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)"></div>`).join('')}</div>
2234
+ <div class="brk-alloc-legend">${allocSegments.map((s) => `<span><i style="background:${s.color}"></i><b>${esc(s.label)}</b> — ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)</span>`).join('')}</div>
2235
+ </div>` : '';
2236
+
2237
+ // Считаем сколько ₽ в стратегии и сколько ₽ защищено
2238
+ let strategyRub = 0, protectedRub = 0;
2239
+ for (const p of ls?.positions || []) {
2240
+ const aq = algoQtyOf(p);
2241
+ const aVal = Math.abs(aq * (p.price || 0));
2242
+ strategyRub += aVal;
2243
+ protectedRub += Math.max(0, Math.abs(p.valueRub || 0) - aVal);
2244
+ }
2245
+ const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
1764
2246
 
2247
+ // Вкладка 1: Активы портфеля
2248
+ const posHtml = ls?.positions?.length
2249
+ ? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
2250
+ const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
2251
+ const fcRow = fcRowMap?.get(p.symbol);
2252
+ const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
2253
+ return `<tr>
2254
+ <td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
2255
+ <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
2256
+ <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
2257
+ <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
2258
+ <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
2259
+ <td class="num ${cls(p.pnlRub)}"><b>${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</b>${p.pnlPct ? ` <span class="small">(${p.pnlPct >= 0 ? '+' : ''}${fmtNum(p.pnlPct, 1)}%)</span>` : ''}</td>
2260
+ <td>${posStatus(p)}${fcBadge}</td>
2261
+ </tr>`;
2262
+ }).join('')}</tbody></table>`
2263
+ : `<p class="muted small">${ls?.ok ? 'Открытых позиций на счёте сейчас нет (все средства в рублях).' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и активы с брокера.'}</p>`;
2264
+
2265
+ // Вкладка 2: Торговля и заявки стратегии
2266
+ const activeOrdHtml = ls?.activeOrders?.length
2267
+ ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b></div><table><thead><tr><th>Бумага</th><th>Направление</th><th class="num">Лотов (исп./всего)</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${ls.activeOrders.map((o) => `<tr><td><b>${esc(o.symbol)}</b> ${o.name ? `<span class="muted small">${esc(o.name)}</span>` : ''}</td><td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td><td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td><td class="num">${fmtNum(o.price, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td></tr>`).join('')}</tbody></table></div>`
2268
+ : '';
1765
2269
  let batchHtml = '';
1766
2270
  if (pb) {
1767
2271
  const ordRows = pb.orders?.length
1768
- ? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
1769
- : '<p class="muted small">По текущему прогнозу новых сделок не требуется.</p>';
2272
+ ? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
2273
+ : `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
1770
2274
  const resHtml = pb.results?.length
1771
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>`
1772
2276
  : '';
1773
- batchHtml = `<div class="stab-box" style="margin-top:10px">
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
+ : '';
2280
+ batchHtml = `<div class="stab-box">
1774
2281
  <div class="stab-head">
1775
2282
  <b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
1776
2283
  ${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
1777
2284
  <span class="grow"></span>
1778
- ${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>` : ''}
1779
- <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>`}
1780
2286
  </div>
1781
2287
  ${ordRows}
1782
2288
  ${resHtml}
2289
+ ${throttleHtml}
1783
2290
  </div>`;
2291
+ } else {
2292
+ batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
1784
2293
  }
1785
2294
 
1786
- return `<div class="live-card" data-bid="${esc(a.id)}">
1787
- <div class="live-head">
1788
- <div>
1789
- <b>${esc(a.name)}</b>
1790
- <span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
2295
+ // Вкладка 3: История операций и динамика счёта
2296
+ const eqHist = Array.isArray(a.equityHistory) ? a.equityHistory : [];
2297
+ const eqHistHtml = eqHist.length > 1
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>`
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
+
2398
+ const histHtml = lh?.operations?.length
2399
+ ? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2400
+ <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
2401
+ <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2402
+ <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
2403
+ <div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
2404
+ </div>
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>` : ''}
1791
2413
  <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
1792
2414
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
1793
2415
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
1794
- <div class="muted small">Предохранители: лимит ${fmtNum(a.maxCapitalRub, 0)} ₽ · дневной стоп −${fmtNum(a.maxDailyDrawdownPct, 1)}% · отступ лимитки ${fmtNum(a.limitOffsetPct, 2)}%${a.skipEdgeMinutes ? ' · пропуск краёв сессии' : ''}${a.baseRunLabel ? ` · настройки ${esc(a.baseRunLabel)}` : ''}</div>
1795
2416
  </div>
1796
- <div class="row-gap">
1797
- <button type="button" data-btrade="sync" data-bid="${esc(a.id)}">Обновить портфель</button>
1798
- <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>` : ''}
2419
+ </div>
2420
+ </div>
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>
1799
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>
1800
2454
  </div>
1801
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(3,1fr)"><div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте</div></div><div class="kpi"><div class="v">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли</div></div><div class="kpi"><div class="v ${cls(-(ls.dayDrawdownPct || 0))}">${fmtNum(-(ls.dayDrawdownPct || 0), 2)}%</div><div class="l">изменение за день (стоп −${fmtNum(a.maxDailyDrawdownPct, 1)}%)</div></div></div>` : ''}
1802
- ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
1803
- ${posHtml}
1804
- ${batchHtml}
1805
2455
  </div>`;
1806
2456
  }).join('');
1807
2457
  }
@@ -1812,15 +2462,141 @@ $('#btn-brokers-cfg').addEventListener('click', () => {
1812
2462
  });
1813
2463
 
1814
2464
  const brokerForm = $('#broker-form');
2465
+ let brokerInitialSnap = '';
2466
+
2467
+ function getBrokerFormSnap() {
2468
+ const f = brokerForm.elements;
2469
+ return JSON.stringify({
2470
+ name: f.name.value.trim(),
2471
+ broker: f.broker.value,
2472
+ accountId: f.accountId.value.trim(),
2473
+ token: f.token.value.trim(),
2474
+ mode: f.mode.value,
2475
+ legacyMode: f.legacyMode?.value || 'isolate',
2476
+ baseRunId: f.baseRunId.value || '',
2477
+ maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2478
+ maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2479
+ limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2480
+ autoSchedule: Boolean(f.autoSchedule.checked),
2481
+ skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
2482
+ sandbox: Boolean(f.sandbox.checked),
2483
+ });
2484
+ }
2485
+
2486
+ function updateBrokerSaveBtnState() {
2487
+ const btn = $('#broker-save-btn');
2488
+ if (!btn) return;
2489
+ if (!editingBrokerId) {
2490
+ const f = brokerForm.elements;
2491
+ const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim());
2492
+ btn.disabled = !hasNewInput;
2493
+ btn.classList.toggle('primary', hasNewInput);
2494
+ btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
2495
+ return;
2496
+ }
2497
+ const changed = getBrokerFormSnap() !== brokerInitialSnap;
2498
+ btn.disabled = !changed;
2499
+ btn.classList.toggle('primary', changed);
2500
+ btn.title = changed ? 'Сохранить внесённые изменения' : 'Нет изменений для сохранения';
2501
+ }
2502
+
2503
+ brokerForm.addEventListener('input', updateBrokerSaveBtnState);
2504
+ brokerForm.addEventListener('change', updateBrokerSaveBtnState);
2505
+
2506
+ function startBrokerEdit(acc) {
2507
+ editingBrokerId = acc.id;
2508
+ const f = brokerForm.elements;
2509
+ f.id.value = acc.id;
2510
+ f.name.value = acc.name;
2511
+ f.broker.value = acc.broker;
2512
+ f.accountId.value = acc.accountId || '';
2513
+ f.token.value = '';
2514
+ f.mode.value = acc.mode || 'confirm';
2515
+ if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2516
+ if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
2517
+ const opt = document.createElement('option');
2518
+ opt.value = String(acc.baseRunId);
2519
+ opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
2520
+ f.baseRunId.appendChild(opt);
2521
+ }
2522
+ f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
2523
+ f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
2524
+ f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
2525
+ f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2526
+ f.autoSchedule.checked = acc.autoSchedule !== false;
2527
+ f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2528
+ f.sandbox.checked = !!acc.sandbox;
2529
+ $('#broker-add-box').open = true;
2530
+ $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
2531
+ $('#broker-save-btn').textContent = 'Сохранить изменения';
2532
+ $('#broker-cancel-btn').hidden = false;
2533
+ brokerInitialSnap = getBrokerFormSnap();
2534
+ updateBrokerSaveBtnState();
2535
+ }
2536
+
1815
2537
  function stopBrokerEdit() {
1816
2538
  editingBrokerId = null;
1817
2539
  brokerForm.reset();
1818
2540
  brokerForm.elements.id.value = '';
2541
+ if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
1819
2542
  $('#broker-add-sum').textContent = 'Добавить брокерский счёт';
1820
2543
  $('#broker-save-btn').textContent = 'Сохранить счёт';
1821
2544
  $('#broker-cancel-btn').hidden = true;
2545
+ brokerInitialSnap = getBrokerFormSnap();
2546
+ updateBrokerSaveBtnState();
1822
2547
  }
1823
2548
  $('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
2549
+ updateBrokerSaveBtnState();
2550
+
2551
+ // Авто-поиск всех субсчетов в Т-Инвестициях по токену
2552
+ $('#btn-broker-discover')?.addEventListener('click', async () => {
2553
+ const btn = $('#btn-broker-discover');
2554
+ const box = $('#broker-discovered');
2555
+ const f = brokerForm.elements;
2556
+ btn.disabled = true;
2557
+ const old = btn.textContent;
2558
+ btn.textContent = 'Ищем счета…';
2559
+ try {
2560
+ const res = await post('/api/brokers/discover', {
2561
+ id: editingBrokerId || undefined,
2562
+ broker: f.broker.value,
2563
+ token: f.token.value,
2564
+ sandbox: f.sandbox.checked,
2565
+ });
2566
+ const list = res.accounts || [];
2567
+ if (!list.length) {
2568
+ box.hidden = false;
2569
+ box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2570
+ } else {
2571
+ box.hidden = false;
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)">
2573
+ <div>
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>` : ''}
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>
2576
+ </div>
2577
+ <button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
2578
+ </div>`).join('');
2579
+ }
2580
+ } catch (err) {
2581
+ toast(err.message, 'error');
2582
+ } finally {
2583
+ btn.disabled = false;
2584
+ btn.textContent = old;
2585
+ }
2586
+ });
2587
+
2588
+ $('#broker-discovered')?.addEventListener('click', (e) => {
2589
+ const b = e.target.closest('button[data-pick-acc]');
2590
+ if (!b) return;
2591
+ const f = brokerForm.elements;
2592
+ f.accountId.value = b.dataset.pickAcc;
2593
+ if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
2594
+ f.name.value = `Т-Банк · ${b.dataset.pickName}`;
2595
+ }
2596
+ updateBrokerSaveBtnState();
2597
+ toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
2598
+ });
2599
+
1824
2600
  brokerForm.addEventListener('submit', async (e) => {
1825
2601
  e.preventDefault();
1826
2602
  const f = brokerForm.elements;
@@ -1831,6 +2607,7 @@ brokerForm.addEventListener('submit', async (e) => {
1831
2607
  accountId: f.accountId.value,
1832
2608
  token: f.token.value,
1833
2609
  mode: f.mode.value,
2610
+ legacyMode: f.legacyMode?.value || 'isolate',
1834
2611
  baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
1835
2612
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
1836
2613
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
@@ -1852,30 +2629,22 @@ brokerForm.addEventListener('submit', async (e) => {
1852
2629
 
1853
2630
  $('#brokers-list').addEventListener('click', async (e) => {
1854
2631
  const b = e.target.closest('button[data-bact]');
1855
- 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
+ }
1856
2642
  const id = b.closest('[data-bid]').dataset.bid;
1857
2643
  const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
1858
2644
  if (!acc) return;
1859
2645
  if (b.dataset.bact === 'edit') {
1860
- editingBrokerId = acc.id;
1861
- const f = brokerForm.elements;
1862
- f.id.value = acc.id;
1863
- f.name.value = acc.name;
1864
- f.broker.value = acc.broker;
1865
- f.accountId.value = acc.accountId || '';
1866
- f.token.value = '';
1867
- f.mode.value = acc.mode || 'confirm';
1868
- f.baseRunId.value = acc.baseRunId || '';
1869
- f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
1870
- f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
1871
- f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
1872
- f.autoSchedule.checked = acc.autoSchedule !== false;
1873
- f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
1874
- f.sandbox.checked = !!acc.sandbox;
1875
- $('#broker-add-box').open = true;
1876
- $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
1877
- $('#broker-save-btn').textContent = 'Сохранить изменения';
1878
- $('#broker-cancel-btn').hidden = false;
2646
+ startBrokerEdit(acc);
2647
+ $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1879
2648
  return;
1880
2649
  }
1881
2650
  if (b.dataset.bact === 'del') {
@@ -1901,19 +2670,65 @@ $('#brokers-list').addEventListener('click', async (e) => {
1901
2670
  });
1902
2671
 
1903
2672
  $('#brokers-trade').addEventListener('click', async (e) => {
2673
+ const tabBtn = e.target.closest('button[data-btab]');
2674
+ if (tabBtn) {
2675
+ const id = tabBtn.dataset.bid;
2676
+ const t = tabBtn.dataset.btab;
2677
+ setBrokerSubTab(id, t);
2678
+ renderBrokers();
2679
+ if (t === 'history') {
2680
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2681
+ if (acc && !acc.lastHistory) {
2682
+ post(`/api/brokers/${encodeURIComponent(id)}/history`).then((st) => { state.brokers = st; renderBrokers(); }).catch((err) => toast(err.message, 'error'));
2683
+ }
2684
+ }
2685
+ return;
2686
+ }
1904
2687
  const b = e.target.closest('button[data-btrade]');
1905
- 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
+ }
1906
2698
  const id = b.dataset.bid;
1907
2699
  const act = b.dataset.btrade;
2700
+ if (act === 'cfg') {
2701
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2702
+ switchTab('settings');
2703
+ showSettingsSec('brokers');
2704
+ if (acc) {
2705
+ startBrokerEdit(acc);
2706
+ setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
2707
+ }
2708
+ return;
2709
+ }
1908
2710
  b.disabled = true;
1909
2711
  const old = b.textContent;
1910
2712
  try {
1911
2713
  if (act === 'sync') {
1912
- b.textContent = 'Обновляем…';
2714
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
1913
2715
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
1914
2716
  toast('Портфель обновлён');
2717
+ } else if (act === 'hist') {
2718
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Загружаем…';
2719
+ setBrokerSubTab(id, 'history');
2720
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
2721
+ toast('История операций загружена');
2722
+ } else if (act === 'freeze') {
2723
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
2724
+ const updated = (state.brokers?.accounts || []).find((x) => x.id === id);
2725
+ const isIso = updated?.legacyMode === 'isolate';
2726
+ toast(isIso
2727
+ ? '🔒 Защита бумаг включена (режим «Изолированный карман» — старые бумаги не продаются)'
2728
+ : '🔓 Включён режим «Плавный переход» (разрешено плавное замещение старых бумаг под сигналы)');
1915
2729
  } else if (act === 'prep') {
1916
- b.textContent = 'Считаем лоты…';
2730
+ if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Считаем лоты…';
2731
+ setBrokerSubTab(id, 'trade');
1917
2732
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
1918
2733
  toast('Пакет заявок по прогнозу рассчитан');
1919
2734
  } else if (act === 'exec') {
@@ -2004,6 +2819,8 @@ function connectWs() {
2004
2819
  case 'journal:update': loadJournal().catch(() => {}); break;
2005
2820
  case 'research:start':
2006
2821
  state.active = m.runId;
2822
+ resetLiveTrain(m.runId);
2823
+ delete liveTrainState.firstTestLeft;
2007
2824
  researchUi(true);
2008
2825
  setProgress(1, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}: начинаем…`);
2009
2826
  liveLine(`r-${m.runId}`, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}${m.note ? ` — ${esc(m.note)}` : ''}: <span class="pg">начинаем…</span><div class="bar-s"><div></div></div>`);
@@ -2015,7 +2832,19 @@ function connectWs() {
2015
2832
  $('.pg', el).textContent = m.text;
2016
2833
  $('.bar-s div', el).style.width = `${pct}%`;
2017
2834
  if (!state.active) { state.active = m.runId; researchUi(true); }
2018
- if (m.runId === state.active) setProgress(pct, m.text);
2835
+ if (m.runId === state.active) {
2836
+ setProgress(pct, m.text);
2837
+ if (liveTrainState.runId !== m.runId) { resetLiveTrain(m.runId); delete liveTrainState.firstTestLeft; }
2838
+ liveTrainState.stage = m.stage;
2839
+ liveTrainState.text = m.text || '';
2840
+ if (m.symbol && !liveTrainState.loadedSymbols.includes(m.symbol)) liveTrainState.loadedSymbols.push(m.symbol);
2841
+ if (m.viz) {
2842
+ if (m.viz.symbols) liveTrainState.symbols = m.viz.symbols;
2843
+ if (m.viz.foldIc !== undefined && m.viz.fold) liveTrainState.foldIcs[m.viz.fold - 1] = m.viz.foldIc;
2844
+ liveTrainState.lastViz = m.viz;
2845
+ }
2846
+ if (state.run?.id === m.runId && state.run?.status !== 'done') renderLiveTraining();
2847
+ }
2019
2848
  break;
2020
2849
  }
2021
2850
  case 'research:done':
@@ -2061,7 +2890,6 @@ function connectWs() {
2061
2890
  case 'stability:progress':
2062
2891
  stabilityUi(m.runId);
2063
2892
  setProgress(m.pct, m.text);
2064
- $('#btn-stability').textContent = `${m.pct}% · проверяем…`;
2065
2893
  break;
2066
2894
  case 'stability:done':
2067
2895
  stabilityUi(null);
@@ -2083,7 +2911,42 @@ function connectWs() {
2083
2911
  };
2084
2912
  }
2085
2913
 
2086
- // ---------- профиль, тарифы Ю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 ----------
2087
2950
  $('#btn-user')?.addEventListener('click', () => {
2088
2951
  switchTab('settings');
2089
2952
  showSettingsSec('cloud');
@@ -2096,11 +2959,19 @@ async function renderCloudPane() {
2096
2959
  const me = await api('/api/auth/me').catch(() => state.config || {});
2097
2960
  state.config = { ...(state.config || {}), ...me };
2098
2961
  const u = me.user;
2962
+ const lic = u?.localLicense;
2963
+ const isDefaultLocal = !me.cloudMode && u?.id === 1 && u?.email === 'owner@neuroalpha.local';
2964
+ const dispName = (lic?.name && lic.name !== 'Владелец платформы' && lic.name !== 'Пользователь')
2965
+ ? lic.name
2966
+ : (isDefaultLocal ? 'Пользователь' : (u?.name || 'Пользователь'));
2967
+ const dispEmail = (lic?.email && lic.email !== 'owner@neuroalpha.local')
2968
+ ? lic.email
2969
+ : (isDefaultLocal ? '' : (u?.email || ''));
2099
2970
  const userBtn = $('#btn-user');
2100
- if (userBtn) userBtn.textContent = u ? `${u.name} · ${u.planName}` : 'Войти';
2971
+ if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Войти';
2101
2972
 
2102
2973
  if (!u) {
2103
- box.innerHTML = `<div class="srow"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><a class="btn primary" href="/landing">Войти / Регистрация</a></div>`;
2974
+ box.innerHTML = `<div class="srow sblock"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><div class="row-gap"><a class="btn primary" href="/landing">Войти / Регистрация</a></div></div>`;
2104
2975
  return;
2105
2976
  }
2106
2977
 
@@ -2109,129 +2980,83 @@ async function renderCloudPane() {
2109
2980
  const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
2110
2981
  const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
2111
2982
 
2112
- let adminHtml = '';
2983
+ let customBotsHtml = '';
2984
+ let adminLinkHtml = '';
2113
2985
  if (u.role === 'admin') {
2114
- const adm = await api('/api/admin/state').catch(() => null);
2115
- if (adm) {
2116
- const b = adm.billing || {}, n = adm.notify || {};
2117
- adminHtml = `<div style="margin-top:22px;border-top:1px solid var(--line);padding-top:16px">
2118
- <div class="slbl" style="margin-bottom:10px">Панель администратора облака (ЮKassa, боты Telegram / MAX, пользователи)</div>
2119
- <form id="adm-billing-form" style="display:grid;gap:10px;margin-bottom:16px">
2120
- <div class="muted small"><b>Приём оплаты ЮKassa (API v3)</b> · Webhook URL: <code>${location.origin}/api/billing/yookassa/webhook</code></div>
2121
- <div class="row-gap" style="flex-wrap:wrap">
2122
- <input name="shopId" value="${esc(b.shopId || '')}" placeholder="Shop ID ЮKassa" style="width:180px">
2123
- <input name="secretKey" type="password" placeholder="${b.hasSecret ? 'Секретный ключ задан (оставьте пустым)' : 'Секретный ключ ЮKassa'}" style="flex:1;min-width:220px">
2124
- <label class="row-gap small muted" style="align-items:center"><input type="checkbox" name="sendReceipt" class="switch"${b.sendReceipt ? ' checked' : ''}>Чеки 54-ФЗ</label>
2125
- <button type="submit" class="primary">Сохранить ЮKassa</button>
2126
- </div>
2127
- </form>
2128
- <form id="adm-notify-form" style="display:grid;gap:10px;margin-bottom:16px">
2129
- <div class="muted small"><b>Боты уведомлений и подтверждения заявок (Telegram и MAX)</b></div>
2986
+ if (me.cloudMode) {
2987
+ adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
2988
+ <div class="stxt">
2989
+ <div class="slbl">Консоль администратора платформы</div>
2990
+ <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
2991
+ </div>
2992
+ <div class="row-gap" style="flex-wrap:wrap">
2993
+ <a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
2994
+ </div>
2995
+ </div>`;
2996
+ } else {
2997
+ customBotsHtml = `<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
2998
+ <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
2999
+ <form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
3000
+ <div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
2130
3001
  <div class="row-gap" style="flex-wrap:wrap">
2131
- <input name="tgBotName" value="${esc(n.tgBotName || '')}" placeholder="Имя @бота Telegram" style="width:180px">
2132
- <input name="tgToken" type="password" placeholder="${n.hasTgToken ? 'Токен Telegram задан' : 'Токен Telegram Bot API'}" style="flex:1;min-width:220px">
3002
+ <input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
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">
2133
3004
  </div>
2134
3005
  <div class="row-gap" style="flex-wrap:wrap">
2135
- <input name="maxBotName" value="${esc(n.maxBotName || '')}" placeholder="Имя бота MAX" style="width:180px">
2136
- <input name="maxToken" type="password" placeholder="${n.hasMaxToken ? 'Токен MAX задан' : 'Токен MAX Bot API'}" style="flex:1;min-width:220px">
3006
+ <input name="maxBotName" value="${esc(nt.maxBotName || '')}" placeholder="Имя бота MAX" style="width:200px">
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">
2137
3008
  <button type="submit" class="primary">Сохранить ботов</button>
2138
3009
  </div>
3010
+ <div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
2139
3011
  </form>
2140
- <form id="adm-promo-form" style="display:grid;gap:8px;margin-bottom:16px">
2141
- <div class="muted small"><b>Промокоды (выдача пробного доступа)</b></div>
2142
- <div class="row-gap" style="flex-wrap:wrap">
2143
- <input name="code" placeholder="Код (напр. ALPHA14)" style="width:150px" required>
2144
- <select name="plan" style="width:150px"><option value="investor">Инвестор</option><option value="pro">Профи</option></select>
2145
- <input name="days" type="number" value="14" min="1" max="365" style="width:90px" title="На сколько дней">
2146
- <input name="maxUses" type="number" value="50" min="1" max="10000" style="width:90px" title="Макс. активаций">
2147
- <button type="submit">Создать промокод</button>
2148
- </div>
2149
- ${adm.promos?.length ? `<div class="muted small">Активные коды: ${adm.promos.map((p) => `<code>${esc(p.code)}</code> (${esc(p.plan)}, ${p.days} дн., исп. ${p.used_count}/${p.max_uses})`).join(' · ')}</div>` : ''}
2150
- </form>
2151
- <div class="muted small" style="margin-bottom:6px"><b>Пользователи платформы (${adm.users?.length || 0})</b></div>
2152
- <table><thead><tr><th>ID</th><th>Имя / Email</th><th>Тариф</th><th>Мессенджеры</th><th>Сменить тариф</th></tr></thead><tbody>
2153
- ${(adm.users || []).map((usr) => `<tr>
2154
- <td>${usr.id}</td>
2155
- <td><b>${esc(usr.name)}</b> <span class="muted small">${esc(usr.email)}</span></td>
2156
- <td><span class="tag ok">${esc(usr.planName)}</span></td>
2157
- <td class="small">${usr.tgConnected ? '<span class="pos">TG ✓</span> ' : ''}${usr.maxConnected ? '<span class="pos">MAX ✓</span>' : (!usr.tgConnected ? '—' : '')}</td>
2158
- <td>
2159
- <select data-adm-plan="${usr.id}" class="sctrl" style="padding:2px 6px">
2160
- <option value="free"${usr.plan === 'free' ? ' selected' : ''}>Старт (Free)</option>
2161
- <option value="investor"${usr.plan === 'investor' ? ' selected' : ''}>Инвестор</option>
2162
- <option value="pro"${usr.plan === 'pro' ? ' selected' : ''}>Профи</option>
2163
- </select>
2164
- </td>
2165
- </tr>`).join('')}
2166
- </tbody></table>
2167
- </div>`;
3012
+ </details>`;
2168
3013
  }
2169
3014
  }
2170
3015
 
2171
- const apiKey = u.apiKey || '';
2172
- const npxCmd = apiKey ? `npx neuroalpha --key ${apiKey}` : 'npx neuroalpha';
2173
- const lic = u.localLicense;
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 ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
2174
3025
 
2175
3026
  box.innerHTML = `
2176
- <div class="srow">
3027
+ <div class="srow sblock">
2177
3028
  <div class="stxt">
2178
- <div class="slbl">${esc(u.name)} · <span class="muted">${esc(u.email)}</span> <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
2179
- <div class="hint">Лимиты тарифа: до ${u.limits?.maxRunsPerDay ?? 2} экспериментов в сутки · виртуальных счетов: до ${u.limits?.maxVirtualAccounts ?? 1} · брокерских счетов: до ${u.limits?.maxBrokerAccounts ?? 0}${u.planUntil ? ` · оплачен до ${fmtDate(u.planUntil)}` : ''}.</div>
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>
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>
2180
3033
  </div>
2181
- <div class="row-gap">
3034
+ ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
2182
3035
  <button type="button" id="btn-cloud-verify">Проверить оплату</button>
2183
3036
  <button type="button" id="btn-cloud-logout">Выйти</button>
2184
- </div>
3037
+ </div>` : ''}
2185
3038
  </div>
2186
- <div class="srow">
3039
+ <div class="srow sblock">
2187
3040
  <div class="stxt">
2188
- <div class="slbl">Ваш персональный API-ключ (для локального запуска через <code>npx</code> и API)</div>
3041
+ <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
2189
3042
  <div class="hint">
2190
- Ключ: <b><code id="user-api-key">${esc(apiKey || 'не создан')}</code></b><br>
2191
- Команда для запуска на вашем ПК (ключи брокера остаются локально): <code>${esc(npxCmd)}</code>
2192
- ${lic ? `<br><span class="pos">✓ Активирована лицензия: ${esc(lic.planName)} (${esc(lic.email || '')})</span>` : ''}
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>).
2193
3047
  </div>
2194
3048
  </div>
2195
3049
  <div class="row-gap" style="flex-wrap:wrap">
2196
- <button type="button" id="btn-copy-apikey" class="primary">Скопировать ключ</button>
2197
- <button type="button" id="btn-copy-npx">Скопировать npx-команду</button>
2198
- <button type="button" id="btn-regen-apikey" title="Перевыпустить API-ключ (старый перестанет работать)">Перевыпустить</button>
3050
+ <input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
3051
+ <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
3052
+ <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
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>
2199
3055
  </div>
2200
3056
  </div>
2201
- ${!me.cloudMode ? `<div class="srow">
3057
+ <div class="srow sblock">
2202
3058
  <div class="stxt">
2203
- <div class="slbl">Активировать API-ключ подписки с сайта</div>
2204
- <div class="hint">Если вы зарегистрировались и оплатили тариф на сайте, вставьте полученный <code>na_live_…</code> ключ сюда для разблокировки тарифа на этом компьютере.</div>
2205
- </div>
2206
- <div class="row-gap" style="flex-wrap:wrap">
2207
- <input id="lic-key-in" value="${esc(lic?.apiKey || '')}" placeholder="na_live_…" style="width:210px">
2208
- <input id="lic-url-in" value="${esc(lic?.cloudUrl && lic.cloudUrl !== 'local' ? lic.cloudUrl : '')}" placeholder="URL сайта (если внешний)" style="width:170px">
2209
- <button type="button" id="btn-lic-activate">Активировать ключ</button>
2210
- </div>
2211
- </div>` : ''}
2212
- <div class="srow">
2213
- <div class="stxt">
2214
- <div class="slbl">Тариф и подписка (карты МИР и СБП)</div>
2215
- <div class="hint">Мгновенное подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram и MAX и расширенных лимитов.</div>
2216
- </div>
2217
- <div class="row-gap" style="flex-wrap:wrap">
2218
- <button type="button" class="primary" data-checkout="investor">Оформить «Инвестор» · 3 900 ₽/мес</button>
2219
- <button type="button" class="primary" data-checkout="pro">Оформить «Профи» · 9 900 ₽/мес</button>
2220
- </div>
2221
- </div>
2222
- <div class="srow">
2223
- <div class="stxt">
2224
- <div class="slbl">Активировать промокод</div>
2225
- <div class="hint">Введите промокод партнёра или пробного периода для подключения тарифа «Инвестор» или «Профи».</div>
2226
- </div>
2227
- <div class="row-gap">
2228
- <input id="promo-code-in" placeholder="Промокод" style="width:140px">
2229
- <button type="button" id="btn-promo-redeem">Активировать</button>
2230
- </div>
2231
- </div>
2232
- <div class="srow">
2233
- <div class="stxt">
2234
- <div class="slbl">Боты Telegram и MAX (уведомления и исполнение заявок в 1 клик)</div>
3059
+ <div class="slbl">Уведомления в Telegram и MAX (подтверждение заявок в 1 клик)</div>
2235
3060
  <div class="hint">
2236
3061
  Ваш код привязки: <b><code>${esc(code)}</code></b> — отправьте этот код боту в Telegram или в мессенджер MAX.<br>
2237
3062
  Статус: Telegram — ${u.tgConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'} · MAX — ${u.maxConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'}
@@ -2241,39 +3066,13 @@ async function renderCloudPane() {
2241
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>` : ''}
2242
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>` : ''}
2243
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>
2244
3070
  </div>
2245
3071
  </div>
2246
- ${adminHtml}
3072
+ ${adminLinkHtml}
3073
+ ${customBotsHtml}
2247
3074
  `;
2248
3075
 
2249
- box.querySelector('#btn-copy-apikey')?.addEventListener('click', () => {
2250
- if (!apiKey) return;
2251
- navigator.clipboard?.writeText(apiKey);
2252
- toast('API-ключ скопирован в буфер обмена');
2253
- });
2254
- box.querySelector('#btn-copy-npx')?.addEventListener('click', () => {
2255
- navigator.clipboard?.writeText(npxCmd);
2256
- toast(`Команда «${npxCmd}» скопирована`);
2257
- });
2258
- box.querySelector('#btn-regen-apikey')?.addEventListener('click', async () => {
2259
- if (!confirm('Перевыпустить API-ключ? Прежний ключ перестанет работать.')) return;
2260
- try {
2261
- await post('/api/auth/api-key/regenerate');
2262
- toast('Новый API-ключ выпущен');
2263
- renderCloudPane();
2264
- } catch (e) { toast(e.message, 'error'); }
2265
- });
2266
- box.querySelector('#btn-lic-activate')?.addEventListener('click', async () => {
2267
- const k = box.querySelector('#lic-key-in')?.value.trim();
2268
- const uUrl = box.querySelector('#lic-url-in')?.value.trim();
2269
- if (!k) return toast('Введите API-ключ (na_live_…)', 'error');
2270
- try {
2271
- const r = await post('/api/license/activate', { apiKey: k, cloudUrl: uUrl });
2272
- toast(`Ключ активирован! Тариф: ${r.license?.planName}`);
2273
- renderCloudPane();
2274
- } catch (e) { toast(e.message, 'error'); }
2275
- });
2276
-
2277
3076
  box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
2278
3077
  await post('/api/auth/logout');
2279
3078
  location.href = '/landing';
@@ -2285,11 +3084,14 @@ async function renderCloudPane() {
2285
3084
  renderCloudPane();
2286
3085
  } catch (e) { toast(e.message, 'error'); }
2287
3086
  });
2288
- box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', async () => {
2289
- try {
2290
- const r = await post('/api/billing/checkout', { plan: b.dataset.checkout, months: 1, returnOrigin: location.origin });
2291
- if (r.confirmationUrl) location.href = r.confirmationUrl;
2292
- } 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
+ });
2293
3095
  }));
2294
3096
  box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
2295
3097
  try {
@@ -2297,32 +3099,16 @@ async function renderCloudPane() {
2297
3099
  toast('Тестовое уведомление отправлено в привязанные мессенджеры');
2298
3100
  } catch (e) { toast(e.message, 'error'); }
2299
3101
  });
2300
- box.querySelector('#btn-promo-redeem')?.addEventListener('click', async () => {
3102
+ box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
2301
3103
  const code = box.querySelector('#promo-code-in')?.value.trim();
2302
- if (!code) return;
2303
- try {
2304
- const r = await post('/api/auth/redeem-promo', { code });
2305
- toast(`Промокод активирован! Ваш тариф: ${r.user?.planName}`);
2306
- renderCloudPane();
2307
- } catch (e) { toast(e.message, 'error'); }
2308
- });
2309
- box.querySelector('#adm-promo-form')?.addEventListener('submit', async (e) => {
2310
- e.preventDefault();
2311
- const f = e.target.elements;
2312
- try {
2313
- await post('/api/admin/promo', { code: f.code.value, plan: f.plan.value, days: f.days.value, maxUses: f.maxUses.value });
2314
- toast('Промокод создан');
2315
- renderCloudPane();
2316
- } catch (err) { toast(err.message, 'error'); }
2317
- });
2318
- box.querySelector('#adm-billing-form')?.addEventListener('submit', async (e) => {
2319
- e.preventDefault();
2320
- const f = e.target.elements;
2321
- try {
2322
- await post('/api/admin/billing', { shopId: f.shopId.value, secretKey: f.secretKey.value, sendReceipt: f.sendReceipt.checked });
2323
- toast('Настройки ЮKassa сохранены');
2324
- renderCloudPane();
2325
- } catch (err) { toast(err.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
+ });
2326
3112
  });
2327
3113
  box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
2328
3114
  e.preventDefault();
@@ -2333,13 +3119,6 @@ async function renderCloudPane() {
2333
3119
  renderCloudPane();
2334
3120
  } catch (err) { toast(err.message, 'error'); }
2335
3121
  });
2336
- box.querySelectorAll('[data-adm-plan]').forEach((sel) => sel.addEventListener('change', async () => {
2337
- try {
2338
- await post('/api/admin/user-plan', { userId: Number(sel.dataset.admPlan), plan: sel.value, days: 30 });
2339
- toast('Тариф пользователя обновлён');
2340
- renderCloudPane();
2341
- } catch (err) { toast(err.message, 'error'); }
2342
- }));
2343
3122
  }
2344
3123
 
2345
3124
  // ---------- тема (тёмная / светлая) ----------
@@ -2366,15 +3145,28 @@ $('#btn-theme')?.addEventListener('click', () => {
2366
3145
  if (localStorage.getItem('theme') === 'light') applyTheme(true);
2367
3146
 
2368
3147
  // ---------- справка ----------
2369
- let helpLoaded = false;
2370
- $('#btn-help').addEventListener('click', async () => {
3148
+ const helpCache = new Map();
3149
+ async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
2371
3150
  $('#help').hidden = false;
2372
- if (helpLoaded) return;
3151
+ document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
3152
+ if (helpCache.has(docName)) {
3153
+ $('#help-body').innerHTML = helpCache.get(docName);
3154
+ $('#help-body').scrollTop = 0;
3155
+ return;
3156
+ }
3157
+ $('#help-body').textContent = 'Загрузка…';
2373
3158
  try {
2374
- const res = await fetch(`/docs/${encodeURIComponent('ИНСТРУКЦИЯ.md')}`);
2375
- $('#help-body').innerHTML = md(await res.text());
2376
- helpLoaded = true;
3159
+ const res = await fetch(`/docs/${encodeURIComponent(docName)}`);
3160
+ const html = md(await res.text());
3161
+ helpCache.set(docName, html);
3162
+ $('#help-body').innerHTML = html;
3163
+ $('#help-body').scrollTop = 0;
2377
3164
  } catch (e) { $('#help-body').textContent = e.message; }
3165
+ }
3166
+ $('#btn-help').addEventListener('click', () => openHelpDoc('ИНСТРУКЦИЯ.md'));
3167
+ $('#help-tabs')?.addEventListener('click', (e) => {
3168
+ const b = e.target.closest('button[data-hdoc]');
3169
+ if (b) openHelpDoc(b.dataset.hdoc);
2378
3170
  });
2379
3171
  $('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
2380
3172
  $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
@@ -2386,7 +3178,7 @@ document.addEventListener('keydown', (e) => {
2386
3178
  }
2387
3179
  });
2388
3180
 
2389
- // ---------- старт ----------
3181
+ // ---------- старт и восстановление сохранённого места в интерфейсе ----------
2390
3182
  (async function init() {
2391
3183
  state.config = await api('/api/config');
2392
3184
  buildForm();
@@ -2405,6 +3197,17 @@ document.addEventListener('keydown', (e) => {
2405
3197
  renderCloudPane();
2406
3198
  }).catch(() => {});
2407
3199
  }
2408
- const initRunId = Number(urlParams.get('run')) || undefined;
3200
+ const savedNav = loadSavedNav();
3201
+ const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
2409
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);
2410
3213
  })().catch((e) => toast(e.message, 'error'));