neuroalpha 0.3.15 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -103,7 +103,6 @@ function setChatClosed(closed) {
103
103
  try {
104
104
  eqChart.timeScale().fitContent();
105
105
  icChart.timeScale().fitContent();
106
- if (!$('#tab-map')?.hidden) drawMap();
107
106
  } catch { /* ignore */ }
108
107
  });
109
108
  }
@@ -132,93 +131,67 @@ $('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
132
131
  sp.classList.remove('drag');
133
132
  document.body.classList.remove('dragging');
134
133
  localStorage.setItem('chatWidth', getComputedStyle(document.documentElement).getPropertyValue('--chat-w').trim());
135
- if (!$('#tab-map').hidden) drawMap();
136
134
  });
137
135
  sp.addEventListener('dblclick', () => {
138
136
  document.documentElement.style.removeProperty('--chat-w');
139
137
  localStorage.removeItem('chatWidth');
140
- if (!$('#tab-map').hidden) drawMap();
141
138
  });
142
139
  })();
143
140
 
144
141
  // ======================================================================
145
- // ВКЛАДКИ
142
+ // НАВИГАЦИЯ В ЕДИНОМ РАБОЧЕМ ОКНЕ
146
143
  // ======================================================================
144
+ function isOwnerUser() {
145
+ const u = state.config?.user;
146
+ return Boolean(u?.isOwner || u?.role === 'admin' || u?.plan === 'owner' || u?.limits?.allowResearch);
147
+ }
148
+
147
149
  function updateTabBadges() {
148
- // 1. Эксперименты: общее количество + иконка, если прямо сейчас идёт расчёт или стресс-тест
149
- const ovEl = $('#tstat-overview');
150
- if (ovEl) {
151
- const total = state.runs?.length || 0;
152
- const isRunning = Boolean(state.active || state.stabilityActive || state.runs?.some((r) => r.status === 'running'));
153
- const parts = [];
154
- if (total > 0) parts.push(`<span class="tbadge" title="Всего экспериментов в истории: ${total}">${total}</span>`);
155
- if (isRunning) {
156
- const rId = state.active || state.stabilityActive || state.runs?.find((r) => r.status === 'running')?.id;
157
- parts.push(`<span class="tbadge run" title="Сейчас идёт расчёт${rId ? ` №${rId}` : ''}"><i class="tspin"></i></span>`);
150
+ const ovNav = $('#nnav-overview');
151
+ if (ovNav) {
152
+ const owner = isOwnerUser();
153
+ ovNav.hidden = !owner;
154
+ if (owner) {
155
+ const total = state.runs?.length || 0;
156
+ const isRunning = Boolean(state.active || state.stabilityActive || state.runs?.some((r) => r.status === 'running'));
157
+ ovNav.textContent = total > 0 ? `Эксперименты (${total})${isRunning ? ' ⟳' : ''}` : 'Эксперименты';
158
158
  }
159
- ovEl.innerHTML = parts.join('');
160
159
  }
161
-
162
- // 2. Торговля: сколько виртуальных и реальных брокерских счетов
163
- const nowEl = $('#tstat-now');
164
- if (nowEl) {
165
- const vAccs = state.live?.accounts || [];
166
- const vRun = vAccs.filter((a) => a.running).length;
160
+ const brkNav = $('#nnav-brokers');
161
+ if (brkNav) {
167
162
  const bAccs = state.brokers?.accounts || [];
168
- const bActive = bAccs.filter((a) => !a.haltedReason && a.mode !== 'readonly').length;
169
- const parts = [];
170
- if (vAccs.length > 0) {
171
- parts.push(`<span class="tbadge${vRun > 0 ? ' ok' : ''}" title="Виртуальные счета: запущено ${vRun}, всего ${vAccs.length}">${vRun > 0 ? `▶ ${vRun}` : vAccs.length} вирт.</span>`);
172
- }
173
- if (bAccs.length > 0) {
174
- parts.push(`<span class="tbadge${bActive > 0 ? ' ok' : ''}" title="Брокерские счета: подключено ${bAccs.length}">${bAccs.length} реал.</span>`);
175
- }
176
- nowEl.innerHTML = parts.join('');
177
- }
178
-
179
- // 3. Карта и журнал: сколько записей в журнале гипотез (+ индикатор обновления профилей)
180
- const mapEl = $('#tstat-map');
181
- if (mapEl) {
182
- const jCount = state.journalCount ?? 0;
183
- const parts = [];
184
- if (jCount > 0) parts.push(`<span class="tbadge" title="Записей в журнале гипотез: ${jCount}">${jCount}</span>`);
185
- if (state.profilesBusy) parts.push(`<span class="tbadge run" title="Составляются описания компаний"><i class="tspin"></i></span>`);
186
- mapEl.innerHTML = parts.join('');
187
- }
188
-
189
- // 4. Настройки: сколько параметров изменено относительно умолчаний
190
- const setEl = $('#tstat-settings');
191
- if (setEl && state.settings && state.config?.defaults) {
192
- const d = state.config.defaults;
193
- const shownKeys = (state.config.params || []).filter((p) => !p.hidden).map((p) => p.key);
194
- const diff = shownKeys.filter((k) => state.settings[k] !== undefined && JSON.stringify(state.settings[k]) !== JSON.stringify(d[k])).length;
195
- setEl.innerHTML = diff > 0 ? `<span class="tbadge" title="Изменено настроек от значений по умолчанию: ${diff}">${diff}</span>` : '';
163
+ brkNav.textContent = bAccs.length ? `Брокерские счета (${bAccs.length})` : 'Брокерские счета';
196
164
  }
197
165
  }
198
166
 
199
- function switchTab(t) {
200
- if (t === 'journal') t = 'map';
201
- $('#tabs').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.tab === t));
202
- document.querySelectorAll('.right > .tab').forEach((el) => { el.hidden = el.id !== `tab-${t}`; });
203
- saveNavPatch({ tab: t });
204
- if (t === 'overview') {
167
+ function showMainSec(sec) {
168
+ if (sec === 'now') sec = 'brokers';
169
+ if (sec === 'settings' || sec === 'g1') sec = 'g0';
170
+ if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
171
+ document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
172
+ document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
173
+ if ($('#main-workspace')) $('#main-workspace').scrollTop = 0;
174
+ saveNavPatch({ mainSec: sec });
175
+ if (sec === 'overview') {
205
176
  requestAnimationFrame(() => {
206
177
  eqChart.timeScale().fitContent();
207
178
  icChart.timeScale().fitContent();
208
179
  });
209
- }
210
- if (t === 'settings') loadHistory().catch(() => {});
211
- if (t === 'now') { refreshForecastIfStale(); loadNews(); }
212
- if (t === 'map') {
213
- setupMapSlider();
214
- drawMap();
215
- loadJournal();
216
- if (state.mapMode === 'text' && !state.profiles) loadProfilesData().then(drawMap);
180
+ } else if (sec === 'forecast') {
181
+ refreshForecastIfStale();
182
+ } else if (sec === 'news') {
183
+ loadNews();
184
+ } else if (sec === 'cloud') {
185
+ renderCloudPane().catch(() => {});
217
186
  }
218
187
  }
219
- $('#tabs').addEventListener('click', (e) => {
220
- const b = e.target.closest('button[data-tab]');
221
- if (b) switchTab(b.dataset.tab);
188
+ const switchTab = (t) => showMainSec(t);
189
+ const showNowSec = (s) => showMainSec(s);
190
+ const showSettingsSec = (s) => showMainSec(s);
191
+
192
+ $('#main-nav')?.addEventListener('click', (e) => {
193
+ const b = e.target.closest('button[data-sec]');
194
+ if (b) showMainSec(b.dataset.sec);
222
195
  });
223
196
 
224
197
  // ======================================================================
@@ -261,7 +234,7 @@ const RUN_PARTS = [
261
234
  ['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
262
235
  ['modelTrainDays', (v) => `Окно: ${v}дн`],
263
236
  ['modelHistoryDays', (v) => `История: ${v}дн`],
264
- ['modelEmbedDim', (v) => `Эмбд.: ${v}`],
237
+ ['modelEmbedDim', (v) => `Эмбд.: ${Number(v) === 0 ? 'Авто' : v}`],
265
238
  ['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
266
239
  ['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
267
240
  ];
@@ -325,12 +298,10 @@ async function loadRuns(selectId) {
325
298
 
326
299
  async function openRun(id) {
327
300
  state.run = await api(`/api/runs/${id}`);
328
- $('#run-select').value = String(id);
301
+ if ($('#run-select')) $('#run-select').value = String(id);
329
302
  saveNavPatch({ runId: Number(id) });
330
303
  syncUiAvailability();
331
304
  renderOverview();
332
- setupMapSlider();
333
- if (!$('#tab-map').hidden) drawMap();
334
305
  }
335
306
 
336
307
  $('#run-select').addEventListener('change', (e) => e.target.value && openRun(Number(e.target.value)));
@@ -445,37 +416,16 @@ function syncSettingsDependencies() {
445
416
 
446
417
  const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
447
418
  if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
448
- }
449
- $('#btn-to-live').addEventListener('click', async () => {
450
- const r = state.run;
451
- if (!r || r.status !== 'done') return;
452
- const rawCap = prompt(`Стартовая сумма виртуального счёта (по настройкам эксперимента №${r.id}), ₽:`, String(r.params?.capital || 1000000));
453
- if (rawCap === null) return;
454
- const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
455
- try {
456
- const res = await post('/api/live/start', { baseRunId: r.id, capital });
457
- renderLive(res.status);
458
- toast(`Виртуальный счёт №${res.runId} запущен по эксперименту №${r.id}`, 'info', {
459
- label: 'Открыть',
460
- fn: () => { switchTab('now'); showNowSec('virtual'); },
461
- });
462
- } catch (e) { toast(e.message, 'error'); }
463
- });
464
- // «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
465
- const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
466
- function runSettingsDiff(r = state.run) {
467
- if (!r?.params || !state.settings) return { entries: [], lines: [] };
468
- const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
469
- const diffKeys = APPLY_KEYS.filter((k) => pv(k) !== undefined && JSON.stringify(pv(k)) !== JSON.stringify(state.settings[k]));
470
- return {
471
- entries: diffKeys.map((k) => [k, pv(k)]),
472
- lines: diffKeys.map((k) => `${labelOf(k)}: ${state.settings[k] ?? '—'} → ${pv(k) ?? '—'}`),
473
- };
419
+ if ($('#btn-emb-auto')) {
420
+ const isAutoEmb = Number(s.modelEmbedDim ?? 0) === 0;
421
+ $('#btn-emb-auto').classList.toggle('primary', isAutoEmb);
422
+ $('#btn-emb-auto').textContent = isAutoEmb ? '✓ Авто (0)' : '⚡ Авто (0)';
423
+ }
474
424
  }
475
425
  function syncShowcaseBtn() {
476
426
  const btn = $('#btn-showcase');
477
427
  if (!btn) return;
478
- const isAdmin = state.config?.user?.role === 'admin';
428
+ const isAdmin = isOwnerUser();
479
429
  const r = state.run;
480
430
  btn.hidden = !isAdmin || !r || r.status !== 'done';
481
431
  if (!btn.hidden) {
@@ -497,27 +447,7 @@ $('#btn-showcase')?.addEventListener('click', async () => {
497
447
 
498
448
  function syncApplyBtn() {
499
449
  syncShowcaseBtn();
500
- const btn = $('#btn-apply');
501
- if (!btn) return;
502
- const isDone = state.run?.status === 'done' && state.run.id !== state.active;
503
- const { lines } = runSettingsDiff();
504
- btn.hidden = !isDone || !lines.length;
505
- if (lines.length) {
506
- btn.textContent = `✓ Применить эти настройки (${lines.length})`;
507
- btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
508
- }
509
450
  }
510
- $('#btn-apply').addEventListener('click', async () => {
511
- const r = state.run;
512
- if (!r) return;
513
- const { entries, lines } = runSettingsDiff(r);
514
- if (!lines.length) { syncApplyBtn(); return; }
515
- if (!confirm(`Применить настройки из эксперимента №${r.id}?\n\n${lines.join('\n')}`)) return;
516
- await saveSettings(Object.fromEntries(entries));
517
- fillForm(state.settings);
518
- syncApplyBtn();
519
- toast(`Настройки из эксперимента №${r.id} применены`);
520
- });
521
451
 
522
452
  // Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
523
453
  function calcPeriodStats(period, returnPct, marketPct) {
@@ -715,7 +645,17 @@ function renderLiveTraining() {
715
645
  ].join('');
716
646
  }
717
647
 
648
+ const isCalcStage = st.stage === 'calc';
649
+ const calcBanner = isCalcStage ? `<div class="ld-calc-banner">
650
+ <div class="ld-calc-spin"></div>
651
+ <div style="min-width:0;flex:1">
652
+ <div style="font-weight:600;font-size:13.5px;color:var(--accent)">Идёт финальный пересчёт сделок, кривых капитала и фазовой устойчивости…</div>
653
+ <div class="small muted" style="margin-top:2px">${esc(st.text || 'Симуляция портфеля и расчёт метрик…')}</div>
654
+ </div>
655
+ </div>` : '';
656
+
718
657
  empty.innerHTML = `
658
+ ${calcBanner}
719
659
  <div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
720
660
  <div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
721
661
  <div class="kpi"><div class="v ${cls(meanIc)}">${meanIc > 0 ? '+' : ''}${fmtNum(meanIc, 3)}</div><div class="l">среднее совпадение (IC)</div><div class="sub">шагов в плюсе: ${posFolds} из ${ics.length} (${ics.length ? Math.round((posFolds / ics.length) * 100) : 0}%)</div></div>
@@ -937,14 +877,49 @@ $('#btn-cancel').addEventListener('click', async () => {
937
877
  // СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
938
878
  // ======================================================================
939
879
  let forecastBusy = false;
880
+ let pulseClearTimer = null;
940
881
  const prevClientExp = new Map();
941
882
  const prevClientRank = new Map();
942
883
 
884
+ function triggerTradingPulse(text = 'Обновление котировок и сигналов…', durationMs = 2400) {
885
+ state.tradingPulseActive = true;
886
+ state.tradingPulseText = text;
887
+ updateTabBadges();
888
+ const badge = $('#fc-live-badge');
889
+ if (badge) {
890
+ badge.innerHTML = `<i class="tspin" style="margin-right:5px;vertical-align:-1px"></i>${esc(text)}`;
891
+ badge.style.background = 'rgba(59, 130, 246, 0.18)';
892
+ badge.style.borderColor = 'var(--accent)';
893
+ badge.style.color = 'var(--accent)';
894
+ }
895
+ clearTimeout(pulseClearTimer);
896
+ pulseClearTimer = setTimeout(() => {
897
+ state.tradingPulseActive = false;
898
+ updateTabBadges();
899
+ if (badge && state.forecast) {
900
+ const liveTimeStr = new Date((state.forecast.liveTs || state.forecast.at || Date.now()) + MSK).toISOString().slice(11, 19);
901
+ badge.textContent = `● живой радар · ${liveTimeStr}`;
902
+ badge.style.background = '';
903
+ badge.style.borderColor = '';
904
+ badge.style.color = '';
905
+ }
906
+ }, durationMs);
907
+ }
908
+
943
909
  async function loadForecast(force, silent = false) {
944
910
  if (forecastBusy) return;
945
911
  forecastBusy = true;
912
+ triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
946
913
  const box = $('#forecast');
947
- if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
914
+ if (!silent && !state.forecast) {
915
+ box.innerHTML = `<div class="ld-calc-banner" style="margin:4px 0">
916
+ <div class="ld-calc-spin"></div>
917
+ <div>
918
+ <div style="font-weight:600;color:var(--accent)">Синхронизация живых котировок Мосбиржи и расчёт матрицы внимания…</div>
919
+ <div class="small muted">Занимает ~1–2 секунды (чтение локального кэша свечей и живого стакана TQBR)</div>
920
+ </div>
921
+ </div>`;
922
+ }
948
923
  try {
949
924
  const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
950
925
  // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
@@ -968,8 +943,8 @@ async function loadForecast(force, silent = false) {
968
943
  const dot = (n) => (n ? ` <span class="${n.tone >= 0.15 ? 'pos' : n.tone <= -0.15 ? 'neg' : 'muted'}" title="${esc(`${n.top}${n.count > 1 ? ` (и ещё ${n.count - 1})` : ''} — ${n.src}, ${fmtTs(n.ts)}`)}">●</span>` : '');
969
944
  const arrowOf = (r) => {
970
945
  const d = Number(r.deltaPct || 0);
971
- if (d > 0.002) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">▲ +${fmtNum(d, 2)}%</span>`;
972
- if (d < -0.002) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">▼ ${fmtNum(d, 2)}%</span>`;
946
+ if (d >= 0.005) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">+${fmtNum(d, 2)}% ▲</span>`;
947
+ if (d <= -0.005) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">${fmtNum(d, 2)}% ▼</span>`;
973
948
  return '<span class="muted small" title="Без изменений">—</span>';
974
949
  };
975
950
  const rankBadge = (r) => {
@@ -978,19 +953,30 @@ async function loadForecast(force, silent = false) {
978
953
  if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
979
954
  return '';
980
955
  };
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>`;
956
+ const colgroup = `<colgroup><col style="width:33%"><col style="width:12%"><col style="width:12%"><col style="width:11%"><col style="width:12%"><col style="width:20%"></colgroup>`;
957
+ const sigBadge = (r) => {
958
+ if (r.signal) return '<span class="tag ok">купить</span>';
959
+ if (r.shortSignal) return '<span class="tag no">шорт</span>';
960
+ if (r.shortBlockedByBroker) return '<span class="tag" title="Ожидание пробило порог шорта, но брокер запрещает короткие продажи по этой бумаге (shortEnabledFlag = false)">без шорта</span>';
961
+ return '<span class="muted small">—</span>';
962
+ };
982
963
  const row = (r) => `<tr>
983
964
  <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
984
965
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
985
966
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
986
967
  <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>
968
+ <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
988
969
  <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
989
970
  </tr>`;
971
+ const buyThr = f.buyThresholdPct ?? f.thresholdPct;
972
+ const shortThr = f.shortThresholdPct ?? f.thresholdPct;
973
+ const shortRows = [
974
+ ...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
975
+ ...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
976
+ ].slice(0, 4);
990
977
  box.innerHTML = `<table style="table-layout:fixed">${colgroup}<thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
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();
978
+ <table style="margin-top:14px;table-layout:fixed">${colgroup}<thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${shortRows.map(row).join('')}</tbody></table>
979
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
994
980
  if (state.brokers?.accounts?.length) renderBrokers();
995
981
  } catch (e) {
996
982
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -1008,424 +994,26 @@ function refreshForecastIfStale() {
1008
994
  loadForecast(false, true);
1009
995
  }
1010
996
  }
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
- }
1117
-
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
- });
997
+ setInterval(() => { if (!document.hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
1220
998
 
1221
- // ---------- новости: только для прогноза «сейчас» и живой проверки ----------
999
+ // ---------- новости ----------
1222
1000
  async function loadNews() {
1223
1001
  try {
1224
1002
  const { items } = await api('/api/news?limit=20');
1225
1003
  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>';
1004
+ if ($('#news-list')) {
1005
+ $('#news-list').innerHTML = items.length
1006
+ ? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}">${s.symbol}</b>`).join(', ')} · ` : ''}<a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`).join('')
1007
+ : '<span class="muted">Свежих размеченных новостей пока нет.</span>';
1008
+ }
1229
1009
  } catch { /* ignore */ }
1230
1010
  }
1231
1011
 
1232
1012
  // ======================================================================
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
- // ЖУРНАЛ И НАСТРОЙКИ
1013
+ // НАСТРОЙКИ (ИЗДЕРЖКИ И НОВОСТИ)
1405
1014
  // ======================================================================
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
1015
  const GROUPS = [
1426
- ['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
1427
- ['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
1428
- ['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
1016
+ ['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
1429
1017
  ];
1430
1018
  function buildForm() {
1431
1019
  const field = (p) => {
@@ -1436,45 +1024,13 @@ function buildForm() {
1436
1024
  return `<div class="srow" data-key="${p.key}"><div class="stxt"><div class="slbl">${esc(p.label)}<button type="button" class="mark" data-def="${p.key}" hidden title="Отличается от умолчания — нажмите, чтобы вернуть">изменено</button></div><div class="hint">${esc(p.hint)}</div></div>${input}</div>`;
1437
1025
  };
1438
1026
  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>`;
1027
+ const curSec = document.querySelector('#main-nav [data-sec].active')?.dataset.sec || 'brokers';
1441
1028
  $('#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>` : '';
1029
+ const items = keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean);
1030
+ const secKey = `g${i}`;
1031
+ return items.length ? `<section class="ssec" data-sec="${secKey}"${curSec === secKey ? '' : ' hidden'}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}</div></section>` : '';
1445
1032
  }).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
- });
1033
+ }
1478
1034
  function fillForm(s) {
1479
1035
  for (const el of $('#params').elements) {
1480
1036
  if (!el.name || el === document.activeElement) continue;
@@ -1510,38 +1066,6 @@ $('#params').addEventListener('click', async (e) => {
1510
1066
  await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
1511
1067
  fillForm(state.settings);
1512
1068
  });
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'); }
1544
- });
1545
1069
 
1546
1070
  // ======================================================================
1547
1071
  // ЧАТ СО СТРАТЕГОМ
@@ -1551,6 +1075,7 @@ const TOOL_LABELS = {
1551
1075
  run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
1552
1076
  journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
1553
1077
  set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
1078
+ broker_onboarding_guide: 'Смотрит инструкцию по брокеру', discover_broker_accounts: 'Ищет субсчета по токену', connect_broker_account: 'Подключение брокерского счёта',
1554
1079
  };
1555
1080
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1556
1081
  const feed = $('#feed');
@@ -1577,6 +1102,8 @@ function argsPreview(name, a) {
1577
1102
  if (name === 'compare_runs') return `№${a.a} и №${a.b}`;
1578
1103
  if (name === 'journal_add') return a.hypothesis || '';
1579
1104
  if (name === 'market_neighbors') return a.symbol || '';
1105
+ if (name === 'broker_onboarding_guide') return BROKER_LABELS[a.broker] || a.broker || '';
1106
+ if (name === 'connect_broker_account') return `${BROKER_LABELS[a.broker] || a.broker || ''}${a.name ? ` · ${a.name}` : ''}${a.accountId ? ` (${a.accountId})` : ''}`;
1580
1107
  return '';
1581
1108
  }
1582
1109
 
@@ -1588,7 +1115,7 @@ function actHtml(m, call) {
1588
1115
  const w = state.chat.state?.waiting;
1589
1116
  const waiting = status === 'pending' && w && w.msgId === m.id && w.callId === call.id;
1590
1117
  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);
1118
+ const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop', 'connect_broker_account'].includes(call.function.name);
1592
1119
  if (!big && !waiting) {
1593
1120
  return `<div class="ev">${esc(TOOL_LABELS[call.function.name] || call.function.name)}${prev ? `: ${esc(prev.split('\n')[0])}` : ''} <span class="st ${status}">· ${STATUS[status] || status}</span></div>`;
1594
1121
  }
@@ -2354,10 +1881,9 @@ let brokerPollBusy = false;
2354
1881
  let lastBrokerPollTs = 0;
2355
1882
 
2356
1883
  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;
1884
+ if (brokerPollBusy || document.hidden || editingBrokerId) return;
1885
+ const brokersSecHidden = $('#brokers-trade-box')?.hidden;
1886
+ if (brokersSecHidden) return;
2361
1887
  const accs = state.brokers?.accounts || [];
2362
1888
  if (!accs.length) return;
2363
1889
  const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
@@ -2393,58 +1919,23 @@ async function loadBrokers() {
2393
1919
 
2394
1920
  function renderBrokers() {
2395
1921
  const accs = state.brokers?.accounts || [];
2396
- const navBtn = $('#snav-brokers');
2397
- if (navBtn) navBtn.textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
2398
1922
  if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
2399
1923
  updateTabBadges();
2400
1924
 
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>';
1925
+ // Перед перерисовкой карточек возвращаем форму настроек в верхний слот, чтобы она не уничтожилась при замене innerHTML
1926
+ const formBox = $('#broker-add-box');
1927
+ const formSlot = $('#broker-form-slot');
1928
+ if (formBox && formSlot && formBox.parentElement !== formSlot) {
1929
+ formSlot.appendChild(formBox);
2438
1930
  }
2439
1931
 
2440
- // 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
2441
1932
  const tradeEl = $('#brokers-trade');
2442
1933
  if (!tradeEl) return;
2443
1934
  if (!accs.length) {
2444
- tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
1935
+ tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
2445
1936
  return;
2446
1937
  }
2447
- if (!accs.some((a) => a.id === openBrokerId)) {
1938
+ if (openBrokerId === undefined) {
2448
1939
  openBrokerId = accs[0].id;
2449
1940
  }
2450
1941
  const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
@@ -2500,7 +1991,12 @@ function renderBrokers() {
2500
1991
  <div class="brk-alloc-legend">${allocSegments.map((s) => `<span><i style="background:${s.color}"></i><b>${esc(s.label)}</b> — ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)</span>`).join('')}</div>
2501
1992
  </div>` : '';
2502
1993
 
2503
- // Считаем сколько ₽ в стратегии и сколько ₽ защищено
1994
+ // Считаем стартовый лимит и реинвестированную прибыль сверх лимита
1995
+ const initBaseRub = a.initialTotalRub > 0 ? a.initialTotalRub : (a.equityHistory?.[0]?.totalRub > 0 ? a.equityHistory[0].totalRub : (ls?.totalRub || a.maxCapitalRub || 100000));
1996
+ const reinvProfitRub = ls?.totalRub > 0 ? Math.max(0, Math.round(ls.totalRub - Math.max(initBaseRub, a.maxCapitalRub || 100000))) : 0;
1997
+ const capSpecHtml = reinvProfitRub > 0
1998
+ ? `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> <span class="pos">(+ реинвестировано прибыли +${fmtNum(reinvProfitRub, 0)} ₽ → в работе <b>${fmtNum((a.maxCapitalRub || 0) + reinvProfitRub, 0)} ₽</b>)</span>`
1999
+ : `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> (+ реинвестирование прибыли)`;
2504
2000
  let strategyRub = 0, protectedRub = 0;
2505
2001
  for (const p of ls?.positions || []) {
2506
2002
  const aq = algoQtyOf(p);
@@ -2511,8 +2007,16 @@ function renderBrokers() {
2511
2007
  const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
2512
2008
 
2513
2009
  // Вкладка 1: Активы портфеля
2010
+ const fmtOwnership = (p) => {
2011
+ if (!(p.issueSize > 0) || !(p.qty > 0)) return '<span class="muted small">—</span>';
2012
+ const pct = (p.qty / p.issueSize) * 100;
2013
+ const dec = pct >= 0.01 ? 4 : pct >= 0.0001 ? 6 : 8;
2014
+ const pctStr = pct.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: dec });
2015
+ const tip = `Доля владения в компании ${p.name || p.symbol}: ${fmtNum(p.qty, 0)} из ${fmtNum(p.issueSize, 0)} акций всего выпуска на Мосбирже`;
2016
+ return `<span title="${esc(tip)}" style="white-space:nowrap"><b>${pctStr}%</b></span>`;
2017
+ };
2514
2018
  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) => {
2019
+ ? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num" title="Доля ваших акций в общем выпуске ценных бумаг компании-эмитента на Мосбирже (ISSUESIZE)">Доля владения</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
2516
2020
  const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
2517
2021
  const fcRow = fcRowMap?.get(p.symbol);
2518
2022
  const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
@@ -2520,6 +2024,7 @@ function renderBrokers() {
2520
2024
  <td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
2521
2025
  <td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
2522
2026
  <td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
2027
+ <td class="num">${fmtOwnership(p)}</td>
2523
2028
  <td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
2524
2029
  <td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
2525
2030
  <td class="num ${cls(p.pnlRub)}"><b>${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</b>${p.pnlPct ? ` <span class="small">(${p.pnlPct >= 0 ? '+' : ''}${fmtNum(p.pnlPct, 1)}%)</span>` : ''}</td>
@@ -2530,7 +2035,19 @@ function renderBrokers() {
2530
2035
 
2531
2036
  // Вкладка 2: Торговля и заявки стратегии
2532
2037
  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>`
2038
+ ? `<div class="stab-box" style="margin-bottom:10px">
2039
+ <div class="stab-head">
2040
+ <b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b>
2041
+ <span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
2042
+ </div>
2043
+ <table><thead><tr><th>Бумага</th><th>Направление</th><th class="num">Лотов (исп./всего)</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${ls.activeOrders.map((o) => `<tr>
2044
+ <td><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></td>
2045
+ <td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
2046
+ <td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
2047
+ <td class="num">${fmtNum(o.price, 4)} ₽</td>
2048
+ <td class="num">${fmtNum(o.amountRub, 0)} ₽</td>
2049
+ </tr>`).join('')}</tbody></table>
2050
+ </div>`
2534
2051
  : '';
2535
2052
  let batchHtml = '';
2536
2053
  if (pb) {
@@ -2570,7 +2087,7 @@ function renderBrokers() {
2570
2087
  const rows = [];
2571
2088
  for (const [, dayOps] of byDay) {
2572
2089
  let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
2573
- const symQty = new Map(), symCash = new Map();
2090
+ const symQty = new Map(), symCash = new Map(), symBuyRub = new Map();
2574
2091
  for (const op of dayOps) {
2575
2092
  const pay = Number(op.paymentRub) || 0;
2576
2093
  if (op.kind === 'buy') {
@@ -2579,6 +2096,7 @@ function renderBrokers() {
2579
2096
  if (op.symbol) {
2580
2097
  symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
2581
2098
  symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
2099
+ symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
2582
2100
  }
2583
2101
  } else if (op.kind === 'sell') {
2584
2102
  tradesCnt++;
@@ -2596,19 +2114,22 @@ function renderBrokers() {
2596
2114
  inOutRub += pay;
2597
2115
  }
2598
2116
  }
2599
- // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
2600
- let closedTradePnl = 0, closedSyms = 0;
2117
+ // Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
2118
+ let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
2601
2119
  for (const [sym, netQ] of symQty) {
2602
2120
  if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
2603
2121
  closedTradePnl += symCash.get(sym);
2122
+ closedBaseRub += symBuyRub.get(sym) || 0;
2604
2123
  closedSyms++;
2605
2124
  }
2606
2125
  }
2607
2126
  if (closedSyms > 0) closedTradePnl += feeRub;
2127
+ const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
2608
2128
 
2609
2129
  const summaryParts = [];
2610
2130
  if (closedSyms > 0) {
2611
- summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
2131
+ const pctPart = closedTradePct !== null ? ` (${closedTradePct > 0 ? '+' : ''}${fmtNum(closedTradePct, 2)}%)` : '';
2132
+ summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽${pctPart}</b>`);
2612
2133
  } else if (tradesCnt > 0) {
2613
2134
  const tParts = [];
2614
2135
  if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
@@ -2629,7 +2150,7 @@ function renderBrokers() {
2629
2150
  for (const op of dayOps) {
2630
2151
  rows.push(`<tr>
2631
2152
  <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>
2153
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}" title="${esc(op.title || op.kindLabel)}">${esc(op.kindLabel)}</span></td>
2633
2154
  <td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
2634
2155
  <td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
2635
2156
  <td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
@@ -2727,52 +2248,86 @@ function renderBrokers() {
2727
2248
  ${yearsHtml}`
2728
2249
  : '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
2729
2250
 
2251
+ const isPaused = a.autoSchedule === false || a.mode === 'readonly';
2252
+ const isEditingThis = editingBrokerId === a.id;
2253
+ const collapsedMetricsHtml = !isOpen && ls?.ok
2254
+ ? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b> · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b></span>`
2255
+ : '';
2256
+
2730
2257
  return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2731
2258
  <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
2732
2259
  <div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2733
2260
  <span class="acc-caret">▶</span>
2734
2261
  <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>
2262
+ <span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
2736
2263
  ${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
2737
2264
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2265
+ ${collapsedMetricsHtml}
2738
2266
  </div>
2739
2267
  <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>
2268
+ <button type="button" class="brk-toggle ${!isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
2269
+ ${isOpen && ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
2270
+ ${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
2742
2271
  </div>
2743
2272
  </div>
2744
2273
  <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>
2274
+ <div class="brk-cfg-mount" data-cfg-mount="${esc(a.id)}" ${isEditingThis ? '' : 'hidden'} style="margin-top:12px"></div>
2275
+ <div class="brk-trade-view" ${isEditingThis ? 'hidden' : ''}>
2276
+ <div class="brk-specs">
2277
+ <span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
2278
+ <span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">на паузе</span>'}</b></span>
2279
+ <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2280
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2281
+ <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2282
+ <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2283
+ </div>
2284
+ ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2285
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2286
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2287
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2288
+ <div class="kpi"><div class="v" style="white-space:nowrap">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2289
+ </div>` : ''}
2290
+ ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2291
+ <div class="brk-subtabs">
2292
+ <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
2293
+ <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
2294
+ <button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
2295
+ </div>
2296
+ <div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
2297
+ <div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
2298
+ <div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
2764
2299
  </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
2300
  </div>
2769
2301
  </div>`;
2770
2302
  }).join('');
2303
+
2304
+ // Если сейчас открыто редактирование конкретного счёта, монтируем форму прямо внутрь его карточки!
2305
+ if (editingBrokerId && formBox) {
2306
+ const mount = tradeEl.querySelector(`[data-cfg-mount="${CSS.escape(editingBrokerId)}"]`);
2307
+ if (mount) {
2308
+ mount.appendChild(formBox);
2309
+ formBox.hidden = false;
2310
+ }
2311
+ }
2771
2312
  }
2772
2313
 
2773
- $('#btn-brokers-cfg').addEventListener('click', () => {
2774
- switchTab('settings');
2775
- showSettingsSec('brokers');
2314
+ $('#btn-broker-add')?.addEventListener('click', () => {
2315
+ const formBox = $('#broker-add-box');
2316
+ const formSlot = $('#broker-form-slot');
2317
+ if (editingBrokerId === null && formBox && !formBox.hidden && formBox.parentElement === formSlot) {
2318
+ stopBrokerEdit();
2319
+ return;
2320
+ }
2321
+ stopBrokerEdit();
2322
+ if (formBox && formSlot) {
2323
+ formSlot.appendChild(formBox);
2324
+ formBox.hidden = false;
2325
+ syncBrokerGuideUi();
2326
+ updateBrokerSaveBtnState();
2327
+ if (brokerForm?.elements?.broker?.value === 'tbank' && brokerForm.elements.token.value.trim()) {
2328
+ runBrokerDiscover({ silent: true });
2329
+ }
2330
+ }
2776
2331
  });
2777
2332
 
2778
2333
  const brokerForm = $('#broker-form');
@@ -2787,7 +2342,6 @@ function getBrokerFormSnap() {
2787
2342
  token: f.token.value.trim(),
2788
2343
  mode: f.mode.value,
2789
2344
  legacyMode: f.legacyMode?.value || 'isolate',
2790
- baseRunId: f.baseRunId.value || '',
2791
2345
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2792
2346
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2793
2347
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
@@ -2801,15 +2355,49 @@ function syncBrokerGuideUi() {
2801
2355
  const f = brokerForm?.elements;
2802
2356
  if (!f) return;
2803
2357
  const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2358
+ const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
2804
2359
  if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2805
- if ($('#broker-token-input')) $('#broker-token-input').placeholder = editingBrokerId ? 'не меняется — оставьте пустым' : g.tokenPh;
2360
+ if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
2806
2361
  if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2807
2362
  if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2808
2363
  if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
2364
+ if (!g.canDiscover && $('#broker-discovered')) {
2365
+ $('#broker-discovered').hidden = true;
2366
+ $('#broker-discovered').innerHTML = '';
2367
+ }
2809
2368
  if ($('#broker-guide-sum')) $('#broker-guide-sum').textContent = g.summary;
2810
2369
  if ($('#broker-guide-body')) $('#broker-guide-body').innerHTML = g.html;
2370
+ if ($('#btn-broker-ai-help')) $('#btn-broker-ai-help').textContent = `🤖 Помочь с подключением (${brkName})`;
2811
2371
  }
2812
2372
 
2373
+ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2374
+ const f = brokerForm?.elements;
2375
+ if (!f) return;
2376
+ const brk = f.broker.value || 'tbank';
2377
+ const brkName = BROKER_LABELS[brk] || brk;
2378
+ const tok = f.token.value.trim();
2379
+ const accId = f.accountId.value.trim();
2380
+ const cap = Number(f.maxCapitalRub.value) || 100000;
2381
+ let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
2382
+ if (tok && accId) {
2383
+ promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
2384
+ } else if (tok) {
2385
+ promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
2386
+ } else if (accId) {
2387
+ promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
2388
+ } else {
2389
+ promptText += ` Подскажи, как быстро открыть счёт (если его ещё нет), как включить маржинальную торговлю и выпустить токен API, либо подключи счёт вместе со мной.`;
2390
+ }
2391
+ if (state.chat.state?.mode === 'discuss' && state.chat.state?.tools !== false) {
2392
+ try {
2393
+ state.chat.state = await post('/api/chat/mode', { mode: 'advisor' });
2394
+ renderChatState();
2395
+ } catch { /* ignore */ }
2396
+ }
2397
+ toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
2398
+ await sendChat(promptText);
2399
+ });
2400
+
2813
2401
  function updateBrokerSaveBtnState() {
2814
2402
  const btn = $('#broker-save-btn');
2815
2403
  if (!btn) return;
@@ -2834,88 +2422,137 @@ brokerForm.addEventListener('change', () => {
2834
2422
  updateBrokerSaveBtnState();
2835
2423
  });
2836
2424
 
2425
+ let brokerDiscoverTimer = null;
2426
+
2427
+ async function runBrokerDiscover({ silent = false } = {}) {
2428
+ const btn = $('#btn-broker-discover');
2429
+ const box = $('#broker-discovered');
2430
+ const f = brokerForm?.elements;
2431
+ if (!f || !box) return;
2432
+ if (f.broker.value !== 'tbank') {
2433
+ box.hidden = true;
2434
+ box.innerHTML = '';
2435
+ return;
2436
+ }
2437
+ const tok = f.token.value.trim();
2438
+ if (!tok && !editingBrokerId) {
2439
+ box.hidden = true;
2440
+ box.innerHTML = '';
2441
+ return;
2442
+ }
2443
+ if (btn) btn.disabled = true;
2444
+ const old = btn ? btn.textContent : '';
2445
+ if (btn) btn.textContent = 'Ищем счета…';
2446
+ try {
2447
+ const res = await post('/api/brokers/discover', {
2448
+ id: editingBrokerId || undefined,
2449
+ broker: f.broker.value,
2450
+ token: tok,
2451
+ sandbox: f.sandbox.checked,
2452
+ });
2453
+ const list = res.accounts || [];
2454
+ const curAccId = f.accountId.value.trim();
2455
+ if (!list.length) {
2456
+ box.hidden = false;
2457
+ box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
2458
+ } else {
2459
+ box.hidden = false;
2460
+ box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => {
2461
+ const isSelected = curAccId && a.id === curAccId;
2462
+ return `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid ${isSelected ? 'var(--accent)' : 'var(--border)'};border-radius:6px;background:var(--bg1)">
2463
+ <div>
2464
+ <b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
2465
+ <div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
2466
+ </div>
2467
+ <button type="button" class="${isSelected ? '' : 'primary '}small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">${isSelected ? '✓ Выбран' : 'Выбрать этот счёт'}</button>
2468
+ </div>`;
2469
+ }).join('');
2470
+ }
2471
+ } catch (err) {
2472
+ if (!silent) toast(err.message, 'error');
2473
+ } finally {
2474
+ if (btn) {
2475
+ btn.disabled = false;
2476
+ btn.textContent = old || '🔍 Найти мои счета по токену';
2477
+ }
2478
+ }
2479
+ }
2480
+
2837
2481
  function startBrokerEdit(acc) {
2838
2482
  editingBrokerId = acc.id;
2483
+ openBrokerId = acc.id;
2839
2484
  const f = brokerForm.elements;
2840
2485
  f.id.value = acc.id;
2841
2486
  f.name.value = acc.name;
2842
2487
  f.broker.value = acc.broker;
2843
2488
  f.accountId.value = acc.accountId || '';
2844
- f.token.value = '';
2489
+ f.token.value = acc.token || '';
2845
2490
  f.mode.value = acc.mode || 'confirm';
2846
2491
  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
2492
  f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
2855
2493
  f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
2856
2494
  f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
2857
2495
  f.autoSchedule.checked = acc.autoSchedule !== false;
2858
2496
  f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
2859
2497
  f.sandbox.checked = !!acc.sandbox;
2860
- $('#broker-add-box').open = true;
2861
- $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
2498
+ $('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
2862
2499
  $('#broker-save-btn').textContent = 'Сохранить изменения';
2863
2500
  $('#broker-cancel-btn').hidden = false;
2501
+ if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = false;
2864
2502
  syncBrokerGuideUi();
2865
2503
  brokerInitialSnap = getBrokerFormSnap();
2866
2504
  updateBrokerSaveBtnState();
2505
+ renderBrokers();
2506
+ if (acc.broker === 'tbank' && (acc.token || acc.hasToken)) {
2507
+ runBrokerDiscover({ silent: true });
2508
+ }
2867
2509
  }
2868
2510
 
2869
2511
  function stopBrokerEdit() {
2512
+ const wasEditing = editingBrokerId !== null;
2870
2513
  editingBrokerId = null;
2871
2514
  brokerForm.reset();
2872
2515
  brokerForm.elements.id.value = '';
2873
2516
  if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
2874
2517
  $('#broker-add-sum').textContent = 'Добавить брокерский счёт';
2875
2518
  $('#broker-save-btn').textContent = 'Сохранить счёт';
2876
- $('#broker-cancel-btn').hidden = true;
2519
+ if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = true;
2520
+ const formBox = $('#broker-add-box');
2521
+ const formSlot = $('#broker-form-slot');
2522
+ if (formBox && formSlot) {
2523
+ formSlot.appendChild(formBox);
2524
+ formBox.hidden = true;
2525
+ }
2877
2526
  syncBrokerGuideUi();
2878
2527
  brokerInitialSnap = getBrokerFormSnap();
2879
2528
  updateBrokerSaveBtnState();
2529
+ if (wasEditing) renderBrokers();
2880
2530
  }
2881
2531
  $('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
2532
+ $('#broker-delete-btn')?.addEventListener('click', async () => {
2533
+ if (!editingBrokerId) return;
2534
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId);
2535
+ if (!confirm(`Удалить брокерский счёт «${acc?.name || editingBrokerId}»?`)) return;
2536
+ try {
2537
+ const delId = editingBrokerId;
2538
+ stopBrokerEdit();
2539
+ state.brokers = await api(`/api/brokers/${encodeURIComponent(delId)}`, { method: 'DELETE' });
2540
+ renderBrokers();
2541
+ toast('Брокерский счёт удалён');
2542
+ } catch (err) { toast(err.message, 'error'); }
2543
+ });
2882
2544
  syncBrokerGuideUi();
2883
2545
  updateBrokerSaveBtnState();
2884
2546
 
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;
2547
+ // Авто-поиск всех субсчетов в Т-Инвестициях по токену (по кнопке, при открытии редактирования и автоматически при вводе/смене токена)
2548
+ $('#btn-broker-discover')?.addEventListener('click', () => runBrokerDiscover({ silent: false }));
2549
+ $('#broker-token-input')?.addEventListener('input', () => {
2550
+ clearTimeout(brokerDiscoverTimer);
2551
+ const f = brokerForm?.elements;
2552
+ if (!f || f.broker.value !== 'tbank') return;
2553
+ const tok = f.token.value.trim();
2554
+ if (tok.length >= 20) {
2555
+ brokerDiscoverTimer = setTimeout(() => runBrokerDiscover({ silent: true }), 350);
2919
2556
  }
2920
2557
  });
2921
2558
 
@@ -2927,6 +2564,13 @@ $('#broker-discovered')?.addEventListener('click', (e) => {
2927
2564
  if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
2928
2565
  f.name.value = `Т-Банк · ${b.dataset.pickName}`;
2929
2566
  }
2567
+ $('#broker-discovered').querySelectorAll('button[data-pick-acc]').forEach((btn) => {
2568
+ const sel = btn.dataset.pickAcc === b.dataset.pickAcc;
2569
+ btn.classList.toggle('primary', !sel);
2570
+ btn.textContent = sel ? '✓ Выбран' : 'Выбрать этот счёт';
2571
+ const row = btn.closest('div[style]');
2572
+ if (row) row.style.borderColor = sel ? 'var(--accent)' : 'var(--border)';
2573
+ });
2930
2574
  updateBrokerSaveBtnState();
2931
2575
  toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
2932
2576
  });
@@ -2942,7 +2586,7 @@ brokerForm.addEventListener('submit', async (e) => {
2942
2586
  token: f.token.value,
2943
2587
  mode: f.mode.value,
2944
2588
  legacyMode: f.legacyMode?.value || 'isolate',
2945
- baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
2589
+ baseRunId: null,
2946
2590
  maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
2947
2591
  maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
2948
2592
  limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
@@ -2969,48 +2613,6 @@ brokerForm.addEventListener('submit', async (e) => {
2969
2613
  } catch (err) { toast(err.message, 'error'); }
2970
2614
  });
2971
2615
 
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
2616
  $('#brokers-trade').addEventListener('click', async (e) => {
3015
2617
  const fltBtn = e.target.closest('button[data-hfilter]');
3016
2618
  if (fltBtn) {
@@ -3074,6 +2676,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
3074
2676
  const fold = e.target.closest('[data-brk-fold]');
3075
2677
  if (fold) {
3076
2678
  const fid = fold.dataset.brkFold;
2679
+ if (editingBrokerId === fid) stopBrokerEdit();
3077
2680
  openBrokerId = openBrokerId === fid ? null : fid;
3078
2681
  saveNavPatch({ openBrokerId });
3079
2682
  renderBrokers();
@@ -3083,19 +2686,30 @@ $('#brokers-trade').addEventListener('click', async (e) => {
3083
2686
  const id = b.dataset.bid;
3084
2687
  const act = b.dataset.btrade;
3085
2688
  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);
2689
+ if (editingBrokerId === id) {
2690
+ stopBrokerEdit();
2691
+ } else {
2692
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2693
+ if (acc) startBrokerEdit(acc);
3092
2694
  }
3093
2695
  return;
3094
2696
  }
3095
2697
  b.disabled = true;
3096
2698
  const old = b.textContent;
3097
2699
  try {
3098
- if (act === 'sync') {
2700
+ if (act === 'pause') {
2701
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2702
+ if (acc) {
2703
+ const currentlyPaused = acc.autoSchedule === false || acc.mode === 'readonly';
2704
+ const nextAuto = currentlyPaused;
2705
+ const nextMode = currentlyPaused && acc.mode === 'readonly' ? 'confirm' : acc.mode;
2706
+ state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, {
2707
+ method: 'PUT',
2708
+ body: { id, autoSchedule: nextAuto, mode: nextMode },
2709
+ });
2710
+ toast(nextAuto ? `▶ Торговля на счёте «${acc.name}» возобновлена` : `⏸ Счёт «${acc.name}» поставлен на паузу`);
2711
+ }
2712
+ } else if (act === 'sync') {
3099
2713
  if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
3100
2714
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
3101
2715
  toast('Портфель обновлён');
@@ -3123,6 +2737,12 @@ $('#brokers-trade').addEventListener('click', async (e) => {
3123
2737
  toast('Заявки отправлены брокеру');
3124
2738
  } else if (act === 'rej') {
3125
2739
  state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reject`);
2740
+ } else if (act === 'cancel-ord') {
2741
+ const orderId = b.dataset.oid || 'all';
2742
+ b.textContent = 'Снимаем…';
2743
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/cancel-order`, { orderId });
2744
+ await post(`/api/brokers/${encodeURIComponent(id)}/prepare`).then((st) => { state.brokers = st; }).catch(() => {});
2745
+ toast(orderId === 'all' ? 'Все активные заявки сняты со стакана' : 'Заявка снята со стакана');
3126
2746
  }
3127
2747
  renderBrokers();
3128
2748
  } catch (err) {
@@ -3160,7 +2780,6 @@ function connectWs() {
3160
2780
  const m = JSON.parse(ev.data);
3161
2781
  switch (m.type) {
3162
2782
  case 'hello':
3163
- renderLive(m.live);
3164
2783
  refreshForecastIfStale();
3165
2784
  // если исследование идёт на сервере (запущено раньше или Стратегом) — показать кнопку отмены
3166
2785
  if (!state.active && m.running?.length) {
@@ -3195,13 +2814,12 @@ function connectWs() {
3195
2814
  case 'settings:update':
3196
2815
  state.settings = m.settings;
3197
2816
  fillForm(m.settings);
3198
- loadHistory().catch(() => {});
3199
2817
  if (m.source === 'ai') {
3200
2818
  for (const k of Object.keys(m.changed || {})) { const l = $('#params').elements[k]?.closest('.srow, label'); if (l) { l.classList.remove('flash'); void l.offsetWidth; l.classList.add('flash'); } }
3201
2819
  toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
3202
2820
  }
3203
2821
  break;
3204
- case 'journal:update': loadJournal().catch(() => {}); break;
2822
+ case 'journal:update': break;
3205
2823
  case 'research:start':
3206
2824
  state.active = m.runId;
3207
2825
  resetLiveTrain(m.runId);
@@ -3246,28 +2864,30 @@ function connectWs() {
3246
2864
  loadRuns(m.runId);
3247
2865
  break;
3248
2866
  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
2867
  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
2868
  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);
2869
+ case 'paper:step':
2870
+ case 'paper:status':
2871
+ case 'paper:started':
2872
+ case 'paper:stopped':
2873
+ case 'paper:fill':
3267
2874
  break;
3268
- }
3269
- case 'news:update': if (!$('#tab-now').hidden) loadNews(); break;
2875
+ case 'news:update': loadNews(); break;
3270
2876
  case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
2877
+ case 'trading:pulse': {
2878
+ if (m.kind === 'broker_calc') {
2879
+ triggerTradingPulse(m.text || `Пересчёт заявок брокера…`, 3000);
2880
+ } else if (m.kind === 'broker_done') {
2881
+ triggerTradingPulse(`Заявки рассчитаны (${m.ordersCount || 0})`, 2200);
2882
+ } else if (m.kind === 'broker_sync') {
2883
+ triggerTradingPulse(`Портфель «${m.name || 'брокера'}» синхронизирован`, 1800);
2884
+ } else if (m.kind === 'train') {
2885
+ triggerTradingPulse(m.text || 'Расчёт прогноза…', 2500);
2886
+ } else if (m.kind === 'radar') {
2887
+ triggerTradingPulse(`Радар обновлён · сигналов: ${m.signals ?? 0}`, 1600);
2888
+ }
2889
+ break;
2890
+ }
3271
2891
  case 'stability:start':
3272
2892
  stabilityUi(m.runId);
3273
2893
  setProgress(2, `Проверка устойчивости эксперимента №${m.runId}…`);
@@ -3288,9 +2908,6 @@ function connectWs() {
3288
2908
  stabilityUi(null);
3289
2909
  toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
3290
2910
  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
2911
  case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
3295
2912
  }
3296
2913
  };
@@ -3399,21 +3016,22 @@ async function renderCloudPane() {
3399
3016
  }
3400
3017
 
3401
3018
  const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3402
- ? 'без ограничений суммы'
3019
+ ? 'по согласованию / без ограничений'
3403
3020
  : u.limits?.maxCapitalPerAccountRub > 0
3404
- ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
3405
- : 'только демо-торговля';
3021
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
3022
+ : 'подписка не активна';
3406
3023
  const retLimStr = u.limits?.maxAnnualReturnPct > 0
3407
3024
  ? `до ${u.limits.maxAnnualReturnPct}% годовых`
3408
- : 'без ограничений (полная мощность VIP)';
3025
+ : (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
3409
3026
  const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
3027
+ updateTabBadges();
3410
3028
 
3411
3029
  box.innerHTML = `
3412
3030
  <div class="srow sblock">
3413
3031
  <div class="stxt">
3414
3032
  <div class="slbl">${esc(dispName)}${dispEmail ? ` · <span class="muted">${esc(dispEmail)}</span>` : ''} <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
3415
3033
  <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)}` : ''}.
3034
+ Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Целевая доходность: <b>${retLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3417
3035
  </div>
3418
3036
  </div>
3419
3037
  ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
@@ -3425,10 +3043,9 @@ async function renderCloudPane() {
3425
3043
  <div class="stxt">
3426
3044
  <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
3427
3045
  <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>).
3046
+ • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)», задавленная доходность до 60% годовых;<br>
3047
+ • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)», задавленная доходность до 120% годовых;<br>
3048
+ • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами, задавленная доходность до 240% годовых.
3432
3049
  </div>
3433
3050
  </div>
3434
3051
  <div class="row-gap" style="flex-wrap:wrap">
@@ -3436,7 +3053,7 @@ async function renderCloudPane() {
3436
3053
  <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
3437
3054
  <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
3438
3055
  <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>
3056
+ <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Согласовать VIP →</a>
3440
3057
  </div>
3441
3058
  </div>
3442
3059
  <div class="srow sblock">
@@ -3520,7 +3137,6 @@ function applyTheme(light) {
3520
3137
  };
3521
3138
  eqChart.applyOptions(chartOpts);
3522
3139
  icChart.applyOptions(chartOpts);
3523
- if (!$('#tab-map').hidden) drawMap();
3524
3140
  }
3525
3141
  $('#btn-theme')?.addEventListener('click', () => {
3526
3142
  const next = !document.body.classList.contains('light');
@@ -3566,14 +3182,13 @@ document.addEventListener('keydown', (e) => {
3566
3182
  // ---------- старт и восстановление сохранённого места в интерфейсе ----------
3567
3183
  (async function init() {
3568
3184
  state.config = await api('/api/config');
3185
+ updateTabBadges();
3569
3186
  buildForm();
3570
3187
  state.settings = await api('/api/settings');
3571
3188
  fillForm(state.settings);
3572
- loadHistory().catch(() => {});
3573
3189
  connectWs();
3574
- renderLive(await api('/api/live/status'));
3575
3190
  loadBrokers().catch(() => {});
3576
- loadJournal().catch(() => {});
3191
+ loadNews().catch(() => {});
3577
3192
  renderCloudPane().catch(() => {});
3578
3193
  const urlParams = new URLSearchParams(location.search);
3579
3194
  if (urlParams.get('payment_check') === '1') {
@@ -3583,16 +3198,16 @@ document.addEventListener('keydown', (e) => {
3583
3198
  }).catch(() => {});
3584
3199
  }
3585
3200
  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);
3201
+ if (isOwnerUser()) {
3202
+ const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
3203
+ await loadRuns(initRunId);
3204
+ }
3205
+ if (urlParams.get('run') && isOwnerUser()) {
3206
+ showMainSec('overview');
3207
+ } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'g0', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3208
+ showMainSec(savedNav.mainSec);
3209
+ } else {
3210
+ showMainSec('brokers');
3595
3211
  }
3596
- // В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
3597
3212
  setTimeout(() => refreshForecastIfStale(), 600);
3598
3213
  })().catch((e) => toast(e.message, 'error'));