neuroalpha 0.3.14 → 0.3.16

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.
package/public/app.js CHANGED
@@ -103,7 +103,6 @@ function setChatClosed(closed) {
103
103
  try {
104
104
  eqChart.timeScale().fitContent();
105
105
  icChart.timeScale().fitContent();
106
- if (!$('#tab-map')?.hidden) drawMap();
107
106
  } catch { /* ignore */ }
108
107
  });
109
108
  }
@@ -132,93 +131,67 @@ $('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
132
131
  sp.classList.remove('drag');
133
132
  document.body.classList.remove('dragging');
134
133
  localStorage.setItem('chatWidth', getComputedStyle(document.documentElement).getPropertyValue('--chat-w').trim());
135
- if (!$('#tab-map').hidden) drawMap();
136
134
  });
137
135
  sp.addEventListener('dblclick', () => {
138
136
  document.documentElement.style.removeProperty('--chat-w');
139
137
  localStorage.removeItem('chatWidth');
140
- if (!$('#tab-map').hidden) drawMap();
141
138
  });
142
139
  })();
143
140
 
144
141
  // ======================================================================
145
- // ВКЛАДКИ
142
+ // НАВИГАЦИЯ В ЕДИНОМ РАБОЧЕМ ОКНЕ
146
143
  // ======================================================================
