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 +1 -1
- package/public/app.js +283 -42
- package/server/api/routes.js +1 -1
- package/server/brokers/index.js +94 -36
- package/server/model/forecast.js +176 -18
- package/server/model/live.js +31 -7
- package/server/model/research.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.3.
|
|
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
|
|
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="
|
|
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"
|
|
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"
|
|
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">${
|
|
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
|
-
<
|
|
1079
|
-
|
|
1080
|
-
|
|
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"
|
|
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
|
-
|
|
2387
|
-
const
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
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
|
-
${
|
|
2591
|
+
${bodyContent}
|
|
2395
2592
|
</details>`;
|
|
2396
2593
|
}).join('');
|
|
2397
2594
|
|
|
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>
|
|
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
|
-
:
|
|
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?.
|
|
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
|
|
2681
|
-
|
|
2682
|
-
|
|
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
|
}
|
package/server/api/routes.js
CHANGED
|
@@ -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))));
|
package/server/brokers/index.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
|
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('Сначала
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1326
|
-
const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(
|
|
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
|
}
|
package/server/model/forecast.js
CHANGED
|
@@ -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
|
-
// Обучение
|
|
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
|
|
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) Если
|
|
227
|
-
if (!force && baseState && baseState.key === key && now - baseState.
|
|
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
|
|
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 -
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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,
|
package/server/model/live.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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);
|
package/server/model/research.js
CHANGED
|
@@ -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(
|
|
316
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
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.
|
|
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) {
|