neuroalpha 0.3.13 → 0.3.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1020,6 +1020,12 @@ $('#now-nav').addEventListener('click', (e) => {
1020
1020
  });
1021
1021
 
1022
1022
  let openLiveId = loadSavedNav().openLiveId ?? null;
1023
+ const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
1024
+ function setLiveSubTab(id, tab) {
1025
+ liveTabById.set(String(id), tab);
1026
+ saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
1027
+ }
1028
+
1023
1029
  function renderLive(st) {
1024
1030
  state.live = st;
1025
1031
  const accs = st?.accounts || [];
@@ -1034,11 +1040,19 @@ function renderLive(st) {
1034
1040
  }
1035
1041
  $('#live').innerHTML = accs.map((a) => {
1036
1042
  const isOpen = String(a.runId) === String(openLiveId);
1037
- const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
1043
+ const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
1044
+ const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
1038
1045
  const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
1039
1046
  const retPct = a.returnPct ?? 0;
1040
1047
  const step = a.recent?.[0];
1041
1048
  const p = a.params || {};
1049
+ const mf = a.metrics?.full || {};
1050
+ const trades = Array.isArray(a.trades) ? a.trades : [];
1051
+ const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
1052
+ const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
1053
+ const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
1054
+ const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
1055
+ const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
1042
1056
  const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
1043
1057
  let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
1044
1058
  if (a.corridor) {
@@ -1052,6 +1066,64 @@ function renderLive(st) {
1052
1066
  corrHtml = `<div class="kpi" title="Ожидание по темпу эксперимента №${c.runId} (история: ${fmtPct(c.histReturnPct)}, макс. просадка ${fmtNum(c.histMaxDdPct, 1)}%)"><div class="v ${cls(c.expectedPct)}">${fmtPct(c.expectedPct, 2)} <span class="muted small">(${fmtPct(c.minPct, 1)}…${fmtPct(c.maxPct, 1)})</span></div><div class="l">ориентир №${c.runId} · <span class="${sCls === 'no' ? 'neg' : 'pos'}">${sLbl}</span></div></div>`;
1053
1067
  }
1054
1068
  const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
1069
+
1070
+ // Вкладка 1: Текущие позиции и ожидающие заявки
1071
+ const posPaneHtml = `
1072
+ ${s.pending?.length ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Ожидают исполнения на открытии следующей свечи (${s.pending.length})</b></div><div class="muted small">${s.pending.map((o) => `<span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${{ buy: 'купить', short: 'открыть шорт', cover: 'закрыть шорт' }[o.action] || 'продать'} <b>${esc(o.symbol)}</b></span>`).join(' ')}</div></div>` : ''}
1073
+ ${s.positions.length ? `<table><thead><tr><th>Бумага</th><th>Направление</th><th>Открыта</th><th class="num">Кол-во</th><th class="num">Цена входа → Сейчас</th><th class="num">Текущий доход</th></tr></thead><tbody>${s.positions.map((pos) => {
1074
+ const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
1075
+ return `<tr>
1076
+ <td><b>${esc(pos.symbol)}</b></td>
1077
+ <td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
1078
+ <td class="muted small">${fmtTs(pos.entryTs)}</td>
1079
+ <td class="num">${fmtNum(pos.qty, 0)} шт.</td>
1080
+ <td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
1081
+ <td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
1082
+ </tr>`;
1083
+ }).join('')}</tbody></table>` : `<p class="muted small" style="margin:6px 0">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'открытых позиций нет' : 'ничего не куплено'} (все <b>${fmtNum(s.cash ?? s.equity, 0)} ₽</b> в свободном кэше) — стратегия ждёт сильного сигнала.</p>`}
1084
+ ${step ? `<p class="muted small" style="margin:8px 0 0">Последняя проверка рынка (${fmtTs(step.ts)}): ${step.decisions?.length ? step.decisions.map((d) => `<b>${esc(d.symbol)}</b>: ${esc(d.text)}`).join(' · ') : 'без изменений'}${step.top?.length ? ` · лидеры прогноза: ${step.top.slice(0, 3).map((x) => `${esc(x.symbol)} (${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%)`).join(', ')}` : ''}</p>` : ''}
1085
+ `;
1086
+
1087
+ // Вкладка 2: История закрытых сделок и по-дневной доход
1088
+ let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
1089
+ if (trades.length) {
1090
+ const byDay = new Map();
1091
+ for (const t of trades) {
1092
+ const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
1093
+ if (!byDay.has(dKey)) byDay.set(dKey, []);
1094
+ byDay.get(dKey).push(t);
1095
+ }
1096
+ const rows = [];
1097
+ for (const [, dayTrades] of byDay) {
1098
+ const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
1099
+ const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
1100
+ const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
1101
+ rows.push(`<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTrades[0]?.exitTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">сделок: <b>${dayTrades.length}</b> (в плюс: ${dayWins}) · комиссия: ${fmtNum(dayFee, 0)} ₽ · доход за день: <b class="${cls(dayPnl)}">${dayPnl >= 0 ? '+' : ''}${fmtNum(dayPnl, 0)} ₽</b></span></div></td></tr>`);
1102
+ for (const t of dayTrades) {
1103
+ rows.push(`<tr>
1104
+ <td><b>${esc(t.symbol)}</b></td>
1105
+ <td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
1106
+ <td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
1107
+ <td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
1108
+ <td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
1109
+ <td class="num ${cls(t.pnl)}" style="white-space:nowrap"><b>${t.pnl >= 0 ? '+' : ''}${fmtNum(t.pnl, 0)} ₽</b> <span class="small">(${fmtPct(t.pnlPct, 2)})</span></td>
1110
+ <td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
1111
+ </tr>`);
1112
+ }
1113
+ }
1114
+ tradesPaneHtml = `<table><thead><tr><th>Бумага</th><th>Тип</th><th>Вход → Выход</th><th class="num">Кол-во</th><th class="num">Цена входа → выхода</th><th class="num">Чистый доход</th><th>Причина</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
1115
+ }
1116
+
1117
+ // Вкладка 3: Журнал шагов и решений ИИ по свечам
1118
+ const stepsList = Array.isArray(a.recent) ? a.recent : [];
1119
+ const stepsPaneHtml = stepsList.length
1120
+ ? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
1121
+ <td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
1122
+ <td>${stItem.decisions?.length ? stItem.decisions.map((d) => `<span class="tag ${d.action === 'buy' ? 'ok' : d.action === 'short' || d.action === 'sell' ? 'mid' : ''}">${esc(d.symbol)}</span> <span class="small">${esc(d.text)}</span>`).join('<br>') : '<span class="muted small">Позиции без изменений</span>'}</td>
1123
+ <td class="small muted">${stItem.top?.length ? stItem.top.slice(0, 3).map((x) => `<b>${esc(x.symbol)}</b> <span class="${cls(x.expectedPct)}">${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%</span>`).join(' · ') : '—'}</td>
1124
+ </tr>`).join('')}</tbody></table>`
1125
+ : '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
1126
+
1055
1127
  return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
1056
1128
  <div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
1057
1129
  <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
@@ -1061,23 +1133,31 @@ function renderLive(st) {
1061
1133
  ${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
1062
1134
  </div>
1063
1135
  <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
1136
  <button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
1066
1137
  ${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
1067
1138
  <button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
1068
1139
  </div>
1069
1140
  </div>
1070
1141
  <div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
1071
- <div class="muted small" style="margin-bottom:8px">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
1142
+ <div class="brk-specs" style="margin-bottom:10px">
1143
+ <span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
1144
+ <span class="k">Статус и время работы:</span><span class="v">запущен <b>${fmtTs(a.startedAt)}</b>${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча <b>${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}</b>${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}</span>
1145
+ <span class="k">Структура дохода:</span><span class="v">зафиксировано по сделкам: <b class="${cls(realizedPnl)}">${realizedPnl >= 0 ? '+' : ''}${fmtNum(realizedPnl, 0)} ₽</b> · в открытых позициях: <b class="${cls(openPnl)}">${openPnl >= 0 ? '+' : ''}${fmtNum(openPnl, 0)} ₽</b> · учтено комиссий: <span class="muted">−${fmtNum(totalFee, 0)} ₽</span> · макс. просадка: <b>${fmtNum(mf.maxDrawdownPct || 0, 2)}%</b></span>
1146
+ </div>
1072
1147
  <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>
1148
+ <div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
1149
+ <div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">чистый доход с начала</div></div>
1075
1150
  ${corrHtml}
1076
- <div class="kpi"><div class="v">${a.metrics?.full?.trades ?? 0}</div><div class="l">закрыто сделок</div></div>
1151
+ <div class="kpi"><div class="v">${trades.length || (mf.trades ?? 0)} <span class="muted small">(${winRate}% в плюс)</span></div><div class="l">закрыто сделок · профит-фактор ${fmtNum(mf.profitFactor || 0, 2)}</div></div>
1152
+ </div>
1153
+ <div class="brk-subtabs" style="margin-top:10px">
1154
+ <button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
1155
+ <button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
1156
+ <button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
1077
1157
  </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>` : ''}