144
+ function isOwnerUser() {
145
+ const u = state.config?.user;
146
+ return Boolean(u?.isOwner || u?.role === 'admin' || u?.plan === 'owner' || u?.limits?.allowResearch);
147
+ }
148
+
147
149
  function updateTabBadges() {
148
- // 1. Эксперименты: общее количество + иконка, если прямо сейчас идёт расчёт или стресс-тест
149
- const ovEl = $('#tstat-overview');
150
- if (ovEl) {
151
- const total = state.runs?.length || 0;
152
- const isRunning = Boolean(state.active || state.stabilityActive || state.runs?.some((r) => r.status === 'running'));
153
- const parts = [];
154
- if (total > 0) parts.push(`<span class="tbadge" title="Всего экспериментов в истории: ${total}">${total}</span>`);
155
- if (isRunning) {
156
- const rId = state.active || state.stabilityActive || state.runs?.find((r) => r.status === 'running')?.id;
157
- parts.push(`<span class="tbadge run" title="Сейчас идёт расчёт${rId ? ` №${rId}` : ''}"><i class="tspin"></i></span>`);
150
+ const ovNav = $('#nnav-overview');
151
+ if (ovNav) {
152
+ const owner = isOwnerUser();
153
+ ovNav.hidden = !owner;
154
+ if (owner) {
155
+ const total = state.runs?.length || 0;
156
+ const isRunning = Boolean(state.active || state.stabilityActive || state.runs?.some((r) => r.status === 'running'));
157
+ ovNav.textContent = total > 0 ? `Эксперименты (${total})${isRunning ? ' ⟳' : ''}` : 'Эксперименты';
158
158
  }
159
- ovEl.innerHTML = parts.join('');
160
159
  }
161
-
162
- // 2. Торговля: сколько виртуальных и реальных брокерских счетов
163
- const nowEl = $('#tstat-now');
164
- if (nowEl) {
165
- const vAccs = state.live?.accounts || [];
166
- const vRun = vAccs.filter((a) => a.running).length;
160
+ const brkNav = $('#nnav-brokers');
161
+ if (brkNav) {
167
162
  const bAccs = state.brokers?.accounts || [];
168
- const bActive = bAccs.filter((a) => !a.haltedReason && a.mode !== 'readonly').length;
169
- const parts = [];
170
- if (vAccs.length > 0) {
171
- parts.push(`<span class="tbadge${vRun > 0 ? ' ok' : ''}" title="Виртуальные счета: запущено ${vRun}, всего ${vAccs.length}">${vRun > 0 ? `▶ ${vRun}` : vAccs.length} вирт.</span>`);
172
- }
173
- if (bAccs.length > 0) {
174
- parts.push(`<span class="tbadge${bActive > 0 ? ' ok' : ''}" title="Брокерские счета: подключено ${bAccs.length}">${bAccs.length} реал.</span>`);
175
- }
176
- nowEl.innerHTML = parts.join('');
177
- }
178
-
179
- // 3. Карта и журнал: сколько записей в журнале гипотез (+ индикатор обновления профилей)
180
- const mapEl = $('#tstat-map');
181
- if (mapEl) {
182
- const jCount = state.journalCount ?? 0;
183
- const parts = [];
184
- if (jCount > 0) parts.push(`<span class="tbadge" title="Записей в журнале гипотез: ${jCount}">${jCount}</span>`);
185
- if (state.profilesBusy) parts.push(`<span class="tbadge run" title="Составляются описания компаний"><i class="tspin"></i></span>`);
186
- mapEl.innerHTML = parts.join('');
187
- }
188
-
189
- // 4. Настройки: сколько параметров изменено относительно умолчаний
190
- const setEl = $('#tstat-settings');
191
- if (setEl && state.settings && state.config?.defaults) {
192
- const d = state.config.defaults;
193
- const shownKeys = (state.config.params || []).filter((p) => !p.hidden).map((p) => p.key);
194
- const diff = shownKeys.filter((k) => state.settings[k] !== undefined && JSON.stringify(state.settings[k]) !== JSON.stringify(d[k])).length;
195
- setEl.innerHTML = diff > 0 ? `<span class="tbadge" title="Изменено настроек от значений по умолчанию: ${diff}">${diff}</span>` : '';
163
+ brkNav.textContent = bAccs.length ? `Брокерские счета (${bAccs.length})` : 'Брокерские счета';
196
164
  }
197
165
  }
198
166
 
199
- function switchTab(t) {
200
- if (t === 'journal') t = 'map';
201
- $('#tabs').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.tab === t));
202
- document.querySelectorAll('.right > .tab').forEach((el) => { el.hidden = el.id !== `tab-${t}`; });
203
- saveNavPatch({ tab: t });
204
- if (t === 'overview') {
167
+ function showMainSec(sec) {
168
+ if (sec === 'now') sec = 'brokers';
169
+ if (sec === 'settings' || sec === 'g1') sec = 'g0';
170
+ if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
171
+ document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
172
+ document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
173
+ if ($('#main-workspace')) $('#main-workspace').scrollTop = 0;
174
+ saveNavPatch({ mainSec: sec });
175
+ if (sec === 'overview') {
205
176
  requestAnimationFrame(() => {
206
177
  eqChart.timeScale().fitContent();
207
178
  icChart.timeScale().fitContent();
208
179
  });
209
- }
210
- if (t === 'settings') loadHistory().catch(() => {});
211
- if (t === 'now') { refreshForecastIfStale(); loadNews(); }
212
- if (t === 'map') {
213
- setupMapSlider();
214
- drawMap();
215
- loadJournal();
216
- if (state.mapMode === 'text' && !state.profiles) loadProfilesData().then(drawMap);
180
+ } else if (sec === 'forecast') {
181
+ refreshForecastIfStale();
182
+ } else if (sec === 'news') {
183
+ loadNews();
184
+ } else if (sec === 'cloud') {
185
+ renderCloudPane().catch(() => {});
217
186
  }
218
187
  }
219
- $('#tabs').addEventListener('click', (e) => {
220
- const b = e.target.closest('button[data-tab]');
221
- if (b) switchTab(b.dataset.tab);
188
+ const switchTab = (t) => showMainSec(t);
189
+ const showNowSec = (s) => showMainSec(s);
190
+ const showSettingsSec = (s) => showMainSec(s);
191
+
192
+ $('#main-nav')?.addEventListener('click', (e) => {
193
+ const b = e.target.closest('button[data-sec]');
194
+ if (b) showMainSec(b.dataset.sec);
222
195
  });
223
196
 
224
197
  // ======================================================================
@@ -261,7 +234,7 @@ const RUN_PARTS = [
261
234
  ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
262
235
  ['modelTrainDays', (v) => `Окно: ${v}дн`],
263
236
  ['modelHistoryDays', (v) => `История: ${v}дн`],
264
- ['modelEmbedDim', (v) => `Эмбд.: ${v}`],
237
+ ['modelEmbedDim', (v) => `Эмбд.: ${Number(v) === 0 ? 'Авто' : v}`],
265
238
  ['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
266
239
  ['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
267
240
  ];
@@ -325,12 +298,10 @@ async function loadRuns(selectId) {
325
298
 
326
299
  async function openRun(id) {
327
300
  state.run = await api(`/api/runs/${id}`);
328
- $('#run-select').value = String(id);
301
+ if ($('#run-select')) $('#run-select').value = String(id);
329
302
  saveNavPatch({ runId: Number(id) });
330
303
  syncUiAvailability();
331
304
  renderOverview();
332
- setupMapSlider();
333
- if (!$('#tab-map').hidden) drawMap();
334
305
  }
335
306
 
336
307
  $('#run-select').addEventListener('change', (e) => e.target.value && openRun(Number(e.target.value)));
@@ -445,37 +416,16 @@ function syncSettingsDependencies() {
445
416
 
446
417
  const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
447
418
  if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
448
- }
449
- $('#btn-to-live').addEventListener('click', async () => {
450
- const r = state.run;
451
- if (!r || r.status !== 'done') return;
452
- const rawCap = prompt(`Стартовая сумма виртуального счёта (по настройкам эксперимента №${r.id}), ₽:`, String(r.params?.capital || 1000000));
453
- if (rawCap === null) return;
454
- const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
455
- try {
456
- const res = await post('/api/live/start', { baseRunId: r.id, capital });
457
- renderLive(res.status);
458
- toast(`Виртуальный счёт №${res.runId} запущен по эксперименту №${r.id}`, 'info', {
459
- label: 'Открыть',
460
- fn: () => { switchTab('now'); showNowSec('virtual'); },
461
- });
462
- } catch (e) { toast(e.message, 'error'); }
463
- });
464
- // «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
465
- const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
466
- function runSettingsDiff(r = state.run) {
467
- if (!r?.params || !state.settings) return { entries: [], lines: [] };
468
- const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
469
- const diffKeys = APPLY_KEYS.filter((k) => pv(k) !== undefined && JSON.stringify(pv(k)) !== JSON.stringify(state.settings[k]));
470
- return {
471
- entries: diffKeys.map((k) => [k, pv(k)]),
472
- lines: diffKeys.map((k) => `${labelOf(k)}: ${state.settings[k] ?? '—'} → ${pv(k) ?? '—'}`),
473
- };
419
+ if ($('#btn-emb-auto')) {
420
+ const isAutoEmb = Number(s.modelEmbedDim ?? 0) === 0;
421
+ $('#btn-emb-auto').classList.toggle('primary', isAutoEmb);
422
+ $('#btn-emb-auto').textContent = isAutoEmb ? '✓ Авто (0)' : '⚡ Авто (0)';
423
+ }
474
424
  }
475
425
  function syncShowcaseBtn() {
476
426
  const btn = $('#btn-showcase');
477
427
  if (!btn) return;
478
- const isAdmin = state.config?.user?.role === 'admin';
428
+ const isAdmin = isOwnerUser();
479
429
  const r = state.run;
480
430
  btn.hidden = !isAdmin || !r || r.status !== 'done';
481
431
  if (!btn.hidden) {
@@ -497,27 +447,7 @@ $('#btn-showcase')?.addEventListener('click', async () => {
497
447
 
498
448
  function syncApplyBtn() {
499
449
  syncShowcaseBtn();
500
- const btn = $('#btn-apply');
501
- if (!btn) return;
502
- const isDone = state.run?.status === 'done' && state.run.id !== state.active;
503
- const { lines } = runSettingsDiff();
504
- btn.hidden = !isDone || !lines.length;
505
- if (lines.length) {
506
- btn.textContent = `✓ Применить эти настройки (${lines.length})`;
507
- btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
508
- }
509
450
  }
510
- $('#btn-apply').addEventListener('click', async () => {
511
- const r = state.run;
512
- if (!r) return;
513
- const { entries, lines } = runSettingsDiff(r);
514
- if (!lines.length) { syncApplyBtn(); return; }
515
- if (!confirm(`Применить настройки из эксперимента №${r.id}?\n\n${lines.join('\n')}`)) return;
516
- await saveSettings(Object.fromEntries(entries));
517
- fillForm(state.settings);
518
- syncApplyBtn();
519
- toast(`Настройки из эксперимента №${r.id} применены`);
520
- });
521
451
 
522
452
  // Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
523
453
  function calcPeriodStats(period, returnPct, marketPct) {
@@ -715,7 +645,17 @@ function renderLiveTraining() {
715
645
  ].join('');
716
646
  }
717
647
 
648
+ const isCalcStage = st.stage === 'calc';
649
+ const calcBanner = isCalcStage ? `<div class="ld-calc-banner">
650
+ <div class="ld-calc-spin"></div>
651
+ <div style="min-width:0;flex:1">
652
+ <div style="font-weight:600;font-size:13.5px;color:var(--accent)">Идёт финальный пересчёт сделок, кривых капитала и фазовой устойчивости…</div>
653
+ <div class="small muted" style="margin-top:2px">${esc(st.text || 'Симуляция портфеля и расчёт метрик…')}</div>
654
+ </div>
655
+ </div>` : '';
656
+
718
657
  empty.innerHTML = `
658
+ ${calcBanner}
719
659
  <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
720
660
  <div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
721
661
  <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>
@@ -937,14 +877,49 @@ $('#btn-cancel').addEventListener('click', async () => {
937
877
  // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
938
878
  // ======================================================================
939
879
  let forecastBusy = false;
880
+ let pulseClearTimer = null;
940
881
  const prevClientExp = new Map();
941
882
  const prevClientRank = new Map();
942
883
 
884
+ function triggerTradingPulse(text = 'Обновление котировок и сигналов…', durationMs = 2400) {
885
+ state.tradingPulseActive = true;
886
+ state.tradingPulseText = text;
887
+ updateTabBadges();
888
+ const badge = $('#fc-live-badge');
889
+ if (badge) {
890
+ badge.innerHTML = `<i class="tspin" style="margin-right:5px;vertical-align:-1px"></i>${esc(text)}`;
891
+ badge.style.background = 'rgba(59, 130, 246, 0.18)';
892
+ badge.style.borderColor = 'var(--accent)';
893
+ badge.style.color = 'var(--accent)';
894
+ }
895
+ clearTimeout(pulseClearTimer);
896
+ pulseClearTimer = setTimeout(() => {
897
+ state.tradingPulseActive = false;
898
+ updateTabBadges();
899
+ if (badge && state.forecast) {
900
+ const liveTimeStr = new Date((state.forecast.liveTs || state.forecast.at || Date.now()) + MSK).toISOString().slice(11, 19);
901
+ badge.textContent = `● живой радар · ${liveTimeStr}`;
902
+ badge.style.background = '';
903
+ badge.style.borderColor = '';
904
+ badge.style.color = '';
905
+ }
906
+ }, durationMs);
907
+ }
908
+
943
909
  async function loadForecast(force, silent = false) {
944
910
  if (forecastBusy) return;
945
911
  forecastBusy = true;
912
+ triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
946
913
  const box = $('#forecast');
947
- if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
914
+ if (!silent && !state.forecast) {
915
+ box.innerHTML = `<div class="ld-calc-banner" style="margin:4px 0">
916
+ <div class="ld-calc-spin"></div>
917
+ <div>
918
+ <div style="font-weight:600;color:var(--accent)">Синхронизация живых котировок Мосбиржи и расчёт матрицы внимания…</div>
919
+ <div class="small muted">Занимает ~1–2 секунды (чтение локального кэша свечей и живого стакана TQBR)</div>
920
+ </div>
921
+ </div>`;
922
+ }
948
923
  try {
949
924
  const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
950
925
  // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
@@ -968,8 +943,8 @@ async function loadForecast(force, silent = false) {
968
943
  const dot = (n) => (n ? ` <span class="${n.tone >= 0.15 ? 'pos' : n.tone <= -0.15 ? 'neg' : 'muted'}" title="${esc(`${n.top}${n.count > 1 ? ` (и ещё ${n.count - 1})` : ''} — ${n.src}, ${fmtTs(n.ts)}`)}">●</span>` : '');
969
944
  const arrowOf = (r) => {
970
945
  const d = Number(r.deltaPct || 0);
971
- if (d > 0.002) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">▲ +${fmtNum(d, 2)}%</span>`;
972
- if (d < -0.002) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">▼ ${fmtNum(d, 2)}%</span>`;
946
+ if (d >= 0.005) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">+${fmtNum(d, 2)}% ▲</span>`;
947
+ if (d <= -0.005) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">${fmtNum(d, 2)}% ▼</span>`;
973
948
  return '<span class="muted small" title="Без изменений">—</span>';
974
949
  };
975
950
  const rankBadge = (r) => {
@@ -978,18 +953,30 @@ async function loadForecast(force, silent = false) {
978
953
  if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
979
954
  return '';
980
955
  };
956
+ const colgroup = `<colgroup><col style="width:33%"><col style="width:12%"><col style="width:12%"><col style="width:11%"><col style="width:12%"><col style="width:20%"></colgroup>`;
957
+ const sigBadge = (r) => {
958
+ if (r.signal) return '<span class="tag ok">купить</span>';
959
+ if (r.shortSignal) return '<span class="tag no">шорт</span>';
960
+ if (r.shortBlockedByBroker) return '<span class="tag" title="Ожидание пробило порог шорта, но брокер запрещает короткие продажи по этой бумаге (shortEnabledFlag = false)">без шорта</span>';
961
+ return '<span class="muted small">—</span>';
962
+ };
981
963
  const row = (r) => `<tr>
982
964
  <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
983
965
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
984
966
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
985
967
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
986
- <td>${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">шорт</span>' : ''}</td>
987
- <td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
968
+ <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
969
+ <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
988
970
  </tr>`;
989
- box.innerHTML = `<table><thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
990
- <table style="margin-top:10px"><thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
991
- <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога ${fmtPct(f.thresholdPct, 2)}.${f.direction && f.direction !== 'long' ? ' «Шорт» — ожидание ниже −порога.' : ''}</p>`;
992
- setupMapSlider();
971
+ const buyThr = f.buyThresholdPct ?? f.thresholdPct;
972
+ const shortThr = f.shortThresholdPct ?? f.thresholdPct;
973
+ const shortRows = [
974
+ ...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
975
+ ...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
976
+ ].slice(0, 4);
977
+ box.innerHTML = `<table style="table-layout:fixed">${colgroup}<thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
978
+ <table style="margin-top:14px;table-layout:fixed">${colgroup}<thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${shortRows.map(row).join('')}</tbody></table>
979
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
993
980
  if (state.brokers?.accounts?.length) renderBrokers();
994
981
  } catch (e) {
995
982
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -1007,424 +994,26 @@ function refreshForecastIfStale() {
1007
994
  loadForecast(false, true);
1008
995
  }
1009
996
  }
1010
- setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
1011
-
1012
- function showNowSec(sec) {
1013
- document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
1014
- document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
1015
- saveNavPatch({ nowSec: sec });
1016
- }
1017
- $('#now-nav').addEventListener('click', (e) => {
1018
- const b = e.target.closest('[data-nsec]');
1019
- if (b) showNowSec(b.dataset.nsec);
1020
- });
1021
-
1022
- let openLiveId = loadSavedNav().openLiveId ?? null;
1023
- const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
1024
- function setLiveSubTab(id, tab) {
1025
- liveTabById.set(String(id), tab);
1026
- saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
1027
- }
1028
-
1029
- function renderLive(st) {
1030
- state.live = st;
1031
- const accs = st?.accounts || [];
1032
- if ($('#nnav-virtual')) $('#nnav-virtual').textContent = accs.length ? `Виртуальные счета (${accs.length})` : 'Виртуальные счета';
1033
- updateTabBadges();
1034
- if (!accs.length) {
1035
- $('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
1036
- return;
1037
- }
1038
- if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
1039
- openLiveId = accs[0].runId;
1040
- }
1041
- $('#live').innerHTML = accs.map((a) => {
1042
- const isOpen = String(a.runId) === String(openLiveId);
1043
- const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
1044
- const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
1045
- const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
1046
- const retPct = a.returnPct ?? 0;
1047
- const step = a.recent?.[0];
1048
- const p = a.params || {};
1049
- const mf = a.metrics?.full || {};
1050
- const trades = Array.isArray(a.trades) ? a.trades : [];
1051
- const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
1052
- const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
1053
- const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
1054
- const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
1055
- const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
1056
- const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
1057
- let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
1058
- if (a.corridor) {
1059
- const c = a.corridor;
1060
- const statusMap = {
1061
- inside: ['в коридоре истории', 'ok'],
1062
- above: ['опережает историю', 'ok'],
1063
- below: ['ниже коридора истории — проверьте', 'no'],
1064
- };
1065
- const [sLbl, sCls] = statusMap[c.status] || ['', ''];
1066
- corrHtml = `<div class="kpi" title="Ожидание по темпу эксперимента №${c.runId} (история: ${fmtPct(c.histReturnPct)}, макс. просадка ${fmtNum(c.histMaxDdPct, 1)}%)"><div class="v ${cls(c.expectedPct)}">${fmtPct(c.expectedPct, 2)} <span class="muted small">(${fmtPct(c.minPct, 1)}…${fmtPct(c.maxPct, 1)})</span></div><div class="l">ориентир №${c.runId} · <span class="${sCls === 'no' ? 'neg' : 'pos'}">${sLbl}</span></div></div>`;
1067
- }
1068
- const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
1069
-
1070
- // Вкладка 1: Текущие позиции и ожидающие заявки
1071
- const posPaneHtml = `
1072
- ${s.pending?.length ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Ожидают исполнения на открытии следующей свечи (${s.pending.length})</b></div><div class="muted small">${s.pending.map((o) => `<span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${{ buy: 'купить', short: 'открыть шорт', cover: 'закрыть шорт' }[o.action] || 'продать'} <b>${esc(o.symbol)}</b></span>`).join(' ')}</div></div>` : ''}
1073
- ${s.positions.length ? `<table><thead><tr><th>Бумага</th><th>Направление</th><th>Открыта</th><th class="num">Кол-во</th><th class="num">Цена входа → Сейчас</th><th class="num">Текущий доход</th></tr></thead><tbody>${s.positions.map((pos) => {
1074
- const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
1075
- return `<tr>
1076
- <td><b>${esc(pos.symbol)}</b></td>
1077
- <td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
1078
- <td class="muted small">${fmtTs(pos.entryTs)}</td>
1079
- <td class="num">${fmtNum(pos.qty, 0)} шт.</td>
1080
- <td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
1081
- <td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
1082
- </tr>`;
1083
- }).join('')}</tbody></table>` : `<p class="muted small" style="margin:6px 0">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'открытых позиций нет' : 'ничего не куплено'} (все <b>${fmtNum(s.cash ?? s.equity, 0)} ₽</b> в свободном кэше) — стратегия ждёт сильного сигнала.</p>`}
1084
- ${step ? `<p class="muted small" style="margin:8px 0 0">Последняя проверка рынка (${fmtTs(step.ts)}): ${step.decisions?.length ? step.decisions.map((d) => `<b>${esc(d.symbol)}</b>: ${esc(d.text)}`).join(' · ') : 'без изменений'}${step.top?.length ? ` · лидеры прогноза: ${step.top.slice(0, 3).map((x) => `${esc(x.symbol)} (${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%)`).join(', ')}` : ''}</p>` : ''}
1085
- `;
1086
-
1087
- // Вкладка 2: История закрытых сделок и по-дневной доход
1088
- let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
1089
- if (trades.length) {
1090
- const byDay = new Map();
1091
- for (const t of trades) {
1092
- const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
1093
- if (!byDay.has(dKey)) byDay.set(dKey, []);
1094
- byDay.get(dKey).push(t);
1095
- }
1096
- const rows = [];
1097
- for (const [, dayTrades] of byDay) {
1098
- const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
1099
- const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
1100
- const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
1101
- rows.push(`<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTrades[0]?.exitTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">сделок: <b>${dayTrades.length}</b> (в плюс: ${dayWins}) · комиссия: ${fmtNum(dayFee, 0)} ₽ · доход за день: <b class="${cls(dayPnl)}">${dayPnl >= 0 ? '+' : ''}${fmtNum(dayPnl, 0)} ₽</b></span></div></td></tr>`);
1102
- for (const t of dayTrades) {
1103
- rows.push(`<tr>
1104
- <td><b>${esc(t.symbol)}</b></td>
1105
- <td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
1106
- <td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
1107
- <td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
1108
- <td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
1109
- <td class="num ${cls(t.pnl)}" style="white-space:nowrap"><b>${t.pnl >= 0 ? '+' : ''}${fmtNum(t.pnl, 0)} ₽</b> <span class="small">(${fmtPct(t.pnlPct, 2)})</span></td>
1110
- <td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
1111
- </tr>`);
1112
- }
1113
- }
1114
- tradesPaneHtml = `<table><thead><tr><th>Бумага</th><th>Тип</th><th>Вход → Выход</th><th class="num">Кол-во</th><th class="num">Цена входа → выхода</th><th class="num">Чистый доход</th><th>Причина</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
1115
- }
1116
-
1117
- // Вкладка 3: Журнал шагов и решений ИИ по свечам
1118
- const stepsList = Array.isArray(a.recent) ? a.recent : [];
1119
- const stepsPaneHtml = stepsList.length
1120
- ? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
1121
- <td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
1122
- <td>${stItem.decisions?.length ? stItem.decisions.map((d) => `<span class="tag ${d.action === 'buy' ? 'ok' : d.action === 'short' || d.action === 'sell' ? 'mid' : ''}">${esc(d.symbol)}</span> <span class="small">${esc(d.text)}</span>`).join('<br>') : '<span class="muted small">Позиции без изменений</span>'}</td>
1123
- <td class="small muted">${stItem.top?.length ? stItem.top.slice(0, 3).map((x) => `<b>${esc(x.symbol)}</b> <span class="${cls(x.expectedPct)}">${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%</span>`).join(' · ') : '—'}</td>
1124
- </tr>`).join('')}</tbody></table>`
1125
- : '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
1126
-
1127
- return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
1128
- <div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
1129
- <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
1130
- <span class="acc-caret">▶</span>
1131
- <b>${esc(dispName)}</b>
1132
- <span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
1133
- ${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
1134
- </div>
1135
- <div class="row-gap" style="align-items:center">
1136
- <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
1137
- ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
1138
- <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
1139
- </div>
1140
- </div>
1141
- <div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
1142
- <div class="brk-specs" style="margin-bottom:10px">
1143
- <span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
1144
- <span class="k">Статус и время работы:</span><span class="v">запущен <b>${fmtTs(a.startedAt)}</b>${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча <b>${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}</b>${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}</span>
1145
- <span class="k">Структура дохода:</span><span class="v">зафиксировано по сделкам: <b class="${cls(realizedPnl)}">${realizedPnl >= 0 ? '+' : ''}${fmtNum(realizedPnl, 0)} ₽</b> · в открытых позициях: <b class="${cls(openPnl)}">${openPnl >= 0 ? '+' : ''}${fmtNum(openPnl, 0)} ₽</b> · учтено комиссий: <span class="muted">−${fmtNum(totalFee, 0)} ₽</span> · макс. просадка: <b>${fmtNum(mf.maxDrawdownPct || 0, 2)}%</b></span>
1146
- </div>
1147
- <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
1148
- <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
1149
- <div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">чистый доход с начала</div></div>
1150
- ${corrHtml}
1151
- <div class="kpi"><div class="v">${trades.length || (mf.trades ?? 0)} <span class="muted small">(${winRate}% в плюс)</span></div><div class="l">закрыто сделок · профит-фактор ${fmtNum(mf.profitFactor || 0, 2)}</div></div>
1152
- </div>
1153
- <div class="brk-subtabs" style="margin-top:10px">
1154
- <button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
1155
- <button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
1156
- <button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
1157
- </div>
1158
- <div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
1159
- <div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
1160
- <div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
1161
- </div>
1162
- </div>`;
1163
- }).join('');
1164
- syncUiAvailability();
1165
- }
1166
- $('#btn-live').addEventListener('click', async () => {
1167
- const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
1168
- if (rawCap === null) return;
1169
- const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
1170
- try {
1171
- const res = await post('/api/live/start', { capital });
1172
- renderLive(res.status);
1173
- toast(`Виртуальный счёт №${res.runId} запущен`);
1174
- } catch (e) { toast(e.message, 'error'); }
1175
- });
1176
- $('#live').addEventListener('click', async (e) => {
1177
- const tabBtn = e.target.closest('button[data-ltab]');
1178
- if (tabBtn) {
1179
- setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
1180
- renderLive(state.live);
1181
- return;
1182
- }
1183
- const stopBtn = e.target.closest('[data-live-stop]');
1184
- if (stopBtn) {
1185
- const id = Number(stopBtn.dataset.liveStop);
1186
- if (!confirm(`Остановить виртуальный счёт №${id}?`)) return;
1187
- try { const r = await post('/api/live/stop', { runId: id }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
1188
- return;
1189
- }
1190
- const delBtn = e.target.closest('[data-live-del]');
1191
- if (delBtn) {
1192
- const id = Number(delBtn.dataset.liveDel);
1193
- if (!confirm(`Удалить виртуальный счёт №${id} и его сделки?`)) return;
1194
- try { const r = await api(`/api/live/${id}`, { method: 'DELETE' }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
1195
- return;
1196
- }
1197
- const openBtn = e.target.closest('[data-open-run]');
1198
- if (openBtn) {
1199
- const id = Number(openBtn.dataset.openRun);
1200
- document.querySelector('[data-tab=overview]')?.click();
1201
- openRun(id);
1202
- return;
1203
- }
1204
- const askBtn = e.target.closest('[data-live-ask]');
1205
- if (askBtn) {
1206
- const id = Number(askBtn.dataset.liveAsk);
1207
- const acc = (state.live?.accounts || []).find((x) => x.runId === id);
1208
- sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
1209
- return;
1210
- }
1211
- const foldHead = e.target.closest('[data-live-fold]');
1212
- if (foldHead) {
1213
- const id = foldHead.dataset.liveFold;
1214
- openLiveId = String(openLiveId) === String(id) ? null : id;
1215
- saveNavPatch({ openLiveId });
1216
- renderLive(state.live);
1217
- }
1218
- });
997
+ setInterval(() => { if (!document.hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
1219
998
 
1220
- // ---------- новости: только для прогноза «сейчас» и живой проверки ----------
999
+ // ---------- новости ----------
1221
1000
  async function loadNews() {
1222
1001
  try {
1223
1002
  const { items } = await api('/api/news?limit=20');
1224
1003
  if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
1225
- $('#news-list').innerHTML = items.length
1226
- ? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}">${s.symbol}</b>`).join(', ')} · ` : ''}<a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`).join('')
1227
- : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
1004
+ if ($('#news-list')) {
1005
+ $('#news-list').innerHTML = items.length
1006
+ ? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}">${s.symbol}</b>`).join(', ')} · ` : ''}<a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`).join('')
1007
+ : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
1008
+ }
1228
1009
  } catch { /* ignore */ }
1229
1010
  }
1230
1011
 
1231
1012
  // ======================================================================
1232
- // КАРТА
1233
- // ======================================================================
1234
- function mapFrames() {
1235
- const ex = state.run?.extra;
1236
- const fr = (ex?.map?.snapshots || []).map((s) => ({ ts: s.ts, pts: s.pts, cl: s.cl, symbols: ex.symbols, names: ex.names }));
1237
- if (state.forecast?.map) fr.push({ ts: state.forecast.barTs, now: true, ...state.forecast.map });
1238
- return fr;
1239
- }
1240
- function setupMapSlider() {
1241
- const s = $('#map-slider');
1242
- const atEnd = Number(s.value) >= Number(s.max);
1243
- s.max = Math.max(0, mapFrames().length - 1);
1244
- if (atEnd) s.value = s.max;
1245
- }
1246
- function layout(f, W, H) {
1247
- const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
1248
- const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
1249
- const pad = 36;
1250
- return f.pts.map(([x, y]) => [pad + ((x - x0) / (x1 - x0 || 1)) * (W - 2 * pad), H - pad - ((y - y0) / (y1 - y0 || 1)) * (H - 2 * pad)]);
1251
- }
1252
- // ближайшая к клику точка карты (общая для карты по поведению и по описанию)
1253
- function pickPoint(f, e) {
1254
- const cv = $('#map-canvas');
1255
- const rect = cv.getBoundingClientRect();
1256
- const xy = layout(f, cv.clientWidth, cv.clientHeight);
1257
- let best = -1, bd = 16;
1258
- xy.forEach(([x, y], i) => { const d = Math.hypot(x - (e.clientX - rect.left), y - (e.clientY - rect.top)); if (d < bd) { bd = d; best = i; } });
1259
- return best;
1260
- }
1261
- function drawMap() {
1262
- if (state.mapMode === 'text') return drawProfilesMap();
1263
- const cv = $('#map-canvas');
1264
- const W = cv.clientWidth, H = cv.clientHeight;
1265
- if (!W) return;
1266
- $('#map-slider').hidden = false;
1267
- $('#map-text-box').hidden = true;
1268
- const dpr = window.devicePixelRatio || 1;
1269
- cv.width = W * dpr; cv.height = H * dpr;
1270
- const ctx = cv.getContext('2d');
1271
- ctx.scale(dpr, dpr);
1272
- const frames = mapFrames();
1273
- if (!frames.length) {
1274
- ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
1275
- ctx.fillText('Карта появится после исследования или прогноза.', 20, 30);
1276
- $('#map-date').textContent = '';
1277
- $('#map-info').textContent = '';
1278
- return;
1279
- }
1280
- const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
1281
- $('#map-date').textContent = f.now ? `Сейчас · ${fmtDate(f.ts)} · заливка — прогноз` : `Месяц с ${fmtDate(f.ts)}`;
1282
- const xy = layout(f, W, H);
1283
- const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
1284
- const fc = f.now && state.forecast ? Object.fromEntries(state.forecast.rows.map((r) => [r.symbol, r])) : null;
1285
- if (sel >= 0) {
1286
- const near = xy.map((p, i) => [i, Math.hypot(p[0] - xy[sel][0], p[1] - xy[sel][1])]).filter(([i]) => i !== sel).sort((a, b) => a[1] - b[1]).slice(0, 5);
1287
- ctx.strokeStyle = document.body.classList.contains('light') ? 'rgba(36, 87, 214, 0.38)' : '#ffffff40';
1288
- for (const [i] of near) { ctx.beginPath(); ctx.moveTo(...xy[sel]); ctx.lineTo(...xy[i]); ctx.stroke(); }
1289
- }
1290
- f.symbols.forEach((s, i) => {
1291
- const [x, y] = xy[i];
1292
- const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
1293
- const r = fc?.[s] ? 3 + Math.min(7, Math.abs(fc[s].expectedPct) * 15) : 4;
1294
- ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
1295
- ctx.beginPath(); ctx.arc(x, y, i === sel ? r + 2 : r, 0, Math.PI * 2);
1296
- ctx.fillStyle = fc?.[s] ? (fc[s].expectedPct >= 0 ? '#3fb950' : '#f85149') : col;
1297
- ctx.fill();
1298
- ctx.globalAlpha = 1;
1299
- const light = document.body.classList.contains('light');
1300
- ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
1301
- ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
1302
- ctx.fillText(s, x + r + 3, y + 3);
1303
- });
1304
- if (sel < 0) { $('#map-info').textContent = 'Близко — значит акции обычно движутся вместе; цвет — найденная группа. Нажмите на акцию, чтобы увидеть соседей.'; return; }
1305
- const near = xy.map((p, i) => [i, Math.hypot(p[0] - xy[sel][0], p[1] - xy[sel][1])]).filter(([i]) => i !== sel).sort((a, b) => a[1] - b[1]).slice(0, 5).map(([i]) => f.symbols[i]);
1306
- const r = state.forecast?.rows.find((x) => x.symbol === f.symbols[sel]);
1307
- $('#map-info').innerHTML = `<b style="color:var(--text)">${f.symbols[sel]}</b> ${esc(f.names?.[sel] || '')} · рядом: ${near.join(', ')} · группа: ${f.symbols.filter((_, i) => f.cl[i] === f.cl[sel]).join(', ')}${r ? ` · прогноз ${fmtPct(r.expectedPct, 2)}` : ''}`;
1308
- }
1309
- $('#map-slider').addEventListener('input', drawMap);
1310
- $('#map-canvas').addEventListener('click', (e) => {
1311
- if (state.mapMode === 'text') {
1312
- const d = state.profiles;
1313
- if (!d?.map) return;
1314
- const best = pickPoint(d.map, e);
1315
- state.mapSel = best >= 0 ? d.map.symbols[best] : null;
1316
- drawMap();
1317
- return;
1318
- }
1319
- const frames = mapFrames();
1320
- if (!frames.length) return;
1321
- const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
1322
- const best = pickPoint(f, e);
1323
- state.mapSel = best >= 0 ? f.symbols[best] : null;
1324
- drawMap();
1325
- });
1326
- window.addEventListener('resize', debounce(() => { if (!$('#tab-map').hidden) drawMap(); }, 150));
1327
-
1328
- // ---------- карта «по описанию»: 2D-проекция устойчивых признаков бизнеса ----------
1329
- async function loadProfilesData() {
1330
- try { state.profiles = await api('/api/profiles'); } catch (e) { toast(e.message, 'error'); }
1331
- }
1332
- function drawProfilesMap() {
1333
- const cv = $('#map-canvas');
1334
- const W = cv.clientWidth, H = cv.clientHeight;
1335
- if (!W) return;
1336
- const dpr = window.devicePixelRatio || 1;
1337
- cv.width = W * dpr; cv.height = H * dpr;
1338
- const ctx = cv.getContext('2d');
1339
- ctx.scale(dpr, dpr);
1340
- $('#map-slider').hidden = true;
1341
- const d = state.profiles;
1342
- $('#map-date').textContent = d?.map ? `Описания бизнеса · ${d.count} из ${d.total} компаний` : 'Описания компаний';
1343
- if (!d?.map) {
1344
- ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
1345
- ctx.fillText(d?.count ? 'Описаний пока мало — карту по описаниям построить не из чего.' : 'Карты по описаниям пока нет.', 20, 30);
1346
- $('#map-text-box').hidden = false;
1347
- $('#btn-profiles').disabled = state.profilesBusy;
1348
- $('#map-info').textContent = 'Компании рядом, если похож их бизнес: экспортёр, банк, дивиденды и другие устойчивые свойства.';
1349
- return;
1350
- }
1351
- $('#map-text-box').hidden = true;
1352
- const f = d.map;
1353
- const xy = layout(f, W, H);
1354
- const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
1355
- f.symbols.forEach((s, i) => {
1356
- const [x, y] = xy[i];
1357
- ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
1358
- ctx.beginPath(); ctx.arc(x, y, i === sel ? 6 : 4, 0, Math.PI * 2);
1359
- ctx.fillStyle = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
1360
- ctx.fill();
1361
- ctx.globalAlpha = 1;
1362
- const light = document.body.classList.contains('light');
1363
- ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
1364
- ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
1365
- ctx.fillText(s, x + 7, y + 3);
1366
- });
1367
- if (sel < 0) { $('#map-info').textContent = 'Рядом — похожий бизнес. Цвет — найденная группа. Нажмите на акцию, чтобы увидеть её самые выраженные свойства.'; return; }
1368
- const prof = d.profiles[f.symbols[sel]];
1369
- const top = (prof || []).map((v, k) => [v, k]).sort((a, b) => b[0] - a[0]).filter(([v]) => v >= 0.3).slice(0, 5);
1370
- $('#map-info').innerHTML = `<b style="color:var(--text)">${f.symbols[sel]}</b> ${esc(f.names[sel] || '')}${top.length ? ` · свойства: ${top.map(([v, k]) => `${esc(d.features[k].label)} ${Math.round(v * 100)}%`).join(', ')}` : ' · нет ярко выраженных свойств'}`;
1371
- }
1372
- async function showMapSec(sec) {
1373
- document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
1374
- saveNavPatch({ mapSec: sec });
1375
- if (sec === 'journal') {
1376
- $('#map-sec').hidden = true;
1377
- $('#journal-box').hidden = false;
1378
- await loadJournal();
1379
- return;
1380
- }
1381
- $('#map-sec').hidden = false;
1382
- $('#journal-box').hidden = true;
1383
- state.mapMode = sec;
1384
- $('#map-title-lbl').textContent = sec === 'text' ? 'Карта рынка · по описаниям бизнеса' : 'Карта рынка · по совместному поведению';
1385
- if (sec === 'text' && !state.profiles) await loadProfilesData();
1386
- drawMap();
1387
- }
1388
- $('#map-nav').addEventListener('click', (e) => {
1389
- const b = e.target.closest('[data-msec]');
1390
- if (b) showMapSec(b.dataset.msec);
1391
- });
1392
- $('#btn-profiles').addEventListener('click', async () => {
1393
- try {
1394
- const r = await post('/api/profiles/refresh');
1395
- if (r.started === false) return toast('Описания уже составляются — подождите');
1396
- state.profilesBusy = true;
1397
- $('#btn-profiles').disabled = true;
1398
- $('#profiles-msg').textContent = 'Составляем описания компаний…';
1399
- } catch (e) { toast(e.message, 'error'); }
1400
- });
1401
-
1402
- // ======================================================================
1403
- // ЖУРНАЛ И НАСТРОЙКИ
1013
+ // НАСТРОЙКИ (ИЗДЕРЖКИ И НОВОСТИ)
1404
1014
  // ======================================================================
1405
- const VERDICT = { confirmed: ['подтвердилась', 'ok'], rejected: ['не подтвердилась', 'no'], unclear: ['неясно', 'mid'] };
1406
- async function loadJournal() {
1407
- const list = await api('/api/journal');
1408
- state.journalCount = list.length;
1409
- updateTabBadges();
1410
- if ($('#mnav-journal')) $('#mnav-journal').textContent = list.length ? `Журнал гипотез (${list.length})` : 'Журнал гипотез';
1411
- $('#journal').innerHTML = list.length
1412
- ? `<p class="muted small">Память Стратега: он читает журнал в начале каждого разговора. Неверную запись можно удалить.</p><table><thead><tr><th>Гипотеза</th><th>Результат</th><th>Вывод</th><th></th></tr></thead><tbody>${list.map((j) => {
1413
- const [vl, vc] = VERDICT[j.verdict] || [j.verdict, ''];
1414
- return `<tr><td>${esc(j.hypothesis)}<div class="muted small">${fmtTs(j.ts)}${j.change ? ` · ${esc(j.change)}` : ''}</div></td><td class="muted">${esc(j.result)}</td><td><span class="tag ${vc}">${vl}</span></td><td><button class="icon" data-jdel="${j.id}">✕</button></td></tr>`;
1415
- }).join('')}</tbody></table>`
1416
- : '<p class="muted">Журнал пуст. Попросите Стратега провести эксперимент — он запишет вывод сюда.</p>';
1417
- $('#journal').querySelectorAll('[data-jdel]').forEach((b) => b.addEventListener('click', async () => {
1418
- if (confirm('Удалить запись? Стратег её «забудет».')) await api(`/api/journal/${b.dataset.jdel}`, { method: 'DELETE' });
1419
- }));
1420
- }
1421
-
1422
- // Логические группы настроек по смыслу:
1423
- // g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
1424
1015
  const GROUPS = [
1425
- ['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
1426
- ['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
1427
- ['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
1016
+ ['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
1428
1017
  ];
1429
1018
  function buildForm() {
1430
1019
  const field = (p) => {
@@ -1435,45 +1024,13 @@ function buildForm() {
1435
1024
  return `<div class="srow" data-key="${p.key}"><div class="stxt"><div class="slbl">${esc(p.label)}<button type="button" class="mark" data-def="${p.key}" hidden title="Отличается от умолчания — нажмите, чтобы вернуть">изменено</button></div><div class="hint">${esc(p.hint)}</div></div>${input}</div>`;
1436
1025
  };
1437
1026
  const shown = state.config.params.filter((p) => !p.hidden);
1438
- const listed = new Set(GROUPS.flatMap(([, keys]) => keys));
1439
- 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>`;
1027
+ const curSec = document.querySelector('#main-nav [data-sec].active')?.dataset.sec || 'brokers';
1440
1028
  $('#params').innerHTML = GROUPS.map(([name, keys], i) => {
1441
- const items = keys.length ? keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean) : shown.filter((p) => !listed.has(p.key));
1442
- const extra = i === 2 ? cleanRow : '';
1443
- 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>` : '';
1029
+ const items = keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean);
1030
+ const secKey = `g${i}`;
1031
+ return items.length ? `<section class="ssec" data-sec="${secKey}"${curSec === secKey ? '' : ' hidden'}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}</div></section>` : '';
1444
1032
  }).join('');
1445
- $('#btn-clean')?.addEventListener('click', cleanRunsHistory);
1446
- }
1447
- function showSettingsSec(sec) {
1448
- $('#s-search').value = '';
1449
- document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
1450
- document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
1451
- document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
1452
- saveNavPatch({ settingsSec: sec });
1453
- if (sec === 'cloud') renderCloudPane().catch(() => {});
1454
- }
1455
- document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
1456
- const b = e.target.closest('[data-sec]');
1457
- if (b) showSettingsSec(b.dataset.sec);
1458
- });
1459
- $('#s-search').addEventListener('input', (e) => {
1460
- const q = e.target.value.trim().toLowerCase();
1461
- if (!q) {
1462
- const cur = document.querySelector('#tab-settings .snav [data-sec].active')?.dataset.sec || 'g0';
1463
- showSettingsSec(cur);
1464
- return;
1465
- }
1466
- document.querySelectorAll('#tab-settings .ssec').forEach((sec) => {
1467
- if (!sec.dataset.sec.startsWith('g')) { sec.hidden = true; return; }
1468
- let vis = 0;
1469
- sec.querySelectorAll('.srow').forEach((r) => {
1470
- const ok = r.textContent.toLowerCase().includes(q);
1471
- r.hidden = !ok;
1472
- if (ok) vis++;
1473
- });
1474
- sec.hidden = !vis;
1475
- });
1476
- });
1033
+ }
1477
1034
  function fillForm(s) {
1478
1035
  for (const el of $('#params').elements) {
1479
1036
  if (!el.name || el === document.activeElement) continue;
@@ -1509,38 +1066,6 @@ $('#params').addEventListener('click', async (e) => {
1509
1066
  await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
1510
1067
  fillForm(state.settings);
1511
1068
  });
1512
- $('#btn-reset').addEventListener('click', async () => {
1513
- if (!confirm('Вернуть все настройки по умолчанию?')) return;
1514
- state.settings = await post('/api/settings/reset');
1515
- fillForm(state.settings);
1516
- loadHistory().catch(() => {});
1517
- });
1518
-
1519
- // ---------- история изменений настроек ----------
1520
- async function loadHistory() {
1521
- const list = await api('/api/settings/history');
1522
- $('#snav-hist').textContent = list.length ? `История изменений (${list.length})` : 'История изменений';
1523
- $('#history').innerHTML = list.length
1524
- ? `<table><thead><tr><th>Дата</th><th>Кто</th><th>Что изменилось</th><th></th></tr></thead><tbody>${list.map((h) => `<tr>
1525
- <td class="muted small">${fmtTs(h.ts)}</td>
1526
- <td>${h.source === 'ai' ? 'Стратег' : 'вы'}</td>
1527
- <td>${Object.entries(h.changes).map(([k, v]) => `${esc(labelOf(k))}: ${esc(v.было)} → ${esc(v.стало)}`).join('; ')}${h.reason ? `<div class="muted small">${esc(h.reason)}</div>` : ''}</td>
1528
- <td><button class="icon" data-revert="${h.id}" title="Вернуть значения «было»">Вернуть</button></td>
1529
- </tr>`).join('')}</tbody></table>`
1530
- : '<p class="muted">Изменений ещё не было.</p>';
1531
- }
1532
- $('#history').addEventListener('click', async (e) => {
1533
- const b = e.target.closest('[data-revert]');
1534
- if (!b) return;
1535
- try {
1536
- const r = await post(`/api/settings/history/${b.dataset.revert}/revert`);
1537
- if (r.errors?.length) toast(r.errors.join('; '), 'error');
1538
- else if (!Object.keys(r.changed).length) toast('Настройки уже в этом состоянии — откатывать нечего');
1539
- state.settings = r.settings;
1540
- fillForm(state.settings);
1541
- await loadHistory();
1542
- } catch (err) { toast(err.message, 'error'); }
1543
- });
1544
1069
 
1545
1070
  // ======================================================================
1546
1071
  // ЧАТ СО СТРАТЕГОМ
@@ -1550,6 +1075,7 @@ const TOOL_LABELS = {
1550
1075
  run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
1551
1076
  journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
1552
1077
  set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
1078
+ broker_onboarding_guide: 'Смотрит инструкцию по брокеру', discover_broker_accounts: 'Ищет субсчета по токену', connect_broker_account: 'Подключение брокерского счёта',
1553
1079
  };
1554
1080
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1555
1081
  const feed = $('#feed');
@@ -1576,6 +1102,8 @@ function argsPreview(name, a) {
1576
1102
  if (name === 'compare_runs') return `№${a.a} и №${a.b}`;
1577
1103
  if (name === 'journal_add') return a.hypothesis || '';
1578
1104
  if (name === 'market_neighbors') return a.symbol || '';
1105
+ if (name === 'broker_onboarding_guide') return BROKER_LABELS[a.broker] || a.broker || '';
1106
+ if (name === 'connect_broker_account') return `${BROKER_LABELS[a.broker] || a.broker || ''}${a.name ? ` · ${a.name}` : ''}${a.accountId ? ` (${a.accountId})` : ''}`;
1579
1107
  return '';
1580
1108
  }
1581
1109
 
@@ -1587,7 +1115,7 @@ function actHtml(m, call) {
1587
1115
  const w = state.chat.state?.waiting;
1588
1116
  const waiting = status === 'pending' && w && w.msgId === m.id && w.callId === call.id;
1589
1117
  const prev = t.preview || argsPreview(call.function.name, args);
1590
- const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop'].includes(call.function.name);
1118
+ const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop', 'connect_broker_account'].includes(call.function.name);
1591
1119
  if (!big && !waiting) {
1592
1120
  return `<div class="ev">${esc(TOOL_LABELS[call.function.name] || call.function.name)}${prev ? `: ${esc(prev.split('\n')[0])}` : ''} <span class="st ${status}">· ${STATUS[status] || status}</span></div>`;
1593
1121
  }
@@ -2181,7 +1709,130 @@ addForm.addEventListener('submit', async (e) => {
2181
1709
  // ======================================================================
2182
1710
  // БРОКЕРСКИЕ СЧЕТА (Т-БАНК, ФИНАМ, АЛОР, БКС)
2183
1711
  // ======================================================================
2184
- const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
1712
+ const BROKER_LABELS = {
1713
+ tbank: 'Т-Банк',
1714
+ finam: 'Финам',
1715
+ bcs: 'БКС',
1716
+ alor: 'Алор',
1717
+ cifra: 'Цифра брокер',
1718
+ iticapital: 'ITI Capital',
1719
+ sber: 'СберИнвестиции',
1720
+ vtb: 'ВТБ Инвестиции',
1721
+ alfa: 'Альфа-Инвестиции',
1722
+ };
1723
+ const BROKER_GUIDES = {
1724
+ tbank: {
1725
+ tokenLbl: 'Токен T-Invest API',
1726
+ tokenPh: 't.… (токен Т-Инвестиций)',
1727
+ accLbl: 'ID субсчёта Т-Банка',
1728
+ accPh: 'ID счёта (или нажмите кнопку справа →)',
1729
+ canDiscover: true,
1730
+ summary: '📖 Как получить токен T-Invest API в Т-Банке (за 1 минуту)',
1731
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1732
+ <li>Откройте веб-кабинет <b>Т-Инвестиций</b> на сайте <code>tbank.ru/invest/settings</code> (раздел <b>Настройки → Токены T-Invest API</b>).</li>
1733
+ <li>Нажмите <b>«Выпустить токен»</b>, выберите права: <b>«Полный доступ»</b> (для выставления заявок) или «Только чтение», и укажите <b>«Все счета»</b>.</li>
1734
+ <li>Скопируйте полученный токен (начинается на <code>t.…</code>) в поле «Токен T-Invest API» ниже и нажмите кнопку <b>«🔍 Найти мои счета по токену»</b> — платформа сама покажет все ваши субсчета, их баланс и статус маржинальной торговли.</li>
1735
+ <li><b>Для работы с шортами (тарифы Профи и VIP):</b> в мобильном приложении Т-Инвестиций зайдите в настройки выбранного счёта (шестерёнка в правом верхнем углу счёта) и включите тумблер <b>«Маржинальная торговля»</b>.</li>
1736
+ </ol>`,
1737
+ },
1738
+ finam: {
1739
+ tokenLbl: 'Токен Finam Trade API',
1740
+ tokenPh: 'CA… (токен доступа Finam Trade API)',
1741
+ accLbl: 'Код клиента (ClientId)',
1742
+ accPh: 'Например, 123456R (Единый счёт Мосбиржи)',
1743
+ canDiscover: false,
1744
+ summary: '📖 Как получить токен Trade API и ClientId в Финаме',
1745
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1746
+ <li>Войдите в личный кабинет клиента Финам (<code>edox.finam.ru</code> / <code>finam.ru</code>) → раздел <b>«Торговля» → «Токены доступа (Finam Trade API)»</b>.</li>
1747
+ <li>Нажмите <b>«Создать новый токен»</b>, подтвердите выпуск по СМС и скопируйте строку токена (начинается на <code>CA…</code>) в поле «Токен Finam Trade API».</li>
1748
+ <li>В поле <b>«Код клиента (ClientId)»</b> укажите торговый код вашего фондового счёта Мосбиржи (обычно вида <code>123456R</code> или <code>КлФ-…</code>, он отображается рядом с токеном и в терминале FinamTrade).</li>
1749
+ </ol>`,
1750
+ },
1751
+ bcs: {
1752
+ tokenLbl: 'Refresh-токен БКС Trade API',
1753
+ tokenPh: 'Refresh-токен из веб-терминала БКС Мир инвестиций',
1754
+ accLbl: 'Номер соглашения / субсчёта',
1755
+ accPh: 'Оставьте пустым для основного счёта (или укажите номер соглашения)',
1756
+ canDiscover: false,
1757
+ summary: '📖 Как получить Refresh-токен в БКС Мир инвестиций',
1758
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1759
+ <li>Откройте веб-терминал <b>«БКС Мир инвестиций»</b> в браузере → перейдите в <b>Профиль / Настройки → «Торговый API (BCS Trade API)»</b>.</li>
1760
+ <li>Выпустите новый токен с правом <b>«Чтение и торговля»</b> (<code>trade-api-write</code>).</li>
1761
+ <li>Скопируйте полученный <b>Refresh-токен</b> в поле ниже. Платформа автоматически обменивает его на короткоживущий JWT-токен при каждом обращении.</li>
1762
+ </ol>`,
1763
+ },
1764
+ alor: {
1765
+ tokenLbl: 'Refresh-токен Alor OpenAPI',
1766
+ tokenPh: 'Refresh-токен с портала alor.dev',
1767
+ accLbl: 'Фондовый портфель Мосбиржи',
1768
+ accPh: 'Например, D12345',
1769
+ canDiscover: false,
1770
+ summary: '📖 Как получить Refresh-токен и номер портфеля в Алор Брокере',
1771
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1772
+ <li>Авторизуйтесь под учётной записью клиента Алор на портале разработчика <code>alor.dev</code> (или в личном кабинете <code>my.alor.ru</code> → раздел <b>OpenAPI</b>).</li>
1773
+ <li>Нажмите <b>«Выпустить Refresh Token»</b> для боевого контура (или контура Разработки, если включён тумблер «Песочница»).</li>
1774
+ <li>В поле <b>«Фондовый портфель Мосбиржи»</b> укажите ваш фондовый портфель с литерой <code>D</code> (например, <code>D39004</code>).</li>
1775
+ </ol>`,
1776
+ },
1777
+ cifra: {
1778
+ tokenLbl: 'Ключи Tradernet (Public:Secret)',
1779
+ tokenPh: 'PublicKey:SecretKey (через двоеточие)',
1780
+ accLbl: 'Номер счёта Tradernet',
1781
+ accPh: 'Необязательно (определяется автоматически по ключу)',
1782
+ canDiscover: false,
1783
+ summary: '📖 Как получить API-ключи Tradernet в «Цифра брокер» (Freedom24)',
1784
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1785
+ <li>Войдите в веб-терминал <b>Tradernet («Цифра брокер»)</b> на сайте <code>tradernet.ru</code> → откройте <b>Настройки профиля → «API ключи»</b>.</li>
1786
+ <li>Сгенерируйте пару ключей: <b>Публичный ключ (Public Key)</b> и <b>Секретный ключ (Secret Key)</b>.</li>
1787
+ <li>Вставьте их в поле ниже <b>через двоеточие без пробелов</b>: <code>ВашPublicKey:ВашSecretKey</code>. Платформа подписывает каждый запрос алгоритмом HMAC-SHA256.</li>
1788
+ </ol>`,
1789
+ },
1790
+ iticapital: {
1791
+ tokenLbl: 'Refresh-токен ITI Capital',
1792
+ tokenPh: 'Refresh-токен OpenAPI ITI Capital',
1793
+ accLbl: 'Код портфеля Мосбиржи',
1794
+ accPh: 'Например, BP12345',
1795
+ canDiscover: false,
1796
+ summary: '📖 Как подключить счёт ITI Capital по OpenAPI',
1797
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1798
+ <li>В личном кабинете <b>ITI Capital</b> откройте раздел <b>Торговые системы → OpenAPI</b> и выпустите <b>Refresh Token</b>.</li>
1799
+ <li>Скопируйте токен в поле ниже и укажите ваш фондовый портфель Мосбиржи вида <code>BP12345</code>.</li>
1800
+ </ol>`,
1801
+ },
1802
+ sber: {
1803
+ tokenLbl: 'Адрес моста QUIK (необязательно)',
1804
+ tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://127.0.0.1:8080)',
1805
+ accLbl: 'Номер счёта СберИнвестиций',
1806
+ accPh: 'Например, 400ABCD (для отображения и учёта)',
1807
+ canDiscover: false,
1808
+ summary: '📖 Особенности подключения «СберИнвестиций» (Сбербанк)',
1809
+ html: `<div style="display:grid;gap:6px">
1810
+ <div>У розничного брокера <b>«СберИнвестиции»</b> нет прямого облачного REST-токена для физлиц (алготорговля в Сбере предоставляется через терминал <b>QUIK</b>). В «НейроАльфе» доступны <b>2 режима работы со счётом Сбера</b>:</div>
1811
+ <ol style="margin:0;padding-left:18px;display:grid;gap:4px">
1812
+ <li><b>Режим «Подтверждение заявок» через Telegram / MAX (рекомендуется, без настройки QUIK):</b> оставьте поле «Адрес моста QUIK» <b>пустым</b>, укажите ваш лимит суммы на стратегию и сохраните счёт. Платформа будет рассчитывать точные лимитные заявки в лотах Мосбиржи под ваш баланс и присылать их в Telegram / MAX и во вкладку «Торговля и заявки». После выставления заявки в приложении СберИнвестиций нажмите <b>«Исполнить заявки»</b> — платформа сама учтёт купленные бумаги и остаток кэша в портфеле.</li>
1813
+ <li><b>Автоматический режим через HTTP-мост QUIK:</b> если у вас установлен терминал QUIK Сбербанка с запущенным локальным HTTP REST-шлюзом (<code>QuikHttpBridge</code>), укажите его адрес (например, <code>http://ваш_ip:8080</code>) в поле выше.</li>
1814
+ </ol>
1815
+ </div>`,
1816
+ },
1817
+ vtb: {
1818
+ tokenLbl: 'Адрес моста QUIK (необязательно)',
1819
+ tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://…)',
1820
+ accLbl: 'Номер счёта ВТБ Инвестиций',
1821
+ accPh: 'Например, номер субсчёта ВТБ',
1822
+ canDiscover: false,
1823
+ summary: '📖 Особенности подключения «ВТБ Мои Инвестиции»',
1824
+ html: `<div>У <b>«ВТБ Мои Инвестиции»</b> нет облачного REST-токена для физлиц (доступен только терминал QUIK). Оставьте поле адреса QUIK <b>пустым</b>, чтобы работать в режиме <b>«Подтверждение заявок»</b> (расчёт точных лотов Мосбиржи с уведомлениями в Telegram / MAX и автоматическим учётом позиций после подтверждения), либо укажите URL вашего HTTP-шлюза QUIK.</div>`,
1825
+ },
1826
+ alfa: {
1827
+ tokenLbl: 'Адрес моста QUIK (необязательно)',
1828
+ tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://…)',
1829
+ accLbl: 'Номер счёта Альфа-Инвестиций',
1830
+ accPh: 'Например, номер счёта Альфа-Инвестиций',
1831
+ canDiscover: false,
1832
+ summary: '📖 Особенности подключения «Альфа-Инвестиций»',
1833
+ html: `<div>У <b>«Альфа-Инвестиций»</b> для физлиц нет облачного REST-токена. Оставьте поле адреса <b>пустым</b> для работы в режиме <b>«Подтверждение заявок»</b> (точные лимитные заявки в лотах с пушами в Telegram / MAX и ведением портфеля внутри платформы) или укажите адрес вашего торгового моста.</div>`,
1834
+ },
1835
+ };
2185
1836
  const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
2186
1837
  const LEGACY_MODES = {
2187
1838
  isolate: 'Изолированный карман (старые бумаги не трогаются)',
@@ -2230,10 +1881,9 @@ let brokerPollBusy = false;
2230
1881
  let lastBrokerPollTs = 0;
2231
1882
 
2232
1883
  async function pollActiveBrokerAccount(force = false) {
2233
- if (brokerPollBusy || document.hidden) return;
2234
- const nowPageHidden = $('#tab-now')?.hidden;
2235
- const brokersSecHidden = $('#nsec-brokers')?.hidden;
2236
- if (nowPageHidden || brokersSecHidden) return;
1884
+ if (brokerPollBusy || document.hidden || editingBrokerId) return;
1885
+ const brokersSecHidden = $('#brokers-trade-box')?.hidden;
1886
+ if (brokersSecHidden) return;
2237
1887
  const accs = state.brokers?.accounts || [];
2238
1888
  if (!accs.length) return;
2239
1889
  const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
@@ -2269,58 +1919,23 @@ async function loadBrokers() {
2269
1919
 
2270
1920
  function renderBrokers() {
2271
1921
  const accs = state.brokers?.accounts || [];
2272
- const navBtn = $('#snav-brokers');
2273
- if (navBtn) navBtn.textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
2274
1922
  if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
2275
1923
  updateTabBadges();
2276
1924
 
2277
- // 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
2278
- const listEl = $('#brokers-list');
2279
- if (listEl) {
2280
- if (accs.length && !accs.some((a) => a.id === openBrokerCfgId)) {
2281
- openBrokerCfgId = accs[0].id;
2282
- }
2283
- listEl.innerHTML = accs.length ? accs.map((a) => {
2284
- const ls = a.lastSync;
2285
- const isCfgOpen = a.id === openBrokerCfgId;
2286
- return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2287
- <div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
2288
- <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2289
- <span class="acc-caret">▶</span>
2290
- <b>${esc(a.name)}</b>
2291
- <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2292
- ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2293
- ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
2294
- </div>
2295
- <div class="row-gap" style="align-items:center">
2296
- ${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
2297
- <button type="button" data-bact="sync">Проверить связь</button>
2298
- <button type="button" data-bact="edit" class="icon">Изменить</button>
2299
- <button type="button" data-bact="del" class="icon">Удалить</button>
2300
- </div>
2301
- </div>
2302
- <div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
2303
- <span class="k">Брокер и субсчёт:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · ID <code>${esc(a.accountId || ls?.accountId || 'авто')}</code> (токен: ${a.hasToken ? '<span class="pos">задан ✓</span>' : '<span class="nokey">не задан</span>'})</span>
2304
- <span class="k">Режим управления:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-расписание: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
2305
- <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
2306
- <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2307
- <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2308
- <span class="k">Предохранители:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b></span>
2309
- ${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
2310
- ${ls ? `<span class="k">Синхронизация (${fmtTs(ls.at)}):</span><span class="v">${ls.ok ? `<span class="pos">на счёте <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽, бумаг ${ls.positions?.length || 0})</span>` : `<span class="neg">${esc(ls.error)}</span>`}</span>` : ''}
2311
- </div>
2312
- </div>`;
2313
- }).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
1925
+ // Перед перерисовкой карточек возвращаем форму настроек в верхний слот, чтобы она не уничтожилась при замене innerHTML
1926
+ const formBox = $('#broker-add-box');
1927
+ const formSlot = $('#broker-form-slot');
1928
+ if (formBox && formSlot && formBox.parentElement !== formSlot) {
1929
+ formSlot.appendChild(formBox);
2314
1930
  }
2315
1931
 
2316
- // 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
2317
1932
  const tradeEl = $('#brokers-trade');
2318
1933
  if (!tradeEl) return;
2319
1934
  if (!accs.length) {
2320
- tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
1935
+ tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
2321
1936
  return;
2322
1937
  }
2323
- if (!accs.some((a) => a.id === openBrokerId)) {
1938
+ if (openBrokerId === undefined) {
2324
1939
  openBrokerId = accs[0].id;
2325
1940
  }
2326
1941
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
@@ -2376,7 +1991,12 @@ function renderBrokers() {
2376
1991
  <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>
2377
1992
  </div>` : '';
2378
1993
 
2379
- // Считаем сколько ₽ в стратегии и сколько ₽ защищено
1994
+ // Считаем стартовый лимит и реинвестированную прибыль сверх лимита
1995
+ const initBaseRub = a.initialTotalRub > 0 ? a.initialTotalRub : (a.equityHistory?.[0]?.totalRub > 0 ? a.equityHistory[0].totalRub : (ls?.totalRub || a.maxCapitalRub || 100000));
1996
+ const reinvProfitRub = ls?.totalRub > 0 ? Math.max(0, Math.round(ls.totalRub - Math.max(initBaseRub, a.maxCapitalRub || 100000))) : 0;
1997
+ const capSpecHtml = reinvProfitRub > 0
1998
+ ? `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> <span class="pos">(+ реинвестировано прибыли +${fmtNum(reinvProfitRub, 0)} ₽ → в работе <b>${fmtNum((a.maxCapitalRub || 0) + reinvProfitRub, 0)} ₽</b>)</span>`
1999
+ : `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> (+ реинвестирование прибыли)`;
2380
2000
  let strategyRub = 0, protectedRub = 0;
2381
2001
  for (const p of ls?.positions || []) {
2382
2002
  const aq = algoQtyOf(p);
@@ -2387,8 +2007,16 @@ function renderBrokers() {
2387
2007
  const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
2388
2008
 
2389
2009
  // Вкладка 1: Активы портфеля
2010
+ const fmtOwnership = (p) => {
2011
+ if (!(p.issueSize > 0) || !(p.qty > 0)) return '<span class="muted small">—</span>';
2012
+ const pct = (p.qty / p.issueSize) * 100;
2013
+ const dec = pct >= 0.01 ? 4 : pct >= 0.0001 ? 6 : 8;
2014
+ const pctStr = pct.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: dec });
2015
+ const tip = `Доля владения в компании ${p.name || p.symbol}: ${fmtNum(p.qty, 0)} из ${fmtNum(p.issueSize, 0)} акций всего выпуска на Мосбирже`;
2016
+ return `<span title="${esc(tip)}" style="white-space:nowrap"><b>${pctStr}%</b></span>`;
2017
+ };
2390
2018
  const posHtml = ls?.positions?.length
2391
- ? `${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) => {
2019
+ ? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num" title="Доля ваших акций в общем выпуске ценных бумаг компании-эмитента на Мосбирже (ISSUESIZE)">Доля владения</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
2392
2020
  const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
2393
2021
  const fcRow = fcRowMap?.get(p.symbol);
2394
2022
  const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
@@ -2396,6 +2024,7 @@ function renderBrokers() {
2396
2024
  <td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
2397
2025
  <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
2398
2026
  <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
2027
+ <td class="num">${fmtOwnership(p)}</td>
2399
2028
  <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
2400
2029
  <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
2401
2030
  <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>
@@ -2406,7 +2035,19 @@ function renderBrokers() {
2406
2035
 
2407
2036
  // Вкладка 2: Торговля и заявки стратегии
2408
2037
  const activeOrdHtml = ls?.activeOrders?.length
2409
- ? `<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>`
2038
+ ? `<div class="stab-box" style="margin-bottom:10px">
2039
+ <div class="stab-head">
2040
+ <b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b>
2041
+ <span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
2042
+ </div>
2043
+ <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>
2044
+ <td><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></td>
2045
+ <td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
2046
+ <td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
2047
+ <td class="num">${fmtNum(o.price, 4)} ₽</td>
2048
+ <td class="num">${fmtNum(o.amountRub, 0)} ₽</td>
2049
+ </tr>`).join('')}</tbody></table>
2050
+ </div>`
2410
2051
  : '';
2411
2052
  let batchHtml = '';
2412
2053
  if (pb) {
@@ -2446,7 +2087,7 @@ function renderBrokers() {
2446
2087
  const rows = [];
2447
2088
  for (const [, dayOps] of byDay) {
2448
2089
  let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2449
- const symQty = new Map(), symCash = new Map();
2090
+ const symQty = new Map(), symCash = new Map(), symBuyRub = new Map();
2450
2091
  for (const op of dayOps) {
2451
2092
  const pay = Number(op.paymentRub) || 0;
2452
2093
  if (op.kind === 'buy') {
@@ -2455,6 +2096,7 @@ function renderBrokers() {
2455
2096
  if (op.symbol) {
2456
2097
  symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2457
2098
  symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2099
+ symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
2458
2100
  }
2459
2101
  } else if (op.kind === 'sell') {
2460
2102
  tradesCnt++;
@@ -2472,19 +2114,22 @@ function renderBrokers() {
2472
2114
  inOutRub += pay;
2473
2115
  }
2474
2116
  }
2475
- // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
2476
- let closedTradePnl = 0, closedSyms = 0;
2117
+ // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
2118
+ let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
2477
2119
  for (const [sym, netQ] of symQty) {
2478
2120
  if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2479
2121
  closedTradePnl += symCash.get(sym);
2122
+ closedBaseRub += symBuyRub.get(sym) || 0;
2480
2123
  closedSyms++;
2481
2124
  }
2482
2125
  }
2483
2126
  if (closedSyms > 0) closedTradePnl += feeRub;
2127
+ const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
2484
2128
 
2485
2129
  const summaryParts = [];
2486
2130
  if (closedSyms > 0) {
2487
- summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
2131
+ const pctPart = closedTradePct !== null ? ` (${closedTradePct > 0 ? '+' : ''}${fmtNum(closedTradePct, 2)}%)` : '';
2132
+ summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽${pctPart}</b>`);
2488
2133
  } else if (tradesCnt > 0) {
2489
2134
  const tParts = [];
2490
2135
  if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
@@ -2505,7 +2150,7 @@ function renderBrokers() {
2505
2150
  for (const op of dayOps) {
2506
2151
  rows.push(`<tr>
2507
2152
  <td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
2508
- <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span> <span class="muted small">${esc(op.title !== op.kindLabel ? op.title : '')}</span></td>
2153
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}" title="${esc(op.title || op.kindLabel)}">${esc(op.kindLabel)}</span></td>
2509
2154
  <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2510
2155
  <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2511
2156
  <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
@@ -2603,52 +2248,86 @@ function renderBrokers() {
2603
2248
  ${yearsHtml}`
2604
2249
  : '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
2605
2250
 
2251
+ const isPaused = a.autoSchedule === false || a.mode === 'readonly';
2252
+ const isEditingThis = editingBrokerId === a.id;
2253
+ const collapsedMetricsHtml = !isOpen && ls?.ok
2254
+ ? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b> · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b></span>`
2255
+ : '';
2256
+
2606
2257
  return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2607
2258
  <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
2608
2259
  <div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2609
2260
  <span class="acc-caret">▶</span>
2610
2261
  <b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
2611
- <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2262
+ <span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
2612
2263
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2613
2264
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2265
+ ${collapsedMetricsHtml}
2614
2266
  </div>
2615
2267
  <div class="row-gap" style="align-items:center">
2616
- ${ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
2617
- <button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
2268
+ <button type="button" class="brk-toggle ${!isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
2269
+ ${isOpen && ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
2270
+ ${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
2618
2271
  </div>
2619
2272
  </div>
2620
2273
  <div ${isOpen ? '' : 'hidden'}>
2621
- <div class="brk-specs">
2622
- <span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
2623
- <span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
2624
- <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2625
- <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2626
- <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2627
- <span class="k">Предохранители и настройки:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2628
- </div>
2629
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2630
- <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2631
- <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2632
- <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2633
- <div class="kpi"><div class="v" style="white-space:nowrap">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2634
- </div>` : ''}
2635
- ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2636
- <div class="brk-subtabs">
2637
- <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2638
- <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2639
- <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2274
+ <div class="brk-cfg-mount" data-cfg-mount="${esc(a.id)}" ${isEditingThis ? '' : 'hidden'} style="margin-top:12px"></div>
2275
+ <div class="brk-trade-view" ${isEditingThis ? 'hidden' : ''}>
2276
+ <div class="brk-specs">
2277
+ <span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
2278
+ <span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">на паузе</span>'}</b></span>
2279
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2280
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2281
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2282
+ <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2283
+ </div>
2284
+ ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2285
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2286
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2287
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2288
+ <div class="kpi"><div class="v" style="white-space:nowrap">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2289
+ </div>` : ''}
2290
+ ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2291
+ <div class="brk-subtabs">
2292
+ <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2293
+ <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2294
+ <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2295
+ </div>
2296
+ <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2297
+ <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2298
+ <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2640
2299
  </div>
2641
- <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2642
- <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2643
- <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2644
2300
  </div>
2645
2301
  </div>`;
2646
2302
  }).join('');
2303
+
2304
+ // Если сейчас открыто редактирование конкретного счёта, монтируем форму прямо внутрь его карточки!
2305
+ if (editingBrokerId && formBox) {
2306
+ const mount = tradeEl.querySelector(`[data-cfg-mount="${CSS.escape(editingBrokerId)}"]`);
2307
+ if (mount) {
2308
+ mount.appendChild(formBox);
2309
+ formBox.hidden = false;
2310
+ }
2311
+ }
2647
2312
  }
2648
2313
 
2649
- $('#btn-brokers-cfg').addEventListener('click', () => {
2650
- switchTab('settings');
2651
- showSettingsSec('brokers');
2314
+ $('#btn-broker-add')?.addEventListener('click', () => {
2315
+ const formBox = $('#broker-add-box');
2316
+ const formSlot = $('#broker-form-slot');
2317
+ if (editingBrokerId === null && formBox && !formBox.hidden && formBox.parentElement === formSlot) {
2318
+ stopBrokerEdit();
2319
+ return;
2320
+ }
2321
+ stopBrokerEdit();
2322
+ if (formBox && formSlot) {
2323
+ formSlot.appendChild(formBox);
2324
+ formBox.hidden = false;
2325
+ syncBrokerGuideUi();
2326
+ updateBrokerSaveBtnState();
2327
+ if (brokerForm?.elements?.broker?.value === 'tbank' && brokerForm.elements.token.value.trim()) {
2328
+ runBrokerDiscover({ silent: true });
2329
+ }
2330
+ }
2652
2331
  });
2653
2332
 
2654
2333
  const brokerForm = $('#broker-form');
@@ -2663,7 +2342,6 @@ function getBrokerFormSnap() {
2663
2342
  token: f.token.value.trim(),
2664
2343
  mode: f.mode.value,
2665
2344
  legacyMode: f.legacyMode?.value || 'isolate',
2666
- baseRunId: f.baseRunId.value || '',
2667
2345
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2668
2346
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2669
2347
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
@@ -2673,12 +2351,60 @@ function getBrokerFormSnap() {
2673
2351
  });
2674
2352
  }
2675
2353
 
2354
+ function syncBrokerGuideUi() {
2355
+ const f = brokerForm?.elements;
2356
+ if (!f) return;
2357
+ const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2358
+ const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
2359
+ if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2360
+ if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
2361
+ if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2362
+ if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2363
+ if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
2364
+ if (!g.canDiscover && $('#broker-discovered')) {
2365
+ $('#broker-discovered').hidden = true;
2366
+ $('#broker-discovered').innerHTML = '';
2367
+ }
2368
+ if ($('#broker-guide-sum')) $('#broker-guide-sum').textContent = g.summary;
2369
+ if ($('#broker-guide-body')) $('#broker-guide-body').innerHTML = g.html;
2370
+ if ($('#btn-broker-ai-help')) $('#btn-broker-ai-help').textContent = `🤖 Помочь с подключением (${brkName})`;
2371
+ }
2372
+
2373
+ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2374
+ const f = brokerForm?.elements;
2375
+ if (!f) return;
2376
+ const brk = f.broker.value || 'tbank';
2377
+ const brkName = BROKER_LABELS[brk] || brk;
2378
+ const tok = f.token.value.trim();
2379
+ const accId = f.accountId.value.trim();
2380
+ const cap = Number(f.maxCapitalRub.value) || 100000;
2381
+ let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
2382
+ if (tok && accId) {
2383
+ promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
2384
+ } else if (tok) {
2385
+ promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
2386
+ } else if (accId) {
2387
+ promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
2388
+ } else {
2389
+ promptText += ` Подскажи, как быстро открыть счёт (если его ещё нет), как включить маржинальную торговлю и выпустить токен API, либо подключи счёт вместе со мной.`;
2390
+ }
2391
+ if (state.chat.state?.mode === 'discuss' && state.chat.state?.tools !== false) {
2392
+ try {
2393
+ state.chat.state = await post('/api/chat/mode', { mode: 'advisor' });
2394
+ renderChatState();
2395
+ } catch { /* ignore */ }
2396
+ }
2397
+ toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
2398
+ await sendChat(promptText);
2399
+ });
2400
+
2676
2401
  function updateBrokerSaveBtnState() {
2677
2402
  const btn = $('#broker-save-btn');
2678
2403
  if (!btn) return;
2679
2404
  if (!editingBrokerId) {
2680
2405
  const f = brokerForm.elements;
2681
- const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim());
2406
+ const isManualCapable = ['sber', 'vtb', 'alfa'].includes(f.broker.value);
2407
+ const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim() || isManualCapable);
2682
2408
  btn.disabled = !hasNewInput;
2683
2409
  btn.classList.toggle('primary', hasNewInput);
2684
2410
  btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
@@ -2691,87 +2417,142 @@ function updateBrokerSaveBtnState() {
2691
2417
  }
2692
2418
 
2693
2419
  brokerForm.addEventListener('input', updateBrokerSaveBtnState);
2694
- brokerForm.addEventListener('change', updateBrokerSaveBtnState);
2420
+ brokerForm.addEventListener('change', () => {
2421
+ syncBrokerGuideUi();
2422
+ updateBrokerSaveBtnState();
2423
+ });
2424
+
2425
+ let brokerDiscoverTimer = null;
2426
+
2427
+ async function runBrokerDiscover({ silent = false } = {}) {
2428
+ const btn = $('#btn-broker-discover');
2429
+ const box = $('#broker-discovered');
2430
+ const f = brokerForm?.elements;
2431
+ if (!f || !box) return;
2432
+ if (f.broker.value !== 'tbank') {
2433
+ box.hidden = true;
2434
+ box.innerHTML = '';
2435
+ return;
2436
+ }
2437
+ const tok = f.token.value.trim();
2438
+ if (!tok && !editingBrokerId) {
2439
+ box.hidden = true;
2440
+ box.innerHTML = '';
2441
+ return;
2442
+ }
2443
+ if (btn) btn.disabled = true;
2444
+ const old = btn ? btn.textContent : '';
2445
+ if (btn) btn.textContent = 'Ищем счета…';
2446
+ try {
2447
+ const res = await post('/api/brokers/discover', {
2448
+ id: editingBrokerId || undefined,
2449
+ broker: f.broker.value,
2450
+ token: tok,
2451
+ sandbox: f.sandbox.checked,
2452
+ });
2453
+ const list = res.accounts || [];
2454
+ const curAccId = f.accountId.value.trim();
2455
+ if (!list.length) {
2456
+ box.hidden = false;
2457
+ box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2458
+ } else {
2459
+ box.hidden = false;
2460
+ box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => {
2461
+ const isSelected = curAccId && a.id === curAccId;
2462
+ return `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid ${isSelected ? 'var(--accent)' : 'var(--border)'};border-radius:6px;background:var(--bg1)">
2463
+ <div>
2464
+ <b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
2465
+ <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>
2466
+ </div>
2467
+ <button type="button" class="${isSelected ? '' : 'primary '}small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">${isSelected ? '✓ Выбран' : 'Выбрать этот счёт'}</button>
2468
+ </div>`;
2469
+ }).join('');
2470
+ }
2471
+ } catch (err) {
2472
+ if (!silent) toast(err.message, 'error');
2473
+ } finally {
2474
+ if (btn) {
2475
+ btn.disabled = false;
2476
+ btn.textContent = old || '🔍 Найти мои счета по токену';
2477
+ }
2478
+ }
2479
+ }
2695
2480
 
2696
2481
  function startBrokerEdit(acc) {
2697
2482
  editingBrokerId = acc.id;
2483
+ openBrokerId = acc.id;
2698
2484
  const f = brokerForm.elements;
2699
2485
  f.id.value = acc.id;
2700
2486
  f.name.value = acc.name;
2701
2487
  f.broker.value = acc.broker;
2702
2488
  f.accountId.value = acc.accountId || '';
2703
- f.token.value = '';
2489
+ f.token.value = acc.token || '';
2704
2490
  f.mode.value = acc.mode || 'confirm';
2705
2491
  if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2706
- if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
2707
- const opt = document.createElement('option');
2708
- opt.value = String(acc.baseRunId);
2709
- opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
2710
- f.baseRunId.appendChild(opt);
2711
- }
2712
- f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
2713
2492
  f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
2714
2493
  f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
2715
2494
  f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2716
2495
  f.autoSchedule.checked = acc.autoSchedule !== false;
2717
2496
  f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2718
2497
  f.sandbox.checked = !!acc.sandbox;
2719
- $('#broker-add-box').open = true;
2720
- $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
2498
+ $('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
2721
2499
  $('#broker-save-btn').textContent = 'Сохранить изменения';
2722
2500
  $('#broker-cancel-btn').hidden = false;
2501
+ if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = false;
2502
+ syncBrokerGuideUi();
2723
2503
  brokerInitialSnap = getBrokerFormSnap();
2724
2504
  updateBrokerSaveBtnState();
2505
+ renderBrokers();
2506
+ if (acc.broker === 'tbank' && (acc.token || acc.hasToken)) {
2507
+ runBrokerDiscover({ silent: true });
2508
+ }
2725
2509
  }
2726
2510
 
2727
2511
  function stopBrokerEdit() {
2512
+ const wasEditing = editingBrokerId !== null;
2728
2513
  editingBrokerId = null;
2729
2514
  brokerForm.reset();
2730
2515
  brokerForm.elements.id.value = '';
2731
2516
  if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
2732
2517
  $('#broker-add-sum').textContent = 'Добавить брокерский счёт';
2733
2518
  $('#broker-save-btn').textContent = 'Сохранить счёт';
2734
- $('#broker-cancel-btn').hidden = true;
2519
+ if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = true;
2520
+ const formBox = $('#broker-add-box');
2521
+ const formSlot = $('#broker-form-slot');
2522
+ if (formBox && formSlot) {
2523
+ formSlot.appendChild(formBox);
2524
+ formBox.hidden = true;
2525
+ }
2526
+ syncBrokerGuideUi();
2735
2527
  brokerInitialSnap = getBrokerFormSnap();
2736
2528
  updateBrokerSaveBtnState();
2529
+ if (wasEditing) renderBrokers();
2737
2530
  }
2738
2531
  $('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
2532
+ $('#broker-delete-btn')?.addEventListener('click', async () => {
2533
+ if (!editingBrokerId) return;
2534
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId);
2535
+ if (!confirm(`Удалить брокерский счёт «${acc?.name || editingBrokerId}»?`)) return;
2536
+ try {
2537
+ const delId = editingBrokerId;
2538
+ stopBrokerEdit();
2539
+ state.brokers = await api(`/api/brokers/${encodeURIComponent(delId)}`, { method: 'DELETE' });
2540
+ renderBrokers();
2541
+ toast('Брокерский счёт удалён');
2542
+ } catch (err) { toast(err.message, 'error'); }
2543
+ });
2544
+ syncBrokerGuideUi();
2739
2545
  updateBrokerSaveBtnState();
2740
2546
 
2741
- // Авто-поиск всех субсчетов в Т-Инвестициях по токену
2742
- $('#btn-broker-discover')?.addEventListener('click', async () => {
2743
- const btn = $('#btn-broker-discover');
2744
- const box = $('#broker-discovered');
2745
- const f = brokerForm.elements;
2746
- btn.disabled = true;
2747
- const old = btn.textContent;
2748
- btn.textContent = 'Ищем счета…';
2749
- try {
2750
- const res = await post('/api/brokers/discover', {
2751
- id: editingBrokerId || undefined,
2752
- broker: f.broker.value,
2753
- token: f.token.value,
2754
- sandbox: f.sandbox.checked,
2755
- });
2756
- const list = res.accounts || [];
2757
- if (!list.length) {
2758
- box.hidden = false;
2759
- box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2760
- } else {
2761
- box.hidden = false;
2762
- box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg1)">
2763
- <div>
2764
- <b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
2765
- <div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
2766
- </div>
2767
- <button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
2768
- </div>`).join('');
2769
- }
2770
- } catch (err) {
2771
- toast(err.message, 'error');
2772
- } finally {
2773
- btn.disabled = false;
2774
- btn.textContent = old;
2547
+ // Авто-поиск всех субсчетов в Т-Инвестициях по токену (по кнопке, при открытии редактирования и автоматически при вводе/смене токена)
2548
+ $('#btn-broker-discover')?.addEventListener('click', () => runBrokerDiscover({ silent: false }));
2549
+ $('#broker-token-input')?.addEventListener('input', () => {
2550
+ clearTimeout(brokerDiscoverTimer);
2551
+ const f = brokerForm?.elements;
2552
+ if (!f || f.broker.value !== 'tbank') return;
2553
+ const tok = f.token.value.trim();
2554
+ if (tok.length >= 20) {
2555
+ brokerDiscoverTimer = setTimeout(() => runBrokerDiscover({ silent: true }), 350);
2775
2556
  }
2776
2557
  });
2777
2558
 
@@ -2783,6 +2564,13 @@ $('#broker-discovered')?.addEventListener('click', (e) => {
2783
2564
  if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
2784
2565
  f.name.value = `Т-Банк · ${b.dataset.pickName}`;
2785
2566
  }
2567
+ $('#broker-discovered').querySelectorAll('button[data-pick-acc]').forEach((btn) => {
2568
+ const sel = btn.dataset.pickAcc === b.dataset.pickAcc;
2569
+ btn.classList.toggle('primary', !sel);
2570
+ btn.textContent = sel ? '✓ Выбран' : 'Выбрать этот счёт';
2571
+ const row = btn.closest('div[style]');
2572
+ if (row) row.style.borderColor = sel ? 'var(--accent)' : 'var(--border)';
2573
+ });
2786
2574
  updateBrokerSaveBtnState();
2787
2575
  toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
2788
2576
  });
@@ -2798,7 +2586,7 @@ brokerForm.addEventListener('submit', async (e) => {
2798
2586
  token: f.token.value,
2799
2587
  mode: f.mode.value,
2800
2588
  legacyMode: f.legacyMode?.value || 'isolate',
2801
- baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
2589
+ baseRunId: null,
2802
2590
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2803
2591
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2804
2592
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
@@ -2825,48 +2613,6 @@ brokerForm.addEventListener('submit', async (e) => {
2825
2613
  } catch (err) { toast(err.message, 'error'); }
2826
2614
  });
2827
2615
 
2828
- $('#brokers-list').addEventListener('click', async (e) => {
2829
- const b = e.target.closest('button[data-bact]');
2830
- if (!b) {
2831
- const fold = e.target.closest('[data-bcfg-fold]');
2832
- if (fold) {
2833
- const fid = fold.dataset.bcfgFold;
2834
- openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
2835
- saveNavPatch({ openBrokerCfgId });
2836
- renderBrokers();
2837
- }
2838
- return;
2839
- }
2840
- const id = b.closest('[data-bid]').dataset.bid;
2841
- const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2842
- if (!acc) return;
2843
- if (b.dataset.bact === 'edit') {
2844
- startBrokerEdit(acc);
2845
- $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2846
- return;
2847
- }
2848
- if (b.dataset.bact === 'del') {
2849
- if (!confirm(`Удалить брокерский счёт «${acc.name}»?`)) return;
2850
- try { state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, { method: 'DELETE' }); renderBrokers(); } catch (err) { toast(err.message, 'error'); }
2851
- return;
2852
- }
2853
- if (b.dataset.bact === 'sync') {
2854
- b.disabled = true;
2855
- const old = b.textContent;
2856
- b.textContent = 'Проверяем…';
2857
- try {
2858
- state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
2859
- renderBrokers();
2860
- toast(`Связь с «${acc.name}» проверена`);
2861
- } catch (err) {
2862
- await loadBrokers().catch(() => {});
2863
- toast(err.message, 'error');
2864
- } finally {
2865
- if (b.isConnected) { b.disabled = false; b.textContent = old; }
2866
- }
2867
- }
2868
- });
2869
-
2870
2616
  $('#brokers-trade').addEventListener('click', async (e) => {
2871
2617
  const fltBtn = e.target.closest('button[data-hfilter]');
2872
2618
  if (fltBtn) {
@@ -2930,6 +2676,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2930
2676
  const fold = e.target.closest('[data-brk-fold]');
2931
2677
  if (fold) {
2932
2678
  const fid = fold.dataset.brkFold;
2679
+ if (editingBrokerId === fid) stopBrokerEdit();
2933
2680
  openBrokerId = openBrokerId === fid ? null : fid;
2934
2681
  saveNavPatch({ openBrokerId });
2935
2682
  renderBrokers();
@@ -2939,19 +2686,30 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2939
2686
  const id = b.dataset.bid;
2940
2687
  const act = b.dataset.btrade;
2941
2688
  if (act === 'cfg') {
2942
- const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2943
- switchTab('settings');
2944
- showSettingsSec('brokers');
2945
- if (acc) {
2946
- startBrokerEdit(acc);
2947
- setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
2689
+ if (editingBrokerId === id) {
2690
+ stopBrokerEdit();
2691
+ } else {
2692
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2693
+ if (acc) startBrokerEdit(acc);
2948
2694
  }
2949
2695
  return;
2950
2696
  }
2951
2697
  b.disabled = true;
2952
2698
  const old = b.textContent;
2953
2699
  try {
2954
- if (act === 'sync') {
2700
+ if (act === 'pause') {
2701
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2702
+ if (acc) {
2703
+ const currentlyPaused = acc.autoSchedule === false || acc.mode === 'readonly';
2704
+ const nextAuto = currentlyPaused;
2705
+ const nextMode = currentlyPaused && acc.mode === 'readonly' ? 'confirm' : acc.mode;
2706
+ state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, {
2707
+ method: 'PUT',
2708
+ body: { id, autoSchedule: nextAuto, mode: nextMode },
2709
+ });
2710
+ toast(nextAuto ? `▶ Торговля на счёте «${acc.name}» возобновлена` : `⏸ Счёт «${acc.name}» поставлен на паузу`);
2711
+ }
2712
+ } else if (act === 'sync') {
2955
2713
  if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
2956
2714
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
2957
2715
  toast('Портфель обновлён');
@@ -2979,6 +2737,12 @@ $('#brokers-trade').addEventListener('click', async (e) => {
2979
2737
  toast('Заявки отправлены брокеру');
2980
2738
  } else if (act === 'rej') {
2981
2739
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reject`);
2740
+ } else if (act === 'cancel-ord') {
2741
+ const orderId = b.dataset.oid || 'all';
2742
+ b.textContent = 'Снимаем…';
2743
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/cancel-order`, { orderId });
2744
+ await post(`/api/brokers/${encodeURIComponent(id)}/prepare`).then((st) => { state.brokers = st; }).catch(() => {});
2745
+ toast(orderId === 'all' ? 'Все активные заявки сняты со стакана' : 'Заявка снята со стакана');
2982
2746
  }
2983
2747
  renderBrokers();
2984
2748
  } catch (err) {
@@ -3016,7 +2780,6 @@ function connectWs() {
3016
2780
  const m = JSON.parse(ev.data);
3017
2781
  switch (m.type) {
3018
2782
  case 'hello':
3019
- renderLive(m.live);
3020
2783
  refreshForecastIfStale();
3021
2784
  // если исследование идёт на сервере (запущено раньше или Стратегом) — показать кнопку отмены
3022
2785
  if (!state.active && m.running?.length) {
@@ -3051,13 +2814,12 @@ function connectWs() {
3051
2814
  case 'settings:update':
3052
2815
  state.settings = m.settings;
3053
2816
  fillForm(m.settings);
3054
- loadHistory().catch(() => {});
3055
2817
  if (m.source === 'ai') {
3056
2818
  for (const k of Object.keys(m.changed || {})) { const l = $('#params').elements[k]?.closest('.srow, label'); if (l) { l.classList.remove('flash'); void l.offsetWidth; l.classList.add('flash'); } }
3057
2819
  toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
3058
2820
  }
3059
2821
  break;
3060
- case 'journal:update': loadJournal().catch(() => {}); break;
2822
+ case 'journal:update': break;
3061
2823
  case 'research:start':
3062
2824
  state.active = m.runId;
3063
2825
  resetLiveTrain(m.runId);
@@ -3102,28 +2864,30 @@ function connectWs() {
3102
2864
  loadRuns(m.runId);
3103
2865
  break;
3104
2866
  case 'profiles:progress':
3105
- state.profilesBusy = true;
3106
- $('#btn-profiles').disabled = true;
3107
- $('#profiles-msg').textContent = `Составляем описания компаний: ${m.done} из ${m.total}`;
3108
- updateTabBadges();
3109
- break;
3110
2867
  case 'profiles:done':
3111
- state.profilesBusy = false;
3112
- $('#btn-profiles').disabled = false;
3113
- state.profiles = null; // перечитаем с сервера
3114
- updateTabBadges();
3115
- loadProfilesData().then(() => { if (!$('#tab-map').hidden) drawMap(); });
3116
- toast(m.error ? `Описания компаний: ошибка — ${m.error}` : `Описания компаний готовы (${m.count})`, m.error ? 'error' : 'info');
3117
2868
  break;
3118
- case 'paper:step': {
3119
- const s = m.step;
3120
- liveLine(`p-${s.ts}`, `Живая проверка · ${fmtTs(s.ts)}: ${s.decisions.length ? s.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}`);
3121
- const keys = [...liveEls.keys()].filter((k) => k.startsWith('p-'));
3122
- for (const k of keys.slice(0, -5)) dropLive(k);
2869
+ case 'paper:step':
2870
+ case 'paper:status':
2871
+ case 'paper:started':
2872
+ case 'paper:stopped':
2873
+ case 'paper:fill':
3123
2874
  break;
3124
- }
3125
- case 'news:update': if (!$('#tab-now').hidden) loadNews(); break;
2875
+ case 'news:update': loadNews(); break;
3126
2876
  case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
2877
+ case 'trading:pulse': {
2878
+ if (m.kind === 'broker_calc') {
2879
+ triggerTradingPulse(m.text || `Пересчёт заявок брокера…`, 3000);
2880
+ } else if (m.kind === 'broker_done') {
2881
+ triggerTradingPulse(`Заявки рассчитаны (${m.ordersCount || 0})`, 2200);
2882
+ } else if (m.kind === 'broker_sync') {
2883
+ triggerTradingPulse(`Портфель «${m.name || 'брокера'}» синхронизирован`, 1800);
2884
+ } else if (m.kind === 'train') {
2885
+ triggerTradingPulse(m.text || 'Расчёт прогноза…', 2500);
2886
+ } else if (m.kind === 'radar') {
2887
+ triggerTradingPulse(`Радар обновлён · сигналов: ${m.signals ?? 0}`, 1600);
2888
+ }
2889
+ break;
2890
+ }
3127
2891
  case 'stability:start':
3128
2892
  stabilityUi(m.runId);
3129
2893
  setProgress(2, `Проверка устойчивости эксперимента №${m.runId}…`);
@@ -3144,9 +2908,6 @@ function connectWs() {
3144
2908
  stabilityUi(null);
3145
2909
  toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
3146
2910
  break;
3147
- case 'paper:status': renderLive(m.status); break;
3148
- case 'paper:started': case 'paper:stopped': api('/api/live/status').then(renderLive); break;
3149
- case 'paper:fill': toast(`Живая проверка: куплено ${m.position.symbol}`); break;
3150
2911
  case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
3151
2912
  }
3152
2913
  };
@@ -3255,21 +3016,22 @@ async function renderCloudPane() {
3255
3016
  }
3256
3017
 
3257
3018
  const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3258
- ? 'без ограничений суммы'
3019
+ ? 'по согласованию / без ограничений'
3259
3020
  : u.limits?.maxCapitalPerAccountRub > 0
3260
- ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
3261
- : 'только демо-торговля';
3021
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
3022
+ : 'подписка не активна';
3262
3023
  const retLimStr = u.limits?.maxAnnualReturnPct > 0
3263
3024
  ? `до ${u.limits.maxAnnualReturnPct}% годовых`
3264
- : 'без ограничений (полная мощность VIP)';
3025
+ : (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
3265
3026
  const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
3027
+ updateTabBadges();
3266
3028
 
3267
3029
  box.innerHTML = `
3268
3030
  <div class="srow sblock">
3269
3031
  <div class="stxt">
3270
3032
  <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>
3271
3033
  <div class="hint">
3272
- Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Предел доходности стратегии: <b>${retLimStr}</b> · Виртуальных счетов: до <b>${u.limits?.maxVirtualAccounts ?? 1}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3034
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Целевая доходность: <b>${retLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3273
3035
  </div>
3274
3036
  </div>
3275
3037
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
@@ -3281,10 +3043,9 @@ async function renderCloudPane() {
3281
3043
  <div class="stxt">
3282
3044
  <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
3283
3045
  <div class="hint">
3284
- • <b>«Старт» (0 ₽)</b> — тестирование стратегий любой сложности без реальной торговли;<br>
3285
- • <b>«Инвестор» (3 900 ₽/мес)</b> — до 2 брокерских счетов (до 1 млн ₽ на каждом), торговля «Только в рост (без шорта)», до 50% годовых;<br>
3286
- • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов (до 10 млн ₽ на каждом), «В рост и в падение (с шортом)», до 100% годовых;<br>
3287
- • <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
3046
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)», задавленная доходность до 60% годовых;<br>
3047
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)», задавленная доходность до 120% годовых;<br>
3048
+ • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами, задавленная доходность до 240% годовых.
3288
3049
  </div>
3289
3050
  </div>
3290
3051
  <div class="row-gap" style="flex-wrap:wrap">
@@ -3292,7 +3053,7 @@ async function renderCloudPane() {
3292
3053
  <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
3293
3054
  <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
3294
3055
  <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
3295
- <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Запросить VIP →</a>
3056
+ <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Согласовать VIP →</a>
3296
3057
  </div>
3297
3058
  </div>
3298
3059
  <div class="srow sblock">
@@ -3376,7 +3137,6 @@ function applyTheme(light) {
3376
3137
  };
3377
3138
  eqChart.applyOptions(chartOpts);
3378
3139
  icChart.applyOptions(chartOpts);
3379
- if (!$('#tab-map').hidden) drawMap();
3380
3140
  }
3381
3141
  $('#btn-theme')?.addEventListener('click', () => {
3382
3142
  const next = !document.body.classList.contains('light');
@@ -3422,14 +3182,13 @@ document.addEventListener('keydown', (e) => {
3422
3182
  // ---------- старт и восстановление сохранённого места в интерфейсе ----------
3423
3183
  (async function init() {
3424
3184
  state.config = await api('/api/config');
3185
+ updateTabBadges();
3425
3186
  buildForm();
3426
3187
  state.settings = await api('/api/settings');
3427
3188
  fillForm(state.settings);
3428
- loadHistory().catch(() => {});
3429
3189
  connectWs();
3430
- renderLive(await api('/api/live/status'));
3431
3190
  loadBrokers().catch(() => {});
3432
- loadJournal().catch(() => {});
3191
+ loadNews().catch(() => {});
3433
3192
  renderCloudPane().catch(() => {});
3434
3193
  const urlParams = new URLSearchParams(location.search);
3435
3194
  if (urlParams.get('payment_check') === '1') {
@@ -3439,16 +3198,16 @@ document.addEventListener('keydown', (e) => {
3439
3198
  }).catch(() => {});
3440
3199
  }
3441
3200
  const savedNav = loadSavedNav();
3442
- const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
3443
- await loadRuns(initRunId);
3444
-
3445
- // Восстанавливаем открытые подразделы и главную вкладку, где находился пользователь
3446
- if (savedNav.nowSec) showNowSec(savedNav.nowSec);
3447
- if (savedNav.settingsSec) showSettingsSec(savedNav.settingsSec);
3448
- if (savedNav.mapSec) showMapSec(savedNav.mapSec).catch(() => {});
3449
- if (savedNav.tab && ['overview', 'now', 'map', 'settings'].includes(savedNav.tab) && !urlParams.get('run')) {
3450
- switchTab(savedNav.tab);
3201
+ if (isOwnerUser()) {
3202
+ const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
3203
+ await loadRuns(initRunId);
3204
+ }
3205
+ if (urlParams.get('run') && isOwnerUser()) {
3206
+ showMainSec('overview');
3207
+ } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'g0', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3208
+ showMainSec(savedNav.mainSec);
3209
+ } else {
3210
+ showMainSec('brokers');
3451
3211
  }
3452
- // В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
3453
3212
  setTimeout(() => refreshForecastIfStale(), 600);
3454
3213
  })().catch((e) => toast(e.message, 'error'));