neuroalpha 0.3.15 → 0.3.17

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,66 @@ $('#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 === 'settings' || sec === 'g0' || sec === 'g1') sec = 'brokers';
169
+ if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
170
+ document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
171
+ document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
172
+ if ($('#main-workspace')) $('#main-workspace').scrollTop = 0;
173
+ saveNavPatch({ mainSec: sec });
174
+ if (sec === 'overview') {
205
175
  requestAnimationFrame(() => {
206
176
  eqChart.timeScale().fitContent();
207
177
  icChart.timeScale().fitContent();
208
178
  });
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);
179
+ } else if (sec === 'forecast') {
180
+ refreshForecastIfStale();
181
+ } else if (sec === 'news') {
182
+ loadNews();
183
+ } else if (sec === 'cloud') {
184
+ renderCloudPane().catch(() => {});
217
185
  }
218
186
  }
219
- $('#tabs').addEventListener('click', (e) => {
220
- const b = e.target.closest('button[data-tab]');
221
- if (b) switchTab(b.dataset.tab);
187
+ const switchTab = (t) => showMainSec(t);
188
+ const showNowSec = (s) => showMainSec(s);
189
+ const showSettingsSec = (s) => showMainSec(s);
190
+
191
+ $('#main-nav')?.addEventListener('click', (e) => {
192
+ const b = e.target.closest('button[data-sec]');
193
+ if (b) showMainSec(b.dataset.sec);
222
194
  });
223
195
 
224
196
  // ======================================================================
@@ -261,7 +233,7 @@ const RUN_PARTS = [
261
233
  ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
262
234
  ['modelTrainDays', (v) => `Окно: ${v}дн`],
263
235
  ['modelHistoryDays', (v) => `История: ${v}дн`],
264
- ['modelEmbedDim', (v) => `Эмбд.: ${v}`],
236
+ ['modelEmbedDim', (v) => `Эмбд.: ${Number(v) === 0 ? 'Авто' : v}`],
265
237
  ['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
266
238
  ['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
267
239
  ];
@@ -325,12 +297,10 @@ async function loadRuns(selectId) {
325
297
 
326
298
  async function openRun(id) {
327
299
  state.run = await api(`/api/runs/${id}`);
328
- $('#run-select').value = String(id);
300
+ if ($('#run-select')) $('#run-select').value = String(id);
329
301
  saveNavPatch({ runId: Number(id) });
330
302
  syncUiAvailability();
331
303
  renderOverview();
332
- setupMapSlider();
333
- if (!$('#tab-map').hidden) drawMap();
334
304
  }
335
305
 
336
306
  $('#run-select').addEventListener('change', (e) => e.target.value && openRun(Number(e.target.value)));
@@ -420,62 +390,14 @@ function syncUiAvailability() {
420
390
  }
421
391
 
422
392
  function syncSettingsDependencies() {
423
- const form = $('#params');
424
- if (!form || !state.settings) return;
425
- const s = state.settings;
426
- const autoOn = s.modelAutoAdapt !== false;
427
- const dir = s.modelDirection || 'long';
428
-
429
- const setRowEnabled = (key, enabled, whyDisabled = '') => {
430
- const row = form.querySelector(`.srow[data-key="${key}"]`);
431
- if (!row) return;
432
- row.style.opacity = enabled ? '1' : '0.45';
433
- const ctrl = form.elements[key];
434
- if (ctrl) {
435
- ctrl.disabled = !enabled;
436
- ctrl.title = enabled ? '' : whyDisabled;
437
- }
438
- };
439
-
440
- setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
441
- setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
442
- setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
443
- setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
444
- setRowEnabled('modelMarketFilter', true, '');
445
-
446
- const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
447
- if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
448
- }
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
- };
393
+ if ($('#chk-news-filter') && state.settings) {
394
+ $('#chk-news-filter').checked = Boolean(state.settings.newsFilter);
395
+ }
474
396
  }
475
397
  function syncShowcaseBtn() {
476
398
  const btn = $('#btn-showcase');
477
399
  if (!btn) return;
478
- const isAdmin = state.config?.user?.role === 'admin';
400
+ const isAdmin = isOwnerUser();
479
401
  const r = state.run;
480
402
  btn.hidden = !isAdmin || !r || r.status !== 'done';
481
403
  if (!btn.hidden) {
@@ -497,27 +419,7 @@ $('#btn-showcase')?.addEventListener('click', async () => {
497
419
 
498
420
  function syncApplyBtn() {
499
421
  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
422
  }
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
423
 
522
424
  // Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
523
425
  function calcPeriodStats(period, returnPct, marketPct) {
@@ -715,7 +617,17 @@ function renderLiveTraining() {
715
617
  ].join('');
716
618
  }
717
619
 
620
+ const isCalcStage = st.stage === 'calc';
621
+ const calcBanner = isCalcStage ? `<div class="ld-calc-banner">
622
+ <div class="ld-calc-spin"></div>
623
+ <div style="min-width:0;flex:1">
624
+ <div style="font-weight:600;font-size:13.5px;color:var(--accent)">Идёт финальный пересчёт сделок, кривых капитала и фазовой устойчивости…</div>
625
+ <div class="small muted" style="margin-top:2px">${esc(st.text || 'Симуляция портфеля и расчёт метрик…')}</div>
626
+ </div>
627
+ </div>` : '';
628
+
718
629
  empty.innerHTML = `
630
+ ${calcBanner}
719
631
  <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
720
632
  <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
633
  <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 +849,49 @@ $('#btn-cancel').addEventListener('click', async () => {
937
849
  // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
938
850
  // ======================================================================
939
851
  let forecastBusy = false;
852
+ let pulseClearTimer = null;
940
853
  const prevClientExp = new Map();
941
854
  const prevClientRank = new Map();
942
855
 
856
+ function triggerTradingPulse(text = 'Обновление котировок и сигналов…', durationMs = 2400) {
857
+ state.tradingPulseActive = true;
858
+ state.tradingPulseText = text;
859
+ updateTabBadges();
860
+ const badge = $('#fc-live-badge');
861
+ if (badge) {
862
+ badge.innerHTML = `<i class="tspin" style="margin-right:5px;vertical-align:-1px"></i>${esc(text)}`;
863
+ badge.style.background = 'rgba(59, 130, 246, 0.18)';
864
+ badge.style.borderColor = 'var(--accent)';
865
+ badge.style.color = 'var(--accent)';
866
+ }
867
+ clearTimeout(pulseClearTimer);
868
+ pulseClearTimer = setTimeout(() => {
869
+ state.tradingPulseActive = false;
870
+ updateTabBadges();
871
+ if (badge && state.forecast) {
872
+ const liveTimeStr = new Date((state.forecast.liveTs || state.forecast.at || Date.now()) + MSK).toISOString().slice(11, 19);
873
+ badge.textContent = `● живой радар · ${liveTimeStr}`;
874
+ badge.style.background = '';
875
+ badge.style.borderColor = '';
876
+ badge.style.color = '';
877
+ }
878
+ }, durationMs);
879
+ }
880
+
943
881
  async function loadForecast(force, silent = false) {
944
882
  if (forecastBusy) return;
945
883
  forecastBusy = true;
884
+ triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
946
885
  const box = $('#forecast');
947
- if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
886
+ if (!silent && !state.forecast) {
887
+ box.innerHTML = `<div class="ld-calc-banner" style="margin:4px 0">
888
+ <div class="ld-calc-spin"></div>
889
+ <div>
890
+ <div style="font-weight:600;color:var(--accent)">Синхронизация живых котировок Мосбиржи и расчёт матрицы внимания…</div>
891
+ <div class="small muted">Занимает ~1–2 секунды (чтение локального кэша свечей и живого стакана TQBR)</div>
892
+ </div>
893
+ </div>`;
894
+ }
948
895
  try {
949
896
  const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
950
897
  // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
@@ -968,8 +915,8 @@ async function loadForecast(force, silent = false) {
968
915
  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
916
  const arrowOf = (r) => {
970
917
  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>`;
918
+ if (d >= 0.005) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">+${fmtNum(d, 2)}% ▲</span>`;
919
+ if (d <= -0.005) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">${fmtNum(d, 2)}% ▼</span>`;
973
920
  return '<span class="muted small" title="Без изменений">—</span>';
974
921
  };
975
922
  const rankBadge = (r) => {
@@ -978,19 +925,30 @@ async function loadForecast(force, silent = false) {
978
925
  if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
979
926
  return '';
980
927
  };
981
- const colgroup = `<colgroup><col style="width:34%"><col style="width:12%"><col style="width:12%"><col style="width:12%"><col style="width:10%"><col style="width:20%"></colgroup>`;
928
+ 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>`;
929
+ const sigBadge = (r) => {
930
+ if (r.signal) return '<span class="tag ok">купить</span>';
931
+ if (r.shortSignal) return '<span class="tag no">шорт</span>';
932
+ if (r.shortBlockedByBroker) return '<span class="tag" title="Ожидание пробило порог шорта, но брокер запрещает короткие продажи по этой бумаге (shortEnabledFlag = false)">без шорта</span>';
933
+ return '<span class="muted small">—</span>';
934
+ };
982
935
  const row = (r) => `<tr>
983
936
  <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
984
- <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
937
+ <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
985
938
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
986
939
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
987
- <td style="text-align:center;white-space:nowrap">${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">шорт</span>' : '<span class="muted small">—</span>'}</td>
940
+ <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
988
941
  <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
989
942
  </tr>`;
943
+ const buyThr = f.buyThresholdPct ?? f.thresholdPct;
944
+ const shortThr = f.shortThresholdPct ?? f.thresholdPct;
945
+ const shortRows = [
946
+ ...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
947
+ ...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
948
+ ].slice(0, 4);
990
949
  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>
991
- <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>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
992
- <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога ${fmtPct(f.thresholdPct, 2)}.${f.direction && f.direction !== 'long' ? ' «Шорт» — ожидание ниже −порога.' : ''}</p>`;
993
- setupMapSlider();
950
+ <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>
951
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
994
952
  if (state.brokers?.accounts?.length) renderBrokers();
995
953
  } catch (e) {
996
954
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -1008,489 +966,30 @@ function refreshForecastIfStale() {
1008
966
  loadForecast(false, true);
1009
967
  }
1010
968
  }
1011
- setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
1012
-
1013
- function showNowSec(sec) {
1014
- document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
1015
- document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
1016
- saveNavPatch({ nowSec: sec });
1017
- }
1018
- $('#now-nav').addEventListener('click', (e) => {
1019
- const b = e.target.closest('[data-nsec]');
1020
- if (b) showNowSec(b.dataset.nsec);
1021
- });
1022
-
1023
- let openLiveId = loadSavedNav().openLiveId ?? null;
1024
- const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
1025
- function setLiveSubTab(id, tab) {
1026
- liveTabById.set(String(id), tab);
1027
- saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
1028
- }
1029
-
1030
- function renderLive(st) {
1031
- state.live = st;
1032
- const accs = st?.accounts || [];
1033
- if ($('#nnav-virtual')) $('#nnav-virtual').textContent = accs.length ? `Виртуальные счета (${accs.length})` : 'Виртуальные счета';
1034
- updateTabBadges();
1035
- if (!accs.length) {
1036
- $('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
1037
- return;
1038
- }
1039
- if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
1040
- openLiveId = accs[0].runId;
1041
- }
1042
- $('#live').innerHTML = accs.map((a) => {
1043
- const isOpen = String(a.runId) === String(openLiveId);
1044
- const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
1045
- const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
1046
- const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
1047
- const retPct = a.returnPct ?? 0;
1048
- const step = a.recent?.[0];
1049
- const p = a.params || {};
1050
- const mf = a.metrics?.full || {};
1051
- const trades = Array.isArray(a.trades) ? a.trades : [];
1052
- const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
1053
- const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
1054
- const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
1055
- const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
1056
- const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
1057
- const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
1058
- let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
1059
- if (a.corridor) {
1060
- const c = a.corridor;
1061
- const statusMap = {
1062
- inside: ['в коридоре истории', 'ok'],
1063
- above: ['опережает историю', 'ok'],
1064
- below: ['ниже коридора истории — проверьте', 'no'],
1065
- };
1066
- const [sLbl, sCls] = statusMap[c.status] || ['', ''];
1067
- 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>`;
1068
- }
1069
- const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
1070
-
1071
- // Вкладка 1: Текущие позиции и ожидающие заявки
1072
- const posPaneHtml = `
1073
- ${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>` : ''}
1074
- ${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) => {
1075
- const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
1076
- return `<tr>
1077
- <td><b>${esc(pos.symbol)}</b></td>
1078
- <td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
1079
- <td class="muted small">${fmtTs(pos.entryTs)}</td>
1080
- <td class="num">${fmtNum(pos.qty, 0)} шт.</td>
1081
- <td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
1082
- <td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
1083
- </tr>`;
1084
- }).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>`}
1085
- ${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>` : ''}
1086
- `;
1087
-
1088
- // Вкладка 2: История закрытых сделок и по-дневной доход
1089
- let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
1090
- if (trades.length) {
1091
- const byDay = new Map();
1092
- for (const t of trades) {
1093
- const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
1094
- if (!byDay.has(dKey)) byDay.set(dKey, []);
1095
- byDay.get(dKey).push(t);
1096
- }
1097
- const rows = [];
1098
- for (const [, dayTrades] of byDay) {
1099
- const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
1100
- const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
1101
- const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
1102
- 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>`);
1103
- for (const t of dayTrades) {
1104
- rows.push(`<tr>
1105
- <td><b>${esc(t.symbol)}</b></td>
1106
- <td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
1107
- <td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
1108
- <td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
1109
- <td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
1110
- <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>
1111
- <td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
1112
- </tr>`);
1113
- }
1114
- }
1115
- 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>`;
1116
- }
969
+ setInterval(() => { if (!document.hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
1117
970
 
1118
- // Вкладка 3: Журнал шагов и решений ИИ по свечам
1119
- const stepsList = Array.isArray(a.recent) ? a.recent : [];
1120
- const stepsPaneHtml = stepsList.length
1121
- ? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
1122
- <td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
1123
- <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>
1124
- <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>
1125
- </tr>`).join('')}</tbody></table>`
1126
- : '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
1127
-
1128
- return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
1129
- <div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
1130
- <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
1131
- <span class="acc-caret">▶</span>
1132
- <b>${esc(dispName)}</b>
1133
- <span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
1134
- ${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
1135
- </div>
1136
- <div class="row-gap" style="align-items:center">
1137
- <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
1138
- ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
1139
- <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
1140
- </div>
1141
- </div>
1142
- <div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
1143
- <div class="brk-specs" style="margin-bottom:10px">
1144
- <span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
1145
- <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>
1146
- <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>
1147
- </div>
1148
- <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
1149
- <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
1150
- <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>
1151
- ${corrHtml}
1152
- <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>
1153
- </div>
1154
- <div class="brk-subtabs" style="margin-top:10px">
1155
- <button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
1156
- <button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
1157
- <button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
1158
- </div>
1159
- <div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
1160
- <div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
1161
- <div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
1162
- </div>
1163
- </div>`;
1164
- }).join('');
1165
- syncUiAvailability();
1166
- }
1167
- $('#btn-live').addEventListener('click', async () => {
1168
- const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
1169
- if (rawCap === null) return;
1170
- const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
1171
- try {
1172
- const res = await post('/api/live/start', { capital });
1173
- renderLive(res.status);
1174
- toast(`Виртуальный счёт №${res.runId} запущен`);
1175
- } catch (e) { toast(e.message, 'error'); }
1176
- });
1177
- $('#live').addEventListener('click', async (e) => {
1178
- const tabBtn = e.target.closest('button[data-ltab]');
1179
- if (tabBtn) {
1180
- setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
1181
- renderLive(state.live);
1182
- return;
1183
- }
1184
- const stopBtn = e.target.closest('[data-live-stop]');
1185
- if (stopBtn) {
1186
- const id = Number(stopBtn.dataset.liveStop);
1187
- if (!confirm(`Остановить виртуальный счёт №${id}?`)) return;
1188
- try { const r = await post('/api/live/stop', { runId: id }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
1189
- return;
1190
- }
1191
- const delBtn = e.target.closest('[data-live-del]');
1192
- if (delBtn) {
1193
- const id = Number(delBtn.dataset.liveDel);
1194
- if (!confirm(`Удалить виртуальный счёт №${id} и его сделки?`)) return;
1195
- try { const r = await api(`/api/live/${id}`, { method: 'DELETE' }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
1196
- return;
1197
- }
1198
- const openBtn = e.target.closest('[data-open-run]');
1199
- if (openBtn) {
1200
- const id = Number(openBtn.dataset.openRun);
1201
- document.querySelector('[data-tab=overview]')?.click();
1202
- openRun(id);
1203
- return;
1204
- }
1205
- const askBtn = e.target.closest('[data-live-ask]');
1206
- if (askBtn) {
1207
- const id = Number(askBtn.dataset.liveAsk);
1208
- const acc = (state.live?.accounts || []).find((x) => x.runId === id);
1209
- sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
1210
- return;
1211
- }
1212
- const foldHead = e.target.closest('[data-live-fold]');
1213
- if (foldHead) {
1214
- const id = foldHead.dataset.liveFold;
1215
- openLiveId = String(openLiveId) === String(id) ? null : id;
1216
- saveNavPatch({ openLiveId });
1217
- renderLive(state.live);
1218
- }
1219
- });
1220
-
1221
- // ---------- новости: только для прогноза «сейчас» и живой проверки ----------
971
+ // ---------- новости ----------
1222
972
  async function loadNews() {
1223
973
  try {
1224
974
  const { items } = await api('/api/news?limit=20');
1225
975
  if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
1226
- $('#news-list').innerHTML = items.length
1227
- ? 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('')
1228
- : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
976
+ if ($('#news-list')) {
977
+ $('#news-list').innerHTML = items.length
978
+ ? 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('')
979
+ : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
980
+ }
1229
981
  } catch { /* ignore */ }
1230
982
  }
1231
983
 
1232
984
  // ======================================================================
1233
- // КАРТА
1234
- // ======================================================================
1235
- function mapFrames() {
1236
- const ex = state.run?.extra;
1237
- const fr = (ex?.map?.snapshots || []).map((s) => ({ ts: s.ts, pts: s.pts, cl: s.cl, symbols: ex.symbols, names: ex.names }));
1238
- if (state.forecast?.map) fr.push({ ts: state.forecast.barTs, now: true, ...state.forecast.map });
1239
- return fr;
1240
- }
1241
- function setupMapSlider() {
1242
- const s = $('#map-slider');
1243
- const atEnd = Number(s.value) >= Number(s.max);
1244
- s.max = Math.max(0, mapFrames().length - 1);
1245
- if (atEnd) s.value = s.max;
1246
- }
1247
- function layout(f, W, H) {
1248
- const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
1249
- const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
1250
- const pad = 36;
1251
- 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)]);
1252
- }
1253
- // ближайшая к клику точка карты (общая для карты по поведению и по описанию)
1254
- function pickPoint(f, e) {
1255
- const cv = $('#map-canvas');
1256
- const rect = cv.getBoundingClientRect();
1257
- const xy = layout(f, cv.clientWidth, cv.clientHeight);
1258
- let best = -1, bd = 16;
1259
- 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; } });
1260
- return best;
1261
- }
1262
- function drawMap() {
1263
- if (state.mapMode === 'text') return drawProfilesMap();
1264
- const cv = $('#map-canvas');
1265
- const W = cv.clientWidth, H = cv.clientHeight;
1266
- if (!W) return;
1267
- $('#map-slider').hidden = false;
1268
- $('#map-text-box').hidden = true;
1269
- const dpr = window.devicePixelRatio || 1;
1270
- cv.width = W * dpr; cv.height = H * dpr;
1271
- const ctx = cv.getContext('2d');
1272
- ctx.scale(dpr, dpr);
1273
- const frames = mapFrames();
1274
- if (!frames.length) {
1275
- ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
1276
- ctx.fillText('Карта появится после исследования или прогноза.', 20, 30);
1277
- $('#map-date').textContent = '';
1278
- $('#map-info').textContent = '';
1279
- return;
1280
- }
1281
- const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
1282
- $('#map-date').textContent = f.now ? `Сейчас · ${fmtDate(f.ts)} · заливка — прогноз` : `Месяц с ${fmtDate(f.ts)}`;
1283
- const xy = layout(f, W, H);
1284
- const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
1285
- const fc = f.now && state.forecast ? Object.fromEntries(state.forecast.rows.map((r) => [r.symbol, r])) : null;
1286
- if (sel >= 0) {
1287
- 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);
1288
- ctx.strokeStyle = document.body.classList.contains('light') ? 'rgba(36, 87, 214, 0.38)' : '#ffffff40';
1289
- for (const [i] of near) { ctx.beginPath(); ctx.moveTo(...xy[sel]); ctx.lineTo(...xy[i]); ctx.stroke(); }
1290
- }
1291
- f.symbols.forEach((s, i) => {
1292
- const [x, y] = xy[i];
1293
- const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
1294
- const r = fc?.[s] ? 3 + Math.min(7, Math.abs(fc[s].expectedPct) * 15) : 4;
1295
- ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
1296
- ctx.beginPath(); ctx.arc(x, y, i === sel ? r + 2 : r, 0, Math.PI * 2);
1297
- ctx.fillStyle = fc?.[s] ? (fc[s].expectedPct >= 0 ? '#3fb950' : '#f85149') : col;
1298
- ctx.fill();
1299
- ctx.globalAlpha = 1;
1300
- const light = document.body.classList.contains('light');
1301
- ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
1302
- ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
1303
- ctx.fillText(s, x + r + 3, y + 3);
1304
- });
1305
- if (sel < 0) { $('#map-info').textContent = 'Близко — значит акции обычно движутся вместе; цвет — найденная группа. Нажмите на акцию, чтобы увидеть соседей.'; return; }
1306
- 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]);
1307
- const r = state.forecast?.rows.find((x) => x.symbol === f.symbols[sel]);
1308
- $('#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)}` : ''}`;
1309
- }
1310
- $('#map-slider').addEventListener('input', drawMap);
1311
- $('#map-canvas').addEventListener('click', (e) => {
1312
- if (state.mapMode === 'text') {
1313
- const d = state.profiles;
1314
- if (!d?.map) return;
1315
- const best = pickPoint(d.map, e);
1316
- state.mapSel = best >= 0 ? d.map.symbols[best] : null;
1317
- drawMap();
1318
- return;
1319
- }
1320
- const frames = mapFrames();
1321
- if (!frames.length) return;
1322
- const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
1323
- const best = pickPoint(f, e);
1324
- state.mapSel = best >= 0 ? f.symbols[best] : null;
1325
- drawMap();
1326
- });
1327
- window.addEventListener('resize', debounce(() => { if (!$('#tab-map').hidden) drawMap(); }, 150));
1328
-
1329
- // ---------- карта «по описанию»: 2D-проекция устойчивых признаков бизнеса ----------
1330
- async function loadProfilesData() {
1331
- try { state.profiles = await api('/api/profiles'); } catch (e) { toast(e.message, 'error'); }
1332
- }
1333
- function drawProfilesMap() {
1334
- const cv = $('#map-canvas');
1335
- const W = cv.clientWidth, H = cv.clientHeight;
1336
- if (!W) return;
1337
- const dpr = window.devicePixelRatio || 1;
1338
- cv.width = W * dpr; cv.height = H * dpr;
1339
- const ctx = cv.getContext('2d');
1340
- ctx.scale(dpr, dpr);
1341
- $('#map-slider').hidden = true;
1342
- const d = state.profiles;
1343
- $('#map-date').textContent = d?.map ? `Описания бизнеса · ${d.count} из ${d.total} компаний` : 'Описания компаний';
1344
- if (!d?.map) {
1345
- ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
1346
- ctx.fillText(d?.count ? 'Описаний пока мало — карту по описаниям построить не из чего.' : 'Карты по описаниям пока нет.', 20, 30);
1347
- $('#map-text-box').hidden = false;
1348
- $('#btn-profiles').disabled = state.profilesBusy;
1349
- $('#map-info').textContent = 'Компании рядом, если похож их бизнес: экспортёр, банк, дивиденды и другие устойчивые свойства.';
1350
- return;
1351
- }
1352
- $('#map-text-box').hidden = true;
1353
- const f = d.map;
1354
- const xy = layout(f, W, H);
1355
- const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
1356
- f.symbols.forEach((s, i) => {
1357
- const [x, y] = xy[i];
1358
- ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
1359
- ctx.beginPath(); ctx.arc(x, y, i === sel ? 6 : 4, 0, Math.PI * 2);
1360
- ctx.fillStyle = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
1361
- ctx.fill();
1362
- ctx.globalAlpha = 1;
1363
- const light = document.body.classList.contains('light');
1364
- ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
1365
- ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
1366
- ctx.fillText(s, x + 7, y + 3);
1367
- });
1368
- if (sel < 0) { $('#map-info').textContent = 'Рядом — похожий бизнес. Цвет — найденная группа. Нажмите на акцию, чтобы увидеть её самые выраженные свойства.'; return; }
1369
- const prof = d.profiles[f.symbols[sel]];
1370
- const top = (prof || []).map((v, k) => [v, k]).sort((a, b) => b[0] - a[0]).filter(([v]) => v >= 0.3).slice(0, 5);
1371
- $('#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(', ')}` : ' · нет ярко выраженных свойств'}`;
1372
- }
1373
- async function showMapSec(sec) {
1374
- document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
1375
- saveNavPatch({ mapSec: sec });
1376
- if (sec === 'journal') {
1377
- $('#map-sec').hidden = true;
1378
- $('#journal-box').hidden = false;
1379
- await loadJournal();
1380
- return;
1381
- }
1382
- $('#map-sec').hidden = false;
1383
- $('#journal-box').hidden = true;
1384
- state.mapMode = sec;
1385
- $('#map-title-lbl').textContent = sec === 'text' ? 'Карта рынка · по описаниям бизнеса' : 'Карта рынка · по совместному поведению';
1386
- if (sec === 'text' && !state.profiles) await loadProfilesData();
1387
- drawMap();
1388
- }
1389
- $('#map-nav').addEventListener('click', (e) => {
1390
- const b = e.target.closest('[data-msec]');
1391
- if (b) showMapSec(b.dataset.msec);
1392
- });
1393
- $('#btn-profiles').addEventListener('click', async () => {
1394
- try {
1395
- const r = await post('/api/profiles/refresh');
1396
- if (r.started === false) return toast('Описания уже составляются — подождите');
1397
- state.profilesBusy = true;
1398
- $('#btn-profiles').disabled = true;
1399
- $('#profiles-msg').textContent = 'Составляем описания компаний…';
1400
- } catch (e) { toast(e.message, 'error'); }
1401
- });
1402
-
1403
- // ======================================================================
1404
- // ЖУРНАЛ И НАСТРОЙКИ
985
+ // НАСТРОЙКА ФИЛЬТРА НОВОСТЕЙ
1405
986
  // ======================================================================
1406
- const VERDICT = { confirmed: ['подтвердилась', 'ok'], rejected: ['не подтвердилась', 'no'], unclear: ['неясно', 'mid'] };
1407
- async function loadJournal() {
1408
- const list = await api('/api/journal');
1409
- state.journalCount = list.length;
1410
- updateTabBadges();
1411
- if ($('#mnav-journal')) $('#mnav-journal').textContent = list.length ? `Журнал гипотез (${list.length})` : 'Журнал гипотез';
1412
- $('#journal').innerHTML = list.length
1413
- ? `<p class="muted small">Память Стратега: он читает журнал в начале каждого разговора. Неверную запись можно удалить.</p><table><thead><tr><th>Гипотеза</th><th>Результат</th><th>Вывод</th><th></th></tr></thead><tbody>${list.map((j) => {
1414
- const [vl, vc] = VERDICT[j.verdict] || [j.verdict, ''];
1415
- 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>`;
1416
- }).join('')}</tbody></table>`
1417
- : '<p class="muted">Журнал пуст. Попросите Стратега провести эксперимент — он запишет вывод сюда.</p>';
1418
- $('#journal').querySelectorAll('[data-jdel]').forEach((b) => b.addEventListener('click', async () => {
1419
- if (confirm('Удалить запись? Стратег её «забудет».')) await api(`/api/journal/${b.dataset.jdel}`, { method: 'DELETE' });
1420
- }));
1421
- }
1422
-
1423
- // Логические группы настроек по смыслу:
1424
- // g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
1425
- const GROUPS = [
1426
- ['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
1427
- ['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
1428
- ['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
1429
- ];
1430
- function buildForm() {
1431
- const field = (p) => {
1432
- let input;
1433
- if (p.type === 'select') input = `<select name="${p.key}" class="sctrl">${p.options.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select>`;
1434
- else if (p.type === 'checkbox') input = `<input type="checkbox" name="${p.key}" class="switch">`;
1435
- else input = `<input type="number" step="${p.step ?? 'any'}" name="${p.key}" min="${p.min}" max="${p.max}" class="sctrl">`;
1436
- 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>`;
1437
- };
1438
- const shown = state.config.params.filter((p) => !p.hidden);
1439
- const listed = new Set(GROUPS.flatMap(([, keys]) => keys));
1440
- 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>`;
1441
- $('#params').innerHTML = GROUPS.map(([name, keys], i) => {
1442
- const items = keys.length ? keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean) : shown.filter((p) => !listed.has(p.key));
1443
- const extra = i === 2 ? cleanRow : '';
1444
- 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>` : '';
1445
- }).join('');
1446
- $('#btn-clean')?.addEventListener('click', cleanRunsHistory);
1447
- }
1448
- function showSettingsSec(sec) {
1449
- $('#s-search').value = '';
1450
- document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
1451
- document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
1452
- document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
1453
- saveNavPatch({ settingsSec: sec });
1454
- if (sec === 'cloud') renderCloudPane().catch(() => {});
1455
- }
1456
- document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
1457
- const b = e.target.closest('[data-sec]');
1458
- if (b) showSettingsSec(b.dataset.sec);
1459
- });
1460
- $('#s-search').addEventListener('input', (e) => {
1461
- const q = e.target.value.trim().toLowerCase();
1462
- if (!q) {
1463
- const cur = document.querySelector('#tab-settings .snav [data-sec].active')?.dataset.sec || 'g0';
1464
- showSettingsSec(cur);
1465
- return;
1466
- }
1467
- document.querySelectorAll('#tab-settings .ssec').forEach((sec) => {
1468
- if (!sec.dataset.sec.startsWith('g')) { sec.hidden = true; return; }
1469
- let vis = 0;
1470
- sec.querySelectorAll('.srow').forEach((r) => {
1471
- const ok = r.textContent.toLowerCase().includes(q);
1472
- r.hidden = !ok;
1473
- if (ok) vis++;
1474
- });
1475
- sec.hidden = !vis;
1476
- });
1477
- });
987
+ function buildForm() {}
1478
988
  function fillForm(s) {
1479
- for (const el of $('#params').elements) {
1480
- if (!el.name || el === document.activeElement) continue;
1481
- if (el.type === 'checkbox') el.checked = !!s[el.name];
1482
- else el.value = s[el.name];
1483
- }
1484
- markDefaults(s);
1485
- }
1486
- // Метка «изменено» у параметров, отличающихся от умолчания
1487
- function markDefaults(s) {
1488
- const d = state.config?.defaults || {};
1489
- $('#params').querySelectorAll('[data-def]').forEach((b) => {
1490
- b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
1491
- });
989
+ if ($('#chk-news-filter') && s) {
990
+ $('#chk-news-filter').checked = Boolean(s.newsFilter);
991
+ }
1492
992
  syncApplyBtn();
1493
- syncSettingsDependencies();
1494
993
  updateTabBadges();
1495
994
  }
1496
995
  const saveSettings = async (body) => {
@@ -1498,59 +997,30 @@ const saveSettings = async (body) => {
1498
997
  const r = await api('/api/settings', { method: 'PUT', body });
1499
998
  if (r.errors?.length) toast(r.errors.join('; '), 'error');
1500
999
  state.settings = r.settings;
1501
- markDefaults(state.settings);
1000
+ fillForm(state.settings);
1502
1001
  } catch (e) { toast(e.message, 'error'); }
1503
1002
  };
1504
1003
  const saveSetting = (k, v) => saveSettings({ [k]: v });
1505
- $('#params').addEventListener('change', (e) => e.target.name && saveSetting(e.target.name, e.target.type === 'checkbox' ? e.target.checked : e.target.value));
1506
- // клик по метке «изменено» — вернуть параметр к умолчанию
1507
- $('#params').addEventListener('click', async (e) => {
1508
- const b = e.target.closest('[data-def]');
1509
- if (!b) return;
1510
- await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
1511
- fillForm(state.settings);
1512
- });
1513
- $('#btn-reset').addEventListener('click', async () => {
1514
- if (!confirm('Вернуть все настройки по умолчанию?')) return;
1515
- state.settings = await post('/api/settings/reset');
1516
- fillForm(state.settings);
1517
- loadHistory().catch(() => {});
1518
- });
1519
-
1520
- // ---------- история изменений настроек ----------
1521
- async function loadHistory() {
1522
- const list = await api('/api/settings/history');
1523
- $('#snav-hist').textContent = list.length ? `История изменений (${list.length})` : 'История изменений';
1524
- $('#history').innerHTML = list.length
1525
- ? `<table><thead><tr><th>Дата</th><th>Кто</th><th>Что изменилось</th><th></th></tr></thead><tbody>${list.map((h) => `<tr>
1526
- <td class="muted small">${fmtTs(h.ts)}</td>
1527
- <td>${h.source === 'ai' ? 'Стратег' : 'вы'}</td>
1528
- <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>
1529
- <td><button class="icon" data-revert="${h.id}" title="Вернуть значения «было»">Вернуть</button></td>
1530
- </tr>`).join('')}</tbody></table>`
1531
- : '<p class="muted">Изменений ещё не было.</p>';
1532
- }
1533
- $('#history').addEventListener('click', async (e) => {
1534
- const b = e.target.closest('[data-revert]');
1535
- if (!b) return;
1536
- try {
1537
- const r = await post(`/api/settings/history/${b.dataset.revert}/revert`);
1538
- if (r.errors?.length) toast(r.errors.join('; '), 'error');
1539
- else if (!Object.keys(r.changed).length) toast('Настройки уже в этом состоянии — откатывать нечего');
1540
- state.settings = r.settings;
1541
- fillForm(state.settings);
1542
- await loadHistory();
1543
- } catch (err) { toast(err.message, 'error'); }
1004
+ $('#chk-news-filter')?.addEventListener('change', (e) => {
1005
+ saveSetting('newsFilter', e.target.checked);
1544
1006
  });
1545
1007
 
1546
1008
  // ======================================================================
1547
1009
  // ЧАТ СО СТРАТЕГОМ
1548
1010
  // ======================================================================
1549
1011
  const TOOL_LABELS = {
1550
- get_settings: 'Смотрит настройки', list_runs: 'Смотрит список исследований', analyze_run: 'Разбирает исследование', compare_runs: 'Сравнивает исследования',
1551
- run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
1552
- journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
1553
- set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
1012
+ get_brokers_status: 'Смотрит брокерские счета и портфель',
1013
+ get_settings: 'Смотрит настройки издержек',
1014
+ list_runs: 'Смотрит список исследований',
1015
+ analyze_run: 'Разбирает исследование',
1016
+ compare_runs: 'Сравнивает исследования',
1017
+ model_forecast: 'Смотрит живой прогноз',
1018
+ get_news: 'Смотрит свежие новости',
1019
+ market_neighbors: 'Смотрит соседей на карте',
1020
+ set_settings: 'Изменение настроек',
1021
+ broker_onboarding_guide: 'Смотрит инструкцию по брокеру',
1022
+ discover_broker_accounts: 'Ищет субсчета по токену',
1023
+ connect_broker_account: 'Подключение брокерского счёта',
1554
1024
  };
1555
1025
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1556
1026
  const feed = $('#feed');
@@ -1577,6 +1047,8 @@ function argsPreview(name, a) {
1577
1047
  if (name === 'compare_runs') return `№${a.a} и №${a.b}`;
1578
1048
  if (name === 'journal_add') return a.hypothesis || '';
1579
1049
  if (name === 'market_neighbors') return a.symbol || '';
1050
+ if (name === 'broker_onboarding_guide') return BROKER_LABELS[a.broker] || a.broker || '';
1051
+ if (name === 'connect_broker_account') return `${BROKER_LABELS[a.broker] || a.broker || ''}${a.name ? ` · ${a.name}` : ''}${a.accountId ? ` (${a.accountId})` : ''}`;
1580
1052
  return '';
1581
1053
  }
1582
1054
 
@@ -1588,7 +1060,7 @@ function actHtml(m, call) {
1588
1060
  const w = state.chat.state?.waiting;
1589
1061
  const waiting = status === 'pending' && w && w.msgId === m.id && w.callId === call.id;
1590
1062
  const prev = t.preview || argsPreview(call.function.name, args);
1591
- const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop'].includes(call.function.name);
1063
+ const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop', 'connect_broker_account'].includes(call.function.name);
1592
1064
  if (!big && !waiting) {
1593
1065
  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>`;
1594
1066
  }
@@ -1725,34 +1197,28 @@ function renderChatState() {
1725
1197
  const s = state.chat.state;
1726
1198
  if (!s) return;
1727
1199
  const noTools = s.tools === false;
1728
- const curMode = noTools ? 'discuss' : (s.mode in MODE_META ? s.mode : 'advisor');
1729
- const mm = MODE_META[curMode] || MODE_META.advisor;
1730
- if ($('#chat-mode-ico')) $('#chat-mode-ico').innerHTML = mm.svg;
1731
- if ($('#chat-mode-lbl')) $('#chat-mode-lbl').textContent = mm.label;
1732
- if ($('#chat-mode-btn')) $('#chat-mode-btn').title = noTools ? 'Выбранная модель не умеет пользоваться инструментами — Стратег только беседует' : MODE_TITLE;
1733
- document.querySelectorAll('#mode-menu [data-mode-val]').forEach((b) => {
1734
- const v = b.dataset.modeVal;
1735
- b.disabled = noTools && v !== 'discuss';
1736
- b.classList.toggle('active', v === curMode);
1737
- });
1738
1200
  $('#chat-stop').hidden = !s.busy && !s.waiting;
1739
1201
  syncActionBtn();
1740
1202
  let st = '';
1741
- if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Настройки → Модели ИИ)';
1742
- else if (s.waiting) st = 'Стратег ждёт вашего решения (или просто напишите ответ)';
1203
+ if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Модели ИИ)';
1204
+ else if (s.waiting) st = 'Стратег ждёт вашего подтверждения (или напишите ответ)';
1743
1205
  else if (s.busy) {
1744
1206
  const m = [...state.chat.msgs.values()].reverse().find((x) => x.tool_calls && Object.values(x.data?.tools || {}).some((t) => t.status === 'running'));
1745
1207
  const c = m?.tool_calls.find((x) => m.data.tools[x.id]?.status === 'running');
1746
1208
  st = c ? `${TOOL_LABELS[c.function.name] || 'Работает'}…` : 'Думает…';
1747
- if (s.mode === 'autopilot') st += ` · осталось исследований: ${s.budgetLeft}`;
1748
- } else if (noTools) st = 'У этой модели нет инструментов: Стратег только беседует';
1209
+ } else if (noTools) st = 'У этой модели нет инструментов: Стратег отвечает по контексту';
1749
1210
  $('#chat-status').textContent = st;
1750
1211
  for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
1751
1212
  renderSuggest();
1752
1213
  syncUiAvailability();
1753
1214
  }
1754
1215
 
1755
- const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
1216
+ const SUGGEST = [
1217
+ 'Помоги подключить брокерский счёт',
1218
+ 'Что стратегия ждёт в ближайшие часы?',
1219
+ 'Разбери мой портфель и текущие заявки',
1220
+ 'Какие сейчас важные новости по рынку?',
1221
+ ];
1756
1222
  function renderSuggest() {
1757
1223
  const has = [...state.chat.msgs.values()].some((m) => m.kind === 'user');
1758
1224
  $('#suggest').innerHTML = has || state.chat.state?.busy ? '' : SUGGEST.map((s) => `<button>${esc(s)}</button>`).join('');
@@ -1785,31 +1251,12 @@ function closeModeMenu() {
1785
1251
  if (m) m.hidden = true;
1786
1252
  }
1787
1253
 
1788
- $('#chat-mode-btn')?.addEventListener('click', (e) => {
1789
- e.stopPropagation();
1790
- closeModelMenu();
1791
- const m = $('#mode-menu');
1792
- if (m) m.hidden = !m.hidden;
1793
- });
1794
-
1795
- $('#mode-menu')?.addEventListener('click', async (e) => {
1796
- const b = e.target.closest('button[data-mode-val]');
1797
- if (!b || b.disabled) return;
1798
- closeModeMenu();
1799
- try {
1800
- state.chat.state = await post('/api/chat/mode', { mode: b.dataset.modeVal });
1801
- renderChatState();
1802
- } catch (err) { toast(err.message, 'error'); }
1803
- });
1804
-
1805
1254
  async function sendChat(text) {
1806
1255
  if (speechRec && speechActive) stopVoiceInput(false);
1807
1256
  text = (text ?? $('#chat-input').value).trim();
1808
1257
  if (!text) return;
1809
1258
  try {
1810
- const s = state.chat.state;
1811
- const mode = s?.tools === false ? 'discuss' : (s?.mode || 'advisor');
1812
- await post('/api/chat/send', { text, mode });
1259
+ await post('/api/chat/send', { text, mode: 'advisor' });
1813
1260
  $('#chat-input').value = '';
1814
1261
  resizeChatInput();
1815
1262
  toBottom();
@@ -2354,10 +1801,9 @@ let brokerPollBusy = false;
2354
1801
  let lastBrokerPollTs = 0;
2355
1802
 
2356
1803
  async function pollActiveBrokerAccount(force = false) {
2357
- if (brokerPollBusy || document.hidden) return;
2358
- const nowPageHidden = $('#tab-now')?.hidden;
2359
- const brokersSecHidden = $('#nsec-brokers')?.hidden;
2360
- if (nowPageHidden || brokersSecHidden) return;
1804
+ if (brokerPollBusy || document.hidden || editingBrokerId) return;
1805
+ const brokersSecHidden = $('#brokers-trade-box')?.hidden;
1806
+ if (brokersSecHidden) return;
2361
1807
  const accs = state.brokers?.accounts || [];
2362
1808
  if (!accs.length) return;
2363
1809
  const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
@@ -2393,58 +1839,23 @@ async function loadBrokers() {
2393
1839
 
2394
1840
  function renderBrokers() {
2395
1841
  const accs = state.brokers?.accounts || [];
2396
- const navBtn = $('#snav-brokers');
2397
- if (navBtn) navBtn.textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
2398
1842
  if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
2399
1843
  updateTabBadges();
2400
1844
 
2401
- // 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
2402
- const listEl = $('#brokers-list');
2403
- if (listEl) {
2404
- if (accs.length && !accs.some((a) => a.id === openBrokerCfgId)) {
2405
- openBrokerCfgId = accs[0].id;
2406
- }
2407
- listEl.innerHTML = accs.length ? accs.map((a) => {
2408
- const ls = a.lastSync;
2409
- const isCfgOpen = a.id === openBrokerCfgId;
2410
- return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2411
- <div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
2412
- <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2413
- <span class="acc-caret">▶</span>
2414
- <b>${esc(a.name)}</b>
2415
- <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2416
- ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2417
- ${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
2418
- </div>
2419
- <div class="row-gap" style="align-items:center">
2420
- ${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
2421
- <button type="button" data-bact="sync">Проверить связь</button>
2422
- <button type="button" data-bact="edit" class="icon">Изменить</button>
2423
- <button type="button" data-bact="del" class="icon">Удалить</button>
2424
- </div>
2425
- </div>
2426
- <div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
2427
- <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>
2428
- <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>
2429
- <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
2430
- <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2431
- <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
2432
- <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>
2433
- ${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
2434
- ${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>` : ''}
2435
- </div>
2436
- </div>`;
2437
- }).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
1845
+ // Перед перерисовкой карточек возвращаем форму настроек в верхний слот, чтобы она не уничтожилась при замене innerHTML
1846
+ const formBox = $('#broker-add-box');
1847
+ const formSlot = $('#broker-form-slot');
1848
+ if (formBox && formSlot && formBox.parentElement !== formSlot) {
1849
+ formSlot.appendChild(formBox);
2438
1850
  }
2439
1851
 
2440
- // 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
2441
1852
  const tradeEl = $('#brokers-trade');
2442
1853
  if (!tradeEl) return;
2443
1854
  if (!accs.length) {
2444
- tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
1855
+ tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
2445
1856
  return;
2446
1857
  }
2447
- if (!accs.some((a) => a.id === openBrokerId)) {
1858
+ if (openBrokerId === undefined) {
2448
1859
  openBrokerId = accs[0].id;
2449
1860
  }
2450
1861
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
@@ -2500,7 +1911,12 @@ function renderBrokers() {
2500
1911
  <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>
2501
1912
  </div>` : '';
2502
1913
 
2503
- // Считаем сколько ₽ в стратегии и сколько ₽ защищено
1914
+ // Считаем стартовый лимит и реинвестированную прибыль сверх лимита
1915
+ const initBaseRub = a.initialTotalRub > 0 ? a.initialTotalRub : (a.equityHistory?.[0]?.totalRub > 0 ? a.equityHistory[0].totalRub : (ls?.totalRub || a.maxCapitalRub || 100000));
1916
+ const reinvProfitRub = ls?.totalRub > 0 ? Math.max(0, Math.round(ls.totalRub - Math.max(initBaseRub, a.maxCapitalRub || 100000))) : 0;
1917
+ const capSpecHtml = reinvProfitRub > 0
1918
+ ? `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> <span class="pos">(+ реинвестировано прибыли +${fmtNum(reinvProfitRub, 0)} ₽ → в работе <b>${fmtNum((a.maxCapitalRub || 0) + reinvProfitRub, 0)} ₽</b>)</span>`
1919
+ : `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> (+ реинвестирование прибыли)`;
2504
1920
  let strategyRub = 0, protectedRub = 0;
2505
1921
  for (const p of ls?.positions || []) {
2506
1922
  const aq = algoQtyOf(p);
@@ -2511,8 +1927,16 @@ function renderBrokers() {
2511
1927
  const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
2512
1928
 
2513
1929
  // Вкладка 1: Активы портфеля
1930
+ const fmtOwnership = (p) => {
1931
+ if (!(p.issueSize > 0) || !(p.qty > 0)) return '<span class="muted small">—</span>';
1932
+ const pct = (p.qty / p.issueSize) * 100;
1933
+ const dec = pct >= 0.01 ? 4 : pct >= 0.0001 ? 6 : 8;
1934
+ const pctStr = pct.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: dec });
1935
+ const tip = `Доля владения в компании ${p.name || p.symbol}: ${fmtNum(p.qty, 0)} из ${fmtNum(p.issueSize, 0)} акций всего выпуска на Мосбирже`;
1936
+ return `<span title="${esc(tip)}" style="white-space:nowrap"><b>${pctStr}%</b></span>`;
1937
+ };
2514
1938
  const posHtml = ls?.positions?.length
2515
- ? `${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) => {
1939
+ ? `${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) => {
2516
1940
  const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
2517
1941
  const fcRow = fcRowMap?.get(p.symbol);
2518
1942
  const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
@@ -2520,6 +1944,7 @@ function renderBrokers() {
2520
1944
  <td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
2521
1945
  <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
2522
1946
  <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
1947
+ <td class="num">${fmtOwnership(p)}</td>
2523
1948
  <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
2524
1949
  <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
2525
1950
  <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>
@@ -2530,7 +1955,19 @@ function renderBrokers() {
2530
1955
 
2531
1956
  // Вкладка 2: Торговля и заявки стратегии
2532
1957
  const activeOrdHtml = ls?.activeOrders?.length
2533
- ? `<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>`
1958
+ ? `<div class="stab-box" style="margin-bottom:10px">
1959
+ <div class="stab-head">
1960
+ <b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b>
1961
+ <span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
1962
+ </div>
1963
+ <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>
1964
+ <td><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></td>
1965
+ <td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
1966
+ <td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
1967
+ <td class="num">${fmtNum(o.price, 4)} ₽</td>
1968
+ <td class="num">${fmtNum(o.amountRub, 0)} ₽</td>
1969
+ </tr>`).join('')}</tbody></table>
1970
+ </div>`
2534
1971
  : '';
2535
1972
  let batchHtml = '';
2536
1973
  if (pb) {
@@ -2570,7 +2007,7 @@ function renderBrokers() {
2570
2007
  const rows = [];
2571
2008
  for (const [, dayOps] of byDay) {
2572
2009
  let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2573
- const symQty = new Map(), symCash = new Map();
2010
+ const symQty = new Map(), symCash = new Map(), symBuyRub = new Map();
2574
2011
  for (const op of dayOps) {
2575
2012
  const pay = Number(op.paymentRub) || 0;
2576
2013
  if (op.kind === 'buy') {
@@ -2579,6 +2016,7 @@ function renderBrokers() {
2579
2016
  if (op.symbol) {
2580
2017
  symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2581
2018
  symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2019
+ symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
2582
2020
  }
2583
2021
  } else if (op.kind === 'sell') {
2584
2022
  tradesCnt++;
@@ -2596,19 +2034,22 @@ function renderBrokers() {
2596
2034
  inOutRub += pay;
2597
2035
  }
2598
2036
  }
2599
- // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
2600
- let closedTradePnl = 0, closedSyms = 0;
2037
+ // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
2038
+ let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
2601
2039
  for (const [sym, netQ] of symQty) {
2602
2040
  if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2603
2041
  closedTradePnl += symCash.get(sym);
2042
+ closedBaseRub += symBuyRub.get(sym) || 0;
2604
2043
  closedSyms++;
2605
2044
  }
2606
2045
  }
2607
2046
  if (closedSyms > 0) closedTradePnl += feeRub;
2047
+ const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
2608
2048
 
2609
2049
  const summaryParts = [];
2610
2050
  if (closedSyms > 0) {
2611
- summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
2051
+ const pctPart = closedTradePct !== null ? ` (${closedTradePct > 0 ? '+' : ''}${fmtNum(closedTradePct, 2)}%)` : '';
2052
+ summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽${pctPart}</b>`);
2612
2053
  } else if (tradesCnt > 0) {
2613
2054
  const tParts = [];
2614
2055
  if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
@@ -2629,7 +2070,7 @@ function renderBrokers() {
2629
2070
  for (const op of dayOps) {
2630
2071
  rows.push(`<tr>
2631
2072
  <td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
2632
- <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>
2073
+ <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>
2633
2074
  <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2634
2075
  <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2635
2076
  <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
@@ -2727,52 +2168,86 @@ function renderBrokers() {
2727
2168
  ${yearsHtml}`
2728
2169
  : '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
2729
2170
 
2171
+ const isPaused = a.autoSchedule === false || a.mode === 'readonly';
2172
+ const isEditingThis = editingBrokerId === a.id;
2173
+ const collapsedMetricsHtml = !isOpen && ls?.ok
2174
+ ? `<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>`
2175
+ : '';
2176
+
2730
2177
  return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2731
2178
  <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
2732
2179
  <div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2733
2180
  <span class="acc-caret">▶</span>
2734
2181
  <b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
2735
- <span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
2182
+ <span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
2736
2183
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2737
2184
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2185
+ ${collapsedMetricsHtml}
2738
2186
  </div>
2739
2187
  <div class="row-gap" style="align-items:center">
2740
- ${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>` : ''}
2741
- <button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
2188
+ <button type="button" class="brk-toggle ${!isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
2189
+ ${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>` : ''}
2190
+ ${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
2742
2191
  </div>
2743
2192
  </div>
2744
2193
  <div ${isOpen ? '' : 'hidden'}>
2745
- <div class="brk-specs">
2746
- <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>
2747
- <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>
2748
- <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2749
- <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2750
- <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2751
- <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>
2752
- </div>
2753
- ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2754
- <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2755
- <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2756
- <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2757
- <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>
2758
- </div>` : ''}
2759
- ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2760
- <div class="brk-subtabs">
2761
- <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2762
- <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2763
- <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2194
+ <div class="brk-cfg-mount" data-cfg-mount="${esc(a.id)}" ${isEditingThis ? '' : 'hidden'} style="margin-top:12px"></div>
2195
+ <div class="brk-trade-view" ${isEditingThis ? 'hidden' : ''}>
2196
+ <div class="brk-specs">
2197
+ <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>
2198
+ <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>
2199
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2200
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2201
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2202
+ <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>
2203
+ </div>
2204
+ ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2205
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2206
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2207
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2208
+ <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>
2209
+ </div>` : ''}
2210
+ ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2211
+ <div class="brk-subtabs">
2212
+ <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2213
+ <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2214
+ <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2215
+ </div>
2216
+ <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2217
+ <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2218
+ <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2764
2219
  </div>
2765
- <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2766
- <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2767
- <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2768
2220
  </div>
2769
2221
  </div>`;
2770
2222
  }).join('');
2223
+
2224
+ // Если сейчас открыто редактирование конкретного счёта, монтируем форму прямо внутрь его карточки!
2225
+ if (editingBrokerId && formBox) {
2226
+ const mount = tradeEl.querySelector(`[data-cfg-mount="${CSS.escape(editingBrokerId)}"]`);
2227
+ if (mount) {
2228
+ mount.appendChild(formBox);
2229
+ formBox.hidden = false;
2230
+ }
2231
+ }
2771
2232
  }
2772
2233
 
2773
- $('#btn-brokers-cfg').addEventListener('click', () => {
2774
- switchTab('settings');
2775
- showSettingsSec('brokers');
2234
+ $('#btn-broker-add')?.addEventListener('click', () => {
2235
+ const formBox = $('#broker-add-box');
2236
+ const formSlot = $('#broker-form-slot');
2237
+ if (editingBrokerId === null && formBox && !formBox.hidden && formBox.parentElement === formSlot) {
2238
+ stopBrokerEdit();
2239
+ return;
2240
+ }
2241
+ stopBrokerEdit();
2242
+ if (formBox && formSlot) {
2243
+ formSlot.appendChild(formBox);
2244
+ formBox.hidden = false;
2245
+ syncBrokerGuideUi();
2246
+ updateBrokerSaveBtnState();
2247
+ if (brokerForm?.elements?.broker?.value === 'tbank' && brokerForm.elements.token.value.trim()) {
2248
+ runBrokerDiscover({ silent: true });
2249
+ }
2250
+ }
2776
2251
  });
2777
2252
 
2778
2253
  const brokerForm = $('#broker-form');
@@ -2787,7 +2262,6 @@ function getBrokerFormSnap() {
2787
2262
  token: f.token.value.trim(),
2788
2263
  mode: f.mode.value,
2789
2264
  legacyMode: f.legacyMode?.value || 'isolate',
2790
- baseRunId: f.baseRunId.value || '',
2791
2265
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2792
2266
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2793
2267
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
@@ -2801,15 +2275,49 @@ function syncBrokerGuideUi() {
2801
2275
  const f = brokerForm?.elements;
2802
2276
  if (!f) return;
2803
2277
  const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2278
+ const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
2804
2279
  if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2805
- if ($('#broker-token-input')) $('#broker-token-input').placeholder = editingBrokerId ? 'не меняется — оставьте пустым' : g.tokenPh;
2280
+ if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
2806
2281
  if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2807
2282
  if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2808
2283
  if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
2284
+ if (!g.canDiscover && $('#broker-discovered')) {
2285
+ $('#broker-discovered').hidden = true;
2286
+ $('#broker-discovered').innerHTML = '';
2287
+ }
2809
2288
  if ($('#broker-guide-sum')) $('#broker-guide-sum').textContent = g.summary;
2810
2289
  if ($('#broker-guide-body')) $('#broker-guide-body').innerHTML = g.html;
2290
+ if ($('#btn-broker-ai-help')) $('#btn-broker-ai-help').textContent = `🤖 Помочь с подключением (${brkName})`;
2811
2291
  }
2812
2292
 
2293
+ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2294
+ const f = brokerForm?.elements;
2295
+ if (!f) return;
2296
+ const brk = f.broker.value || 'tbank';
2297
+ const brkName = BROKER_LABELS[brk] || brk;
2298
+ const tok = f.token.value.trim();
2299
+ const accId = f.accountId.value.trim();
2300
+ const cap = Number(f.maxCapitalRub.value) || 100000;
2301
+ let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
2302
+ if (tok && accId) {
2303
+ promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
2304
+ } else if (tok) {
2305
+ promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
2306
+ } else if (accId) {
2307
+ promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
2308
+ } else {
2309
+ promptText += ` Подскажи, как быстро открыть счёт (если его ещё нет), как включить маржинальную торговлю и выпустить токен API, либо подключи счёт вместе со мной.`;
2310
+ }
2311
+ if (state.chat.state?.mode === 'discuss' && state.chat.state?.tools !== false) {
2312
+ try {
2313
+ state.chat.state = await post('/api/chat/mode', { mode: 'advisor' });
2314
+ renderChatState();
2315
+ } catch { /* ignore */ }
2316
+ }
2317
+ toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
2318
+ await sendChat(promptText);
2319
+ });
2320
+
2813
2321
  function updateBrokerSaveBtnState() {
2814
2322
  const btn = $('#broker-save-btn');
2815
2323
  if (!btn) return;
@@ -2834,88 +2342,137 @@ brokerForm.addEventListener('change', () => {
2834
2342
  updateBrokerSaveBtnState();
2835
2343
  });
2836
2344
 
2345
+ let brokerDiscoverTimer = null;
2346
+
2347
+ async function runBrokerDiscover({ silent = false } = {}) {
2348
+ const btn = $('#btn-broker-discover');
2349
+ const box = $('#broker-discovered');
2350
+ const f = brokerForm?.elements;
2351
+ if (!f || !box) return;
2352
+ if (f.broker.value !== 'tbank') {
2353
+ box.hidden = true;
2354
+ box.innerHTML = '';
2355
+ return;
2356
+ }
2357
+ const tok = f.token.value.trim();
2358
+ if (!tok && !editingBrokerId) {
2359
+ box.hidden = true;
2360
+ box.innerHTML = '';
2361
+ return;
2362
+ }
2363
+ if (btn) btn.disabled = true;
2364
+ const old = btn ? btn.textContent : '';
2365
+ if (btn) btn.textContent = 'Ищем счета…';
2366
+ try {
2367
+ const res = await post('/api/brokers/discover', {
2368
+ id: editingBrokerId || undefined,
2369
+ broker: f.broker.value,
2370
+ token: tok,
2371
+ sandbox: f.sandbox.checked,
2372
+ });
2373
+ const list = res.accounts || [];
2374
+ const curAccId = f.accountId.value.trim();
2375
+ if (!list.length) {
2376
+ box.hidden = false;
2377
+ box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2378
+ } else {
2379
+ box.hidden = false;
2380
+ box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => {
2381
+ const isSelected = curAccId && a.id === curAccId;
2382
+ 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)">
2383
+ <div>
2384
+ <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>` : ''}
2385
+ <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>
2386
+ </div>
2387
+ <button type="button" class="${isSelected ? '' : 'primary '}small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">${isSelected ? '✓ Выбран' : 'Выбрать этот счёт'}</button>
2388
+ </div>`;
2389
+ }).join('');
2390
+ }
2391
+ } catch (err) {
2392
+ if (!silent) toast(err.message, 'error');
2393
+ } finally {
2394
+ if (btn) {
2395
+ btn.disabled = false;
2396
+ btn.textContent = old || '🔍 Найти мои счета по токену';
2397
+ }
2398
+ }
2399
+ }
2400
+
2837
2401
  function startBrokerEdit(acc) {
2838
2402
  editingBrokerId = acc.id;
2403
+ openBrokerId = acc.id;
2839
2404
  const f = brokerForm.elements;
2840
2405
  f.id.value = acc.id;
2841
2406
  f.name.value = acc.name;
2842
2407
  f.broker.value = acc.broker;
2843
2408
  f.accountId.value = acc.accountId || '';
2844
- f.token.value = '';
2409
+ f.token.value = acc.token || '';
2845
2410
  f.mode.value = acc.mode || 'confirm';
2846
2411
  if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
2847
- if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
2848
- const opt = document.createElement('option');
2849
- opt.value = String(acc.baseRunId);
2850
- opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
2851
- f.baseRunId.appendChild(opt);
2852
- }
2853
- f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
2854
2412
  f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
2855
2413
  f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
2856
2414
  f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2857
2415
  f.autoSchedule.checked = acc.autoSchedule !== false;
2858
2416
  f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2859
2417
  f.sandbox.checked = !!acc.sandbox;
2860
- $('#broker-add-box').open = true;
2861
- $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
2418
+ $('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
2862
2419
  $('#broker-save-btn').textContent = 'Сохранить изменения';
2863
2420
  $('#broker-cancel-btn').hidden = false;
2421
+ if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = false;
2864
2422
  syncBrokerGuideUi();
2865
2423
  brokerInitialSnap = getBrokerFormSnap();
2866
2424
  updateBrokerSaveBtnState();
2425
+ renderBrokers();
2426
+ if (acc.broker === 'tbank' && (acc.token || acc.hasToken)) {
2427
+ runBrokerDiscover({ silent: true });
2428
+ }
2867
2429
  }
2868
2430
 
2869
2431
  function stopBrokerEdit() {
2432
+ const wasEditing = editingBrokerId !== null;
2870
2433
  editingBrokerId = null;
2871
2434
  brokerForm.reset();
2872
2435
  brokerForm.elements.id.value = '';
2873
2436
  if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
2874
2437
  $('#broker-add-sum').textContent = 'Добавить брокерский счёт';
2875
2438
  $('#broker-save-btn').textContent = 'Сохранить счёт';
2876
- $('#broker-cancel-btn').hidden = true;
2439
+ if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = true;
2440
+ const formBox = $('#broker-add-box');
2441
+ const formSlot = $('#broker-form-slot');
2442
+ if (formBox && formSlot) {
2443
+ formSlot.appendChild(formBox);
2444
+ formBox.hidden = true;
2445
+ }
2877
2446
  syncBrokerGuideUi();
2878
2447
  brokerInitialSnap = getBrokerFormSnap();
2879
2448
  updateBrokerSaveBtnState();
2449
+ if (wasEditing) renderBrokers();
2880
2450
  }
2881
2451
  $('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
2452
+ $('#broker-delete-btn')?.addEventListener('click', async () => {
2453
+ if (!editingBrokerId) return;
2454
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId);
2455
+ if (!confirm(`Удалить брокерский счёт «${acc?.name || editingBrokerId}»?`)) return;
2456
+ try {
2457
+ const delId = editingBrokerId;
2458
+ stopBrokerEdit();
2459
+ state.brokers = await api(`/api/brokers/${encodeURIComponent(delId)}`, { method: 'DELETE' });
2460
+ renderBrokers();
2461
+ toast('Брокерский счёт удалён');
2462
+ } catch (err) { toast(err.message, 'error'); }
2463
+ });
2882
2464
  syncBrokerGuideUi();
2883
2465
  updateBrokerSaveBtnState();
2884
2466
 
2885
- // Авто-поиск всех субсчетов в Т-Инвестициях по токену
2886
- $('#btn-broker-discover')?.addEventListener('click', async () => {
2887
- const btn = $('#btn-broker-discover');
2888
- const box = $('#broker-discovered');
2889
- const f = brokerForm.elements;
2890
- btn.disabled = true;
2891
- const old = btn.textContent;
2892
- btn.textContent = 'Ищем счета…';
2893
- try {
2894
- const res = await post('/api/brokers/discover', {
2895
- id: editingBrokerId || undefined,
2896
- broker: f.broker.value,
2897
- token: f.token.value,
2898
- sandbox: f.sandbox.checked,
2899
- });
2900
- const list = res.accounts || [];
2901
- if (!list.length) {
2902
- box.hidden = false;
2903
- box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2904
- } else {
2905
- box.hidden = false;
2906
- 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)">
2907
- <div>
2908
- <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>` : ''}
2909
- <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>
2910
- </div>
2911
- <button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
2912
- </div>`).join('');
2913
- }
2914
- } catch (err) {
2915
- toast(err.message, 'error');
2916
- } finally {
2917
- btn.disabled = false;
2918
- btn.textContent = old;
2467
+ // Авто-поиск всех субсчетов в Т-Инвестициях по токену (по кнопке, при открытии редактирования и автоматически при вводе/смене токена)
2468
+ $('#btn-broker-discover')?.addEventListener('click', () => runBrokerDiscover({ silent: false }));
2469
+ $('#broker-token-input')?.addEventListener('input', () => {
2470
+ clearTimeout(brokerDiscoverTimer);
2471
+ const f = brokerForm?.elements;
2472
+ if (!f || f.broker.value !== 'tbank') return;
2473
+ const tok = f.token.value.trim();
2474
+ if (tok.length >= 20) {
2475
+ brokerDiscoverTimer = setTimeout(() => runBrokerDiscover({ silent: true }), 350);
2919
2476
  }
2920
2477
  });
2921
2478
 
@@ -2927,6 +2484,13 @@ $('#broker-discovered')?.addEventListener('click', (e) => {
2927
2484
  if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
2928
2485
  f.name.value = `Т-Банк · ${b.dataset.pickName}`;
2929
2486
  }
2487
+ $('#broker-discovered').querySelectorAll('button[data-pick-acc]').forEach((btn) => {
2488
+ const sel = btn.dataset.pickAcc === b.dataset.pickAcc;
2489
+ btn.classList.toggle('primary', !sel);
2490
+ btn.textContent = sel ? '✓ Выбран' : 'Выбрать этот счёт';
2491
+ const row = btn.closest('div[style]');
2492
+ if (row) row.style.borderColor = sel ? 'var(--accent)' : 'var(--border)';
2493
+ });
2930
2494
  updateBrokerSaveBtnState();
2931
2495
  toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
2932
2496
  });
@@ -2942,7 +2506,7 @@ brokerForm.addEventListener('submit', async (e) => {
2942
2506
  token: f.token.value,
2943
2507
  mode: f.mode.value,
2944
2508
  legacyMode: f.legacyMode?.value || 'isolate',
2945
- baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
2509
+ baseRunId: null,
2946
2510
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2947
2511
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2948
2512
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
@@ -2969,48 +2533,6 @@ brokerForm.addEventListener('submit', async (e) => {
2969
2533
  } catch (err) { toast(err.message, 'error'); }
2970
2534
  });
2971
2535
 
2972
- $('#brokers-list').addEventListener('click', async (e) => {
2973
- const b = e.target.closest('button[data-bact]');
2974
- if (!b) {
2975
- const fold = e.target.closest('[data-bcfg-fold]');
2976
- if (fold) {
2977
- const fid = fold.dataset.bcfgFold;
2978
- openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
2979
- saveNavPatch({ openBrokerCfgId });
2980
- renderBrokers();
2981
- }
2982
- return;
2983
- }
2984
- const id = b.closest('[data-bid]').dataset.bid;
2985
- const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2986
- if (!acc) return;
2987
- if (b.dataset.bact === 'edit') {
2988
- startBrokerEdit(acc);
2989
- $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2990
- return;
2991
- }
2992
- if (b.dataset.bact === 'del') {
2993
- if (!confirm(`Удалить брокерский счёт «${acc.name}»?`)) return;
2994
- try { state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, { method: 'DELETE' }); renderBrokers(); } catch (err) { toast(err.message, 'error'); }
2995
- return;
2996
- }
2997
- if (b.dataset.bact === 'sync') {
2998
- b.disabled = true;
2999
- const old = b.textContent;
3000
- b.textContent = 'Проверяем…';
3001
- try {
3002
- state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
3003
- renderBrokers();
3004
- toast(`Связь с «${acc.name}» проверена`);
3005
- } catch (err) {
3006
- await loadBrokers().catch(() => {});
3007
- toast(err.message, 'error');
3008
- } finally {
3009
- if (b.isConnected) { b.disabled = false; b.textContent = old; }
3010
- }
3011
- }
3012
- });
3013
-
3014
2536
  $('#brokers-trade').addEventListener('click', async (e) => {
3015
2537
  const fltBtn = e.target.closest('button[data-hfilter]');
3016
2538
  if (fltBtn) {
@@ -3074,6 +2596,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
3074
2596
  const fold = e.target.closest('[data-brk-fold]');
3075
2597
  if (fold) {
3076
2598
  const fid = fold.dataset.brkFold;
2599
+ if (editingBrokerId === fid) stopBrokerEdit();
3077
2600
  openBrokerId = openBrokerId === fid ? null : fid;
3078
2601
  saveNavPatch({ openBrokerId });
3079
2602
  renderBrokers();
@@ -3083,19 +2606,30 @@ $('#brokers-trade').addEventListener('click', async (e) => {
3083
2606
  const id = b.dataset.bid;
3084
2607
  const act = b.dataset.btrade;
3085
2608
  if (act === 'cfg') {
3086
- const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
3087
- switchTab('settings');
3088
- showSettingsSec('brokers');
3089
- if (acc) {
3090
- startBrokerEdit(acc);
3091
- setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
2609
+ if (editingBrokerId === id) {
2610
+ stopBrokerEdit();
2611
+ } else {
2612
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2613
+ if (acc) startBrokerEdit(acc);
3092
2614
  }
3093
2615
  return;
3094
2616
  }
3095
2617
  b.disabled = true;
3096
2618
  const old = b.textContent;
3097
2619
  try {
3098
- if (act === 'sync') {
2620
+ if (act === 'pause') {
2621
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2622
+ if (acc) {
2623
+ const currentlyPaused = acc.autoSchedule === false || acc.mode === 'readonly';
2624
+ const nextAuto = currentlyPaused;
2625
+ const nextMode = currentlyPaused && acc.mode === 'readonly' ? 'confirm' : acc.mode;
2626
+ state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, {
2627
+ method: 'PUT',
2628
+ body: { id, autoSchedule: nextAuto, mode: nextMode },
2629
+ });
2630
+ toast(nextAuto ? `▶ Торговля на счёте «${acc.name}» возобновлена` : `⏸ Счёт «${acc.name}» поставлен на паузу`);
2631
+ }
2632
+ } else if (act === 'sync') {
3099
2633
  if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
3100
2634
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
3101
2635
  toast('Портфель обновлён');
@@ -3123,6 +2657,12 @@ $('#brokers-trade').addEventListener('click', async (e) => {
3123
2657
  toast('Заявки отправлены брокеру');
3124
2658
  } else if (act === 'rej') {
3125
2659
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reject`);
2660
+ } else if (act === 'cancel-ord') {
2661
+ const orderId = b.dataset.oid || 'all';
2662
+ b.textContent = 'Снимаем…';
2663
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/cancel-order`, { orderId });
2664
+ await post(`/api/brokers/${encodeURIComponent(id)}/prepare`).then((st) => { state.brokers = st; }).catch(() => {});
2665
+ toast(orderId === 'all' ? 'Все активные заявки сняты со стакана' : 'Заявка снята со стакана');
3126
2666
  }
3127
2667
  renderBrokers();
3128
2668
  } catch (err) {
@@ -3160,7 +2700,6 @@ function connectWs() {
3160
2700
  const m = JSON.parse(ev.data);
3161
2701
  switch (m.type) {
3162
2702
  case 'hello':
3163
- renderLive(m.live);
3164
2703
  refreshForecastIfStale();
3165
2704
  // если исследование идёт на сервере (запущено раньше или Стратегом) — показать кнопку отмены
3166
2705
  if (!state.active && m.running?.length) {
@@ -3195,13 +2734,11 @@ function connectWs() {
3195
2734
  case 'settings:update':
3196
2735
  state.settings = m.settings;
3197
2736
  fillForm(m.settings);
3198
- loadHistory().catch(() => {});
3199
2737
  if (m.source === 'ai') {
3200
- 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'); } }
3201
2738
  toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
3202
2739
  }
3203
2740
  break;
3204
- case 'journal:update': loadJournal().catch(() => {}); break;
2741
+ case 'journal:update': break;
3205
2742
  case 'research:start':
3206
2743
  state.active = m.runId;
3207
2744
  resetLiveTrain(m.runId);
@@ -3246,28 +2783,30 @@ function connectWs() {
3246
2783
  loadRuns(m.runId);
3247
2784
  break;
3248
2785
  case 'profiles:progress':
3249
- state.profilesBusy = true;
3250
- $('#btn-profiles').disabled = true;
3251
- $('#profiles-msg').textContent = `Составляем описания компаний: ${m.done} из ${m.total}`;
3252
- updateTabBadges();
3253
- break;
3254
2786
  case 'profiles:done':
3255
- state.profilesBusy = false;
3256
- $('#btn-profiles').disabled = false;
3257
- state.profiles = null; // перечитаем с сервера
3258
- updateTabBadges();
3259
- loadProfilesData().then(() => { if (!$('#tab-map').hidden) drawMap(); });
3260
- toast(m.error ? `Описания компаний: ошибка — ${m.error}` : `Описания компаний готовы (${m.count})`, m.error ? 'error' : 'info');
3261
2787
  break;
3262
- case 'paper:step': {
3263
- const s = m.step;
3264
- liveLine(`p-${s.ts}`, `Живая проверка · ${fmtTs(s.ts)}: ${s.decisions.length ? s.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}`);
3265
- const keys = [...liveEls.keys()].filter((k) => k.startsWith('p-'));
3266
- for (const k of keys.slice(0, -5)) dropLive(k);
2788
+ case 'paper:step':
2789
+ case 'paper:status':
2790
+ case 'paper:started':
2791
+ case 'paper:stopped':
2792
+ case 'paper:fill':
3267
2793
  break;
3268
- }
3269
- case 'news:update': if (!$('#tab-now').hidden) loadNews(); break;
2794
+ case 'news:update': loadNews(); break;
3270
2795
  case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
2796
+ case 'trading:pulse': {
2797
+ if (m.kind === 'broker_calc') {
2798
+ triggerTradingPulse(m.text || `Пересчёт заявок брокера…`, 3000);
2799
+ } else if (m.kind === 'broker_done') {
2800
+ triggerTradingPulse(`Заявки рассчитаны (${m.ordersCount || 0})`, 2200);
2801
+ } else if (m.kind === 'broker_sync') {
2802
+ triggerTradingPulse(`Портфель «${m.name || 'брокера'}» синхронизирован`, 1800);
2803
+ } else if (m.kind === 'train') {
2804
+ triggerTradingPulse(m.text || 'Расчёт прогноза…', 2500);
2805
+ } else if (m.kind === 'radar') {
2806
+ triggerTradingPulse(`Радар обновлён · сигналов: ${m.signals ?? 0}`, 1600);
2807
+ }
2808
+ break;
2809
+ }
3271
2810
  case 'stability:start':
3272
2811
  stabilityUi(m.runId);
3273
2812
  setProgress(2, `Проверка устойчивости эксперимента №${m.runId}…`);
@@ -3288,9 +2827,6 @@ function connectWs() {
3288
2827
  stabilityUi(null);
3289
2828
  toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
3290
2829
  break;
3291
- case 'paper:status': renderLive(m.status); break;
3292
- case 'paper:started': case 'paper:stopped': api('/api/live/status').then(renderLive); break;
3293
- case 'paper:fill': toast(`Живая проверка: куплено ${m.position.symbol}`); break;
3294
2830
  case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
3295
2831
  }
3296
2832
  };
@@ -3399,21 +2935,22 @@ async function renderCloudPane() {
3399
2935
  }
3400
2936
 
3401
2937
  const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3402
- ? 'без ограничений суммы'
2938
+ ? 'по согласованию / без ограничений'
3403
2939
  : u.limits?.maxCapitalPerAccountRub > 0
3404
- ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
3405
- : 'только демо-торговля';
2940
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
2941
+ : 'подписка не активна';
3406
2942
  const retLimStr = u.limits?.maxAnnualReturnPct > 0
3407
2943
  ? `до ${u.limits.maxAnnualReturnPct}% годовых`
3408
- : 'без ограничений (полная мощность VIP)';
2944
+ : (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
3409
2945
  const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
2946
+ updateTabBadges();
3410
2947
 
3411
2948
  box.innerHTML = `
3412
2949
  <div class="srow sblock">
3413
2950
  <div class="stxt">
3414
2951
  <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>
3415
2952
  <div class="hint">
3416
- Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Предел доходности стратегии: <b>${retLimStr}</b> · Виртуальных счетов: до <b>${u.limits?.maxVirtualAccounts ?? 1}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
2953
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Целевая доходность: <b>${retLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3417
2954
  </div>
3418
2955
  </div>
3419
2956
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
@@ -3425,10 +2962,9 @@ async function renderCloudPane() {
3425
2962
  <div class="stxt">
3426
2963
  <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
3427
2964
  <div class="hint">
3428
- • <b>«Старт» (0 ₽)</b> — тестирование стратегий любой сложности без реальной торговли;<br>
3429
- • <b>«Инвестор» (3 900 ₽/мес)</b> — до 2 брокерских счетов (до 1 млн ₽ на каждом), торговля «Только в рост (без шорта)», до 50% годовых;<br>
3430
- • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов (до 10 млн ₽ на каждом), «В рост и в падение (с шортом)», до 100% годовых;<br>
3431
- • <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
2965
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)», задавленная доходность до 60% годовых;<br>
2966
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)», задавленная доходность до 120% годовых;<br>
2967
+ • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами, задавленная доходность до 240% годовых.
3432
2968
  </div>
3433
2969
  </div>
3434
2970
  <div class="row-gap" style="flex-wrap:wrap">
@@ -3436,7 +2972,7 @@ async function renderCloudPane() {
3436
2972
  <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
3437
2973
  <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
3438
2974
  <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
3439
- <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Запросить VIP →</a>
2975
+ <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Согласовать VIP →</a>
3440
2976
  </div>
3441
2977
  </div>
3442
2978
  <div class="srow sblock">
@@ -3520,7 +3056,6 @@ function applyTheme(light) {
3520
3056
  };
3521
3057
  eqChart.applyOptions(chartOpts);
3522
3058
  icChart.applyOptions(chartOpts);
3523
- if (!$('#tab-map').hidden) drawMap();
3524
3059
  }
3525
3060
  $('#btn-theme')?.addEventListener('click', () => {
3526
3061
  const next = !document.body.classList.contains('light');
@@ -3566,14 +3101,13 @@ document.addEventListener('keydown', (e) => {
3566
3101
  // ---------- старт и восстановление сохранённого места в интерфейсе ----------
3567
3102
  (async function init() {
3568
3103
  state.config = await api('/api/config');
3104
+ updateTabBadges();
3569
3105
  buildForm();
3570
3106
  state.settings = await api('/api/settings');
3571
3107
  fillForm(state.settings);
3572
- loadHistory().catch(() => {});
3573
3108
  connectWs();
3574
- renderLive(await api('/api/live/status'));
3575
3109
  loadBrokers().catch(() => {});
3576
- loadJournal().catch(() => {});
3110
+ loadNews().catch(() => {});
3577
3111
  renderCloudPane().catch(() => {});
3578
3112
  const urlParams = new URLSearchParams(location.search);
3579
3113
  if (urlParams.get('payment_check') === '1') {
@@ -3583,16 +3117,16 @@ document.addEventListener('keydown', (e) => {
3583
3117
  }).catch(() => {});
3584
3118
  }
3585
3119
  const savedNav = loadSavedNav();
3586
- const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
3587
- await loadRuns(initRunId);
3588
-
3589
- // Восстанавливаем открытые подразделы и главную вкладку, где находился пользователь
3590
- if (savedNav.nowSec) showNowSec(savedNav.nowSec);
3591
- if (savedNav.settingsSec) showSettingsSec(savedNav.settingsSec);
3592
- if (savedNav.mapSec) showMapSec(savedNav.mapSec).catch(() => {});
3593
- if (savedNav.tab && ['overview', 'now', 'map', 'settings'].includes(savedNav.tab) && !urlParams.get('run')) {
3594
- switchTab(savedNav.tab);
3120
+ if (isOwnerUser()) {
3121
+ const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
3122
+ await loadRuns(initRunId);
3123
+ }
3124
+ if (urlParams.get('run') && isOwnerUser()) {
3125
+ showMainSec('overview');
3126
+ } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3127
+ showMainSec(savedNav.mainSec);
3128
+ } else {
3129
+ showMainSec('brokers');
3595
3130
  }
3596
- // В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
3597
3131
  setTimeout(() => refreshForecastIfStale(), 600);
3598
3132
  })().catch((e) => toast(e.message, 'error'));