1158
+ <div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
1159
+ <div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
1160
+ <div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
1081
1161
  </div>
1082
1162
  </div>`;
1083
1163
  }).join('');
@@ -1094,6 +1174,12 @@ $('#btn-live').addEventListener('click', async () => {
1094
1174
  } catch (e) { toast(e.message, 'error'); }
1095
1175
  });
1096
1176
  $('#live').addEventListener('click', async (e) => {
1177
+ const tabBtn = e.target.closest('button[data-ltab]');
1178
+ if (tabBtn) {
1179
+ setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
1180
+ renderLive(state.live);
1181
+ return;
1182
+ }
1097
1183
  const stopBtn = e.target.closest('[data-live-stop]');
1098
1184
  if (stopBtn) {
1099
1185
  const id = Number(stopBtn.dataset.liveStop);
@@ -2111,14 +2197,70 @@ const INST_KIND_LABEL = {
2111
2197
  futures: ['Фьючерс', 'mid'],
2112
2198
  };
2113
2199
  const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
2200
+ const HIST_ALL_KINDS = ['buy', 'sell', 'fee', 'div', 'other'];
2114
2201
  const brokerTabById = new Map(Object.entries(loadSavedNav().brokerTabs || {})); // accId -> 'assets' | 'trade' | 'history'
2202
+ const brokerHistFilterById = new Map(Object.entries(loadSavedNav().brokerHistFilters || {})); // accId -> Array of kinds
2203
+ const openHistYears = new Set(); // `${accId}:${year}`
2204
+ const loadingHistYears = new Set(); // `${accId}:${year}`
2205
+ const preparingBrokerIds = new Set(); // accId
2115
2206
  function setBrokerSubTab(id, tab) {
2116
2207
  brokerTabById.set(id, tab);
2117
2208
  saveNavPatch({ brokerTabs: Object.fromEntries(brokerTabById) });
2118
2209
  }
2210
+ function getBrokerHistFilterSet(id) {
2211
+ const saved = brokerHistFilterById.get(id);
2212
+ if (Array.isArray(saved)) return new Set(saved);
2213
+ return new Set(HIST_ALL_KINDS);
2214
+ }
2215
+ function toggleBrokerHistFilter(id, flt) {
2216
+ const cur = getBrokerHistFilterSet(id);
2217
+ if (flt === 'all') {
2218
+ brokerHistFilterById.set(id, [...HIST_ALL_KINDS]);
2219
+ } else {
2220
+ if (cur.has(flt)) cur.delete(flt);
2221
+ else cur.add(flt);
2222
+ brokerHistFilterById.set(id, [...cur]);
2223
+ }
2224
+ saveNavPatch({ brokerHistFilters: Object.fromEntries(brokerHistFilterById) });
2225
+ }
2119
2226
  let openBrokerId = loadSavedNav().openBrokerId ?? null;
2120
2227
  let openBrokerCfgId = loadSavedNav().openBrokerCfgId ?? null;
2121
2228
  let editingBrokerId = null;
2229
+ let brokerPollBusy = false;
2230
+ let lastBrokerPollTs = 0;
2231
+
2232
+ async function pollActiveBrokerAccount(force = false) {
2233
+ if (brokerPollBusy || document.hidden) return;
2234
+ const nowPageHidden = $('#tab-now')?.hidden;
2235
+ const brokersSecHidden = $('#nsec-brokers')?.hidden;
2236
+ if (nowPageHidden || brokersSecHidden) return;
2237
+ const accs = state.brokers?.accounts || [];
2238
+ if (!accs.length) return;
2239
+ const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
2240
+ const activeAcc = accs.find((a) => a.id === activeId);
2241
+ if (!activeAcc?.hasToken) return;
2242
+ const now = Date.now();
2243
+ if (!force && now - lastBrokerPollTs < 25000) return;
2244
+ brokerPollBusy = true;
2245
+ lastBrokerPollTs = now;
2246
+ try {
2247
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/sync`);
2248
+ const curSubTab = brokerTabById.get(activeId) || 'assets';
2249
+ if (curSubTab === 'history') {
2250
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
2251
+ state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/history`, { year: curYear });
2252
+ }
2253
+ renderBrokers();
2254
+ } catch { /* ignore background poll error */ } finally {
2255
+ brokerPollBusy = false;
2256
+ }
2257
+ }
2258
+
2259
+ setInterval(() => pollActiveBrokerAccount(false), 30000);
2260
+ window.addEventListener('focus', () => pollActiveBrokerAccount(true));
2261
+ document.addEventListener('visibilitychange', () => {
2262
+ if (!document.hidden) pollActiveBrokerAccount(true);
2263
+ });
2122
2264
 
2123
2265
  async function loadBrokers() {
2124
2266
  state.brokers = await api('/api/brokers');
@@ -2280,6 +2422,7 @@ function renderBrokers() {
2280
2422
  batchHtml = `<div class="stab-box">
2281
2423
  <div class="stab-head">
2282
2424
  <b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
2425
+ ${preparingBrokerIds.has(a.id) ? '<span class="muted small">· обновляем расчёт…</span>' : ''}
2283
2426
  ${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
2284
2427
  <span class="grow"></span>
2285
2428
  ${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 +2432,10 @@ function renderBrokers() {
2289
2432
  ${throttleHtml}
2290
2433
  </div>`;
2291
2434
  } else {
2292
- batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
2435
+ batchHtml = `<p class="muted small">${preparingBrokerIds.has(a.id) ? 'Считаем пакет лимитных заявок по текущему прогнозу…' : 'Переключитесь на вкладку <b>«⚡ Торговля и заявки»</b> для внеочередного пересчёта заявок (или дождитесь закрытия свечи при включённом авто-расписании).'}</p>`;
2293
2436
  }
2294
2437
 
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
- : '';
2438
+ // Вкладка 3: История операций и динамика счёта (по умолчанию текущий год, прошлые года — по клику)
2300
2439
  const renderOpsTable = (opsList) => {
2301
2440
  const byDay = new Map();
2302
2441
  for (const op of opsList) {
@@ -2377,33 +2516,92 @@ function renderBrokers() {
2377
2516
  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
2517
  };
2379
2518
 
2519
+ const activeHistKinds = getBrokerHistFilterSet(a.id);
2520
+ const isAllHistActive = HIST_ALL_KINDS.every((k) => activeHistKinds.has(k));
2521
+ const opBucketOf = (op) => (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee' || op.kind === 'div' ? op.kind : 'other');
2522
+ const matchHistFilter = (op) => activeHistKinds.has(opBucketOf(op));
2523
+
2380
2524
  const byYear = new Map();
2525
+ const byYearFiltered = new Map();
2526
+ const filterCounts = { all: 0, buy: 0, sell: 0, fee: 0, div: 0, other: 0 };
2381
2527
  for (const op of lh?.operations || []) {
2528
+ filterCounts.all++;
2529
+ const b = opBucketOf(op);
2530
+ filterCounts[b] = (filterCounts[b] || 0) + 1;
2531
+
2382
2532
  const y = op.date ? new Date(op.date + MSK).toISOString().slice(0, 4) : 'Прочее';
2383
2533
  if (!byYear.has(y)) byYear.set(y, []);
2384
2534
  byYear.get(y).push(op);
2535
+ if (matchHistFilter(op)) {
2536
+ if (!byYearFiltered.has(y)) byYearFiltered.set(y, []);
2537
+ byYearFiltered.get(y).push(op);
2538
+ }
2385
2539
  }
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' : ''}>
2540
+
2541
+ const histFilterBarHtml = `<div class="row-gap" style="margin-bottom:10px;align-items:center;flex-wrap:wrap">
2542
+ <span class="muted small" style="margin-right:4px">Фильтры:</span>
2543
+ ${[
2544
+ ['buy', 'Покупка', filterCounts.buy],
2545
+ ['sell', 'Продажа', filterCounts.sell],
2546
+ ['fee', 'Комиссия', filterCounts.fee],
2547
+ ['div', 'Дивиденды', filterCounts.div],
2548
+ ['other', 'Операция', filterCounts.other],
2549
+ ].map(([fKey, fLbl, fCnt]) => {
2550
+ const on = activeHistKinds.has(fKey);
2551
+ return `<button type="button" class="brk-toggle small${on ? ' on' : ''}" data-hfilter="${fKey}" data-bid="${esc(a.id)}" title="${on ? 'Включён (нажмите, чтобы скрыть)' : 'Выключен (нажмите, чтобы показать)'}">${on ? '✓ ' : ''}${fLbl} (${fCnt})</button>`;
2552
+ }).join('')}
2553
+ ${!isAllHistActive ? `<button type="button" class="icon small" data-hfilter="all" data-bid="${esc(a.id)}" title="Включить обратно все типы операций">Показать все</button>` : ''}
2554
+ </div>`;
2555
+
2556
+ const curYearNum = new Date(Date.now() + MSK).getUTCFullYear();
2557
+ const curYearStr = String(curYearNum);
2558
+ openHistYears.add(`${a.id}:${curYearStr}`);
2559
+ const loadedYearsSet = new Set(Array.isArray(lh?.loadedYears) ? lh.loadedYears.map(String) : [...byYear.keys()]);
2560
+ const allYears = [];
2561
+ for (let y = curYearNum; y >= curYearNum - 4; y--) allYears.push(String(y));
2562
+ for (const y of byYear.keys()) if (!allYears.includes(y)) allYears.push(y);
2563
+
2564
+ const yearsHtml = allYears.map((y) => {
2565
+ const yKey = `${a.id}:${y}`;
2566
+ const isYearOpen = openHistYears.has(yKey);
2567
+ const isLoaded = loadedYearsSet.has(y) || byYear.has(y);
2568
+ const isLoading = loadingHistYears.has(yKey);
2569
+ const yOpsAll = byYear.get(y) || [];
2570
+ const yOps = byYearFiltered.get(y) || [];
2571
+ let yMeta = '';
2572
+ if (isLoading) {
2573
+ yMeta = 'загружаем операции с брокера…';
2574
+ } else if (isLoaded) {
2575
+ const yTrades = yOpsAll.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
2576
+ const yDivs = yOpsAll.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
2577
+ const fltSuffix = !isAllHistActive ? ` (показано: ${yOps.length})` : '';
2578
+ yMeta = yOpsAll.length
2579
+ ? `операций: ${yOpsAll.length}${fltSuffix}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`
2580
+ : 'операций за этот год нет';
2581
+ } else {
2582
+ yMeta = 'нажмите, чтобы подгрузить операции за этот год';
2583
+ }
2584
+ const bodyContent = isLoading
2585
+ ? '<p class="muted small" style="padding:10px 12px;margin:0">Запрашиваем выписку у брокера…</p>'
2586
+ : yOps.length
2587
+ ? renderOpsTable(yOps)
2588
+ : `<p class="muted small" style="padding:10px 12px;margin:0">${!isAllHistActive && yOpsAll.length ? 'Все операции за этот год скрыты фильтрами выше.' : 'За выбранный год операций по счёту не найдено.'}</p>`;
2589
+ return `<details class="hist-year-box" data-hyear="${esc(y)}" data-bid="${esc(a.id)}"${isYearOpen ? ' open' : ''}>
2393
2590
  <summary><span>📁 ${esc(y)} год</span><span class="muted small">${yMeta}</span></summary>
2394
- ${renderOpsTable(yOps)}
2591
+ ${bodyContent}
2395
2592
  </details>`;
2396
2593
  }).join('');
2397
2594
 
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>
2595
+ const histHtml = lh?.operations?.length || lh?.loadedYears?.length
2596
+ ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
2597
+ <div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
2401
2598
  <div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
2402
2599
  <div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
2403
2600
  <div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
2404
2601
  </div>
2602
+ ${histFilterBarHtml}
2405
2603
  ${yearsHtml}`
2406
- : `${eqHistHtml}<p class="muted small">Нажмите <b>«↻»</b> или переключитесь сюда, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
2604
+ : '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
2407
2605
 
2408
2606
  return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
2409
2607
  <div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
@@ -2415,7 +2613,8 @@ function renderBrokers() {
2415
2613
  ${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
2416
2614
  </div>
2417
2615
  <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>` : ''}
2616
+ ${ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
2617
+ <button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
2419
2618
  </div>
2420
2619
  </div>
2421
2620
  <div ${isOpen ? '' : 'hidden'}>
@@ -2427,15 +2626,6 @@ function renderBrokers() {
2427
2626
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2428
2627
  <span class="k">Предохранители и настройки:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2429
2628
  </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
2629
  ${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
2440
2630
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2441
2631
  <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
@@ -2618,12 +2808,20 @@ brokerForm.addEventListener('submit', async (e) => {
2618
2808
  resetHalt: true,
2619
2809
  };
2620
2810
  try {
2811
+ const savedId = editingBrokerId;
2621
2812
  state.brokers = editingBrokerId
2622
2813
  ? await api(`/api/brokers/${encodeURIComponent(editingBrokerId)}`, { method: 'PUT', body })
2623
2814
  : await post('/api/brokers', body);
2815
+ const targetId = savedId || state.brokers?.accounts?.[state.brokers.accounts.length - 1]?.id;
2624
2816
  stopBrokerEdit();
2625
2817
  renderBrokers();
2626
- toast('Брокерский счёт сохранён');
2818
+ toast('Настройки счёта сохранены — пересчитываем портфель и заявки…');
2819
+ if (targetId) {
2820
+ post(`/api/brokers/${encodeURIComponent(targetId)}/sync`)
2821
+ .then(() => post(`/api/brokers/${encodeURIComponent(targetId)}/prepare`))
2822
+ .then((st) => { state.brokers = st; renderBrokers(); })
2823
+ .catch(() => {});
2824
+ }
2627
2825
  } catch (err) { toast(err.message, 'error'); }
2628
2826
  });
2629
2827
 
@@ -2670,17 +2868,60 @@ $('#brokers-list').addEventListener('click', async (e) => {
2670
2868
  });
2671
2869
 
2672
2870
  $('#brokers-trade').addEventListener('click', async (e) => {
2871
+ const fltBtn = e.target.closest('button[data-hfilter]');
2872
+ if (fltBtn) {
2873
+ toggleBrokerHistFilter(fltBtn.dataset.bid, fltBtn.dataset.hfilter);
2874
+ renderBrokers();
2875
+ return;
2876
+ }
2877
+ const yearSummary = e.target.closest('.hist-year-box > summary');
2878
+ if (yearSummary) {
2879
+ const det = yearSummary.parentElement;
2880
+ const id = det?.dataset?.bid;
2881
+ const y = det?.dataset?.hyear;
2882
+ if (id && y) {
2883
+ setTimeout(() => {
2884
+ const yKey = `${id}:${y}`;
2885
+ if (det.open) {
2886
+ openHistYears.add(yKey);
2887
+ const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
2888
+ const loaded = Array.isArray(acc?.lastHistory?.loadedYears) && acc.lastHistory.loadedYears.map(String).includes(String(y));
2889
+ const hasOps = (acc?.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).toISOString().slice(0, 4) === String(y));
2890
+ if (!loaded && !hasOps && !loadingHistYears.has(yKey)) {
2891
+ loadingHistYears.add(yKey);
2892
+ renderBrokers();
2893
+ post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: Number(y) })
2894
+ .then((st) => { state.brokers = st; })
2895
+ .catch((err) => toast(err.message, 'error'))
2896
+ .finally(() => { loadingHistYears.delete(yKey); renderBrokers(); });
2897
+ }
2898
+ } else {
2899
+ openHistYears.delete(yKey);
2900
+ }
2901
+ }, 0);
2902
+ }
2903
+ return;
2904
+ }
2673
2905
  const tabBtn = e.target.closest('button[data-btab]');
2674
2906
  if (tabBtn) {
2675
2907
  const id = tabBtn.dataset.bid;
2676
2908
  const t = tabBtn.dataset.btab;
2677
2909
  setBrokerSubTab(id, t);
2910
+ if (t === 'trade') {
2911
+ preparingBrokerIds.add(id);
2912
+ renderBrokers();
2913
+ post(`/api/brokers/${encodeURIComponent(id)}/prepare`)
2914
+ .then((st) => { state.brokers = st; })
2915
+ .catch((err) => toast(err.message, 'error'))
2916
+ .finally(() => { preparingBrokerIds.delete(id); renderBrokers(); });
2917
+ return;
2918
+ }
2678
2919
  renderBrokers();
2679
2920
  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
- }
2921
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
2922
+ post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: curYear })
2923
+ .then((st) => { state.brokers = st; renderBrokers(); })
2924
+ .catch((err) => toast(err.message, 'error'));
2684
2925
  }
2685
2926
  return;
2686
2927
  }
