neuroalpha 0.2.0 → 0.3.12

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 +21 -0
  2. package/README.md +7 -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 +426 -0
  12. package/public/app.js +741 -251
  13. package/public/index.html +45 -20
  14. package/public/landing.html +1004 -870
  15. package/public/legal.html +22 -0
  16. package/public/styles.css +49 -15
  17. package/server/api/routes.js +176 -23
  18. package/server/auth/index.js +544 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1224 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +53 -2
  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 +15 -21
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +489 -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 +119 -172
  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 +20 -25
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +1 -0
  42. package/server/strategist/index.js +6 -6
  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. Licensed under the MIT License.
1
2
  const $ = (s, root = document) => root.querySelector(s);
2
3
  const MSK = 3 * 3600000;
3
4
 
@@ -38,15 +39,14 @@ function toast(text, type = 'info', action = null) {
38
39
  }
39
40
 
40
41
  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: 'Трансформер' };
42
+ const MODELS = ['inertia', 'leadlag', 'attention'];
43
+ const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту' };
43
44
  const MODEL_HINT = {
44
45
  inertia: 'Простейшая планка: акция предсказывается только по своему недавнему движению.',
45
46
  leadlag: 'Каждая акция предсказывается по недавним движениям всех остальных, без карты.',
46
47
  attention: 'Влияние соседей по карте рынка через 12 «голов внимания».',
47
- transformer: 'Нейросеть с вниманием между акциями; учится заново в каждом окне.',
48
48
  };
49
- const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7', transformer: '#39c5cf' };
49
+ const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7' };
50
50
  const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '#0891b2', '#db2777', '#64748b'];
51
51
  const labelOf = (k) => state.config?.params.find((p) => p.key === k)?.label || k;
52
52
 
