neuroalpha 0.3.13 → 0.3.15

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
@@ -978,16 +978,17 @@ async function loadForecast(force, silent = false) {
978
978
  if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
979
979
  return '';
980
980
  };
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>`;
981
982
  const row = (r) => `<tr>
982
983
  <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
983
984
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
984
985
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
985
986
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
986
- <td>${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">шорт</span>' : ''}</td>
987
- <td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
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>
988
+ <td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
988
989
  </tr>`;
989
- box.innerHTML = `<table><thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
990
- <table style="margin-top:10px"><thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
990
+ 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>
991
992
  <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога ${fmtPct(f.thresholdPct, 2)}.${f.direction && f.direction !== 'long' ? ' «Шорт» — ожидание ниже −порога.' : ''}</p>`;
992
993
  setupMapSlider();
993
994
  if (state.brokers?.accounts?.length) renderBrokers();
@@ -1020,6 +1021,12 @@ $('#now-nav').addEventListener('click', (e) => {
1020
1021
  });
1021
1022
 
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
+
1023
1030
  function renderLive(st) {
1024
1031
  state.live = st;
1025
1032
  const accs = st?.accounts || [];
@@ -1034,11 +1041,19 @@ function renderLive(st) {
1034
1041
  }
1035
1042
  $('#live').innerHTML = accs.map((a) => {
1036
1043
  const isOpen = String(a.runId) === String(openLiveId);
1037
- const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
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: [] };
1038
1046
  const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
1039
1047
  const retPct = a.returnPct ?? 0;
1040
1048
  const step = a.recent?.[0];
1041
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);
1042
1057
  const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
1043
1058
  let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
1044
1059
  if (a.corridor) {
@@ -1052,6 +1067,64 @@ function renderLive(st) {
1052
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>`;
1053
1068
  }
1054
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
+
1055
1128
  return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
1056
1129
  <div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
1057
1130
  <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