@@ -476,7 +476,7 @@ export function createRouter() {
476
476
  r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
477
477
  r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
478
478
  r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
479
- r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id))));
479
+ r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id, req.body || {}))));
480
480
  r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
481
481
  r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
482
482
  r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
@@ -136,6 +136,15 @@ export function saveBrokerAccount(input = {}) {
136
136
  const rawMaxCap = Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000)));
137
137
  const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
138
138
  const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
139
+ const nextBaseRunId = input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null);
140
+ const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1)));
141
+ const strategyOrCapChanged = idx < 0
142
+ || acctChanged
143
+ || nextBaseRunId !== (prev.baseRunId || null)
144
+ || maxCapitalRub !== (prev.maxCapitalRub || 100000)
145
+ || legacyMode !== (prev.legacyMode || 'isolate')
146
+ || mode !== (prev.mode || 'confirm')
147
+ || nextLimitOffset !== (prev.limitOffsetPct ?? 0.1);
139
148
  const next = {
140
149
  id,
141
150
  name,
@@ -148,24 +157,34 @@ export function saveBrokerAccount(input = {}) {
148
157
  tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
149
158
  equityHistory: acctChanged ? [] : (prev.equityHistory || []),
150
159
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
151
- baseRunId: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
160
+ baseRunId: nextBaseRunId,
152
161
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
153
162
  maxCapitalRub,
154
163
  maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
155
- limitOffsetPct: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
164
+ limitOffsetPct: nextLimitOffset,
156
165
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
157
166
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
158
- lastScheduledBarTs: prev.lastScheduledBarTs || 0,
167
+ lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
159
168
  haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
160
169
  token,
161
170
  lastSync: prev.lastSync || null,
162
- pendingBatch: prev.pendingBatch || null,
171
+ pendingBatch: strategyOrCapChanged ? null : (prev.pendingBatch || null),
163
172
  };
164
173
  if (idx >= 0) list[idx] = next;
165
174
  else list.push(next);
166
175
  saveRawAccounts(list);
167
176
  const st = brokersState();
168
177
  hub.emit({ type: 'brokers:update', state: st });
178
+ // При изменении стратегии, лимита капитала или режима счёта сразу пересинхронизируем счёт и пересчитываем заявки под новые настройки
179
+ if (strategyOrCapChanged && token) {
180
+ const activeUser = currentUser();
181
+ setImmediate(() => {
182
+ runWithUser(activeUser, async () => {
183
+ await syncBrokerAccount(id).catch(() => {});
184
+ await prepareBrokerOrders(id).catch(() => {});
185
+ });
186
+ });
187
+ }
169
188
  return st;
170
189
  }
171
190
 
@@ -770,7 +789,7 @@ export async function prepareBrokerOrders(id) {
770
789
  // • VIP (без ограничений): полный форсаж (Conviction Slot Sizing до 42% капитала в лучших сигналах + полушаговый перехват H/2)
771
790
  let slotRub;
772
791
  if (vipBoost && autoAdapt) {
773
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
792
+ const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
774
793
  slotRub = budget * maxSlotWeight;
775
794
  } else if (plan.id === 'pro') {
776
795
  slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
@@ -791,7 +810,7 @@ export async function prepareBrokerOrders(id) {
791
810
  }
792
811
  }
793
812
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
794
- const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 1);
813
+ const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct))) : 1);
795
814
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
796
815
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
797
816
  const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
@@ -1174,31 +1193,29 @@ export function rejectBrokerOrders(id) {
1174
1193
  return st;
1175
1194
  }
1176
1195
 
1177
- // ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения за все годы) ----------
1178
- export async function fetchBrokerHistory(id) {
1196
+ // ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения; ленивая подгрузка по годам) ----------
1197
+ export async function fetchBrokerHistory(id, opts = {}) {
1179
1198
  const list = loadRawAccounts();
1180
1199
  const acc = list.find((x) => x.id === id);
1181
1200
  if (!acc) throw new Error('Счёт не найден');
1182
1201
  if (!acc.token) throw new Error('У счёта не задан токен API');
1183
1202
  if (acc.broker !== 'tbank') {
1184
- return { operations: [], summary: { tradesCount: 0, buySumRub: 0, sellSumRub: 0, commissionsRub: 0, dividendsRub: 0, inOutRub: 0 } };
1203
+ return brokersState();
1185
1204
  }
1186
1205
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
1187
1206
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
1188
1207
  const accountId = acc.accountId || acc.lastSync?.accountId;
1189
- if (!accountId) throw new Error('Сначала нажмите «Обновить портфель», чтобы определить ID счёта');
1208
+ if (!accountId) throw new Error('Сначала дождитесь синхронизации портфеля, чтобы определить ID счёта');
1190
1209
 
1191
- // Запрашиваем историю полугодовыми интервалами за последние 5 лет, чтобы не терять операции из-за лимита 1000 записей на один запрос Т-Банка
1192
1210
  const curYear = new Date(Date.now() + MSK).getUTCFullYear();
1211
+ const targetYear = Number(opts?.year) || curYear;
1193
1212
  const intervals = [];
1194
- for (let y = curYear; y >= curYear - 4; y--) {
1195
- const startH2 = Date.UTC(y, 6, 1);
1196
- const endH2 = Math.min(Date.now(), Date.UTC(y, 11, 31, 23, 59, 59));
1197
- if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
1198
- const startH1 = Date.UTC(y, 0, 1);
1199
- const endH1 = Math.min(Date.now(), Date.UTC(y, 5, 30, 23, 59, 59));
1200
- if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
1201
- }
1213
+ const startH2 = Date.UTC(targetYear, 6, 1);
1214
+ const endH2 = Math.min(Date.now(), Date.UTC(targetYear, 11, 31, 23, 59, 59));
1215
+ if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
1216
+ const startH1 = Date.UTC(targetYear, 0, 1);
1217
+ const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
1218
+ if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
1202
1219
 
1203
1220
  const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
1204
1221
  try {
@@ -1226,22 +1243,10 @@ export async function fetchBrokerHistory(id) {
1226
1243
  rawOps.push(op);
1227
1244
  }
1228
1245
  }
1229
- rawOps.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
1230
1246
 
1231
1247
  const figis = [...new Set(rawOps.map((o) => o.figi || o.instrumentUid).filter(Boolean))];
1232
1248
  const metaMap = await resolveTbankMetaMap(base, headers, figis);
1233
1249
 
1234
- let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
1235
- for (const op of rawOps) {
1236
- const pay = qVal(op.payment);
1237
- const t = String(op.operationType || '');
1238
- if (t.includes('BUY')) { tradesCount++; buySumRub += Math.abs(pay); }
1239
- else if (t.includes('SELL')) { tradesCount++; sellSumRub += Math.abs(pay); }
1240
- else if (t.includes('FEE') || t.includes('COMMISSION') || t.includes('TAX')) { commissionsRub += Math.abs(pay); }
1241
- else if (t.includes('DIVIDEND') || t.includes('COUPON')) { dividendsRub += pay; }
1242
- else if (t.includes('INPUT') || t.includes('OUTPUT')) { inOutRub += pay; }
1243
- }
1244
-
1245
1250
  const OP_KIND = (t = '') => {
1246
1251
  if (t.includes('BUY')) return ['buy', 'Покупка'];
1247
1252
  if (t.includes('SELL')) return ['sell', 'Продажа'];
@@ -1254,7 +1259,7 @@ export async function fetchBrokerHistory(id) {
1254
1259
  return ['other', 'Операция'];
1255
1260
  };
1256
1261
 
1257
- const operations = rawOps.slice(0, 5000).map((op) => {
1262
+ const fetchedOps = rawOps.map((op) => {
1258
1263
  const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
1259
1264
  const [kind, kindLabel] = OP_KIND(op.operationType);
1260
1265
  return {
@@ -1271,9 +1276,35 @@ export async function fetchBrokerHistory(id) {
1271
1276
  };
1272
1277
  });
1273
1278
 
1279
+ // Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
1280
+ const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1281
+ const otherYearsOps = prevOps.filter((op) => {
1282
+ const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
1283
+ return y !== targetYear;
1284
+ });
1285
+ const mergedOps = [...fetchedOps, ...otherYearsOps]
1286
+ .sort((a, b) => (b.date || 0) - (a.date || 0))
1287
+ .slice(0, 6000);
1288
+
1289
+ let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
1290
+ for (const op of mergedOps) {
1291
+ const pay = Number(op.paymentRub) || 0;
1292
+ if (op.kind === 'buy') { tradesCount++; buySumRub += Math.abs(pay); }
1293
+ else if (op.kind === 'sell') { tradesCount++; sellSumRub += Math.abs(pay); }
1294
+ else if (op.kind === 'fee') { commissionsRub += Math.abs(pay); }
1295
+ else if (op.kind === 'div') { dividendsRub += pay; }
1296
+ else if (op.kind === 'in' || op.kind === 'out') { inOutRub += pay; }
1297
+ }
1298
+
1299
+ const prevLoadedYears = Array.isArray(acc.lastHistory?.loadedYears)
1300
+ ? acc.lastHistory.loadedYears
1301
+ : [...new Set(prevOps.map((op) => op.date ? new Date(op.date + MSK).getUTCFullYear() : null).filter(Boolean))];
1302
+ const loadedYears = [...new Set([...prevLoadedYears, targetYear])].sort((a, b) => b - a);
1303
+
1274
1304
  const histData = {
1275
1305
  at: Date.now(),
1276
- totalCount: rawOps.length,
1306
+ loadedYears,
1307
+ totalCount: mergedOps.length,
1277
1308
  summary: {
1278
1309
  tradesCount,
1279
1310
  buySumRub: round(buySumRub, 0),
@@ -1282,7 +1313,7 @@ export async function fetchBrokerHistory(id) {
1282
1313
  dividendsRub: round(dividendsRub, 0),
1283
1314
  inOutRub: round(inOutRub, 0),
1284
1315
  },
1285
- operations,
1316
+ operations: mergedOps,
1286
1317
  };
1287
1318
  acc.lastHistory = histData;
1288
1319
  saveRawAccounts(list);
@@ -1322,8 +1353,8 @@ async function tickBrokerSchedule() {
1322
1353
  const autoAdapt = params.modelAutoAdapt !== false;
1323
1354
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
1324
1355
  const H = barsFromHours(effHorizon);
1325
- // Полушаговое сопровождение H/2 (каждые 1 ч 15 мин) активно в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1326
- const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(2, Math.floor(H / 2)) : H;
1356
+ // Внутричасовой перехват H/5 (каждые 30 мин) активен в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1357
+ const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
1327
1358
  if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1328
1359
  acc.lastScheduledBarTs = latest;
1329
1360
  saveRawAccounts(list);
@@ -1362,8 +1393,35 @@ async function resumeBrokerAccountsOnBoot() {
1362
1393
  } catch { /* ignore startup sync errors */ }
1363
1394
  }
1364
1395
 
1396
+ // Автоматическое периодическое фоновое обновление портфеля и истории текущего года (раз в 2 минуты)
1397
+ let autoRefreshBusy = false;
1398
+ async function tickBrokerAutoRefresh() {
1399
+ if (autoRefreshBusy || schedulerBusy) return;
1400
+ autoRefreshBusy = true;
1401
+ try {
1402
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
1403
+ const users = db.prepare('SELECT * FROM users').all();
1404
+ for (const u of users) {
1405
+ await runWithUser(u, async () => {
1406
+ const list = loadRawAccounts();
1407
+ for (const acc of list) {
1408
+ if (!acc.token) continue;
1409
+ await syncBrokerAccount(acc.id).catch(() => {});
1410
+ if (acc.broker === 'tbank') {
1411
+ await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
1412
+ }
1413
+ }
1414
+ });
1415
+ }
1416
+ } finally {
1417
+ autoRefreshBusy = false;
1418
+ }
1419
+ }
1420
+
1365
1421
  export function startBrokerScheduler() {
1366
1422
  setTimeout(resumeBrokerAccountsOnBoot, 2500);
1367
1423
  const timer = setInterval(tickBrokerSchedule, 60000);
1424
+ const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
1368
1425
  timer.unref?.();
1426
+ syncTimer.unref?.();
1369
1427
  }
@@ -1,21 +1,127 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { normalizeParams } from '../settings.js';
3
- import { round, DAY, fmtBarMsk } from '../util.js';
3
+ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
4
4
  import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
- import { buildFold, MODELS, HEADS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
5
+ import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
6
6
  import { loadProfiles } from './profiles.js';
7
7
  import { isMarketDown } from './research.js';
8
8
  import { newsForSymbols } from '../news/index.js';
9
9
 
10
+ const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
10
11
  let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
11
12
  const inflight = new Map();
12
13
 
14
+ // Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
15
+ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
16
+ const { tokens, mret, mclose } = ds;
17
+ const N = fTrain.N;
18
+ const { beta, sigma, cos } = fTrain;
19
+ const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
20
+ const u = idx - lo;
21
+ const W = u + 1;
22
+
23
+ const z = new Float64Array(W * N);
24
+ const vr = new Float64Array(W * N);
25
+ for (let uk = 0; uk < W; uk++) {
26
+ const t = lo + uk;
27
+ const isLast = uk === u && liveSnap?.prices?.size > 0;
28
+ const mPrev = mclose?.[t - 1] || 0;
29
+ const mt = isLast && liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : mret[t];
30
+ for (let i = 0; i < N; i++) {
31
+ const tk = tokens[i];
32
+ let rt = tk.ret[t];
33
+ if (isLast) {
34
+ const prevPx = tk.close[t - 1];
35
+ const livePx = liveSnap.prices.get(tk.symbol);
36
+ if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
37
+ }
38
+ const e = rt - beta[i] * mt;
39
+ z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
40
+ vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
41
+ }
42
+ }
43
+
44
+ const X = KINDS.map(() => SCALES.map(() => new Float32Array(W * N)));
45
+ SCALES.forEach((sc, si) => {
46
+ const Xp = X[0][si], Xf = X[1][si];
47
+ const invP = 1 / Math.sqrt(sc.len), invF = invP / 1.5;
48
+ for (let i = 0; i < N; i++) {
49
+ let sp = 0, sf = 0;
50
+ for (let k = 0; k < sc.len; k++) {
51
+ const uk = u - sc.from - k;
52
+ if (uk < 0) break;
53
+ const zk = z[uk * N + i];
54
+ sp += zk;
55
+ sf += zk * vr[uk * N + i];
56
+ }
57
+ Xp[u * N + i] = sp * invP;
58
+ Xf[u * N + i] = sf * invF;
59
+ }
60
+ });
61
+
62
+ let macro = null;
63
+ if (fTrain.macro?.list?.length && ds.macro?.length) {
64
+ const list = fTrain.macro.list.map((mTrain) => {
65
+ const dm = ds.macro.find((x) => x.id === mTrain.id);
66
+ const zs = new Float32Array(SCALES.length * W);
67
+ if (dm) {
68
+ const msig = mTrain.msig || 1e-3;
69
+ SCALES.forEach((sc, si) => {
70
+ let sum = 0;
71
+ for (let k = 0; k < sc.len; k++) {
72
+ const t = idx - sc.from - k;
73
+ if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
74
+ }
75
+ zs[si * W + u] = sum / Math.sqrt(sc.len);
76
+ });
77
+ }
78
+ return { id: mTrain.id, label: mTrain.label, b: mTrain.b, zs };
79
+ });
80
+ macro = { list };
81
+ }
82
+
83
+ const f = { N, lo, hi: idx + 1, W, beta, sigma, z, X, cos, emb: fTrain.emb, embN: fTrain.embN, macro };
84
+ const att = fitted.attention;
85
+ let fittedForStep = fitted;
86
+ if (att?.As) {
87
+ const F = HEADS.map(() => new Float32Array(W * N));
88
+ const off = u * N;
89
+ for (let hi = 0; hi < HEADS.length; hi++) {
90
+ const h = HEADS[hi];
91
+ const bIdx = BETAS.findIndex((b) => b.v === h.beta);
92
+ const A = att.As[bIdx];
93
+ const Xh = X[h.kind === 'price' ? 0 : 1][h.scale];
94
+ const Fh = F[hi];
95
+ for (let j = 0; j < N; j++) {
96
+ let s = 0;
97
+ const row = j * N;
98
+ for (let i = 0; i < N; i++) s += A[row + i] * Xh[off + i];
99
+ Fh[off + j] = s;
100
+ }
101
+ }
102
+ fittedForStep = { ...fitted, attention: { ...att, F } };
103
+ }
104
+ return { f, u, fitted: fittedForStep };
105
+ }
106
+
13
107
  export const lastClosedIndex = (ds, now = Date.now()) => {
14
108
  let last = ds.T - 1;
15
109
  while (last > 0 && ds.timeline[last] + BAR_MS > now) last--;
16
110
  return last;
17
111
  };
18
112
 
113
+ export const isModelTrainedToday = (p, now = Date.now()) => {
114
+ const autoAdapt = p.modelAutoAdapt !== false;
115
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
116
+ const chosen = p.modelType in MODELS ? p.modelType : 'attention';
117
+ const todayMsk = new Date(now + MSK).toISOString().slice(0, 10);
118
+ const prefix = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight]).slice(0, -1);
119
+ for (const [k, v] of trainedModelCache) {
120
+ if (v.dayMsk === todayMsk && k.startsWith(prefix)) return true;
121
+ }
122
+ return false;
123
+ };
124
+
19
125
  // Быстрое получение живых цен Мосбиржи (TQBR + IMOEX) за 1 лёгкий запрос (~80 мс)
20
126
  async function fetchLiveMoexSnapshot() {
21
127
  try {
@@ -107,28 +213,63 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
107
213
  }
108
214
  }
109
215
 
110
- // Обучение на окне, заканчивающемся свечой idx, и прогноз по этой свече.
111
- // Возвращает строки по всем акциям: ожидаемое движение (относительно рынка), сигнал покупки, кто влияет.
112
- export async function predictAt(ds, p, idx, { explain = true, liveSnap = null } = {}) {
216
+ // Обучение ядра модели (карта рынка + веса внимания) выполняется не чаще 1 раза в торговый день для каждого набора параметров.
217
+ // На всех свечах внутри дня выполняется быстрый расчёт прогноза по уже обученным весам (buildInferenceFoldAt).
218
+ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, forceTrain = false } = {}) {
113
219
  const autoAdapt = p.modelAutoAdapt !== false;
114
220
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
115
221
  const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
116
222
  const H = barsFromHours(effHorizon);
117
223
  const maxH = autoAdapt ? Math.round(H * 1.4) : H;
224
+ const chosen = p.modelType in MODELS ? p.modelType : 'attention';
225
+ const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
226
+ const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
227
+
228
+ const cached = trainedModelCache.get(fitKey);
229
+ if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
230
+ const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
231
+ return {
232
+ ...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
233
+ snap: cached.snap,
234
+ trainedAt: cached.trainedAt,
235
+ };
236
+ }
237
+
118
238
  const trainLen = Math.round(p.modelTrainDays * ds.barsPerDay);
119
239
  const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
120
- // «текстовая» часть карты: устойчивые описания бизнеса (профили готовятся отдельно, здесь только читаем)
121
240
  const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
122
241
  const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
123
242
  const u = idx - f.lo;
124
- const chosen = p.modelType in MODELS ? p.modelType : 'attention';
125
243
  const list = [...new Set(explain ? Object.keys(MODELS) : ['attention', chosen])];
126
244
  const fitted = {};
127
245
  for (const n of list) {
128
246
  fitted[n] = await MODELS[n].fit(f);
129
247
  }
248
+ const snap = mapSnapshot(f);
249
+ // Освобождаем тяжёлые полные 90-дневные буферы признаков из памяти, оставляя только структурное ядро модели на день
250
+ const fTrain = {
251
+ N: f.N,
252
+ beta: f.beta,
253
+ sigma: f.sigma,
254
+ cos: f.cos,
255
+ emb: f.emb,
256
+ embN: f.embN,
257
+ eigenvalues: f.eigenvalues,
258
+ macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
259
+ };
260
+ const lightFitted = {
261
+ ...fitted,
262
+ ...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
263
+ };
264
+ const trainedAt = Date.now();
265
+ trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
266
+
130
267
  if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
131
- return buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap);
268
+ return {
269
+ ...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
270
+ snap,
271
+ trainedAt,
272
+ };
132
273
  }
133
274
 
134
275
  function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap = null) {
@@ -210,6 +351,14 @@ function attachDeltas(rows, stateObj) {
210
351
  }
211
352
  }
212
353
 
354
+ const fmtLiveBarLabel = (barTs, liveSnapTs) => {
355
+ if (liveSnapTs && (!barTs || liveSnapTs > barTs + BAR_MS)) {
356
+ const liveTime = fmtBarMsk(liveSnapTs).slice(-5);
357
+ return `${fmtBarMsk(barTs || liveSnapTs)} · котировки ${liveTime}`;
358
+ }
359
+ return fmtBarMsk(barTs || liveSnapTs || Date.now());
360
+ };
361
+
213
362
  // Прогноз на ближайшие H часов с живым поминутным обновлением котировок (без переобучения тяжёлого окна каждую минуту)
214
363
  export async function forecastNow(input, { force = false, onProgress } = {}) {
215
364
  const p = normalizeParams(input);
@@ -223,18 +372,21 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
223
372
  if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt < 15000) {
224
373
  return withNews(baseState.lastTickResult, p);
225
374
  }
226
- // 2) Если базовая модель обучена менее 10 минут назад и нет force — делаем быстрый живой пересчёт (~80 мс запрос + 2 мс математика)
227
- if (!force && baseState && baseState.key === key && now - baseState.trainedAt < 10 * 60000) {
375
+ // 2) Если срез свечей обновлялся менее 2 минут назад и нет force — делаем быстрый живой пересчёт (~80 мс запрос + 2 мс математика)
376
+ if (!force && baseState && baseState.key === key && now - baseState.dsLoadedAt < 2 * 60000) {
228
377
  const liveSnap = await fetchLiveMoexSnapshot();
229
378
  if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
230
379
  const H = barsFromHours(effHorizon);
231
380
  const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, autoAdapt, true, liveSnap);
232
381
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
233
382
  attachDeltas(sortedRows, baseState);
383
+ const liveTs = liveSnap?.ts || now;
384
+ const barTs = baseState.ds.timeline[baseState.last];
234
385
  const result = {
235
386
  ...baseState.lastTickResult,
236
387
  at: now,
237
- liveTs: liveSnap?.ts || now,
388
+ liveTs,
389
+ bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
238
390
  rows: sortedRows,
239
391
  };
240
392
  baseState.lastTickResult = result;
@@ -244,21 +396,26 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
244
396
 
245
397
  if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
246
398
  const task = (async () => {
399
+ const trainedToday = !force && isModelTrainedToday(p, now);
400
+ const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
247
401
  const [ds, liveSnap] = await Promise.all([
248
- loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress }),
402
+ loadDataset({ fromTs: now - windowDays * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress }),
249
403
  fetchLiveMoexSnapshot(),
250
404
  ]);
251
405
  const last = lastClosedIndex(ds, now);
252
- const r = await predictAt(ds, p, last, { explain: true, liveSnap });
253
- const snap = mapSnapshot(r.f);
406
+ const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
407
+ const snap = r.snap || mapSnapshot(r.f);
254
408
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
255
409
  const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
256
410
  attachDeltas(sortedRows, stateObj);
411
+ const liveTs = liveSnap?.ts || now;
412
+ const barTs = ds.timeline[last];
257
413
  const result = {
258
414
  at: now,
259
- liveTs: liveSnap?.ts || now,
260
- barTs: ds.timeline[last],
261
- bar: fmtBarMsk(ds.timeline[last]),
415
+ trainedAt: r.trainedAt || now,
416
+ liveTs,
417
+ barTs,
418
+ bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
262
419
  horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3), marketDown: r.marketDown,
263
420
  rows: sortedRows,
264
421
  map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
@@ -266,7 +423,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
266
423
  };
267
424
  baseState = {
268
425
  key,
269
- trainedAt: now,
426
+ trainedAt: r.trainedAt || now,
427
+ dsLoadedAt: now,
270
428
  ds,
271
429
  last,
272
430
  f: r.f,
@@ -5,7 +5,7 @@ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
5
5
  import { hub } from '../hub.js';
6
6
  import { latestBarTs } from '../data/moex.js';
7
7
  import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
8
- import { predictAt, lastClosedIndex } from './forecast.js';
8
+ import { predictAt, lastClosedIndex, isModelTrainedToday } from './forecast.js';
9
9
  import { computeReport, downsample } from './metrics.js';
10
10
  import { MODELS } from './models.js';
11
11
  import { blockingNews } from '../news/index.js';
@@ -90,12 +90,32 @@ class LiveEngine {
90
90
  const status = returnPct < expPct - bandPct ? 'below' : returnPct > expPct + bandPct ? 'above' : 'inside';
91
91
  corridor = { runId: s.benchmark.runId, expectedPct: expPct, bandPct, minPct: round(expPct - bandPct, 1), maxPct: round(expPct + bandPct, 1), histReturnPct: s.benchmark.returnPct, histMaxDdPct: s.benchmark.maxDrawdownPct, status };
92
92
  }
93
+ const tradesList = [...(s.trades || [])].reverse().slice(0, 120).map((t) => ({
94
+ symbol: t.symbol,
95
+ side: t.side || 'long',
96
+ qty: t.qty || 0,
97
+ entryTs: t.entryTs,
98
+ exitTs: t.exitTs,
99
+ entryPrice: t.entryPrice || 0,
100
+ exitPrice: t.exitPrice || 0,
101
+ pnl: round(Number(t.pnl || 0), 2),
102
+ pnlPct: round(Number(t.pnlPct || 0), 2),
103
+ commission: round(Number(t.commission || 0), 2),
104
+ barsHeld: t.barsHeld || 0,
105
+ reasonEntry: t.reasonEntry || '',
106
+ reasonExit: t.reasonExit || '',
107
+ }));
108
+ const realizedPnl = round((s.trades || []).reduce((sum, t) => sum + (Number(t.pnl) || 0), 0), 2);
109
+ const totalCommission = round((s.trades || []).reduce((sum, t) => sum + (Number(t.commission) || 0), 0), 2);
93
110
  return {
94
111
  running: s.running, runId: s.runId, name: s.name || `Виртуальный счёт №${s.runId}`,
95
112
  baseRunId: s.baseRunId || null, benchmark: s.benchmark || null, corridor,
96
113
  params: s.params, startedAt: s.startedAt, stoppedAt: s.stoppedAt || null, lastBarTs: s.lastBarTs,
97
114
  error: s.error, busy: s.busy, snapshot: snap, returnPct, pnl,
98
- recent: s.recent.slice(0, 20), metrics: s.metrics,
115
+ realizedPnl, totalCommission,
116
+ trades: tradesList,
117
+ curve: downsample(s.curve || [], 120),
118
+ recent: s.recent.slice(0, 25), metrics: s.metrics,
99
119
  };
100
120
  }
101
121
 
@@ -179,7 +199,9 @@ class LiveEngine {
179
199
  if (!run || run.mode !== 'paper' || !st.params?.modelHorizon) continue;
180
200
  const isRunning = Boolean(st.running && run.status === 'running');
181
201
  const trades = getTrades(st.runId).map((t) => ({
182
- symbol: t.symbol, side: t.side, pnl: t.pnl, pnlPct: t.pnl_pct, entryTs: t.entry_ts, exitTs: t.exit_ts, commission: t.commission,
202
+ symbol: t.symbol, side: t.side, qty: t.qty, entryPrice: t.entry_price, exitPrice: t.exit_price,
203
+ pnl: t.pnl, pnlPct: t.pnl_pct, entryTs: t.entry_ts, exitTs: t.exit_ts, commission: t.commission,
204
+ reasonEntry: t.reason_entry, reasonExit: t.reason_exit,
183
205
  barsHeld: Math.round((t.exit_ts - t.entry_ts) / 600000) * BAR_INTERVAL,
184
206
  }));
185
207
  const curve = st.curve || [];
@@ -239,7 +261,9 @@ class LiveEngine {
239
261
  try {
240
262
  const p = s.params;
241
263
  const now = Date.now();
242
- const ds = await loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
264
+ const trainedToday = isModelTrainedToday(p, now);
265
+ const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
266
+ const ds = await loadDataset({ fromTs: now - windowDays * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
243
267
  s.lastSeenTs = latest;
244
268
  changed = true;
245
269
  const bySym = new Map(ds.tokens.map((t, i) => [t.symbol, i]));
@@ -308,7 +332,7 @@ class LiveEngine {
308
332
  const dir = this.#direction(s);
309
333
  const slots = dir === 'long' ? K : 2 * K;
310
334
  const autoAdapt = s.params.modelAutoAdapt !== false;
311
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
335
+ const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
312
336
  const invVol = s.params.modelAllocMode === 'invvol';
313
337
  const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
314
338
  const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
@@ -320,7 +344,7 @@ class LiveEngine {
320
344
  const side = o.action === 'short' ? 'short' : 'long';
321
345
  const px = price(o.symbol);
322
346
  if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) continue;
323
- const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(o.expectedPct) / needPct))) : 1;
347
+ const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(o.expectedPct) / needPct))) : 1;
324
348
  const baseAlloc = autoAdapt ? Math.min(s.equity * maxSlotWeight * conv, avail / Math.max(1, buyOrders.length - idx)) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
325
349
  let alloc = baseAlloc;
326
350
  if (invVol && o.weight > 0) {
@@ -356,7 +380,7 @@ class LiveEngine {
356
380
  const autoAdapt = p.modelAutoAdapt !== false;
357
381
  const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
358
382
  const H = barsFromHours(effHorizon);
359
- const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
383
+ const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
360
384
  if (s.barsSinceRebalance >= stepBars) {
361
385
  await new Promise((r) => setImmediate(r));
362
386
  const r = await predictAt(ds, p, idx);
@@ -312,8 +312,8 @@ async function run(runId, p, state) {
312
312
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
313
313
  const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
314
314
  let curExitRatio = autoAdapt ? 0.95 : exitRatio;
315
- const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
316
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
315
+ const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
316
+ const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
317
317
 
318
318
  const closePos = (i, h, t, end, why) => {
319
319
  const exitPrice = px(i, t);
@@ -405,7 +405,7 @@ async function run(runId, p, state) {
405
405
  for (let idx = 0; idx < newEntries.length; idx++) {
406
406
  const [i, side, edgeAt] = newEntries[idx];
407
407
  const effEdge = edgeAt !== null ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
408
- const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / needEdge))) : 1;
408
+ const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (effEdge / needEdge))) : 1;
409
409
  const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
410
410
  let alloc = baseAlloc;
411
411
  if (invVol && avgInv > 0 && sg[i] > 0) {