@@ -166,40 +166,41 @@ const eqLines = {
166
166
  inertia: eqChart.addLineSeries({ color: COLORS.inertia, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
167
167
  leadlag: eqChart.addLineSeries({ color: COLORS.leadlag, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
168
168
  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
169
  };
171
170
  const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
172
171
  const icSeries = icChart.addHistogramSeries({ priceFormat: { type: 'price', precision: 3, minMove: 0.001 }, priceLineVisible: false, lastValueVisible: false });
173
172
 
174
173
  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)],
174
+ ['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : 'Авто (мульти-горизонт 1,5–3,5ч)')],
175
+ ['modelHorizon', (v, p) => (p.modelAutoAdapt === false ? `Прогноз: ${fmtNum(v, 3)}ч` : null)],
176
+ ['modelMinEdge', (v, p) => (p.modelAutoAdapt === false ? `Порог: ${fmtNum(v, 3)}` : null)],
177
+ ['modelExitRatio', (v, p) => (p.modelAutoAdapt === false ? `Выход: ${fmtNum(v, 2)}` : null)],
180
178
  ['modelTopK', (v) => `Акции: ${v}`],
181
- ['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : null)],
179
+ ['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : 'Доли поровну')],
180
+ ['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : v === 'neutral' ? 'Хедж 50/50 (рост + падение)' : 'Только в рост (без шорта)')],
181
+ ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
182
182
  ['modelTrainDays', (v) => `Окно: ${v}дн`],
183
+ ['modelHistoryDays', (v) => `История: ${v}дн`],
183
184
  ['modelEmbedDim', (v) => `Эмбд.: ${v}`],
184
- ['modelUseMacro', (v) => (v ? 'Макро' : null)],
185
185
  ['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)],
186
+ ['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
191
187
  ];
192
- function usesTf(p) { return p.modelUseTransformer || p.modelType === 'transformer'; }
193
188
  function runTitle(r, html = false) {
194
189
  const p = r.params || {}, d = state.config?.defaults || {};
195
- const parts = RUN_PARTS.map(([k, f]) => {
196
- if (p[k] == null) return null;
190
+ const m = r.metrics?.full || {};
191
+ const ps = r.metrics?.period ? calcPeriodStats(r.metrics.period, m.returnPct, 0) : null;
192
+ const status = r.status === 'done'
193
+ ? `${fmtPct(m.returnPct)}${ps ? ` (~${fmtPct(ps.cagr, 0)}/год)` : ''}${m.maxDrawdownPct ? `, просадка ${fmtNum(m.maxDrawdownPct, 1)}%` : ''}`
194
+ : r.status === 'running' ? 'идёт…' : 'ошибка';
195
+ const diffs = RUN_PARTS.map(([k, f]) => {
196
+ if (p[k] == null || JSON.stringify(p[k]) === JSON.stringify(d[k])) return null;
197
197
  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;
198
+ if (!t) return null;
199
+ return html ? `<span style="color:var(--yellow)">${t}</span>` : t;
199
200
  }).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(' — ');
201
+ const head = p._title ? `№${r.id} (${html ? esc(p._title) : p._title})` : `№${r.id}`;
202
+ const diffPart = diffs.length ? diffs : ['базовые настройки'];
203
+ return [head, ...diffPart, ...(p._source === 'ai' ? ['Стратег'] : []), status].join(' — ');
203
204
  }
204
205
  const runTip = (r) => {
205
206
  const p = r.params || {};
@@ -222,31 +223,30 @@ async function loadRuns(selectId) {
222
223
  ? state.runs.map((r) => `<option value="${r.id}" title="${runTip(r)}">${runTitle(r)}</option>`).join('')
223
224
  : '<option value="">Исследований пока нет</option>';
224
225
  sel.innerHTML = optHtml;
226
+ const brkSel = $('#broker-run-select');
227
+ if (brkSel) {
228
+ const curVal = brkSel.value;
229
+ brkSel.innerHTML = '<option value="">Текущие настройки</option>' + state.runs.filter((r) => r.status === 'done').map((r) => `<option value="${r.id}">${runTitle(r)}</option>`).join('');
230
+ brkSel.value = curVal;
231
+ }
225
232
  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
233
  const id = selectId ?? state.run?.id ?? state.runs.find((r) => r.status === 'done')?.id;
234
234
  updateTabBadges();
235
235
  if (id && state.runs.some((r) => r.id === id)) {
236
236
  sel.value = String(id);
237
237
  if (state.run?.id !== id || selectId) await openRun(id);
238
- } else renderOverview();
238
+ else syncUiAvailability();
239
+ } else {
240
+ state.run = null;
241
+ syncUiAvailability();
242
+ renderOverview();
243
+ }
239
244
  }
240
245
 
241
246
  async function openRun(id) {
242
247
  state.run = await api(`/api/runs/${id}`);
243
248
  $('#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();
249
+ syncUiAvailability();
250
250
  renderOverview();
251
251
  setupMapSlider();
252
252
  if (!$('#tab-map').hidden) drawMap();
@@ -283,29 +283,88 @@ $('#btn-del').addEventListener('click', async () => {
283
283
  toast(`Эксперимент №${r.id} удалён`);
284
284
  } catch (e) { toast(e.message, 'error'); }
285
285
  });
286
- // Проверить устойчивость открытого прогона (5 сдвигов периода −8…+8 дней)
287
286
  function stabilityUi(runningId) {
288
287
  state.stabilityActive = runningId || null;
289
288
  const busy = Boolean(runningId);
290
- $('#btn-stability').disabled = busy;
291
- $('#btn-stability').textContent = busy ? 'Идёт проверка устойчивости…' : 'Проверить устойчивость';
292
- $('#btn-cancel').hidden = !busy && !state.active;
293
289
  if (!busy && !state.active) setProgress(null);
294
290
  updateTabBadges();
291
+ syncUiAvailability();
295
292
  }
296
- $('#btn-stability').addEventListener('click', async () => {
293
+ // Единая синхронизация видимости и доступности элементов управления от текущего состояния системы
294
+ function syncUiAvailability() {
297
295
  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');
296
+ const isRunning = r?.status === 'running' || (r && r.id === state.active);
297
+ const isDone = r?.status === 'done' && !isRunning;
298
+ const aiReady = Boolean(state.chat.state?.llm && !state.chat.state?.busy);
299
+ const aiBusy = Boolean(state.chat.state?.busy);
300
+
301
+ // 1. Панель действий эксперимента (#run-bar): скрыта во время расчёта
302
+ if ($('#run-bar')) $('#run-bar').hidden = !r || isRunning;
303
+ if ($('#btn-to-live')) $('#btn-to-live').hidden = !isDone;
304
+ if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
305
+ if ($('#btn-ask-run')) {
306
+ $('#btn-ask-run').hidden = !isDone;
307
+ $('#btn-ask-run').disabled = !aiReady;
308
+ $('#btn-ask-run').title = !state.chat.state?.llm
309
+ ? 'ИИ не подключён (Настройки → Модели ИИ)'
310
+ : aiBusy
311
+ ? 'Дождитесь завершения текущего ответа Стратега'
312
+ : 'Попросить Стратега разобрать этот эксперимент';
306
313
  }
307
- });
308
- // Запустить виртуальный счёт прямо из открытого эксперимента (с заморозкой его настроек и ориентиром истории)
314
+ if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
315
+ syncApplyBtn();
316
+
317
+ // 2. Выпадающий список экспериментов и кнопка «Исследовать»
318
+ const anyJobRunning = Boolean(state.active || state.stabilityActive);
319
+ if ($('#btn-model')) $('#btn-model').hidden = anyJobRunning;
320
+ if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
321
+ if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
322
+
323
+ // 3. Кнопки обращения к Стратегу в прогнозе и виртуальных счетах
324
+ if ($('#btn-ask-forecast')) {
325
+ $('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
326
+ $('#btn-ask-forecast').title = !state.forecast
327
+ ? 'Сначала дождитесь расчёта прогноза'
328
+ : !aiReady
329
+ ? 'Стратег сейчас занят или ИИ не подключён'
330
+ : 'Попросить Стратега разобрать текущий прогноз';
331
+ }
332
+ document.querySelectorAll('[data-live-ask]').forEach((b) => {
333
+ b.disabled = !aiReady;
334
+ b.title = !aiReady ? 'Стратег сейчас занят или ИИ не подключён' : 'Попросить Стратега разобрать этот виртуальный счёт';
335
+ });
336
+
337
+ // 4. Зависимые настройки в форме (при включённом Авто-режиме или выключенном Трансформере)
338
+ syncSettingsDependencies();
339
+ }
340
+
341
+ function syncSettingsDependencies() {
342
+ const form = $('#params');
343
+ if (!form || !state.settings) return;
344
+ const s = state.settings;
345
+ const autoOn = s.modelAutoAdapt !== false;
346
+ const dir = s.modelDirection || 'long';
347
+
348
+ const setRowEnabled = (key, enabled, whyDisabled = '') => {
349
+ const row = form.querySelector(`.srow[data-key="${key}"]`);
350
+ if (!row) return;
351
+ row.style.opacity = enabled ? '1' : '0.45';
352
+ const ctrl = form.elements[key];
353
+ if (ctrl) {
354
+ ctrl.disabled = !enabled;
355
+ ctrl.title = enabled ? '' : whyDisabled;
356
+ }
357
+ };
358
+
359
+ setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
360
+ setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
361
+ setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
362
+ setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение» или «Хедж 50/50»)');
363
+ setRowEnabled('modelMarketFilter', dir !== 'neutral', 'В режиме «Хедж 50/50 (рост + падение)» портфель уже захеджирован от направления индекса');
364
+
365
+ const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
366
+ if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
367
+ }
309
368
  $('#btn-to-live').addEventListener('click', async () => {
310
369
  const r = state.run;
311
370
  if (!r || r.status !== 'done') return;
@@ -322,7 +381,7 @@ $('#btn-to-live').addEventListener('click', async () => {
322
381
  } catch (e) { toast(e.message, 'error'); }
323
382
  });
324
383
  // «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
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'];
384
+ const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
326
385
  function runSettingsDiff(r = state.run) {
327
386
  if (!r?.params || !state.settings) return { entries: [], lines: [] };
328
387
  const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
@@ -359,8 +418,9 @@ function syncApplyBtn() {
359
418
  syncShowcaseBtn();
360
419
  const btn = $('#btn-apply');
361
420
  if (!btn) return;
421
+ const isDone = state.run?.status === 'done' && state.run.id !== state.active;
362
422
  const { lines } = runSettingsDiff();
363
- btn.hidden = !state.run || !lines.length;
423
+ btn.hidden = !isDone || !lines.length;
364
424
  if (lines.length) {
365
425
  btn.textContent = `✓ Применить эти настройки (${lines.length})`;
366
426
  btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
@@ -378,10 +438,28 @@ $('#btn-apply').addEventListener('click', async () => {
378
438
  toast(`Настройки из эксперимента №${r.id} применены`);
379
439
  });
380
440
 
381
- function verdictHtml(c, dir) {
441
+ // Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
442
+ function calcPeriodStats(period, returnPct, marketPct) {
443
+ const from = period?.from || 0, till = period?.till || 0;
444
+ const calDays = Math.max(1, Math.round((till - from) / 86400000));
445
+ const years = calDays / 365.25;
446
+ const months = Math.max(1, Math.round(calDays / 30.44));
447
+ const yrsLabel = years >= 1 ? `${fmtNum(years, 1)} г. (${months} мес.)` : `${months} мес.`;
448
+ const dateRange = from && till ? `${fmtDate(from)} — ${fmtDate(till)}` : '';
449
+ const r = Number(returnPct ?? 0);
450
+ const m = Number(marketPct ?? 0);
451
+ // CAGR (среднегодовой темп роста с реинвестированием) и простое среднее в год
452
+ const cagr = years >= 0.25 && r > -99 ? ((Math.pow(1 + r / 100, 1 / years) - 1) * 100) : (r / Math.max(years, 0.1));
453
+ const avgSimple = r / Math.max(years, 0.1);
454
+ const mktAnnual = years >= 0.25 && m > -99 ? ((Math.pow(1 + m / 100, 1 / years) - 1) * 100) : (m / Math.max(years, 0.1));
455
+ return { calDays, years, months, yrsLabel, dateRange, cagr, avgSimple, mktAnnual };
456
+ }
457
+
458
+ function verdictHtml(c, dir, pStats) {
382
459
  const a = c.attention, base = c.inertia;
383
460
  if (!a) return '';
384
461
  const neutral = dir === 'neutral';
462
+ const spanInfo = pStats?.dateRange ? ` за <b>${pStats.yrsLabel}</b> (${pStats.dateRange}, <b>${fmtPct(pStats.cagr, 1)} годовых</b> с реинвестированием / ${fmtPct(pStats.avgSimple, 1)} в среднем в год)` : '';
385
463
  // помогает ли карта рынка — по торговому результату против простейшей модели
386
464
  let vs = '';
387
465
  if (base) {
@@ -394,9 +472,9 @@ function verdictHtml(c, dir) {
394
472
  let unreliable = a.trades < 30 || (base && base.trades < 30) ? ' <span class="muted small">Сделок мало, сравнение ненадёжно.</span>' : '';
395
473
  if (c.transformer) unreliable += `<div class="muted small">Трансформер: итог ${fmtPct(c.transformer.returnPct)} против ${fmtPct(a.returnPct)} у внимания через карту.</div>`;
396
474
  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}`;
475
+ if (neutral && a.returnPct > 0 && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий${spanInfo}; нейтрально к рынку: рынок почти не влияет. ${vs}${unreliable}`;
476
+ if (!neutral && a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий${spanInfo}, рынок за то же время ${fmtPct(a.marketPct)}. ${vs}${unreliable}`;
477
+ if ((a.grossReturnPct ?? 0) > 0) return `<b style="color:var(--yellow)">Сигнал есть, но комиссии его съедают:</b> без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}${spanInfo}. Попробуйте увеличить «Порог выгоды». ${vs}${unreliable}`;
400
478
  return `<b class="neg">Стратегия не заработала даже без комиссий</b> (${fmtPct(a.grossReturnPct)}). ${vs}${unreliable}`;
401
479
  }
402
480
 
@@ -456,37 +534,208 @@ function renderStability(st) {
456
534
  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
535
  }
458
536
 
537
+ // Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
538
+ const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
539
+
540
+ function resetLiveTrain(runId) {
541
+ liveTrainState.runId = runId;
542
+ liveTrainState.stage = 'data';
543
+ liveTrainState.loadedSymbols = [];
544
+ liveTrainState.foldIcs = [];
545
+ liveTrainState.lastViz = null;
546
+ liveTrainState.text = '';
547
+ }
548
+
549
+ function drawLiveTrainMap(viz) {
550
+ const cv = $('#ld-map-cv');
551
+ if (!cv || !viz?.pts?.length) return;
552
+ const W = cv.clientWidth || 440, H = 250;
553
+ const dpr = window.devicePixelRatio || 1;
554
+ cv.width = W * dpr; cv.height = H * dpr;
555
+ const ctx = cv.getContext('2d');
556
+ ctx.scale(dpr, dpr);
557
+ const syms = liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
558
+ const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
559
+ const xy = layout(f, W, H);
560
+ const light = document.body.classList.contains('light');
561
+ // лёгкие соединительные линии внутри одного кластера к ближайшему соседу
562
+ ctx.lineWidth = 1;
563
+ for (let i = 0; i < xy.length; i++) {
564
+ let bestJ = -1, bestD = 68;
565
+ for (let j = i + 1; j < xy.length; j++) {
566
+ if (f.cl[i] !== f.cl[j]) continue;
567
+ const d = Math.hypot(xy[i][0] - xy[j][0], xy[i][1] - xy[j][1]);
568
+ if (d < bestD) { bestD = d; bestJ = j; }
569
+ }
570
+ if (bestJ >= 0) {
571
+ const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
572
+ ctx.strokeStyle = `${col}33`;
573
+ ctx.beginPath(); ctx.moveTo(...xy[i]); ctx.lineTo(...xy[bestJ]); ctx.stroke();
574
+ }
575
+ }
576
+ f.symbols.forEach((s, i) => {
577
+ const [x, y] = xy[i];
578
+ const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
579
+ ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2);
580
+ ctx.fillStyle = col;
581
+ ctx.fill();
582
+ ctx.fillStyle = light ? '#57606a' : '#8b949e';
583
+ ctx.font = '10px system-ui';
584
+ ctx.fillText(s, x + 6, y + 3);
585
+ });
586
+ }
587
+
588
+ function renderLiveTraining() {
589
+ const empty = $('#overview-empty');
590
+ if (!empty || empty.hidden) return;
591
+ const st = liveTrainState;
592
+ empty.classList.add('live-dash');
593
+ const v = st.lastViz;
594
+ if (!v) {
595
+ // Этап 1: загрузка котировок и дивидендов с Мосбиржи
596
+ const chips = st.loadedSymbols.slice(-36).map((s) => `<span class="ld-sym ok">✓ ${esc(s)}</span>`).join('');
597
+ empty.innerHTML = `<div class="ld-card">
598
+ <div class="ld-title"><span>Этап 1 из 2 · Загрузка и сверка исторических свечей и дивидендов МосБиржи</span><span class="muted small">${esc(st.text || 'Подготовка датасета…')}</span></div>
599
+ <div class="muted small">Для каждой из ~100 акций индекса широкого рынка загружаются 10-минутные свечи, сверяются дивидендные отсечки по фактическим утренним разрывам цены и рассчитывается почасовой суточный профиль объёма.</div>
600
+ ${chips ? `<div class="ld-syms">${chips}</div>` : ''}
601
+ </div>`;
602
+ return;
603
+ }
604
+
605
+ // Этап 2: пошаговое скользящее обучение (Walk-Forward)
606
+ const span = Math.max(1, (v.periodTill || 1) - (v.periodFrom || 0));
607
+ const pctPos = (ts) => Math.max(0, Math.min(100, ((ts - v.periodFrom) / span) * 100));
608
+ const trLeft = pctPos(v.trainFrom), trRight = pctPos(v.testFrom), teRight = pctPos(v.testTill);
609
+ const firstTestLeft = st.firstTestLeft ?? trRight;
610
+ if (st.firstTestLeft === undefined) st.firstTestLeft = trRight;
611
+
612
+ const ics = st.foldIcs;
613
+ const posFolds = ics.filter((x) => x > 0).length;
614
+ const meanIc = ics.length ? ics.reduce((a, b) => a + b, 0) / ics.length : 0;
615
+ const maxIc = Math.max(0.05, ...ics.map(Math.abs));
616
+ const icBars = Array.from({ length: v.folds }, (_, idx) => {
617
+ const val = ics[idx];
618
+ if (val === undefined) return `<div class="ld-ic-bar" style="height:3px;background:var(--line)" title="Шаг ${idx + 1}: ещё впереди"></div>`;
619
+ const h = Math.max(5, Math.round((Math.abs(val) / maxIc) * 54));
620
+ const col = val >= 0 ? 'var(--green)' : 'var(--red)';
621
+ return `<div class="ld-ic-bar" style="height:${h}px;background:${col}" title="Шаг ${idx + 1}: совпадение прогноза ${fmtNum(val, 3)}"></div>`;
622
+ }).join('');
623
+
624
+ let headsHtml = '';
625
+ if (v.heads?.length) {
626
+ const maxC = Math.max(...v.heads.map((h) => Math.abs(h.coef)), ...(v.self || []).map(Math.abs), 1e-6);
627
+ const bar = (label, val) => {
628
+ const wd = (Math.abs(val) / maxC) * 50;
629
+ 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>`;
630
+ };
631
+ headsHtml = [
632
+ ...[...v.heads].sort((x, y) => Math.abs(y.coef) - Math.abs(x.coef)).slice(0, 7).map((h) => bar(h.label, h.coef)),
633
+ ...(v.self?.length ? [bar('своё движение за последний час', v.self[0])] : []),
634
+ ].join('');
635
+ }
636
+
637
+ empty.innerHTML = `
638
+ <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
639
+ <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>
640
+ <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>
641
+ <div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
642
+ <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>
643
+ </div>
644
+
645
+ <div class="ld-card">
646
+ <div class="ld-title">
647
+ <span>Движение скользящего окна по истории (без заглядывания в будущее)</span>
648
+ <span class="muted small">Обучение: ${fmtDate(v.trainFrom)} — ${fmtDate(v.testFrom)} → Прогноз: до ${fmtDate(v.testTill)}</span>
649
+ </div>
650
+ <div class="ld-tl">
651
+ <div class="ld-tl-done" style="left:${firstTestLeft.toFixed(1)}%;width:${Math.max(0, trRight - firstTestLeft).toFixed(1)}%"></div>
652
+ <div class="ld-tl-train" style="left:${trLeft.toFixed(1)}%;width:${Math.max(3, trRight - trLeft).toFixed(1)}%">Обучение</div>
653
+ <div class="ld-tl-test" style="left:${trRight.toFixed(1)}%;width:${Math.max(1.5, teRight - trRight).toFixed(1)}%" title="Прогнозируемый месяц"></div>
654
+ </div>
655
+ <div class="ld-title" style="margin-top:10px;margin-bottom:2px"><span class="muted small">Точность угадывания порядка акций (IC) по каждому пройденному месяцу</span><span class="muted small">выше нуля — лучше случайного</span></div>
656
+ <div class="ld-ics">${icBars}</div>
657
+ </div>
658
+
659
+ <div class="ld-grid">
660
+ <div class="ld-card">
661
+ <div class="ld-title"><span>Живая карта рынка на ${fmtDate(v.testFrom)}</span><span class="muted small">кластеры совместного движения</span></div>
662
+ <canvas id="ld-map-cv" style="width:100%;height:250px;display:block"></canvas>
663
+ </div>
664
+ <div class="ld-card">
665
+ <div class="ld-title"><span>Веса голов внимания (текущее окно)</span><span class="muted small"><span class="pos">переток</span> / <span class="neg">возврат</span></span></div>
666
+ ${headsHtml || '<div class="muted small">Расчёт весов…</div>'}
667
+ </div>
668
+ </div>
669
+ `;
670
+ drawLiveTrainMap(v);
671
+ }
672
+
459
673
  function renderOverview() {
460
674
  const run = state.run;
461
675
  const empty = $('#overview-empty');
462
676
  if (!run?.extra || run.status !== 'done') {
463
677
  $('#overview').hidden = true;
464
678
  empty.hidden = false;
679
+ if (run?.status === 'running' || state.active) {
680
+ renderLiveTraining();
681
+ return;
682
+ }
683
+ empty.classList.remove('live-dash');
465
684
  empty.innerHTML = run && run.status !== 'done'
466
- ? (run.status === 'running' ? 'Исследование идёт…' : `Исследование не завершилось: ${esc(run.error || run.status)}`)
685
+ ? `Исследование не завершилось: ${esc(run.error || run.status)}`
467
686
  : `<b>Идея.</b> Каждая акция — как слово со своим местом на карте рынка. Недавние движения всех акций — контекст.
468
687
  Стратегия, как языковая модель, предсказывает «следующее слово» — какие акции сдвинутся в ближайшие часы.<br><br>
469
688
  Нажмите <b>«Исследовать»</b>: стратегия пошагово проверится на двух годах истории (около минуты; первый раз — несколько минут на загрузку цен).
470
689
  Или попросите Стратега слева.`;
471
690
  return;
472
691
  }
692
+ empty.classList.remove('live-dash');
473
693
  empty.hidden = true;
474
694
  $('#overview').hidden = false;
475
695
  const ex = run.extra;
476
696
  const c = Object.fromEntries(MODELS.map((n) => [n, ex.comparison[n]?.full]));
477
697
  const chosen = run.metrics?.model?.chosen || 'attention';
478
698
  const a = c[chosen] || {};
699
+ const fullM = run.metrics?.full || {};
700
+ const oosA = ex.comparison?.[chosen]?.outOfSample || run.metrics?.outOfSample || {};
701
+ const pStats = calcPeriodStats(run.metrics?.period, a.returnPct, a.marketPct);
479
702
  const dir = run.params?.modelDirection || 'long';
480
703
  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>`
704
+ ? `<div class="muted small">Авто-адаптация: порог выхода ${fmtNum(ex.adaptation.exitRatio, 2)}${ex.adaptation.quarantined?.length ? ` · отсеяны убыточные бумаги (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 10).join(', '))}${ex.adaptation.quarantined.length > 10 ? '…' : ''}` : ''}${oosA.returnPct !== undefined ? ` · контрольный период (последние 30%): <b class="${cls(oosA.returnPct)}">${fmtPct(oosA.returnPct)}</b> (${oosA.trades ?? 0} сделок)` : ''}</div>`
482
705
  : '';
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>`;
706
+ $('#verdict').innerHTML = verdictHtml(c, dir, pStats) + adaptNote + (run.params?._note ? `<div class="muted small">Гипотеза: ${esc(run.params._note)}</div>` : '');
707
+ 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>`;
485
708
  $('#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)}%`, 'худшее падение', 'Насколько счёт проседал от своего максимума'),
709
+ kpi(
710
+ `<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`,
711
+ `итог за ${pStats.yrsLabel}`,
712
+ `${pStats.dateRange || 'весь период'} · без ком. ${fmtPct(a.grossReturnPct)}`,
713
+ 'Общий чистый доход портфеля после вычета комиссий, проскальзывания и платы за шорт',
714
+ ),
715
+ kpi(
716
+ `<span class="${cls(pStats.cagr)}">${fmtPct(pStats.cagr, 1)}</span>`,
717
+ 'средний годовой (CAGR)',
718
+ `простое среднее: ${fmtPct(pStats.avgSimple, 1)}/год`,
719
+ 'Среднегодовая доходность с учётом реинвестирования прибыли (CAGR) и простое арифметическое среднее в год',
720
+ ),
721
+ kpi(
722
+ `<span class="${cls(a.marketPct)}">${fmtPct(a.marketPct)}</span>`,
723
+ 'индекс МосБиржи',
724
+ `${fmtPct(pStats.mktAnnual, 1)}/год · альфа ${fmtPct((a.returnPct ?? 0) - (a.marketPct ?? 0))}`,
725
+ 'Изменение индекса МосБиржи (IMOEX) за точно такой же период и преимущество стратегии над рынком',
726
+ ),
727
+ kpi(
728
+ fmtNum(a.trades, 0),
729
+ 'сделок за период',
730
+ `плюсовых ${fmtNum(a.winRate, 1)}% · на 1 ₽ уб.: ${fmtNum(a.profitFactor)} ₽`,
731
+ 'Общее количество сделок, доля прибыльных сделок (Win Rate) и отношение общей прибыли к общему убытку (Profit Factor)',
732
+ ),
733
+ kpi(
734
+ `${fmtNum(a.maxDrawdownPct, 1)}%`,
735
+ 'худшее падение (просадка)',
736
+ `коэф. Шарпа: ${fmtNum(fullM.sharpe, 2)}`,
737
+ 'Максимальная просадка капитала от пика и годовой коэффициент Шарпа (стабильность кривой доходности)',
738
+ ),
490
739
  ].join('');
491
740
  $('#sides').hidden = dir === 'long' || a.shortPct === undefined;
492
741
  $('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
@@ -545,16 +794,6 @@ $('#run-bar').addEventListener('click', async (e) => {
545
794
  }
546
795
  if (e.target.closest('#btn-ask-run')) {
547
796
  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
797
  }
559
798
  });
560
799
  $('#btn-ask-forecast').addEventListener('click', () => {
@@ -568,10 +807,9 @@ function setProgress(pct, text) {
568
807
  $('#progress span').textContent = text || '';
569
808
  }
570
809
  function researchUi(running) {
571
- $('#btn-model').disabled = running;
572
- $('#btn-cancel').hidden = !running && !state.stabilityActive;
573
810
  if (!running && !state.stabilityActive) setProgress(null);
574
811
  updateTabBadges();
812
+ syncUiAvailability();
575
813
  }
576
814
  async function startResearch(force = false) {
577
815
  try {
@@ -632,6 +870,7 @@ async function loadForecast(force, silent = false) {
632
870
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
633
871
  } finally {
634
872
  $('#btn-forecast').disabled = false;
873
+ syncUiAvailability();
635
874
  }
636
875
  }
637
876
  $('#btn-forecast').addEventListener('click', () => loadForecast(true));
@@ -665,7 +904,7 @@ function renderLive(st) {
665
904
  const retPct = a.returnPct ?? 0;
666
905
  const step = a.recent?.[0];
667
906
  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' ? 'нейтрально' : 'покупки+продажи'}` : ''}`;
907
+ const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ` · ${p.modelDirection === 'neutral' ? 'хедж 50/50' : 'в рост и в падение'}` : ' · только в рост'}`;
669
908
  let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
670
909
  if (a.corridor) {
671
910
  const c = a.corridor;
@@ -703,6 +942,7 @@ function renderLive(st) {
703
942
  ${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
704
943
  </div>`;
705
944
  }).join('');
945
+ syncUiAvailability();
706
946
  }
707
947
  $('#btn-live').addEventListener('click', async () => {
708
948
  const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
@@ -945,12 +1185,12 @@ async function loadJournal() {
945
1185
  }));
946
1186
  }
947
1187
 
948
- // Группы настроек в форме; не перечисленные сюда попадают в «Дополнительно»
1188
+ // Логические группы настроек по смыслу:
1189
+ // g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
949
1190
  const GROUPS = [
950
- ['Общие', ['modelAutoAdapt', 'modelHorizon', 'modelTopK', 'modelDirection', 'modelType', 'commissionPct', 'autopilotBudget']],
951
- ['Для карты', ['modelEmbedDim', 'modelTextWeight', 'modelUseMacro']],
952
- ['Для Трансформера', ['modelUseTransformer', 'transformerEpochs', 'transformerGpu']],
953
- ['Дополнительно', []],
1191
+ ['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
1192
+ ['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
1193
+ ['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
954
1194
  ];
955
1195
  function buildForm() {
956
1196
  const field = (p) => {
@@ -965,7 +1205,7 @@ function buildForm() {
965
1205
  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
1206
  $('#params').innerHTML = GROUPS.map(([name, keys], i) => {
967
1207
  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 : '';
1208
+ const extra = i === 2 ? cleanRow : '';
969
1209
  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
1210
  }).join('');
971
1211
  $('#btn-clean')?.addEventListener('click', cleanRunsHistory);
@@ -1013,6 +1253,7 @@ function markDefaults(s) {
1013
1253
  b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
1014
1254
  });
1015
1255
  syncApplyBtn();
1256
+ syncSettingsDependencies();
1016
1257
  updateTabBadges();
1017
1258
  }
1018
1259
  const saveSettings = async (body) => {
@@ -1222,27 +1463,21 @@ function syncActionBtn() {
1222
1463
  btn.hidden = Boolean(s?.busy);
1223
1464
  if (speechActive) {
1224
1465
  // 1. Микрофон включён -> в любом случае Стоп
1225
- $('#ico-mic').hidden = true;
1226
- $('#ico-send').hidden = true;
1227
- $('#ico-stop').hidden = false;
1466
+ btn.dataset.icon = 'stop';
1228
1467
  btn.classList.remove('send-mode');
1229
1468
  btn.classList.add('rec');
1230
1469
  btn.disabled = false;
1231
1470
  btn.title = 'Остановить запись голоса';
1232
1471
  } else if (hasText) {
1233
1472
  // 2. Микрофон выключен, есть текст -> Стрелка
1234
- $('#ico-mic').hidden = true;
1235
- $('#ico-send').hidden = false;
1236
- $('#ico-stop').hidden = true;
1473
+ btn.dataset.icon = 'send';
1237
1474
  btn.classList.add('send-mode');
1238
1475
  btn.classList.remove('rec');
1239
1476
  btn.disabled = !s?.llm;
1240
1477
  btn.title = 'Отправить (Enter)';
1241
1478
  } else {
1242
1479
  // 3. Микрофон выключен, нет текста -> Микрофон
1243
- $('#ico-mic').hidden = false;
1244
- $('#ico-send').hidden = true;
1245
- $('#ico-stop').hidden = true;
1480
+ btn.dataset.icon = 'mic';
1246
1481
  btn.classList.remove('send-mode', 'rec');
1247
1482
  btn.disabled = false;
1248
1483
  btn.title = 'Голосовой ввод (русский язык)';
@@ -1277,6 +1512,7 @@ function renderChatState() {
1277
1512
  $('#chat-status').textContent = st;
1278
1513
  for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
1279
1514
  renderSuggest();
1515
+ syncUiAvailability();
1280
1516
  }
1281
1517
 
1282
1518
  const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
@@ -1711,6 +1947,21 @@ addForm.addEventListener('submit', async (e) => {
1711
1947
  // ======================================================================
1712
1948
  const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
1713
1949
  const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
1950
+ const LEGACY_MODES = {
1951
+ isolate: 'Изолированный карман (старые бумаги не трогаются)',
1952
+ smooth: 'Плавный переход (продажа старых только под новые сигналы)',
1953
+ takeover: 'Полное управление (все акции под стратегией)',
1954
+ };
1955
+ const INST_KIND_LABEL = {
1956
+ share: ['Акция', 'ok'],
1957
+ metal: ['Драгметалл', 'mid'],
1958
+ currency: ['Валюта', 'mid'],
1959
+ bond: ['Облигация', ''],
1960
+ etf: ['Фонд', ''],
1961
+ futures: ['Фьючерс', 'mid'],
1962
+ };
1963
+ const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
1964
+ const brokerTabById = new Map(); // accId -> 'assets' | 'trade' | 'history'
1714
1965
  let editingBrokerId = null;
1715
1966
 
1716
1967
  async function loadBrokers() {
@@ -1733,6 +1984,8 @@ function renderBrokers() {
1733
1984
  <b>${esc(a.name)}</b>
1734
1985
  <span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
1735
1986
  <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
1987
+ <span class="tag" title="Режим работы с начальным портфелем на счёте">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
1988
+ ${a.lastSync?.marginEnabled === false ? '<span class="tag" title="На этом счёте у брокера отключена маржинальная торговля — стратегия автоматически работает в режиме «Только в рост (без шорта)»">только в рост (маржиналка выкл.)</span>' : a.lastSync?.marginEnabled === true ? '<span class="tag ok" title="Маржинальная торговля включена — доступны сделки в рост и в падение (с шортом)">рост + шорт доступны</span>' : ''}
1736
1989
  ${a.autoSchedule !== false ? '<span class="tag ok" title="Заявки рассчитываются автоматически раз в горизонт прогноза и приходят в Telegram / MAX">авто-расписание</span>' : ''}
1737
1990
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
1738
1991
  ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
@@ -1748,29 +2001,106 @@ function renderBrokers() {
1748
2001
  </div>`).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
1749
2002
  }
1750
2003
 
1751
- // 2) карточки во вкладке «Торговля»
2004
+ // 2) карточки во вкладке «Торговля → Брокерские счета» (с 3 вкладками: Активы · Торговля · История)
1752
2005
  const tradeEl = $('#brokers-trade');
1753
2006
  if (!tradeEl) return;
1754
2007
  if (!accs.length) {
1755
2008
  tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
1756
2009
  return;
1757
2010
  }
2011
+ const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
1758
2012
  tradeEl.innerHTML = accs.map((a) => {
1759
2013
  const ls = a.lastSync;
1760
2014
  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>`;
2015
+ const lh = a.lastHistory;
2016
+ const lMode = a.legacyMode || 'isolate';
2017
+ const baseMap = a.baselineQty || {};
2018
+ const curSubTab = brokerTabById.get(a.id) || 'assets';
2019
+
2020
+ const algoQtyOf = (p) => {
2021
+ if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
2022
+ if (lMode === 'takeover') return p.qty;
2023
+ const bq = Number(baseMap[p.symbol] || 0);
2024
+ if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
2025
+ if (p.qty < 0 && bq < 0) return Math.min(0, p.qty - bq);
2026
+ return p.qty;
2027
+ };
2028
+
2029
+ const posStatus = (p) => {
2030
+ if (p.instrumentType && p.instrumentType !== 'share') {
2031
+ const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
2032
+ return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
2033
+ }
2034
+ if (fcRowMap && !fcRowMap.has(p.symbol)) return '<span class="tag" title="Вне индекса широкого рынка — стратегия не трогает">вне словаря (защищена)</span>';
2035
+ if (lMode === 'takeover') return '<span class="tag ok">под управлением стратегии</span>';
2036
+ const bq = Number(baseMap[p.symbol] || 0);
2037
+ const aq = algoQtyOf(p);
2038
+ if (lMode === 'isolate') {
2039
+ if (aq === 0) return `<span class="tag" title="Начальная позиция счёта — робот её не продаёт">защищена (старый портфель: ${fmtNum(bq, 0)} шт.)</span>`;
2040
+ if (bq !== 0) return `<span class="tag ok">старый: ${fmtNum(bq, 0)} шт. · стратегия: ${fmtNum(aq, 0)} шт.</span>`;
2041
+ return '<span class="tag ok">открыта стратегией</span>';
2042
+ }
2043
+ if (aq === 0) return `<span class="tag mid" title="Будет плавно замещена при появлении новых сигналов покупки">плавный переход (${fmtNum(bq, 0)} шт.)</span>`;
2044
+ return '<span class="tag ok">под управлением стратегии</span>';
2045
+ };
1764
2046
 
2047
+ // Полоса структуры активов (Allocation Bar)
2048
+ const totalVal = Math.max(1, Number(ls?.totalRub || 0));
2049
+ const allocSegments = [];
2050
+ (ls?.positions || []).forEach((p, idx) => {
2051
+ const pct = Math.max(0, Math.min(100, (Math.abs(p.valueRub || 0) / totalVal) * 100));
2052
+ 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] });
2053
+ });
2054
+ if ((ls?.cashRub || 0) > 10) {
2055
+ const cPct = Math.max(0, Math.min(100, (ls.cashRub / totalVal) * 100));
2056
+ allocSegments.push({ label: 'Свободные рубли (RUB)', valRub: ls.cashRub, pct: Number(cPct.toFixed(1)), color: '#16a34a' });
2057
+ }
2058
+ const allocBarHtml = allocSegments.length ? `<div class="brk-alloc-wrap">
2059
+ <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>
2060
+ <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>
2061
+ </div>` : '';
2062
+
2063
+ // Считаем сколько ₽ в стратегии и сколько ₽ защищено
2064
+ let strategyRub = 0, protectedRub = 0;
2065
+ for (const p of ls?.positions || []) {
2066
+ const aq = algoQtyOf(p);
2067
+ const aVal = Math.abs(aq * (p.price || 0));
2068
+ strategyRub += aVal;
2069
+ protectedRub += Math.max(0, Math.abs(p.valueRub || 0) - aVal);
2070
+ }
2071
+ const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
2072
+
2073
+ // Вкладка 1: Активы портфеля
2074
+ const posHtml = ls?.positions?.length
2075
+ ? `${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) => {
2076
+ const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
2077
+ const fcRow = fcRowMap?.get(p.symbol);
2078
+ const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
2079
+ return `<tr>
2080
+ <td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
2081
+ <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
2082
+ <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
2083
+ <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
2084
+ <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
2085
+ <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>
2086
+ <td>${posStatus(p)}${fcBadge}</td>
2087
+ </tr>`;
2088
+ }).join('')}</tbody></table>`
2089
+ : `<p class="muted small">${ls?.ok ? 'Открытых позиций на счёте сейчас нет (все средства в рублях).' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и активы с брокера.'}</p>`;
2090
+
2091
+ // Вкладка 2: Торговля и заявки стратегии
2092
+ const activeOrdHtml = ls?.activeOrders?.length
2093
+ ? `<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>`
2094
+ : '';
1765
2095
  let batchHtml = '';
1766
2096
  if (pb) {
1767
2097
  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>';
2098
+ ? `<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>`
2099
+ : `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
1770
2100
  const resHtml = pb.results?.length
1771
2101
  ? `<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
2102
  : '';
1773
- batchHtml = `<div class="stab-box" style="margin-top:10px">
2103
+ batchHtml = `<div class="stab-box">
1774
2104
  <div class="stab-head">
1775
2105
  <b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
1776
2106
  ${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
@@ -1781,27 +2111,68 @@ function renderBrokers() {
1781
2111
  ${ordRows}
1782
2112
  ${resHtml}
1783
2113
  </div>`;
2114
+ } else {
2115
+ batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
1784
2116
  }
1785
2117
 
2118
+ // Вкладка 3: История операций и динамика счёта
2119
+ const eqHist = Array.isArray(a.equityHistory) ? a.equityHistory : [];
2120
+ const eqHistHtml = eqHist.length > 1
2121
+ ? `<div class="muted small" style="margin-bottom:8px">Точки синхронизации баланса (переживают перезапуск сервера): ${eqHist.slice(-6).map((pt) => `${fmtTs(pt.ts)} — <b>${fmtNum(pt.totalRub, 0)} ₽</b>`).join(' · ')}</div>`
2122
+ : '';
2123
+ const histHtml = lh?.operations?.length
2124
+ ? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2125
+ <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
2126
+ <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2127
+ <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
2128
+ <div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
2129
+ </div>
2130
+ <table><thead><tr><th>Дата</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${lh.operations.map((op) => `<tr>
2131
+ <td class="muted small">${op.date ? fmtTs(op.date) : '—'}</td>
2132
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span> <span class="muted small">${esc(op.title !== op.kindLabel ? op.title : '')}</span></td>
2133
+ <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2134
+ <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2135
+ <td class="num">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
2136
+ <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
2137
+ </tr>`).join('')}</tbody></table>`
2138
+ : `${eqHistHtml}<p class="muted small">Нажмите <b>«Загрузить историю операций»</b>, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
2139
+
1786
2140
  return `<div class="live-card" data-bid="${esc(a.id)}">
1787
2141
  <div class="live-head">
1788
2142
  <div>
1789
- <b>${esc(a.name)}</b>
2143
+ <b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? ` <span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
1790
2144
  <span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
1791
2145
  <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2146
+ <span class="tag">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2147
+ ${ls?.marginEnabled === false ? '<span class="tag" title="На этом счёте у брокера отключена маржинальная торговля — стратегия автоматически работает в режиме «Только в рост (без шорта)»">только в рост (маржиналка выкл.)</span>' : ls?.marginEnabled === true ? '<span class="tag ok" title="Маржинальная торговля на счёте включена — доступны сделки в рост и в падение (с шортом)">рост + шорт доступны</span>' : ''}
2148
+ ${a.autoSchedule !== false ? '<span class="tag ok" title="Активно и автоматически восстанавливается после перезапуска сервера">авто-торговля вкл.</span>' : ''}
1792
2149
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
1793
2150
  ${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>
2151
+ <div class="muted small">Счёт: <b>${esc(a.accountId || ls?.accountId || 'авто')}</b> · Лимит стратегии: ${fmtNum(a.maxCapitalRub, 0)} ₽ · Дневной стоп: −${fmtNum(a.maxDailyDrawdownPct, 1)}% · Отступ лимитки: ${fmtNum(a.limitOffsetPct, 2)}%${a.baseRunLabel ? ` · Настройки ${esc(a.baseRunLabel)}` : ''}${ls?.at ? ` · Обновлён ${fmtTs(ls.at)}` : ''}</div>
1795
2152
  </div>
1796
- <div class="row-gap">
2153
+ <div class="row-gap" style="flex-wrap:wrap">
1797
2154
  <button type="button" data-btrade="sync" data-bid="${esc(a.id)}">Обновить портфель</button>
2155
+ <button type="button" data-btrade="hist" data-bid="${esc(a.id)}">История операций</button>
2156
+ ${lMode !== 'takeover' && ls?.positions?.length ? `<button type="button" data-btrade="freeze" data-bid="${esc(a.id)}" title="Запомнить все текущие бумаги на счёте как ваш личный старый портфель, который стратегия не будет продавать в режиме «Изолированный карман»">Защитить текущие бумаги</button>` : ''}
2157
+ <button type="button" data-btrade="cfg" data-bid="${esc(a.id)}" title="Открыть настройки и предохранители этого брокерского счёта">⚙ Настроить счёт</button>
1798
2158
  <button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}">Рассчитать заявки (в лотах)</button>
1799
2159
  </div>
1800
2160
  </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>` : ''}
2161
+ ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2162
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2163
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2164
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2165
+ <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2166
+ </div>` : ''}
1802
2167
  ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
1803
- ${posHtml}
1804
- ${batchHtml}
2168
+ <div class="brk-subtabs">
2169
+ <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2170
+ <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2171
+ <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2172
+ </div>
2173
+ <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2174
+ <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2175
+ <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
1805
2176
  </div>`;
1806
2177
  }).join('');
1807
2178
  }
@@ -1812,15 +2183,141 @@ $('#btn-brokers-cfg').addEventListener('click', () => {
1812
2183
  });
1813
2184
 
1814
2185
  const brokerForm = $('#broker-form');
2186
+ let brokerInitialSnap = '';
2187
+
2188
+ function getBrokerFormSnap() {
2189
+ const f = brokerForm.elements;
2190
+ return JSON.stringify({
2191
+ name: f.name.value.trim(),
2192
+ broker: f.broker.value,
2193
+ accountId: f.accountId.value.trim(),
2194
+ token: f.token.value.trim(),
2195
+ mode: f.mode.value,
2196
+ legacyMode: f.legacyMode?.value || 'isolate',
2197
+ baseRunId: f.baseRunId.value || '',
2198
+ maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2199
+ maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2200
+ limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
2201
+ autoSchedule: Boolean(f.autoSchedule.checked),
2202
+ skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
2203
+ sandbox: Boolean(f.sandbox.checked),
2204
+ });
2205
+ }
2206
+
2207
+ function updateBrokerSaveBtnState() {
2208
+ const btn = $('#broker-save-btn');
2209
+ if (!btn) return;
2210
+ if (!editingBrokerId) {
2211
+ const f = brokerForm.elements;
2212
+ const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim());
2213
+ btn.disabled = !hasNewInput;
2214
+ btn.classList.toggle('primary', hasNewInput);
2215
+ btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
2216
+ return;
2217
+ }
2218
+ const changed = getBrokerFormSnap() !== brokerInitialSnap;
2219
+ btn.disabled = !changed;
2220
+ btn.classList.toggle('primary', changed);
2221
+ btn.title = changed ? 'Сохранить внесённые изменения' : 'Нет изменений для сохранения';
2222
+ }
2223
+
2224
+ brokerForm.addEventListener('input', updateBrokerSaveBtnState);
2225
+ brokerForm.addEventListener('change', updateBrokerSaveBtnState);
2226
+
2227
+ function startBrokerEdit(acc) {
2228
+ editingBrokerId = acc.id;
2229
+ const f = brokerForm.elements;
2230
+ f.id.value = acc.id;
2231
+ f.name.value = acc.name;
2232
+ f.broker.value = acc.broker;
2233
+ f.accountId.value = acc.accountId || '';
2234
+ f.token.value = '';
2235
+ f.mode.value = acc.mode || 'confirm';
2236
+ if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2237
+ if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
2238
+ const opt = document.createElement('option');
2239
+ opt.value = String(acc.baseRunId);
2240
+ opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
2241
+ f.baseRunId.appendChild(opt);
2242
+ }
2243
+ f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
2244
+ f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
2245
+ f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
2246
+ f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2247
+ f.autoSchedule.checked = acc.autoSchedule !== false;
2248
+ f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2249
+ f.sandbox.checked = !!acc.sandbox;
2250
+ $('#broker-add-box').open = true;
2251
+ $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
2252
+ $('#broker-save-btn').textContent = 'Сохранить изменения';
2253
+ $('#broker-cancel-btn').hidden = false;
2254
+ brokerInitialSnap = getBrokerFormSnap();
2255
+ updateBrokerSaveBtnState();
2256
+ }
2257
+
1815
2258
  function stopBrokerEdit() {
1816
2259
  editingBrokerId = null;
1817
2260
  brokerForm.reset();
1818
2261
  brokerForm.elements.id.value = '';
2262
+ if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
1819
2263
  $('#broker-add-sum').textContent = 'Добавить брокерский счёт';
1820
2264
  $('#broker-save-btn').textContent = 'Сохранить счёт';
1821
2265
  $('#broker-cancel-btn').hidden = true;
2266
+ brokerInitialSnap = getBrokerFormSnap();
2267
+ updateBrokerSaveBtnState();
1822
2268
  }
1823
2269
  $('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
2270
+ updateBrokerSaveBtnState();
2271
+
2272
+ // Авто-поиск всех субсчетов в Т-Инвестициях по токену
2273
+ $('#btn-broker-discover')?.addEventListener('click', async () => {
2274
+ const btn = $('#btn-broker-discover');
2275
+ const box = $('#broker-discovered');
2276
+ const f = brokerForm.elements;
2277
+ btn.disabled = true;
2278
+ const old = btn.textContent;
2279
+ btn.textContent = 'Ищем счета…';
2280
+ try {
2281
+ const res = await post('/api/brokers/discover', {
2282
+ id: editingBrokerId || undefined,
2283
+ broker: f.broker.value,
2284
+ token: f.token.value,
2285
+ sandbox: f.sandbox.checked,
2286
+ });
2287
+ const list = res.accounts || [];
2288
+ if (!list.length) {
2289
+ box.hidden = false;
2290
+ box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2291
+ } else {
2292
+ box.hidden = false;
2293
+ box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg1)">
2294
+ <div>
2295
+ <b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">шорт доступен</span>' : '<span class="tag">только в рост (без шорта)</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
2296
+ <div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
2297
+ </div>
2298
+ <button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
2299
+ </div>`).join('');
2300
+ }
2301
+ } catch (err) {
2302
+ toast(err.message, 'error');
2303
+ } finally {
2304
+ btn.disabled = false;
2305
+ btn.textContent = old;
2306
+ }
2307
+ });
2308
+
2309
+ $('#broker-discovered')?.addEventListener('click', (e) => {
2310
+ const b = e.target.closest('button[data-pick-acc]');
2311
+ if (!b) return;
2312
+ const f = brokerForm.elements;
2313
+ f.accountId.value = b.dataset.pickAcc;
2314
+ if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
2315
+ f.name.value = `Т-Банк · ${b.dataset.pickName}`;
2316
+ }
2317
+ updateBrokerSaveBtnState();
2318
+ toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
2319
+ });
2320
+
1824
2321
  brokerForm.addEventListener('submit', async (e) => {
1825
2322
  e.preventDefault();
1826
2323
  const f = brokerForm.elements;
@@ -1831,6 +2328,7 @@ brokerForm.addEventListener('submit', async (e) => {
1831
2328
  accountId: f.accountId.value,
1832
2329
  token: f.token.value,
1833
2330
  mode: f.mode.value,
2331
+ legacyMode: f.legacyMode?.value || 'isolate',
1834
2332
  baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
1835
2333
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
1836
2334
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
@@ -1857,25 +2355,8 @@ $('#brokers-list').addEventListener('click', async (e) => {
1857
2355
  const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
1858
2356
  if (!acc) return;
1859
2357
  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;
2358
+ startBrokerEdit(acc);
2359
+ $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1879
2360
  return;
1880
2361
  }
1881
2362
  if (b.dataset.bact === 'del') {
@@ -1901,10 +2382,34 @@ $('#brokers-list').addEventListener('click', async (e) => {
1901
2382
  });
1902
2383
 
1903
2384
  $('#brokers-trade').addEventListener('click', async (e) => {
2385
+ const tabBtn = e.target.closest('button[data-btab]');
2386
+ if (tabBtn) {
2387
+ const id = tabBtn.dataset.bid;
2388
+ const t = tabBtn.dataset.btab;
2389
+ brokerTabById.set(id, t);
2390
+ renderBrokers();
2391
+ if (t === 'history') {
2392
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2393
+ if (acc && !acc.lastHistory) {
2394
+ post(`/api/brokers/${encodeURIComponent(id)}/history`).then((st) => { state.brokers = st; renderBrokers(); }).catch((err) => toast(err.message, 'error'));
2395
+ }
2396
+ }
2397
+ return;
2398
+ }
1904
2399
  const b = e.target.closest('button[data-btrade]');
1905
2400
  if (!b) return;
1906
2401
  const id = b.dataset.bid;
1907
2402
  const act = b.dataset.btrade;
2403
+ if (act === 'cfg') {
2404
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2405
+ switchTab('settings');
2406
+ showSettingsSec('brokers');
2407
+ if (acc) {
2408
+ startBrokerEdit(acc);
2409
+ setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
2410
+ }
2411
+ return;
2412
+ }
1908
2413
  b.disabled = true;
1909
2414
  const old = b.textContent;
1910
2415
  try {
@@ -1912,8 +2417,18 @@ $('#brokers-trade').addEventListener('click', async (e) => {
1912
2417
  b.textContent = 'Обновляем…';
1913
2418
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
1914
2419
  toast('Портфель обновлён');
2420
+ } else if (act === 'hist') {
2421
+ b.textContent = 'Загружаем…';
2422
+ brokerTabById.set(id, 'history');
2423
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
2424
+ toast('История операций загружена');
2425
+ } else if (act === 'freeze') {
2426
+ b.textContent = 'Фиксируем…';
2427
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
2428
+ toast('Текущие бумаги счёта зафиксированы как неприкосновенный старый портфель');
1915
2429
  } else if (act === 'prep') {
1916
2430
  b.textContent = 'Считаем лоты…';
2431
+ brokerTabById.set(id, 'trade');
1917
2432
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
1918
2433
  toast('Пакет заявок по прогнозу рассчитан');
1919
2434
  } else if (act === 'exec') {
@@ -2004,6 +2519,8 @@ function connectWs() {
2004
2519
  case 'journal:update': loadJournal().catch(() => {}); break;
2005
2520
  case 'research:start':
2006
2521
  state.active = m.runId;
2522
+ resetLiveTrain(m.runId);
2523
+ delete liveTrainState.firstTestLeft;
2007
2524
  researchUi(true);
2008
2525
  setProgress(1, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}: начинаем…`);
2009
2526
  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 +2532,19 @@ function connectWs() {
2015
2532
  $('.pg', el).textContent = m.text;
2016
2533
  $('.bar-s div', el).style.width = `${pct}%`;
2017
2534
  if (!state.active) { state.active = m.runId; researchUi(true); }
2018
- if (m.runId === state.active) setProgress(pct, m.text);
2535
+ if (m.runId === state.active) {
2536
+ setProgress(pct, m.text);
2537
+ if (liveTrainState.runId !== m.runId) { resetLiveTrain(m.runId); delete liveTrainState.firstTestLeft; }
2538
+ liveTrainState.stage = m.stage;
2539
+ liveTrainState.text = m.text || '';
2540
+ if (m.symbol && !liveTrainState.loadedSymbols.includes(m.symbol)) liveTrainState.loadedSymbols.push(m.symbol);
2541
+ if (m.viz) {
2542
+ if (m.viz.symbols) liveTrainState.symbols = m.viz.symbols;
2543
+ if (m.viz.foldIc !== undefined && m.viz.fold) liveTrainState.foldIcs[m.viz.fold - 1] = m.viz.foldIc;
2544
+ liveTrainState.lastViz = m.viz;
2545
+ }
2546
+ if (state.run?.id === m.runId && state.run?.status !== 'done') renderLiveTraining();
2547
+ }
2019
2548
  break;
2020
2549
  }
2021
2550
  case 'research:done':
@@ -2061,7 +2590,6 @@ function connectWs() {
2061
2590
  case 'stability:progress':
2062
2591
  stabilityUi(m.runId);
2063
2592
  setProgress(m.pct, m.text);
2064
- $('#btn-stability').textContent = `${m.pct}% · проверяем…`;
2065
2593
  break;
2066
2594
  case 'stability:done':
2067
2595
  stabilityUi(null);
@@ -2096,11 +2624,19 @@ async function renderCloudPane() {
2096
2624
  const me = await api('/api/auth/me').catch(() => state.config || {});
2097
2625
  state.config = { ...(state.config || {}), ...me };
2098
2626
  const u = me.user;
2627
+ const lic = u?.localLicense;
2628
+ const isDefaultLocal = !me.cloudMode && u?.id === 1 && u?.email === 'owner@neuroalpha.local';
2629
+ const dispName = (lic?.name && lic.name !== 'Владелец платформы' && lic.name !== 'Пользователь')
2630
+ ? lic.name
2631
+ : (isDefaultLocal ? 'Пользователь' : (u?.name || 'Пользователь'));
2632
+ const dispEmail = (lic?.email && lic.email !== 'owner@neuroalpha.local')
2633
+ ? lic.email
2634
+ : (isDefaultLocal ? '' : (u?.email || ''));
2099
2635
  const userBtn = $('#btn-user');
2100
- if (userBtn) userBtn.textContent = u ? `${u.name} · ${u.planName}` : 'Войти';
2636
+ if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Войти';
2101
2637
 
2102
2638
  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>`;
2639
+ 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
2640
  return;
2105
2641
  }
2106
2642
 
@@ -2109,87 +2645,72 @@ async function renderCloudPane() {
2109
2645
  const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
2110
2646
  const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
2111
2647
 
2112
- let adminHtml = '';
2648
+ let customBotsHtml = '';
2649
+ let adminLinkHtml = '';
2113
2650
  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>
2651
+ if (me.cloudMode) {
2652
+ adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
2653
+ <div class="stxt">
2654
+ <div class="slbl">Консоль администратора платформы</div>
2655
+ <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, OAuth-провайдерами (Яндекс, Сбер, Т-Банк, VK) и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
2656
+ </div>
2657
+ <div class="row-gap" style="flex-wrap:wrap">
2658
+ <a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
2659
+ </div>
2660
+ </div>`;
2661
+ } else {
2662
+ customBotsHtml = `<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
2663
+ <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX (необязательно — для автономного локального сервера) и консоль админа</summary>
2664
+ <form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
2665
+ <div class="muted small">Если вы запускаете сервер локально и хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
2130
2666
  <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">
2667
+ <input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
2668
+ <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
2669
  </div>
2134
2670
  <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">
2671
+ <input name="maxBotName" value="${esc(nt.maxBotName || '')}" placeholder="Имя бота MAX" style="width:200px">
2672
+ <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
2673
  <button type="submit" class="primary">Сохранить ботов</button>
2138
2674
  </div>
2675
+ <div class="muted small" style="margin-top:4px">Администрирование SaaS-портала (ЮKassa, пользователи, промокоды, OAuth) доступно в отдельном приложении: <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
2139
2676
  </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>`;
2677
+ </details>`;
2168
2678
  }
2169
2679
  }
2170
2680
 
2171
2681
  const apiKey = u.apiKey || '';
2172
2682
  const npxCmd = apiKey ? `npx neuroalpha --key ${apiKey}` : 'npx neuroalpha';
2173
- const lic = u.localLicense;
2174
2683
 
2175
2684
  box.innerHTML = `
2176
- <div class="srow">
2685
+ <div class="srow sblock">
2177
2686
  <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>
2687
+ <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>
2179
2688
  <div class="hint">Лимиты тарифа: до ${u.limits?.maxRunsPerDay ?? 2} экспериментов в сутки · виртуальных счетов: до ${u.limits?.maxVirtualAccounts ?? 1} · брокерских счетов: до ${u.limits?.maxBrokerAccounts ?? 0}${u.planUntil ? ` · оплачен до ${fmtDate(u.planUntil)}` : ''}.</div>
2180
2689
  </div>
2181
- <div class="row-gap">
2690
+ ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
2182
2691
  <button type="button" id="btn-cloud-verify">Проверить оплату</button>
2183
2692
  <button type="button" id="btn-cloud-logout">Выйти</button>
2184
- </div>
2693
+ </div>` : ''}
2185
2694
  </div>
2186
- <div class="srow">
2695
+ ${!me.cloudMode ? `<div class="srow sblock">
2187
2696
  <div class="stxt">
2188
- <div class="slbl">Ваш персональный API-ключ (для локального запуска через <code>npx</code> и API)</div>
2697
+ <div class="slbl">Лицензионный ключ подписки с <a href="https://neuroalpha.odant.org" target="_blank" rel="noopener" style="color:var(--accent)">neuroalpha.odant.org</a></div>
2698
+ <div class="hint">
2699
+ Вставьте персональный ключ <code>na_live_…</code> из личного кабинета на сайте для активации вашего тарифа и облачного ИИ на этом компьютере.
2700
+ ${lic ? `<br><span class="pos">✓ Активирована лицензия: <b>${esc(lic.planName)}</b>${dispEmail ? ` (${esc(dispEmail)})` : ''}</span>` : ''}
2701
+ </div>
2702
+ </div>
2703
+ <div class="row-gap" style="flex-wrap:wrap">
2704
+ <input id="lic-key-in" value="${esc(lic?.apiKey || '')}" placeholder="na_live_…" style="width:280px">
2705
+ <button type="button" id="btn-lic-activate" class="primary">Активировать ключ</button>
2706
+ <a class="btn" href="https://neuroalpha.odant.org/#pricing" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Получить ключ →</a>
2707
+ </div>
2708
+ </div>` : `<div class="srow sblock">
2709
+ <div class="stxt">
2710
+ <div class="slbl">Ваш персональный API-ключ (для запуска на вашем ПК через <code>npx</code>)</div>
2189
2711
  <div class="hint">
2190
2712
  Ключ: <b><code id="user-api-key">${esc(apiKey || 'не создан')}</code></b><br>
2191
2713
  Команда для запуска на вашем ПК (ключи брокера остаются локально): <code>${esc(npxCmd)}</code>
2192
- ${lic ? `<br><span class="pos">✓ Активирована лицензия: ${esc(lic.planName)} (${esc(lic.email || '')})</span>` : ''}
2193
2714
  </div>
2194
2715
  </div>
2195
2716
  <div class="row-gap" style="flex-wrap:wrap">
@@ -2197,41 +2718,22 @@ async function renderCloudPane() {
2197
2718
  <button type="button" id="btn-copy-npx">Скопировать npx-команду</button>
2198
2719
  <button type="button" id="btn-regen-apikey" title="Перевыпустить API-ключ (старый перестанет работать)">Перевыпустить</button>
2199
2720
  </div>
2200
- </div>
2201
- ${!me.cloudMode ? `<div class="srow">
2721
+ </div>`}
2722
+ <div class="srow sblock">
2202
2723
  <div class="stxt">
2203
- <div class="slbl">Активировать API-ключ подписки с сайта</div>
2204
- <div class="hint">Если вы зарегистрировались и оплатили тариф на сайте, вставьте полученный <code>na_live_…</code> ключ сюда для разблокировки тарифа на этом компьютере.</div>
2724
+ <div class="slbl">Тариф и промокод</div>
2725
+ <div class="hint">Подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram / MAX и расширенных лимитов ИИ.</div>
2205
2726
  </div>
2206
2727
  <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>
2728
+ <input id="promo-code-in" placeholder="Промокод" style="width:150px">
2729
+ <button type="button" id="btn-promo-redeem">Применить код</button>
2730
+ <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
2731
+ <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
2220
2732
  </div>
2221
2733
  </div>
2222
- <div class="srow">
2734
+ <div class="srow sblock">
2223
2735
  <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>
2736
+ <div class="slbl">Уведомления в Telegram и MAX (подтверждение заявок в 1 клик)</div>
2235
2737
  <div class="hint">
2236
2738
  Ваш код привязки: <b><code>${esc(code)}</code></b> — отправьте этот код боту в Telegram или в мессенджер MAX.<br>
2237
2739
  Статус: Telegram — ${u.tgConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'} · MAX — ${u.maxConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'}
@@ -2243,7 +2745,8 @@ async function renderCloudPane() {
2243
2745
  <button type="button" id="btn-notify-test">Тест уведомления</button>
2244
2746
  </div>
2245
2747
  </div>
2246
- ${adminHtml}
2748
+ ${adminLinkHtml}
2749
+ ${customBotsHtml}
2247
2750
  `;
2248
2751
 
2249
2752
  box.querySelector('#btn-copy-apikey')?.addEventListener('click', () => {
@@ -2265,10 +2768,9 @@ async function renderCloudPane() {
2265
2768
  });
2266
2769
  box.querySelector('#btn-lic-activate')?.addEventListener('click', async () => {
2267
2770
  const k = box.querySelector('#lic-key-in')?.value.trim();
2268
- const uUrl = box.querySelector('#lic-url-in')?.value.trim();
2269
2771
  if (!k) return toast('Введите API-ключ (na_live_…)', 'error');
2270
2772
  try {
2271
- const r = await post('/api/license/activate', { apiKey: k, cloudUrl: uUrl });
2773
+ const r = await post('/api/license/activate', { apiKey: k });
2272
2774
  toast(`Ключ активирован! Тариф: ${r.license?.planName}`);
2273
2775
  renderCloudPane();
2274
2776
  } catch (e) { toast(e.message, 'error'); }
@@ -2306,24 +2808,6 @@ async function renderCloudPane() {
2306
2808
  renderCloudPane();
2307
2809
  } catch (e) { toast(e.message, 'error'); }
2308
2810
  });
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'); }
2326
- });
2327
2811
  box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
2328
2812
  e.preventDefault();
2329
2813
  const f = e.target.elements;
@@ -2333,13 +2817,6 @@ async function renderCloudPane() {
2333
2817
  renderCloudPane();
2334
2818
  } catch (err) { toast(err.message, 'error'); }
2335
2819
  });
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
2820
  }
2344
2821
 
2345
2822
  // ---------- тема (тёмная / светлая) ----------
@@ -2366,15 +2843,28 @@ $('#btn-theme')?.addEventListener('click', () => {
2366
2843
  if (localStorage.getItem('theme') === 'light') applyTheme(true);
2367
2844
 
2368
2845
  // ---------- справка ----------
2369
- let helpLoaded = false;
2370
- $('#btn-help').addEventListener('click', async () => {
2846
+ const helpCache = new Map();
2847
+ async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
2371
2848
  $('#help').hidden = false;
2372
- if (helpLoaded) return;
2849
+ document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
2850
+ if (helpCache.has(docName)) {
2851
+ $('#help-body').innerHTML = helpCache.get(docName);
2852
+ $('#help-body').scrollTop = 0;
2853
+ return;
2854
+ }
2855
+ $('#help-body').textContent = 'Загрузка…';
2373
2856
  try {
2374
- const res = await fetch(`/docs/${encodeURIComponent('ИНСТРУКЦИЯ.md')}`);
2375
- $('#help-body').innerHTML = md(await res.text());
2376
- helpLoaded = true;
2857
+ const res = await fetch(`/docs/${encodeURIComponent(docName)}`);
2858
+ const html = md(await res.text());
2859
+ helpCache.set(docName, html);
2860
+ $('#help-body').innerHTML = html;
2861
+ $('#help-body').scrollTop = 0;
2377
2862
  } catch (e) { $('#help-body').textContent = e.message; }
2863
+ }
2864
+ $('#btn-help').addEventListener('click', () => openHelpDoc('ИНСТРУКЦИЯ.md'));
2865
+ $('#help-tabs')?.addEventListener('click', (e) => {
2866
+ const b = e.target.closest('button[data-hdoc]');
2867
+ if (b) openHelpDoc(b.dataset.hdoc);
2378
2868
  });
2379
2869
  $('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
2380
2870
  $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });