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/README.md +1 -1
- package/docs//320/221/320/240/320/236/320/232/320/225/320/240/320/253_API.md +118 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
- package/package.json +1 -1
- package/public/app.js +434 -49
- package/public/index.html +22 -8
- package/public/styles.css +4 -2
- package/server/api/routes.js +1 -1
- package/server/brokers/index.js +388 -45
- package/server/model/forecast.js +176 -18
- package/server/model/live.js +31 -7
- package/server/model/research.js +3 -3
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
|
|
990
|
-
<table style="margin-top:
|
|
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
|
|
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="
|
|
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"
|
|
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"
|
|
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">${
|
|
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
|
-
<
|
|
1079
|
-
|
|
1080
|
-
|
|
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 = {
|
|
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"
|
|
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
|
-
|
|
2387
|
-
const
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
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
|
-
${
|
|
2715
|
+
${bodyContent}
|
|
2395
2716
|
</details>`;
|
|
2396
2717
|
}).join('');
|
|
2397
2718
|
|
|
2398
|
-
const histHtml = lh?.operations?.length
|
|
2399
|
-
?
|
|
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
|
-
:
|
|
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?.
|
|
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
|
|
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',
|
|
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
|
|
2681
|
-
|
|
2682
|
-
|
|
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
|
}
|