@@ -1061,23 +1134,31 @@ function renderLive(st) {
1061
1134
  ${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
1062
1135
  </div>
1063
1136
  <div class="row-gap" style="align-items:center">
1064
- <span class="small muted">На счёте: <b>${fmtNum(s.equity, 0)} ₽</b> (<span class="${cls(pnl)}">${fmtPct(retPct, 2)}</span>) · поз.: ${s.positions.length}</span>
1065
1137
  <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
1066
1138
  ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
1067
1139
  <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
1068
1140
  </div>
1069
1141
  </div>
1070
1142
  <div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
1071
- <div class="muted small" style="margin-bottom:8px">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
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>
1072
1148
  <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
1073
- <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">на счёте</div></div>
1074
- <div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">с начала</div></div>
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>
1075
1151
  ${corrHtml}
1076
- <div class="kpi"><div class="v">${a.metrics?.full?.trades ?? 0}</div><div class="l">закрыто сделок</div></div>
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>
1077
1153
  </div>
1078
- <p class="muted small">Запущен ${fmtTs(a.startedAt)}${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча ${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}${s.pending.length ? ` · ждут исполнения: ${s.pending.map((o) => `${{ buy: 'купить', short: 'продать без покупки', cover: 'закрыть продажу' }[o.action] || 'продать'} ${o.symbol}`).join(', ')}` : ''}</p>
1079
- ${s.positions.length ? `<table><thead><tr><th>Держим</th><th>С</th><th class="num">Цена входа</th><th class="num">Сейчас</th><th class="num">Результат</th></tr></thead><tbody>${s.positions.map((pos) => `<tr><td><b>${pos.symbol}</b>${pos.side === 'short' ? ' <span class="tag no">продажа</span>' : ''}</td><td>${fmtTs(pos.entryTs)}</td><td class="num">${fmtNum(pos.entryPrice, 3)}</td><td class="num">${fmtNum(pos.lastPrice, 3)}</td><td class="num ${cls(pos.pnl)}">${fmtNum(pos.pnl, 0)} ₽</td></tr>`).join('')}</tbody></table>` : `<p class="muted small">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'позиций нет' : 'ничего не куплено'} — стратегия ждёт сильного сигнала.</p>`}
1080
- ${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
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>
1081
1162
  </div>
1082
1163
  </div>`;
1083
1164
  }).join('');
@@ -1094,6 +1175,12 @@ $('#btn-live').addEventListener('click', async () => {
1094
1175
  } catch (e) { toast(e.message, 'error'); }
1095
1176
  });
1096
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
+ }
1097
1184
  const stopBtn = e.target.closest('[data-live-stop]');
1098
1185
  if (stopBtn) {
1099
1186
  const id = Number(stopBtn.dataset.liveStop);
@@ -2095,7 +2182,130 @@ addForm.addEventListener('submit', async (e) => {
2095
2182
  // ======================================================================
2096
2183
  // БРОКЕРСКИЕ СЧЕТА (Т-БАНК, ФИНАМ, АЛОР, БКС)
2097
2184
  // ======================================================================
2098
- const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
2185
+ const BROKER_LABELS = {
2186
+ tbank: 'Т-Банк',
2187
+ finam: 'Финам',
2188
+ bcs: 'БКС',
2189
+ alor: 'Алор',
2190
+ cifra: 'Цифра брокер',
2191
+ iticapital: 'ITI Capital',
2192
+ sber: 'СберИнвестиции',
2193
+ vtb: 'ВТБ Инвестиции',
2194
+ alfa: 'Альфа-Инвестиции',
2195
+ };
2196
+ const BROKER_GUIDES = {
2197
+ tbank: {
2198
+ tokenLbl: 'Токен T-Invest API',
2199
+ tokenPh: 't.… (токен Т-Инвестиций)',
2200
+ accLbl: 'ID субсчёта Т-Банка',
2201
+ accPh: 'ID счёта (или нажмите кнопку справа →)',
2202
+ canDiscover: true,
2203
+ summary: '📖 Как получить токен T-Invest API в Т-Банке (за 1 минуту)',
2204
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2205
+ <li>Откройте веб-кабинет <b>Т-Инвестиций</b> на сайте <code>tbank.ru/invest/settings</code> (раздел <b>Настройки → Токены T-Invest API</b>).</li>
2206
+ <li>Нажмите <b>«Выпустить токен»</b>, выберите права: <b>«Полный доступ»</b> (для выставления заявок) или «Только чтение», и укажите <b>«Все счета»</b>.</li>
2207
+ <li>Скопируйте полученный токен (начинается на <code>t.…</code>) в поле «Токен T-Invest API» ниже и нажмите кнопку <b>«🔍 Найти мои счета по токену»</b> — платформа сама покажет все ваши субсчета, их баланс и статус маржинальной торговли.</li>
2208
+ <li><b>Для работы с шортами (тарифы Профи и VIP):</b> в мобильном приложении Т-Инвестиций зайдите в настройки выбранного счёта (шестерёнка в правом верхнем углу счёта) и включите тумблер <b>«Маржинальная торговля»</b>.</li>
2209
+ </ol>`,
2210
+ },
2211
+ finam: {
2212
+ tokenLbl: 'Токен Finam Trade API',
2213
+ tokenPh: 'CA… (токен доступа Finam Trade API)',
2214
+ accLbl: 'Код клиента (ClientId)',
2215
+ accPh: 'Например, 123456R (Единый счёт Мосбиржи)',
2216
+ canDiscover: false,
2217
+ summary: '📖 Как получить токен Trade API и ClientId в Финаме',
2218
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2219
+ <li>Войдите в личный кабинет клиента Финам (<code>edox.finam.ru</code> / <code>finam.ru</code>) → раздел <b>«Торговля» → «Токены доступа (Finam Trade API)»</b>.</li>
2220
+ <li>Нажмите <b>«Создать новый токен»</b>, подтвердите выпуск по СМС и скопируйте строку токена (начинается на <code>CA…</code>) в поле «Токен Finam Trade API».</li>
2221
+ <li>В поле <b>«Код клиента (ClientId)»</b> укажите торговый код вашего фондового счёта Мосбиржи (обычно вида <code>123456R</code> или <code>КлФ-…</code>, он отображается рядом с токеном и в терминале FinamTrade).</li>
2222
+ </ol>`,
2223
+ },
2224
+ bcs: {
2225
+ tokenLbl: 'Refresh-токен БКС Trade API',
2226
+ tokenPh: 'Refresh-токен из веб-терминала БКС Мир инвестиций',
2227
+ accLbl: 'Номер соглашения / субсчёта',
2228
+ accPh: 'Оставьте пустым для основного счёта (или укажите номер соглашения)',
2229
+ canDiscover: false,
2230
+ summary: '📖 Как получить Refresh-токен в БКС Мир инвестиций',
2231
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2232
+ <li>Откройте веб-терминал <b>«БКС Мир инвестиций»</b> в браузере → перейдите в <b>Профиль / Настройки → «Торговый API (BCS Trade API)»</b>.</li>
2233
+ <li>Выпустите новый токен с правом <b>«Чтение и торговля»</b> (<code>trade-api-write</code>).</li>
2234
+ <li>Скопируйте полученный <b>Refresh-токен</b> в поле ниже. Платформа автоматически обменивает его на короткоживущий JWT-токен при каждом обращении.</li>
2235
+ </ol>`,
2236
+ },
2237
+ alor: {
2238
+ tokenLbl: 'Refresh-токен Alor OpenAPI',
2239
+ tokenPh: 'Refresh-токен с портала alor.dev',
2240
+ accLbl: 'Фондовый портфель Мосбиржи',
2241
+ accPh: 'Например, D12345',
2242
+ canDiscover: false,
2243
+ summary: '📖 Как получить Refresh-токен и номер портфеля в Алор Брокере',
2244
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2245
+ <li>Авторизуйтесь под учётной записью клиента Алор на портале разработчика <code>alor.dev</code> (или в личном кабинете <code>my.alor.ru</code> → раздел <b>OpenAPI</b>).</li>
2246
+ <li>Нажмите <b>«Выпустить Refresh Token»</b> для боевого контура (или контура Разработки, если включён тумблер «Песочница»).</li>
2247
+ <li>В поле <b>«Фондовый портфель Мосбиржи»</b> укажите ваш фондовый портфель с литерой <code>D</code> (например, <code>D39004</code>).</li>
2248
+ </ol>`,
2249
+ },
2250
+ cifra: {
2251
+ tokenLbl: 'Ключи Tradernet (Public:Secret)',
2252
+ tokenPh: 'PublicKey:SecretKey (через двоеточие)',
2253
+ accLbl: 'Номер счёта Tradernet',
2254
+ accPh: 'Необязательно (определяется автоматически по ключу)',
2255
+ canDiscover: false,
2256
+ summary: '📖 Как получить API-ключи Tradernet в «Цифра брокер» (Freedom24)',
2257
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2258
+ <li>Войдите в веб-терминал <b>Tradernet («Цифра брокер»)</b> на сайте <code>tradernet.ru</code> → откройте <b>Настройки профиля → «API ключи»</b>.</li>
2259
+ <li>Сгенерируйте пару ключей: <b>Публичный ключ (Public Key)</b> и <b>Секретный ключ (Secret Key)</b>.</li>
2260
+ <li>Вставьте их в поле ниже <b>через двоеточие без пробелов</b>: <code>ВашPublicKey:ВашSecretKey</code>. Платформа подписывает каждый запрос алгоритмом HMAC-SHA256.</li>
2261
+ </ol>`,
2262
+ },
2263
+ iticapital: {
2264
+ tokenLbl: 'Refresh-токен ITI Capital',
2265
+ tokenPh: 'Refresh-токен OpenAPI ITI Capital',
2266
+ accLbl: 'Код портфеля Мосбиржи',
2267
+ accPh: 'Например, BP12345',
2268
+ canDiscover: false,
2269
+ summary: '📖 Как подключить счёт ITI Capital по OpenAPI',
2270
+ html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2271
+ <li>В личном кабинете <b>ITI Capital</b> откройте раздел <b>Торговые системы → OpenAPI</b> и выпустите <b>Refresh Token</b>.</li>
2272
+ <li>Скопируйте токен в поле ниже и укажите ваш фондовый портфель Мосбиржи вида <code>BP12345</code>.</li>
2273
+ </ol>`,
2274
+ },
2275
+ sber: {
2276
+ tokenLbl: 'Адрес моста QUIK (необязательно)',
2277
+ tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://127.0.0.1:8080)',
2278
+ accLbl: 'Номер счёта СберИнвестиций',
2279
+ accPh: 'Например, 400ABCD (для отображения и учёта)',
2280
+ canDiscover: false,
2281
+ summary: '📖 Особенности подключения «СберИнвестиций» (Сбербанк)',
2282
+ html: `<div style="display:grid;gap:6px">
2283
+ <div>У розничного брокера <b>«СберИнвестиции»</b> нет прямого облачного REST-токена для физлиц (алготорговля в Сбере предоставляется через терминал <b>QUIK</b>). В «НейроАльфе» доступны <b>2 режима работы со счётом Сбера</b>:</div>
2284
+ <ol style="margin:0;padding-left:18px;display:grid;gap:4px">
2285
+ <li><b>Режим «Подтверждение заявок» через Telegram / MAX (рекомендуется, без настройки QUIK):</b> оставьте поле «Адрес моста QUIK» <b>пустым</b>, укажите ваш лимит суммы на стратегию и сохраните счёт. Платформа будет рассчитывать точные лимитные заявки в лотах Мосбиржи под ваш баланс и присылать их в Telegram / MAX и во вкладку «Торговля и заявки». После выставления заявки в приложении СберИнвестиций нажмите <b>«Исполнить заявки»</b> — платформа сама учтёт купленные бумаги и остаток кэша в портфеле.</li>
2286
+ <li><b>Автоматический режим через HTTP-мост QUIK:</b> если у вас установлен терминал QUIK Сбербанка с запущенным локальным HTTP REST-шлюзом (<code>QuikHttpBridge</code>), укажите его адрес (например, <code>http://ваш_ip:8080</code>) в поле выше.</li>
2287
+ </ol>
2288
+ </div>`,
2289
+ },
2290
+ vtb: {
2291
+ tokenLbl: 'Адрес моста QUIK (необязательно)',
2292
+ tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://…)',
2293
+ accLbl: 'Номер счёта ВТБ Инвестиций',
2294
+ accPh: 'Например, номер субсчёта ВТБ',
2295
+ canDiscover: false,
2296
+ summary: '📖 Особенности подключения «ВТБ Мои Инвестиции»',
2297
+ html: `<div>У <b>«ВТБ Мои Инвестиции»</b> нет облачного REST-токена для физлиц (доступен только терминал QUIK). Оставьте поле адреса QUIK <b>пустым</b>, чтобы работать в режиме <b>«Подтверждение заявок»</b> (расчёт точных лотов Мосбиржи с уведомлениями в Telegram / MAX и автоматическим учётом позиций после подтверждения), либо укажите URL вашего HTTP-шлюза QUIK.</div>`,
2298
+ },
2299
+ alfa: {
2300
+ tokenLbl: 'Адрес моста QUIK (необязательно)',
2301
+ tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://…)',
2302
+ accLbl: 'Номер счёта Альфа-Инвестиций',
2303
+ accPh: 'Например, номер счёта Альфа-Инвестиций',
2304
+ canDiscover: false,
2305
+ summary: '📖 Особенности подключения «Альфа-Инвестиций»',
2306
+ html: `<div>У <b>«Альфа-Инвестиций»</b> для физлиц нет облачного REST-токена. Оставьте поле адреса <b>пустым</b> для работы в режиме <b>«Подтверждение заявок»</b> (точные лимитные заявки в лотах с пушами в Telegram / MAX и ведением портфеля внутри платформы) или укажите адрес вашего торгового моста.</div>`,
2307
+ },
2308
+ };
2099
2309
  const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
2100
2310
  const LEGACY_MODES = {
2101
2311
  isolate: 'Изолированный карман (старые бумаги не трогаются)',
@@ -2111,14 +2321,70 @@ const INST_KIND_LABEL = {
2111
2321
  futures: ['Фьючерс', 'mid'],
2112
2322
  };
2113
2323
  const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
2324
+ const HIST_ALL_KINDS = ['buy', 'sell', 'fee', 'div', 'other'];
2114
2325
  const brokerTabById = new Map(Object.entries(loadSavedNav().brokerTabs || {})); // accId -> 'assets' | 'trade' | 'history'
2326
+ const brokerHistFilterById = new Map(Object.entries(loadSavedNav().brokerHistFilters || {})); // accId -> Array of kinds
2327
+ const openHistYears = new Set(); // `${accId}:${year}`
2328
+ const loadingHistYears = new Set(); // `${accId}:${year}`
2329
+ const preparingBrokerIds = new Set(); // accId
2115
2330
  function setBrokerSubTab(id, tab) {
2116
2331
  brokerTabById.set(id, tab);
2117
2332
  saveNavPatch({ brokerTabs: Object.fromEntries(brokerTabById) });
2118
2333
  }
2334
+ function getBrokerHistFilterSet(id) {
2335
+ const saved = brokerHistFilterById.get(id);
2336
+ if (Array.isArray(saved)) return new Set(saved);
2337
+ return new Set(HIST_ALL_KINDS);
2338
+ }
2339
+ function toggleBrokerHistFilter(id, flt) {
2340
+ const cur = getBrokerHistFilterSet(id);
2341
+ if (flt === 'all') {
2342
+ brokerHistFilterById.set(id, [...HIST_ALL_KINDS]);
2343
+ } else {
2344
+ if (cur.has(flt)) cur.delete(flt);
2345
+ else cur.add(flt);
2346
+ brokerHistFilterById.set(id, [...cur]);
2347
+ }
2348
+ saveNavPatch({ brokerHistFilters: Object.fromEntries(brokerHistFilterById) });
2349
+ }
2119
2350
  let openBrokerId = loadSavedNav().openBrokerId ?? null;
2120
2351
  let openBrokerCfgId = loadSavedNav().openBrokerCfgId ?? null;
2121
2352
  let editingBrokerId = null;
2353
+ let brokerPollBusy = false;
2354
+ let lastBrokerPollTs = 0;
2355
+
2356
+ 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;
2361
+ const accs = state.brokers?.accounts || [];
2362
+ if (!accs.length) return;
2363
+ const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
2364
+ const activeAcc = accs.find((a) => a.id === activeId);
2365
+ if (!activeAcc?.hasToken) return;
2366
+ const now = Date.now();
2367
+ if (!force && now - lastBrokerPollTs < 25000) return;
2368
+ brokerPollBusy = true;
2369
+ lastBrokerPollTs = now;
2370
+ try {
2371
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/sync`);
2372
+ const curSubTab = brokerTabById.get(activeId) || 'assets';
2373
+ if (curSubTab === 'history') {
2374
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
2375
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/history`, { year: curYear });
2376
+ }
2377
+ renderBrokers();
2378
+ } catch { /* ignore background poll error */ } finally {
2379
+ brokerPollBusy = false;
2380
+ }
2381
+ }
2382
+
2383
+ setInterval(() => pollActiveBrokerAccount(false), 30000);
2384
+ window.addEventListener('focus', () => pollActiveBrokerAccount(true));
2385
+ document.addEventListener('visibilitychange', () => {
2386
+ if (!document.hidden) pollActiveBrokerAccount(true);
2387
+ });
2122
2388
 
2123
2389
  async function loadBrokers() {
2124
2390
  state.brokers = await api('/api/brokers');
@@ -2280,6 +2546,7 @@ function renderBrokers() {
2280
2546
  batchHtml = `<div class="stab-box">
2281
2547
  <div class="stab-head">
2282
2548
  <b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
2549
+ ${preparingBrokerIds.has(a.id) ? '<span class="muted small">· обновляем расчёт…</span>' : ''}
2283
2550
  ${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
2284
2551
  <span class="grow"></span>
2285
2552
  ${pb.status === 'pending' && pb.orders?.length ? `<button type="button" class="primary" data-btrade="exec" data-bid="${esc(a.id)}"${a.mode === 'readonly' ? ' disabled title="В режиме «Только чтение» отправка отключена"' : ''}>Исполнить заявки (${pb.orders.length})</button><button type="button" data-btrade="rej" data-bid="${esc(a.id)}">Отклонить заявки</button>` : `<button type="button" class="icon small" data-btrade="rej" data-bid="${esc(a.id)}" title="Скрыть сводку">Скрыть</button>`}
@@ -2289,14 +2556,10 @@ function renderBrokers() {
2289
2556
  ${throttleHtml}
2290
2557
  </div>`;
2291
2558
  } else {
2292
- batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
2559
+ batchHtml = `<p class="muted small">${preparingBrokerIds.has(a.id) ? 'Считаем пакет лимитных заявок по текущему прогнозу…' : 'Переключитесь на вкладку <b>«⚡ Торговля и заявки»</b> для внеочередного пересчёта заявок (или дождитесь закрытия свечи при включённом авто-расписании).'}</p>`;
2293
2560
  }
2294
2561
 
2295
- // Вкладка 3: История операций и динамика счёта
2296
- const eqHist = Array.isArray(a.equityHistory) ? a.equityHistory : [];
2297
- const eqHistHtml = eqHist.length > 1
2298
- ? `<div class="muted small" style="margin-bottom:8px">Точки синхронизации баланса (переживают перезапуск сервера): ${eqHist.slice(-6).map((pt) => `${fmtTs(pt.ts)} — <b>${fmtNum(pt.totalRub, 0)} ₽</b>`).join(' · ')}</div>`
2299
- : '';
2562
+ // Вкладка 3: История операций и динамика счёта (по умолчанию текущий год, прошлые года — по клику)
2300
2563
  const renderOpsTable = (opsList) => {
2301
2564
  const byDay = new Map();
2302
2565
  for (const op of opsList) {
@@ -2377,33 +2640,92 @@ function renderBrokers() {
2377
2640
  return `<table><thead><tr><th style="width:64px">Время</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
2378
2641
  };
2379
2642
 
2643
+ const activeHistKinds = getBrokerHistFilterSet(a.id);
2644
+ const isAllHistActive = HIST_ALL_KINDS.every((k) => activeHistKinds.has(k));
2645
+ const opBucketOf = (op) => (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee' || op.kind === 'div' ? op.kind : 'other');
2646
+ const matchHistFilter = (op) => activeHistKinds.has(opBucketOf(op));
2647
+
2380
2648
  const byYear = new Map();
2649
+ const byYearFiltered = new Map();
2650
+ const filterCounts = { all: 0, buy: 0, sell: 0, fee: 0, div: 0, other: 0 };
2381
2651
  for (const op of lh?.operations || []) {
2652
+ filterCounts.all++;
2653
+ const b = opBucketOf(op);
2654
+ filterCounts[b] = (filterCounts[b] || 0) + 1;
2655
+
2382
2656
  const y = op.date ? new Date(op.date + MSK).toISOString().slice(0, 4) : 'Прочее';
2383
2657
  if (!byYear.has(y)) byYear.set(y, []);
2384
2658
  byYear.get(y).push(op);
2659
+ if (matchHistFilter(op)) {
2660
+ if (!byYearFiltered.has(y)) byYearFiltered.set(y, []);
2661
+ byYearFiltered.get(y).push(op);
2662
+ }
2385
2663
  }
2386
- const years = [...byYear.keys()];
2387
- const yearsHtml = years.map((y, idx) => {
2388
- const yOps = byYear.get(y);
2389
- const yTrades = yOps.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
2390
- const yDivs = yOps.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
2391
- const yMeta = `операций: ${yOps.length}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`;
2392
- return `<details class="hist-year-box"${idx === 0 ? ' open' : ''}>
2664
+
2665
+ const histFilterBarHtml = `<div class="row-gap" style="margin-bottom:10px;align-items:center;flex-wrap:wrap">
2666
+ <span class="muted small" style="margin-right:4px">Фильтры:</span>
2667
+ ${[
2668
+ ['buy', 'Покупка', filterCounts.buy],
2669
+ ['sell', 'Продажа', filterCounts.sell],
2670
+ ['fee', 'Комиссия', filterCounts.fee],
2671
+ ['div', 'Дивиденды', filterCounts.div],
2672
+ ['other', 'Операция', filterCounts.other],
2673
+ ].map(([fKey, fLbl, fCnt]) => {
2674
+ const on = activeHistKinds.has(fKey);
2675
+ return `<button type="button" class="brk-toggle small${on ? ' on' : ''}" data-hfilter="${fKey}" data-bid="${esc(a.id)}" title="${on ? 'Включён (нажмите, чтобы скрыть)' : 'Выключен (нажмите, чтобы показать)'}">${on ? '✓ ' : ''}${fLbl} (${fCnt})</button>`;
2676
+ }).join('')}
2677
+ ${!isAllHistActive ? `<button type="button" class="icon small" data-hfilter="all" data-bid="${esc(a.id)}" title="Включить обратно все типы операций">Показать все</button>` : ''}
2678
+ </div>`;
2679
+
2680
+ const curYearNum = new Date(Date.now() + MSK).getUTCFullYear();
2681
+ const curYearStr = String(curYearNum);
2682
+ openHistYears.add(`${a.id}:${curYearStr}`);
2683
+ const loadedYearsSet = new Set(Array.isArray(lh?.loadedYears) ? lh.loadedYears.map(String) : [...byYear.keys()]);
2684
+ const allYears = [];
2685
+ for (let y = curYearNum; y >= curYearNum - 4; y--) allYears.push(String(y));
2686
+ for (const y of byYear.keys()) if (!allYears.includes(y)) allYears.push(y);
2687
+
2688
+ const yearsHtml = allYears.map((y) => {
2689
+ const yKey = `${a.id}:${y}`;
2690
+ const isYearOpen = openHistYears.has(yKey);
2691
+ const isLoaded = loadedYearsSet.has(y) || byYear.has(y);
2692
+ const isLoading = loadingHistYears.has(yKey);
2693
+ const yOpsAll = byYear.get(y) || [];
2694
+ const yOps = byYearFiltered.get(y) || [];
2695
+ let yMeta = '';
2696
+ if (isLoading) {
2697
+ yMeta = 'загружаем операции с брокера…';
2698
+ } else if (isLoaded) {
2699
+ const yTrades = yOpsAll.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
2700
+ const yDivs = yOpsAll.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
2701
+ const fltSuffix = !isAllHistActive ? ` (показано: ${yOps.length})` : '';
2702
+ yMeta = yOpsAll.length
2703
+ ? `операций: ${yOpsAll.length}${fltSuffix}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`
2704
+ : 'операций за этот год нет';
2705
+ } else {
2706
+ yMeta = 'нажмите, чтобы подгрузить операции за этот год';
2707
+ }
2708
+ const bodyContent = isLoading
2709
+ ? '<p class="muted small" style="padding:10px 12px;margin:0">Запрашиваем выписку у брокера…</p>'
2710
+ : yOps.length
2711
+ ? renderOpsTable(yOps)
2712
+ : `<p class="muted small" style="padding:10px 12px;margin:0">${!isAllHistActive && yOpsAll.length ? 'Все операции за этот год скрыты фильтрами выше.' : 'За выбранный год операций по счёту не найдено.'}</p>`;
2713
+ return `<details class="hist-year-box" data-hyear="${esc(y)}" data-bid="${esc(a.id)}"${isYearOpen ? ' open' : ''}>
2393
2714
  <summary><span>📁 ${esc(y)} год</span><span class="muted small">${yMeta}</span></summary>
2394
- ${renderOpsTable(yOps)}
2715
+ ${bodyContent}
2395
2716
  </details>`;
2396
2717
  }).join('');
2397
2718
 
2398
- const histHtml = lh?.operations?.length
2399
- ? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2400
- <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
2719
+ const histHtml = lh?.operations?.length || lh?.loadedYears?.length
2720
+ ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2721
+ <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
2401
2722
  <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2402
2723
  <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
2403
2724
  <div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
2404
2725
  </div>
2726
+ ${histFilterBarHtml}
2405
2727
  ${yearsHtml}`
2406
- : `${eqHistHtml}<p class="muted small">Нажмите <b>«↻»</b> или переключитесь сюда, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
2728
+ : '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
2407
2729
 
2408
2730
  return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2409
2731
  <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
@@ -2415,7 +2737,8 @@ function renderBrokers() {
2415
2737
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2416
2738
  </div>
2417
2739
  <div class="row-gap" style="align-items:center">
2418
- ${ls?.ok ? `<span class="small muted">Всего: <b>${fmtNum(ls.totalRub, 0)} ₽</b> · свободно: <b class="pos">${fmtNum(ls.cashRub, 0)} ₽</b> · поз.: ${ls.positions?.length || 0}</span>` : ''}
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>
2419
2742
  </div>
2420
2743
  </div>
2421
2744
  <div ${isOpen ? '' : 'hidden'}>
@@ -2427,15 +2750,6 @@ function renderBrokers() {
2427
2750
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2428
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>
2429
2752
  </div>
2430
- <div class="brk-toolbar">
2431
- ${ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
2432
- ${a.autoSchedule === false ? `<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}" title="Авто-торговля по расписанию выключена — нажмите для ручного расчёта заявок">⚡ Рассчитать заявки</button>` : ''}
2433
- <span class="grow"></span>
2434
- ${a.autoSchedule !== false ? `<button type="button" class="brk-icon-btn" data-btrade="prep" data-bid="${esc(a.id)}" title="Внеочередной пересчёт пакета заявок прямо сейчас (помимо авто-расписания)">⚡</button>` : ''}
2435
- <button type="button" class="brk-icon-btn" data-btrade="sync" data-bid="${esc(a.id)}" title="Обновить портфель и баланс с брокера">↻</button>
2436
- <button type="button" class="brk-icon-btn" data-btrade="hist" data-bid="${esc(a.id)}" title="Обновить историю операций по счёту">📜</button>
2437
- <button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
2438
- </div>
2439
2753
  ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2440
2754
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2441
2755
  <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
@@ -2483,12 +2797,26 @@ function getBrokerFormSnap() {
2483
2797
  });
2484
2798
  }
2485
2799
 
2800
+ function syncBrokerGuideUi() {
2801
+ const f = brokerForm?.elements;
2802
+ if (!f) return;
2803
+ const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
2804
+ if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
2805
+ if ($('#broker-token-input')) $('#broker-token-input').placeholder = editingBrokerId ? 'не меняется — оставьте пустым' : g.tokenPh;
2806
+ if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
2807
+ if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
2808
+ if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
2809
+ if ($('#broker-guide-sum')) $('#broker-guide-sum').textContent = g.summary;
2810
+ if ($('#broker-guide-body')) $('#broker-guide-body').innerHTML = g.html;
2811
+ }
2812
+
2486
2813
  function updateBrokerSaveBtnState() {
2487
2814
  const btn = $('#broker-save-btn');
2488
2815
  if (!btn) return;
2489
2816
  if (!editingBrokerId) {
2490
2817
  const f = brokerForm.elements;
2491
- const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim());
2818
+ const isManualCapable = ['sber', 'vtb', 'alfa'].includes(f.broker.value);
2819
+ const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim() || isManualCapable);
2492
2820
  btn.disabled = !hasNewInput;
2493
2821
  btn.classList.toggle('primary', hasNewInput);
2494
2822
  btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
@@ -2501,7 +2829,10 @@ function updateBrokerSaveBtnState() {
2501
2829
  }
2502
2830
 
2503
2831
  brokerForm.addEventListener('input', updateBrokerSaveBtnState);
2504
- brokerForm.addEventListener('change', updateBrokerSaveBtnState);
2832
+ brokerForm.addEventListener('change', () => {
2833
+ syncBrokerGuideUi();
2834
+ updateBrokerSaveBtnState();
2835
+ });
2505
2836
 
2506
2837
  function startBrokerEdit(acc) {
2507
2838
  editingBrokerId = acc.id;
@@ -2530,6 +2861,7 @@ function startBrokerEdit(acc) {
2530
2861
  $('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
2531
2862
  $('#broker-save-btn').textContent = 'Сохранить изменения';
2532
2863
  $('#broker-cancel-btn').hidden = false;
2864
+ syncBrokerGuideUi();
2533
2865
  brokerInitialSnap = getBrokerFormSnap();
2534
2866
  updateBrokerSaveBtnState();
2535
2867
  }
@@ -2542,10 +2874,12 @@ function stopBrokerEdit() {
2542
2874
  $('#broker-add-sum').textContent = 'Добавить брокерский счёт';
2543
2875
  $('#broker-save-btn').textContent = 'Сохранить счёт';
2544
2876
  $('#broker-cancel-btn').hidden = true;
2877
+ syncBrokerGuideUi();
2545
2878
  brokerInitialSnap = getBrokerFormSnap();
2546
2879
  updateBrokerSaveBtnState();
2547
2880
  }
2548
2881
  $('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
2882
+ syncBrokerGuideUi();
2549
2883
  updateBrokerSaveBtnState();
2550
2884
 
2551
2885
  // Авто-поиск всех субсчетов в Т-Инвестициях по токену
@@ -2618,12 +2952,20 @@ brokerForm.addEventListener('submit', async (e) => {
2618
2952
  resetHalt: true,
2619
2953
  };
2620
2954
  try {
2955
+ const savedId = editingBrokerId;
2621
2956
  state.brokers = editingBrokerId
2622
2957
  ? await api(`/api/brokers/${encodeURIComponent(editingBrokerId)}`, { method: 'PUT', body })
2623
2958
  : await post('/api/brokers', body);
2959
+ const targetId = savedId || state.brokers?.accounts?.[state.brokers.accounts.length - 1]?.id;
2624
2960
  stopBrokerEdit();
2625
2961
  renderBrokers();
2626
- toast('Брокерский счёт сохранён');
2962
+ toast('Настройки счёта сохранены — пересчитываем портфель и заявки…');
2963
+ if (targetId) {
2964
+ post(`/api/brokers/${encodeURIComponent(targetId)}/sync`)
2965
+ .then(() => post(`/api/brokers/${encodeURIComponent(targetId)}/prepare`))
2966
+ .then((st) => { state.brokers = st; renderBrokers(); })
2967
+ .catch(() => {});
2968
+ }
2627
2969
  } catch (err) { toast(err.message, 'error'); }
2628
2970
  });
2629
2971
 
@@ -2670,17 +3012,60 @@ $('#brokers-list').addEventListener('click', async (e) => {
2670
3012
  });
2671
3013
 
2672
3014
  $('#brokers-trade').addEventListener('click', async (e) => {
3015
+ const fltBtn = e.target.closest('button[data-hfilter]');
3016
+ if (fltBtn) {
3017
+ toggleBrokerHistFilter(fltBtn.dataset.bid, fltBtn.dataset.hfilter);
3018
+ renderBrokers();
3019
+ return;
3020
+ }
3021
+ const yearSummary = e.target.closest('.hist-year-box > summary');
3022
+ if (yearSummary) {
3023
+ const det = yearSummary.parentElement;
3024
+ const id = det?.dataset?.bid;
3025
+ const y = det?.dataset?.hyear;
3026
+ if (id && y) {
3027
+ setTimeout(() => {
3028
+ const yKey = `${id}:${y}`;
3029
+ if (det.open) {
3030
+ openHistYears.add(yKey);
3031
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
3032
+ const loaded = Array.isArray(acc?.lastHistory?.loadedYears) && acc.lastHistory.loadedYears.map(String).includes(String(y));
3033
+ const hasOps = (acc?.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).toISOString().slice(0, 4) === String(y));
3034
+ if (!loaded && !hasOps && !loadingHistYears.has(yKey)) {
3035
+ loadingHistYears.add(yKey);
3036
+ renderBrokers();
3037
+ post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: Number(y) })
3038
+ .then((st) => { state.brokers = st; })
3039
+ .catch((err) => toast(err.message, 'error'))
3040
+ .finally(() => { loadingHistYears.delete(yKey); renderBrokers(); });
3041
+ }
3042
+ } else {
3043
+ openHistYears.delete(yKey);
3044
+ }
3045
+ }, 0);
3046
+ }
3047
+ return;
3048
+ }
2673
3049
  const tabBtn = e.target.closest('button[data-btab]');
2674
3050
  if (tabBtn) {
2675
3051
  const id = tabBtn.dataset.bid;
2676
3052
  const t = tabBtn.dataset.btab;
2677
3053
  setBrokerSubTab(id, t);
3054
+ if (t === 'trade') {
3055
+ preparingBrokerIds.add(id);
3056
+ renderBrokers();
3057
+ post(`/api/brokers/${encodeURIComponent(id)}/prepare`)
3058
+ .then((st) => { state.brokers = st; })
3059
+ .catch((err) => toast(err.message, 'error'))
3060
+ .finally(() => { preparingBrokerIds.delete(id); renderBrokers(); });
3061
+ return;
3062
+ }
2678
3063
  renderBrokers();
2679
3064
  if (t === 'history') {
2680
- const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2681
- if (acc && !acc.lastHistory) {
2682
- post(`/api/brokers/${encodeURIComponent(id)}/history`).then((st) => { state.brokers = st; renderBrokers(); }).catch((err) => toast(err.message, 'error'));
2683
- }
3065
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
3066
+ post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: curYear })
3067
+ .then((st) => { state.brokers = st; renderBrokers(); })
3068
+ .catch((err) => toast(err.message, 'error'));
2684
3069
  }
2685
3070
  return;
2686
3071
  }