neuroalpha 0.6.3 → 0.6.5
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 +12 -3
- package/server/brokers/index.js +257 -111
- package/server/model/dataset.js +8 -2
- package/server/model/forecast.js +22 -9
- package/server/model/policy.js +2 -2
- package/server/model/simulator.js +40 -16
- package/server/model/walkforward.js +3 -1
- package/server/session.js +42 -0
- package/server/strategist/index.js +56 -18
- package/server/strategist/tools.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1076,6 +1076,10 @@ async function loadForecast(force, silent = false) {
|
|
|
1076
1076
|
reasons.push(`Фильтр новостей (сильный позитив): ${r.shortBlockedByNews}`);
|
|
1077
1077
|
if (!label) label = '⛔ новости';
|
|
1078
1078
|
}
|
|
1079
|
+
if (r.shortBlockedByTime) {
|
|
1080
|
+
reasons.push('Вне внутридневного окна открытия новых шортов (утренний старт или конец сессии)');
|
|
1081
|
+
if (!label) label = '⛔ вне окна';
|
|
1082
|
+
}
|
|
1079
1083
|
} else {
|
|
1080
1084
|
const dip = Boolean(r.buyBlockedByDip || (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5));
|
|
1081
1085
|
if (dip) {
|
|
@@ -2964,7 +2968,8 @@ function renderBrokers() {
|
|
|
2964
2968
|
const isPaused = a.autoSchedule === false || a.mode === 'readonly';
|
|
2965
2969
|
const isEditingThis = editingBrokerId === a.id;
|
|
2966
2970
|
const isSpecsHidden = hideBrokerSpecsById.has(a.id);
|
|
2967
|
-
const
|
|
2971
|
+
const hasPortfolioSnapshot = Boolean(ls && (ls.ok || ls.totalRub > 0 || (ls.positions && ls.positions.length > 0)));
|
|
2972
|
+
const collapsedMetricsHtml = !isOpen && hasPortfolioSnapshot
|
|
2968
2973
|
? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b>${ls.marginInfo ? ` · Марж. запас: <b class="pos">${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</b>` : ''} · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b>${a.executionAudit?.periods ? ` · 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub >= 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b>` : ''}</span>`
|
|
2969
2974
|
: '';
|
|
2970
2975
|
|
|
@@ -3005,13 +3010,15 @@ function renderBrokers() {
|
|
|
3005
3010
|
<span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · плечо <b>${fmtNum(a.maxLeverage || 1, 1)}×</b> · стоп по общей просадке <b>${a.maxDrawdownPct > 0 ? `−${fmtNum(a.maxDrawdownPct, 0)}%` : 'выкл.'}</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
|
|
3006
3011
|
${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
|
|
3007
3012
|
</div>
|
|
3008
|
-
${
|
|
3013
|
+
${hasPortfolioSnapshot ? `<div class="kpis">
|
|
3009
3014
|
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${visiblePositions.length} поз.${hideNonTradableAssets && nonTradableCount ? `, скрыто: ${nonTradableCount}` : ''})</div>${ls.marginInfo ? `<div class="sub">марж. запас: ${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</div>` : ''}</div>
|
|
3010
3015
|
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div>${ls.marginInfo?.startingMarginRub > 0 ? `<div class="sub">занято маржи: ${fmtNum(ls.marginInfo.startingMarginRub, 0)} ₽</div>` : ''}</div>
|
|
3011
3016
|
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div>${a.executionAudit?.periods ? `<div class="sub">за 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b></div>` : ''}</div>
|
|
3012
3017
|
<div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div>${a.executionAudit?.periods ? `<div class="sub">сегодня: <b class="${cls(a.executionAudit.periods.day.netRub)}">${a.executionAudit.periods.day.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.day.netRub, 0)} ₽</b></div>` : ''}</div>
|
|
3013
3018
|
</div>` : ''}
|
|
3014
|
-
${ls && !ls.ok
|
|
3019
|
+
${(ls?.stale || (ls && !ls.ok && hasPortfolioSnapshot))
|
|
3020
|
+
? `<p class="small" style="color:var(--yellow);margin:6px 0">⏳ Сервис портфеля брокера временно отвечает с задержкой (${esc(ls.staleReason || ls.error || 'таймаут')}) — показан сохранённый снимок позиций${(ls.lastFullSyncAt || ls.staleSince) ? ` от ${fmtTs(ls.lastFullSyncAt || ls.staleSince)}` : ''}, котировки и заявки обновляются автоматически.</p>`
|
|
3021
|
+
: (ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : '')}
|
|
3015
3022
|
<div class="brk-subtabs" style="align-items:center">
|
|
3016
3023
|
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${visiblePositions.length})</button>
|
|
3017
3024
|
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
|
@@ -4226,6 +4233,8 @@ function buildStockModalHeaderHtml(d) {
|
|
|
4226
4233
|
? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
|
|
4227
4234
|
: fc?.shortBlockedByBroker
|
|
4228
4235
|
? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
|
|
4236
|
+
: fc?.shortBlockedByTime
|
|
4237
|
+
? '<span class="tag blocked" title="Вне внутридневного окна открытия новых шортов">⛔ вне окна шорта</span>'
|
|
4229
4238
|
: fc?.buyBlockedByDip
|
|
4230
4239
|
? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
|
|
4231
4240
|
: fc?.buyBlockedByOverheat
|
package/server/brokers/index.js
CHANGED
|
@@ -1017,46 +1017,84 @@ async function syncTbank(acc) {
|
|
|
1017
1017
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1018
1018
|
let accountId = acc.accountId;
|
|
1019
1019
|
let accountName = acc.tbankAccountName || '';
|
|
1020
|
-
let accountType = '';
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1020
|
+
let accountType = acc.lastSync?.accountType || '';
|
|
1021
|
+
if (!accountId || !accountName || !accountType) {
|
|
1022
|
+
const rAcc = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetAccounts`, {
|
|
1023
|
+
method: 'POST', headers, body: JSON.stringify({}), timeoutMs: 12000,
|
|
1024
|
+
}).catch(() => null);
|
|
1025
|
+
if (rAcc?.ok) {
|
|
1026
|
+
const jAcc = await rAcc.json().catch(() => ({}));
|
|
1027
|
+
const openAccs = (jAcc.accounts || []).filter((a) => !a.status || a.status === 'ACCOUNT_STATUS_OPEN');
|
|
1028
|
+
if (!accountId) accountId = (openAccs[0] || jAcc.accounts?.[0])?.id;
|
|
1029
|
+
const matched = (jAcc.accounts || []).find((a) => a.id === accountId);
|
|
1030
|
+
if (matched?.name) accountName = matched.name;
|
|
1031
|
+
if (matched?.type) accountType = matched.type;
|
|
1032
|
+
}
|
|
1031
1033
|
}
|
|
1032
1034
|
if (!accountId) throw new Error('В Т-Банке не найдено открытых счетов (проверьте токен API)');
|
|
1033
1035
|
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1036
|
+
const needAutoFollowCheck = !acc.lastSync?.autoFollowCheckedAt || (Date.now() - acc.lastSync.autoFollowCheckedAt > 30 * 60000);
|
|
1037
|
+
const fetchPortfolioOnce = (timeoutMs) => brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
|
|
1038
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), timeoutMs,
|
|
1039
|
+
});
|
|
1040
|
+
const fetchPortfolioWithRetry = async () => {
|
|
1041
|
+
try {
|
|
1042
|
+
const r1 = await fetchPortfolioOnce(14000);
|
|
1043
|
+
if (r1.ok || (r1.status !== 429 && r1.status < 500)) return { res: r1, err: null };
|
|
1044
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
1045
|
+
const r2 = await fetchPortfolioOnce(16000);
|
|
1046
|
+
return { res: r2, err: null };
|
|
1047
|
+
} catch (e1) {
|
|
1048
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
1049
|
+
try {
|
|
1050
|
+
const r2 = await fetchPortfolioOnce(16000);
|
|
1051
|
+
return { res: r2, err: null };
|
|
1052
|
+
} catch (e2) {
|
|
1053
|
+
return { res: null, err: e2 || e1 };
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
const [portOutcome, rOrd, rMargin, rRecentOps] = await Promise.all([
|
|
1059
|
+
fetchPortfolioWithRetry(),
|
|
1038
1060
|
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
1039
1061
|
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
1040
1062
|
}).catch(() => null),
|
|
1041
1063
|
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetMarginAttributes`, {
|
|
1042
1064
|
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
1043
1065
|
}).catch(() => null),
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1066
|
+
needAutoFollowCheck
|
|
1067
|
+
? brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
|
|
1068
|
+
method: 'POST',
|
|
1069
|
+
headers,
|
|
1070
|
+
body: JSON.stringify({
|
|
1071
|
+
accountId,
|
|
1072
|
+
from: new Date(Date.now() - 35 * DAY).toISOString(),
|
|
1073
|
+
to: new Date().toISOString(),
|
|
1074
|
+
state: 'OPERATION_STATE_EXECUTED',
|
|
1075
|
+
}),
|
|
1076
|
+
timeoutMs: 10000,
|
|
1077
|
+
}).catch(() => null)
|
|
1078
|
+
: Promise.resolve(null),
|
|
1055
1079
|
]);
|
|
1056
|
-
|
|
1057
|
-
|
|
1080
|
+
const rPort = portOutcome.res;
|
|
1081
|
+
const portErrorMsg = portOutcome.err
|
|
1082
|
+
? portOutcome.err.message
|
|
1083
|
+
: (!rPort?.ok ? `T-Invest GetPortfolio: HTTP ${rPort?.status || 0}` : null);
|
|
1084
|
+
const isTransientPortFailure = Boolean(portErrorMsg && (!rPort || rPort.status === 429 || rPort.status >= 500 || /время ожидания|таймаут|закрыт|ECONN|ETIMEDOUT|socket/i.test(portErrorMsg)));
|
|
1085
|
+
const prevSync = acc.lastSync && Array.isArray(acc.lastSync.positions) && (acc.lastSync.totalRub > 0 || acc.lastSync.positions.length > 0)
|
|
1086
|
+
? acc.lastSync
|
|
1087
|
+
: null;
|
|
1088
|
+
if (portErrorMsg && !(isTransientPortFailure && prevSync)) {
|
|
1089
|
+
throw new Error(portErrorMsg);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let autoFollowDetected = Boolean(acc.lastSync?.autoFollowDetected) || /автослед|сигнал|copy|follow|comon|strategy/i.test(accountName);
|
|
1093
|
+
let autoFollowCheckedAt = acc.lastSync?.autoFollowCheckedAt || 0;
|
|
1058
1094
|
if (rRecentOps?.ok) {
|
|
1095
|
+
autoFollowCheckedAt = Date.now();
|
|
1059
1096
|
const jOps = await rRecentOps.json().catch(() => ({}));
|
|
1097
|
+
autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(accountName);
|
|
1060
1098
|
for (const op of jOps.operations || []) {
|
|
1061
1099
|
const ot = String(op.operationType || '');
|
|
1062
1100
|
const tp = String(op.type || '');
|
|
@@ -1066,8 +1104,11 @@ async function syncTbank(acc) {
|
|
|
1066
1104
|
}
|
|
1067
1105
|
}
|
|
1068
1106
|
}
|
|
1069
|
-
const p = await rPort.json();
|
|
1070
|
-
const
|
|
1107
|
+
const p = rPort?.ok ? await rPort.json().catch(() => null) : null;
|
|
1108
|
+
const usingFallbackPortfolio = !p;
|
|
1109
|
+
const hasShortPos = usingFallbackPortfolio
|
|
1110
|
+
? (prevSync?.positions || []).some((pos) => Number(pos.qty) < 0)
|
|
1111
|
+
: (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
|
|
1071
1112
|
let marginEnabled = Boolean(acc.lastSync?.marginEnabled || hasShortPos);
|
|
1072
1113
|
let marginInfo = acc.lastSync?.marginInfo || null;
|
|
1073
1114
|
if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
|
|
@@ -1133,14 +1174,87 @@ async function syncTbank(acc) {
|
|
|
1133
1174
|
rawOrders = keptOrders;
|
|
1134
1175
|
}
|
|
1135
1176
|
}
|
|
1136
|
-
const
|
|
1137
|
-
const rawPositions = p.positions || [];
|
|
1177
|
+
const rawPositions = p?.positions || [];
|
|
1138
1178
|
const lookupIds = [
|
|
1139
1179
|
...rawPositions.flatMap((pos) => [pos.ticker, pos.figi, pos.instrumentUid]),
|
|
1140
1180
|
...rawOrders.flatMap((o) => [o.ticker, o.figi, o.instrumentUid]),
|
|
1141
1181
|
].filter(Boolean);
|
|
1142
1182
|
const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
|
|
1143
1183
|
|
|
1184
|
+
const activeOrders = rOrd?.ok ? [] : (prevSync?.activeOrders || []);
|
|
1185
|
+
if (rOrd?.ok) {
|
|
1186
|
+
for (const o of rawOrders) {
|
|
1187
|
+
const m = metaMap[o.ticker] || metaMap[o.figi] || metaMap[o.instrumentUid] || {};
|
|
1188
|
+
const sym = m.ticker || o.ticker || o.figi || '—';
|
|
1189
|
+
const lotsReq = Number(o.lotsRequested || 0);
|
|
1190
|
+
const lotsExec = Number(o.lotsExecuted || 0);
|
|
1191
|
+
const remLots = Math.max(0, lotsReq - lotsExec);
|
|
1192
|
+
const totalOrdRub = qVal(o.initialOrderPrice);
|
|
1193
|
+
const remRub = lotsReq > 0 ? round(totalOrdRub * (remLots / lotsReq), 2) : round(totalOrdRub, 2);
|
|
1194
|
+
activeOrders.push({
|
|
1195
|
+
orderId: o.orderId,
|
|
1196
|
+
symbol: sym,
|
|
1197
|
+
name: m.name || '',
|
|
1198
|
+
direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
|
|
1199
|
+
lotsRequested: lotsReq,
|
|
1200
|
+
lotsExecuted: lotsExec,
|
|
1201
|
+
price: round(qVal(o.initialSecurityPrice), 4),
|
|
1202
|
+
amountRub: remRub,
|
|
1203
|
+
orderDate: o.orderDate || null,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Если сервис портфеля Т-Банка (OperationsService/GetPortfolio) временно вернул 502/503/504 или таймаут,
|
|
1209
|
+
// сохраняем последний подтверждённый снимок позиций и обновляем по нему живые котировки (MarketDataService) и заявки (OrdersService)
|
|
1210
|
+
if (usingFallbackPortfolio && prevSync) {
|
|
1211
|
+
const livePrices = await fetchTbankRealtimePrices().catch(() => null);
|
|
1212
|
+
let oldPosValSum = 0;
|
|
1213
|
+
let newPosValSum = 0;
|
|
1214
|
+
let totalPnlRub = 0;
|
|
1215
|
+
const positions = (prevSync.positions || []).map((pos) => {
|
|
1216
|
+
const qty = Number(pos.qty) || 0;
|
|
1217
|
+
const livePx = livePrices?.get(pos.symbol);
|
|
1218
|
+
const px = livePx > 0 ? round(livePx, 4) : (Number(pos.price) || 0);
|
|
1219
|
+
const oldVal = Number(pos.valueRub) || round(qty * (Number(pos.price) || 0), 2);
|
|
1220
|
+
const newVal = px > 0 ? round(qty * px, 2) : oldVal;
|
|
1221
|
+
oldPosValSum += oldVal;
|
|
1222
|
+
newPosValSum += newVal;
|
|
1223
|
+
const avgPx = Number(pos.avgPrice) || 0;
|
|
1224
|
+
const costBasis = avgPx > 0 ? Math.abs(qty) * avgPx : 0;
|
|
1225
|
+
const pnlRub = (livePx > 0 && avgPx > 0)
|
|
1226
|
+
? round((qty > 0 ? (px - avgPx) : (avgPx - px)) * Math.abs(qty), 2)
|
|
1227
|
+
: (Number(pos.pnlRub) || 0);
|
|
1228
|
+
const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : (Number(pos.pnlPct) || 0);
|
|
1229
|
+
totalPnlRub += pnlRub;
|
|
1230
|
+
return { ...pos, price: px, valueRub: newVal, pnlRub, pnlPct };
|
|
1231
|
+
});
|
|
1232
|
+
const baseTotalRub = Number(prevSync.totalRub) || 0;
|
|
1233
|
+
const estTotalRub = baseTotalRub > 0 ? Math.max(0, round(baseTotalRub + (newPosValSum - oldPosValSum), 2)) : 0;
|
|
1234
|
+
const lastFullSyncAt = prevSync.lastFullSyncAt || prevSync.staleSince || prevSync.at || Date.now();
|
|
1235
|
+
return {
|
|
1236
|
+
accountId: accountId || prevSync.accountId,
|
|
1237
|
+
accountName: accountName || prevSync.accountName || '',
|
|
1238
|
+
accountType: accountType || prevSync.accountType || '',
|
|
1239
|
+
marginEnabled,
|
|
1240
|
+
marginInfo,
|
|
1241
|
+
autoFollowDetected,
|
|
1242
|
+
autoFollowCheckedAt,
|
|
1243
|
+
totalRub: estTotalRub || prevSync.totalRub || 0,
|
|
1244
|
+
cashRub: round(Number(prevSync.cashRub) || 0, 2),
|
|
1245
|
+
totalPnlRub: round(totalPnlRub, 2),
|
|
1246
|
+
dailyYieldRub: round(Number(prevSync.dailyYieldRub) || 0, 2),
|
|
1247
|
+
breakdown: prevSync.breakdown || {},
|
|
1248
|
+
positions,
|
|
1249
|
+
activeOrders,
|
|
1250
|
+
stale: true,
|
|
1251
|
+
staleSince: prevSync.staleSince || lastFullSyncAt,
|
|
1252
|
+
staleReason: portErrorMsg,
|
|
1253
|
+
lastFullSyncAt,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const totalRub = round(qVal(p.totalAmountPortfolio), 2);
|
|
1144
1258
|
const positions = [];
|
|
1145
1259
|
let cashRub = 0;
|
|
1146
1260
|
let metalsFxRub = 0;
|
|
@@ -1187,27 +1301,6 @@ async function syncTbank(acc) {
|
|
|
1187
1301
|
});
|
|
1188
1302
|
}
|
|
1189
1303
|
|
|
1190
|
-
const activeOrders = [];
|
|
1191
|
-
for (const o of rawOrders) {
|
|
1192
|
-
const m = metaMap[o.ticker] || metaMap[o.figi] || metaMap[o.instrumentUid] || {};
|
|
1193
|
-
const sym = m.ticker || o.ticker || o.figi || '—';
|
|
1194
|
-
const lotsReq = Number(o.lotsRequested || 0);
|
|
1195
|
-
const lotsExec = Number(o.lotsExecuted || 0);
|
|
1196
|
-
const remLots = Math.max(0, lotsReq - lotsExec);
|
|
1197
|
-
const totalOrdRub = qVal(o.initialOrderPrice);
|
|
1198
|
-
const remRub = lotsReq > 0 ? round(totalOrdRub * (remLots / lotsReq), 2) : round(totalOrdRub, 2);
|
|
1199
|
-
activeOrders.push({
|
|
1200
|
-
orderId: o.orderId,
|
|
1201
|
-
symbol: sym,
|
|
1202
|
-
name: m.name || '',
|
|
1203
|
-
direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
|
|
1204
|
-
lotsRequested: lotsReq,
|
|
1205
|
-
lotsExecuted: lotsExec,
|
|
1206
|
-
price: round(qVal(o.initialSecurityPrice), 4),
|
|
1207
|
-
amountRub: remRub,
|
|
1208
|
-
orderDate: o.orderDate || null,
|
|
1209
|
-
});
|
|
1210
|
-
}
|
|
1211
1304
|
const activeBuyOrdersRub = activeOrders.filter((o) => o.direction === 'buy').reduce((s, o) => s + (o.amountRub || 0), 0);
|
|
1212
1305
|
cashRub = Math.max(0, cashRub - activeBuyOrdersRub);
|
|
1213
1306
|
|
|
@@ -1227,6 +1320,7 @@ async function syncTbank(acc) {
|
|
|
1227
1320
|
marginEnabled,
|
|
1228
1321
|
marginInfo,
|
|
1229
1322
|
autoFollowDetected,
|
|
1323
|
+
autoFollowCheckedAt,
|
|
1230
1324
|
totalRub: totalRub || cashRub,
|
|
1231
1325
|
cashRub: round(cashRub, 2),
|
|
1232
1326
|
totalPnlRub: round(totalPnlRub, 2),
|
|
@@ -1234,6 +1328,10 @@ async function syncTbank(acc) {
|
|
|
1234
1328
|
breakdown,
|
|
1235
1329
|
positions,
|
|
1236
1330
|
activeOrders,
|
|
1331
|
+
stale: false,
|
|
1332
|
+
staleSince: null,
|
|
1333
|
+
staleReason: null,
|
|
1334
|
+
lastFullSyncAt: Date.now(),
|
|
1237
1335
|
};
|
|
1238
1336
|
}
|
|
1239
1337
|
|
|
@@ -1547,7 +1645,7 @@ export async function syncBrokerAccount(id) {
|
|
|
1547
1645
|
// чтобы новые покупки стратегии по тем же тикерам никогда не путались со старым портфелем
|
|
1548
1646
|
if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
|
|
1549
1647
|
acc.baselineQty = Object.fromEntries((data.positions || []).filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
|
|
1550
|
-
} else if (acc.baselineQty && Array.isArray(data.positions)) {
|
|
1648
|
+
} else if (!data.stale && acc.baselineQty && Array.isArray(data.positions)) {
|
|
1551
1649
|
const curPosMap = new Map(data.positions.map((p) => [p.symbol, p.qty]));
|
|
1552
1650
|
for (const [sym, bq] of Object.entries(acc.baselineQty)) {
|
|
1553
1651
|
const curQ = Number(curPosMap.get(sym) || 0);
|
|
@@ -1580,19 +1678,19 @@ export async function syncBrokerAccount(id) {
|
|
|
1580
1678
|
const ddPct = isGlitchZeroPortfolio ? (Number(acc.lastSync?.dayDrawdownPct) || 0) : rawDdPct;
|
|
1581
1679
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
1582
1680
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
1583
|
-
} else if (!isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1681
|
+
} else if (!data.stale && !isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1584
1682
|
// Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
|
|
1585
1683
|
acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
|
|
1586
1684
|
if (acc.ddBreachCount >= 2) {
|
|
1587
1685
|
if (!acc.haltedReason) logBrokerEvent(acc, 'daily_stop', `просадка −${ddPct}% при пороге ${acc.maxDailyDrawdownPct}%`);
|
|
1588
1686
|
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
1589
1687
|
}
|
|
1590
|
-
} else {
|
|
1688
|
+
} else if (!data.stale) {
|
|
1591
1689
|
acc.ddBreachCount = 0;
|
|
1592
1690
|
}
|
|
1593
1691
|
// Стоп по общей просадке от максимума оценки счёта (настройка maxDrawdownPct). Вывод денег со счёта уменьшает оценку —
|
|
1594
1692
|
// после вывода стоп снимается вручную («Снять стоп»), и отсчёт начинается от новой оценки.
|
|
1595
|
-
if (!isGlitchZeroPortfolio && data.totalRub > 0) {
|
|
1693
|
+
if (!data.stale && !isGlitchZeroPortfolio && data.totalRub > 0) {
|
|
1596
1694
|
const peak = Math.max(Number(acc.equityPeakRub) || 0, data.totalRub);
|
|
1597
1695
|
acc.equityPeakRub = round(peak, 2);
|
|
1598
1696
|
const totalDd = peak > 0 ? round(((peak - data.totalRub) / peak) * 100, 2) : 0;
|
|
@@ -1637,16 +1735,18 @@ export async function syncBrokerAccount(id) {
|
|
|
1637
1735
|
}
|
|
1638
1736
|
}
|
|
1639
1737
|
}
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1738
|
+
if (!data.stale) {
|
|
1739
|
+
for (const s of Object.keys(entryMap)) {
|
|
1740
|
+
// Не удаляем метку времени входа, если лимитная заявка по бумаге ещё стоит в стакане
|
|
1741
|
+
// или была отправлена менее 20 минут назад (чтобы не сбросить 65-минутный таймер до исполнения лимитки!)
|
|
1742
|
+
const isRecentEntryOrder = nowTs - entryMap[s] < 20 * 60000;
|
|
1743
|
+
if (!curSymbols.has(s) && !pendingOrderSymbols.has(s) && !isRecentEntryOrder) {
|
|
1744
|
+
delete entryMap[s];
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
for (const s of Object.keys(peakMap)) {
|
|
1748
|
+
if (!curSymbols.has(s)) delete peakMap[s];
|
|
1646
1749
|
}
|
|
1647
|
-
}
|
|
1648
|
-
for (const s of Object.keys(peakMap)) {
|
|
1649
|
-
if (!curSymbols.has(s)) delete peakMap[s];
|
|
1650
1750
|
}
|
|
1651
1751
|
acc.entryTsBySymbol = entryMap;
|
|
1652
1752
|
acc.peakPriceBySymbol = peakMap;
|
|
@@ -1716,10 +1816,10 @@ const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
|
|
|
1716
1816
|
};
|
|
1717
1817
|
|
|
1718
1818
|
async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
|
|
1719
|
-
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1720
1819
|
for (const [k, v] of entryImpulseWatch) {
|
|
1721
1820
|
if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
|
|
1722
1821
|
}
|
|
1822
|
+
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1723
1823
|
const ready = [];
|
|
1724
1824
|
const deferred = [];
|
|
1725
1825
|
for (const r of cands) {
|
|
@@ -1826,7 +1926,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1826
1926
|
let list = loadRawAccounts();
|
|
1827
1927
|
let acc = list.find((x) => x.id === id);
|
|
1828
1928
|
if (!acc) throw new Error('Счёт не найден');
|
|
1829
|
-
|
|
1929
|
+
let exitsOnly = isDailyStopHalt(acc);
|
|
1830
1930
|
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1831
1931
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1832
1932
|
if (onlyEntries && (edgeWarn || exitsOnly)) {
|
|
@@ -1848,6 +1948,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1848
1948
|
list = loadRawAccounts();
|
|
1849
1949
|
acc = list.find((x) => x.id === id) || acc;
|
|
1850
1950
|
}
|
|
1951
|
+
exitsOnly = isDailyStopHalt(acc);
|
|
1952
|
+
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1953
|
+
if (onlyEntries && exitsOnly) return brokersState();
|
|
1851
1954
|
const params = normalizeParams(getSettings());
|
|
1852
1955
|
const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
|
|
1853
1956
|
forecastNow(params),
|
|
@@ -1984,7 +2087,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1984
2087
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1985
2088
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1986
2089
|
const divBlocked = [];
|
|
1987
|
-
const
|
|
2090
|
+
const portfolioStale = Boolean(acc.lastSync?.stale);
|
|
2091
|
+
const entryTimeOk = timeOk && !edgeWarn && !exitsOnly && !portfolioStale;
|
|
1988
2092
|
const canBuyRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol))
|
|
1989
2093
|
|| (isOnImpulseWatch(acc.id, r.symbol, 'buy', nowTs) && r.liquid && r.inIndex !== false && !r.buyBlockedByDip && !r.buyBlockedByOverheat && !r.buyBlockedByDiv && !r.buyBlockedByNews && r.expectedPct >= reqBuyEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
|
|
1990
2094
|
const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
|
|
@@ -2411,6 +2515,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2411
2515
|
if (!orders.length) {
|
|
2412
2516
|
if (exitsOnly) {
|
|
2413
2517
|
diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
|
|
2518
|
+
} else if (portfolioStale) {
|
|
2519
|
+
diagNote = `Сервис портфеля брокера временно отвечает с задержкой (${acc.lastSync?.staleReason || 'таймаут'}) — открытие новых позиций отложено до подтверждения остатков, текущие позиции и заявки под контролем.`;
|
|
2414
2520
|
} else if (!timeOk) {
|
|
2415
2521
|
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2416
2522
|
} else if (edgeWarn) {
|
|
@@ -2866,23 +2972,25 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2866
2972
|
if (!acc) throw new Error('Счёт не найден');
|
|
2867
2973
|
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
2868
2974
|
const gate = tradingGate();
|
|
2869
|
-
if (!gate.allowNew) throw new Error(gate.reason);
|
|
2975
|
+
if (!gate.allowNew && !gate.allowProtective) throw new Error(gate.reason);
|
|
2870
2976
|
const batch = acc.pendingBatch;
|
|
2871
2977
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
2872
2978
|
if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
|
|
2873
|
-
if (acc.haltedReason && !
|
|
2874
|
-
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна
|
|
2979
|
+
if (acc.haltedReason && !isDailyStopHalt(acc)) throw new Error(acc.haltedReason);
|
|
2980
|
+
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов, при дневном стопе или в окне начала сессии не отправляем (выходы сохраняются!)
|
|
2875
2981
|
const execCheckTs = Date.now();
|
|
2876
2982
|
const staleWhy = (() => {
|
|
2983
|
+
if (!gate.allowNew) return gate.reason;
|
|
2984
|
+
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2877
2985
|
if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
|
|
2878
2986
|
if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
|
|
2879
2987
|
if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
|
|
2880
|
-
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2881
2988
|
return null;
|
|
2882
2989
|
})();
|
|
2883
2990
|
if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
|
|
2884
|
-
const
|
|
2885
|
-
|
|
2991
|
+
const keepLqdt = !isDailyStopHalt(acc) && gate.allowNew;
|
|
2992
|
+
const dropped = batch.orders.filter((o) => !isExitOrder(o) && !(keepLqdt && o.symbol === 'LQDT'));
|
|
2993
|
+
batch.orders = batch.orders.filter((o) => isExitOrder(o) || (keepLqdt && o.symbol === 'LQDT'));
|
|
2886
2994
|
logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
|
|
2887
2995
|
if (!batch.orders.length) {
|
|
2888
2996
|
batch.status = 'expired';
|
|
@@ -2892,6 +3000,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2892
3000
|
throw new Error(`Входы не отправлены: ${staleWhy}`);
|
|
2893
3001
|
}
|
|
2894
3002
|
}
|
|
3003
|
+
if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
|
|
2895
3004
|
// Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
|
|
2896
3005
|
// ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
|
|
2897
3006
|
for (const ord of batch.orders) {
|
|
@@ -2944,8 +3053,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2944
3053
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
|
|
2945
3054
|
newlySentOrders.push(ord);
|
|
2946
3055
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2947
|
-
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
2948
|
-
|
|
3056
|
+
if (!acc.entryTsBySymbol[ord.symbol]) acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
3057
|
+
const ordPx = Number(ord.limitPrice || ord.refPrice) || 0;
|
|
3058
|
+
const prevPeak = Number(acc.peakPriceBySymbol[ord.symbol]) || 0;
|
|
3059
|
+
acc.peakPriceBySymbol[ord.symbol] = prevPeak > 0 && ordPx > 0
|
|
3060
|
+
? (ord.action === 'buy' ? Math.max(prevPeak, ordPx) : Math.min(prevPeak, ordPx))
|
|
3061
|
+
: (ordPx || prevPeak);
|
|
2949
3062
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2950
3063
|
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2951
3064
|
delete acc.entryFailReasonBySymbol[ord.symbol];
|
|
@@ -3066,26 +3179,40 @@ export async function fetchBrokerHistory(id, opts = {}) {
|
|
|
3066
3179
|
const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
|
|
3067
3180
|
if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
|
|
3068
3181
|
|
|
3069
|
-
const
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3182
|
+
const chunkOutcomes = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
|
|
3183
|
+
const fromMs = Date.parse(fromIso);
|
|
3184
|
+
const toMs = Date.parse(toIso);
|
|
3185
|
+
const callChunk = (timeoutMs) => brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
|
|
3186
|
+
method: 'POST',
|
|
3187
|
+
headers,
|
|
3188
|
+
body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
|
|
3189
|
+
timeoutMs,
|
|
3190
|
+
});
|
|
3191
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3192
|
+
try {
|
|
3193
|
+
if (attempt > 0) await new Promise((res) => setTimeout(res, 1000));
|
|
3194
|
+
const r = await callChunk(attempt === 0 ? 15000 : 18000);
|
|
3195
|
+
if (r.ok) {
|
|
3196
|
+
const j = await r.json();
|
|
3197
|
+
return { ok: true, fromMs, toMs, ops: j.operations || [] };
|
|
3198
|
+
}
|
|
3199
|
+
if (r.status !== 429 && r.status < 500) break;
|
|
3200
|
+
} catch {
|
|
3201
|
+
// retry once on timeout / transient socket error
|
|
3202
|
+
}
|
|
3082
3203
|
}
|
|
3204
|
+
return { ok: false, fromMs, toMs, ops: [] };
|
|
3083
3205
|
}));
|
|
3084
3206
|
|
|
3207
|
+
const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
3208
|
+
if (intervals.length > 0 && !chunkOutcomes.some((c) => c.ok)) {
|
|
3209
|
+
if (prevOps.length > 0) return brokersState();
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3085
3212
|
const seenIds = new Set();
|
|
3086
3213
|
const rawOps = [];
|
|
3087
|
-
for (const
|
|
3088
|
-
for (const op of
|
|
3214
|
+
for (const chunk of chunkOutcomes) {
|
|
3215
|
+
for (const op of chunk.ops) {
|
|
3089
3216
|
const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
|
|
3090
3217
|
if (seenIds.has(key)) continue;
|
|
3091
3218
|
seenIds.add(key);
|
|
@@ -3138,13 +3265,17 @@ export async function fetchBrokerHistory(id, opts = {}) {
|
|
|
3138
3265
|
};
|
|
3139
3266
|
}).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
|
|
3140
3267
|
|
|
3141
|
-
// Сохраняем операции других годов,
|
|
3142
|
-
const
|
|
3143
|
-
const
|
|
3268
|
+
// Сохраняем операции других годов, а также операции из тех полугодий targetYear, которые временно не ответили (504 / таймаут)
|
|
3269
|
+
const failedChunks = chunkOutcomes.filter((c) => !c.ok);
|
|
3270
|
+
const fetchedKeys = new Set(fetchedOps.map((op) => op.id || `${op.date}_${op.kind}_${op.symbol}_${op.paymentRub}`));
|
|
3271
|
+
const preservedOps = prevOps.filter((op) => {
|
|
3144
3272
|
const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
|
|
3145
|
-
|
|
3273
|
+
if (y !== targetYear) return true;
|
|
3274
|
+
const opKey = op.id || `${op.date}_${op.kind}_${op.symbol}_${op.paymentRub}`;
|
|
3275
|
+
if (fetchedKeys.has(opKey)) return false;
|
|
3276
|
+
return failedChunks.some((c) => op.date >= c.fromMs && op.date <= c.toMs);
|
|
3146
3277
|
});
|
|
3147
|
-
const mergedOps = [...fetchedOps, ...
|
|
3278
|
+
const mergedOps = [...fetchedOps, ...preservedOps]
|
|
3148
3279
|
.sort((a, b) => (b.date || 0) - (a.date || 0))
|
|
3149
3280
|
.slice(0, 6000);
|
|
3150
3281
|
|
|
@@ -3248,7 +3379,8 @@ async function tickBrokerSchedule() {
|
|
|
3248
3379
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
3249
3380
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
3250
3381
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
3251
|
-
|
|
3382
|
+
const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
|
|
3383
|
+
if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
3252
3384
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
3253
3385
|
}
|
|
3254
3386
|
}
|
|
@@ -3372,6 +3504,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3372
3504
|
const headers = tbankHeaders(acc);
|
|
3373
3505
|
const want = new Map();
|
|
3374
3506
|
const activeOrders = acc.lastSync?.activeOrders || [];
|
|
3507
|
+
const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
3375
3508
|
for (const pos of acc.lastSync.positions || []) {
|
|
3376
3509
|
const sym = pos.symbol;
|
|
3377
3510
|
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
@@ -3386,7 +3519,8 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3386
3519
|
const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
|
|
3387
3520
|
const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
|
|
3388
3521
|
const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
|
|
3389
|
-
const
|
|
3522
|
+
const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenDir && op.date > 0);
|
|
3523
|
+
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || Number(pos.price) || 0);
|
|
3390
3524
|
if (lots < 1 || avgPx <= 0) continue;
|
|
3391
3525
|
const side = aQty > 0 ? 'long' : 'short';
|
|
3392
3526
|
const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
|
|
@@ -3452,16 +3586,19 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3452
3586
|
}
|
|
3453
3587
|
}
|
|
3454
3588
|
|
|
3455
|
-
// Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые
|
|
3589
|
+
// Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 25 секунд между шагами расписания
|
|
3456
3590
|
async function checkIntradayProfitLock(accId) {
|
|
3457
|
-
const list = loadRawAccounts();
|
|
3458
|
-
const acc = list.find((x) => x.id === accId);
|
|
3459
|
-
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3460
3591
|
if (!tradingGate().allowProtective) return;
|
|
3461
3592
|
const nowMsk = new Date(Date.now() + MSK);
|
|
3462
3593
|
const dow = nowMsk.getUTCDay();
|
|
3463
3594
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
3464
3595
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
|
|
3596
|
+
const lockToken = acquireAccountLock(accId);
|
|
3597
|
+
if (!lockToken) return;
|
|
3598
|
+
try {
|
|
3599
|
+
const list = loadRawAccounts();
|
|
3600
|
+
const acc = list.find((x) => x.id === accId);
|
|
3601
|
+
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3465
3602
|
// В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
|
|
3466
3603
|
const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
|
|
3467
3604
|
|
|
@@ -3578,7 +3715,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3578
3715
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
|
3579
3716
|
}
|
|
3580
3717
|
}
|
|
3581
|
-
|
|
3718
|
+
const stopBatchObj = {
|
|
3582
3719
|
...(acc.pendingBatch || {}),
|
|
3583
3720
|
id: `batch_stop_${execNowTs}`,
|
|
3584
3721
|
createdAt: execNowTs,
|
|
@@ -3588,13 +3725,19 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3588
3725
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3589
3726
|
results,
|
|
3590
3727
|
};
|
|
3728
|
+
if (acc.pendingBatch?.status !== 'pending' && acc.pendingBatch?.status !== 'executing') {
|
|
3729
|
+
acc.pendingBatch = stopBatchObj;
|
|
3730
|
+
}
|
|
3591
3731
|
saveRawAccounts(list);
|
|
3592
3732
|
if (sentLockOrders.length > 0) {
|
|
3593
|
-
notifyBrokerBatch(currentUserId(), acc, { ...
|
|
3733
|
+
notifyBrokerBatch(currentUserId(), acc, { ...stopBatchObj, orders: sentLockOrders }).catch(() => {});
|
|
3594
3734
|
}
|
|
3595
3735
|
await syncBrokerAccount(accId).catch(() => {});
|
|
3596
3736
|
const st = brokersState();
|
|
3597
3737
|
hub.emit({ type: 'brokers:update', state: st });
|
|
3738
|
+
} finally {
|
|
3739
|
+
releaseAccountLock(accId, lockToken);
|
|
3740
|
+
}
|
|
3598
3741
|
}
|
|
3599
3742
|
|
|
3600
3743
|
// Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
|
|
@@ -3618,14 +3761,17 @@ async function tickBrokerAutoRefresh() {
|
|
|
3618
3761
|
if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
|
|
3619
3762
|
await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
|
|
3620
3763
|
}
|
|
3621
|
-
const
|
|
3622
|
-
const
|
|
3623
|
-
|
|
3624
|
-
|
|
3764
|
+
const freshAcc = loadRawAccounts().find((x) => x.id === acc.id) || acc;
|
|
3765
|
+
const latestEntryTs = Math.max(0, ...Object.values(freshAcc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
|
|
3766
|
+
const hasCurYearOps = (freshAcc.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).getUTCFullYear() === curYear);
|
|
3767
|
+
const historyNeedSync = !freshAcc.lastHistory?.at || !hasCurYearOps || Date.now() - freshAcc.lastHistory.at >= 90000 || (latestEntryTs > (freshAcc.lastHistory.at || 0));
|
|
3768
|
+
const operationsServiceHealthy = freshAcc.lastSync?.ok && !freshAcc.lastSync?.stale;
|
|
3769
|
+
if (freshAcc.broker === 'tbank' && historyNeedSync && (operationsServiceHealthy || !hasCurYearOps)) {
|
|
3770
|
+
await fetchBrokerHistory(freshAcc.id, { year: curYear }).catch(() => {});
|
|
3625
3771
|
}
|
|
3626
|
-
// Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
|
|
3627
|
-
await forecastNow(getSettings()).catch(() => {});
|
|
3628
3772
|
}
|
|
3773
|
+
// Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
|
|
3774
|
+
await forecastNow(getSettings()).catch(() => {});
|
|
3629
3775
|
});
|
|
3630
3776
|
}
|
|
3631
3777
|
} finally {
|
package/server/model/dataset.js
CHANGED
|
@@ -230,8 +230,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
230
230
|
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
|
|
231
231
|
const isCacheFreshForSlot = (entry) => {
|
|
232
232
|
if (!isTradingNow || now - entry.at < 50000) return true;
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
if (entry.at < latestClosedSlotTs + bMs) return false;
|
|
234
|
+
const tl = entry.ds?.timeline;
|
|
235
|
+
if (!tl?.length) return false;
|
|
236
|
+
let lastClosedBar = 0;
|
|
237
|
+
for (let i = tl.length - 1; i >= 0; i--) {
|
|
238
|
+
if (tl[i] + bMs <= now) { lastClosedBar = tl[i]; break; }
|
|
239
|
+
}
|
|
240
|
+
return lastClosedBar >= latestClosedSlotTs;
|
|
235
241
|
};
|
|
236
242
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
237
243
|
const hit = datasetCache.get(key);
|
package/server/model/forecast.js
CHANGED
|
@@ -82,7 +82,6 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
82
82
|
vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
vr[(uClosed + 1) * N] = 1;
|
|
86
85
|
for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
|
|
87
86
|
|
|
88
87
|
if (liveSnap?.prices?.size > 0) {
|
|
@@ -125,10 +124,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
125
124
|
if (dm) {
|
|
126
125
|
const msig = mTrain.msig || 1e-3;
|
|
127
126
|
for (const uk of [uClosed, uClosed + 1]) {
|
|
127
|
+
const baseT = Math.min(idx, lo + uk);
|
|
128
128
|
SCALES.forEach((sc, si) => {
|
|
129
129
|
let sum = 0;
|
|
130
130
|
for (let k = 0; k < sc.len; k++) {
|
|
131
|
-
const t =
|
|
131
|
+
const t = baseT - sc.from - k;
|
|
132
132
|
if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
|
|
133
133
|
}
|
|
134
134
|
zs[si * W + uk] = sum / Math.sqrt(sc.len);
|
|
@@ -316,8 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
316
316
|
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
317
317
|
const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
|
|
318
318
|
const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
|
|
319
|
+
const dayFitKey = `${fitKey}:${barDayMsk}`;
|
|
319
320
|
|
|
320
|
-
const cached = trainedModelCache.get(
|
|
321
|
+
const cached = trainedModelCache.get(dayFitKey);
|
|
321
322
|
if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
|
|
322
323
|
const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
|
|
323
324
|
return {
|
|
@@ -331,7 +332,6 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
331
332
|
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
|
|
332
333
|
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
333
334
|
const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
334
|
-
const u = idx - f.lo;
|
|
335
335
|
// Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
|
|
336
336
|
// не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
|
|
337
337
|
const list = [...new Set(['attention', chosen])];
|
|
@@ -356,7 +356,11 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
356
356
|
...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
|
|
357
357
|
};
|
|
358
358
|
const trainedAt = Date.now();
|
|
359
|
-
trainedModelCache.
|
|
359
|
+
if (trainedModelCache.size >= 16) {
|
|
360
|
+
const firstKey = trainedModelCache.keys().next().value;
|
|
361
|
+
if (firstKey) trainedModelCache.delete(firstKey);
|
|
362
|
+
}
|
|
363
|
+
trainedModelCache.set(dayFitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
|
|
360
364
|
|
|
361
365
|
const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
|
|
362
366
|
return {
|
|
@@ -389,6 +393,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
389
393
|
const idx1d = Math.max(0, idx - bpd);
|
|
390
394
|
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
391
395
|
const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
|
|
396
|
+
const liveScale = liveSnap?.prices?.size > 0 ? (1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap))) : 1;
|
|
392
397
|
const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
|
|
393
398
|
const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
394
399
|
const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
|
|
@@ -403,8 +408,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
403
408
|
const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
|
|
404
409
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
405
410
|
const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
|
|
406
|
-
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
|
|
407
|
-
const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
|
|
411
|
+
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * liveScale * 100;
|
|
412
|
+
const rawPriceMovePct = ((livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100)) * liveScale;
|
|
408
413
|
const lastMovePct = round(rawResPct, 3);
|
|
409
414
|
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
410
415
|
const p30m = tk.close[idx30m] || 0;
|
|
@@ -446,15 +451,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
446
451
|
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
|
|
447
452
|
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
|
|
448
453
|
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
449
|
-
const isExtremeSpike = Boolean(isRegimeSqueeze ||
|
|
454
|
+
const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
450
455
|
const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
|
|
451
|
-
const liveShortOk = !isExtremeSpike;
|
|
456
|
+
const liveShortOk = !isExtremeSpike && !isMorningOpenShortBlocked;
|
|
452
457
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
453
458
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
454
459
|
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
455
460
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
456
461
|
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
457
462
|
const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
|
|
463
|
+
const shortBlockedByTime = Boolean(exp < 0 && !isExtremeSpike && isMorningOpenShortBlocked);
|
|
458
464
|
const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
|
|
459
465
|
const row = {
|
|
460
466
|
i, symbol: tk.symbol, name: tk.name,
|
|
@@ -481,6 +487,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
481
487
|
row.shortSignal = rawShortHit && !shortBlockedByBroker;
|
|
482
488
|
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
483
489
|
if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
|
|
490
|
+
if (shortBlockedByTime) row.shortBlockedByTime = true;
|
|
484
491
|
}
|
|
485
492
|
// контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
|
|
486
493
|
const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
|
|
@@ -531,6 +538,11 @@ function withNews(result, p) {
|
|
|
531
538
|
} else {
|
|
532
539
|
delete r.shortBlockedBySqueeze;
|
|
533
540
|
}
|
|
541
|
+
if (r.expectedPct < 0 && r.shortBlockedByTime) {
|
|
542
|
+
r.shortSignal = false;
|
|
543
|
+
} else if (r.expectedPct >= 0) {
|
|
544
|
+
delete r.shortBlockedByTime;
|
|
545
|
+
}
|
|
534
546
|
if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
|
|
535
547
|
r.buyBlockedByDip = true;
|
|
536
548
|
r.signal = false;
|
|
@@ -648,6 +660,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
648
660
|
if (side === 'short') {
|
|
649
661
|
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
650
662
|
if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
|
|
663
|
+
if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
|
|
651
664
|
if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
|
|
652
665
|
if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
653
666
|
if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
|
package/server/model/policy.js
CHANGED
|
@@ -89,10 +89,10 @@ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, h
|
|
|
89
89
|
if (st.mode === 'vol' && sigmaBarPct > 0) {
|
|
90
90
|
const vol = Math.max(sigmaBarPct, st.useRealized ? realizedBarPct : 0);
|
|
91
91
|
const raw = st.k * vol * Math.sqrt(horizonBars);
|
|
92
|
-
const maxPct = side === 'long' ? (st.maxLongPct ??
|
|
92
|
+
const maxPct = side === 'long' ? (st.maxLongPct ?? 7.0) : (st.maxShortPct ?? 7.0);
|
|
93
93
|
return Math.max(st.minPct ?? 1.0, Math.min(maxPct, raw));
|
|
94
94
|
}
|
|
95
|
-
return side === 'long' ? (st.longPct ??
|
|
95
|
+
return side === 'long' ? (st.longPct ?? 7.0) : (st.shortPct ?? 7.0);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// ---------- плечо и множители размера по эшелонам ----------
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
|
|
18
18
|
} from './edge.js';
|
|
19
19
|
import { nextDividendAt, dividendRules } from './dividends.js';
|
|
20
|
-
import { STRATEGY_POLICY, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
|
|
20
|
+
import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
|
|
21
21
|
|
|
22
22
|
// Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
|
|
23
23
|
export function alignSeries(timeline, rows) {
|
|
@@ -52,7 +52,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
52
52
|
const slotW = policy.slotPct ?? (plan === 'owner' ? 0.45 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
|
|
53
53
|
const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
|
|
54
54
|
const convK = plan === 'owner' ? 0.30 : 0.25;
|
|
55
|
-
const lev = Math.max(1, Number(leverage) || 1);
|
|
55
|
+
const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
|
|
56
56
|
const maxGross = policy.maxGross ?? 0.99 * lev;
|
|
57
57
|
const capScale = policy.capScale ?? 1;
|
|
58
58
|
const tierScale = (i) => (isT1(i) ? (policy.edgeScaleT1 ?? 1) : (policy.edgeScaleT2 ?? 1));
|
|
@@ -106,7 +106,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
106
106
|
}
|
|
107
107
|
held.delete(i);
|
|
108
108
|
cooldownUntil[i] = exitT + (kind === 'stop' || kind === 'thesis' ? stopCooldownBars : cooldownBars);
|
|
109
|
-
stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 *
|
|
109
|
+
stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * c);
|
|
110
110
|
stockCnt[i] += 1;
|
|
111
111
|
};
|
|
112
112
|
const mtmUnreal = (t) => {
|
|
@@ -124,13 +124,26 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
124
124
|
if (firstK < 0) firstK = wf.testTs.length;
|
|
125
125
|
let lastT = -1;
|
|
126
126
|
let prevT = -1;
|
|
127
|
+
const maxDailyDdPct = policy.maxDailyDrawdownPct ?? 3.0;
|
|
128
|
+
let curDayMsk = -1;
|
|
129
|
+
let dayStartTotalRub = capital;
|
|
130
|
+
let dayDdBreachCount = 0;
|
|
131
|
+
let dayHalted = false;
|
|
127
132
|
for (let k = firstK; k < wf.testTs.length; k++) {
|
|
128
133
|
const t = wf.testTs[k];
|
|
129
134
|
if (timeline[t] > tillTs || t + 1 >= T) break;
|
|
130
135
|
lastT = t;
|
|
131
136
|
const fo = wf.foldOf[k];
|
|
132
137
|
const pr = wf.pred[k], sg = wf.foldSigma[fo], mult = wf.foldMult[fo], beta = wf.foldBeta[fo];
|
|
138
|
+
if (!pr || !sg || !mult || !beta) continue;
|
|
133
139
|
const decTs = timeline[t] + BAR_MS;
|
|
140
|
+
const barDayMsk = Math.floor((timeline[t] + 3 * 3600000) / 86400000);
|
|
141
|
+
if (barDayMsk !== curDayMsk) {
|
|
142
|
+
curDayMsk = barDayMsk;
|
|
143
|
+
dayStartTotalRub = Math.max(1, equity + mtmUnreal(t));
|
|
144
|
+
dayDdBreachCount = 0;
|
|
145
|
+
dayHalted = false;
|
|
146
|
+
}
|
|
134
147
|
const isStep = (k - firstK) % stepBars === 0;
|
|
135
148
|
if (isStep) {
|
|
136
149
|
for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
|
|
@@ -149,7 +162,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
149
162
|
}
|
|
150
163
|
prevT = t;
|
|
151
164
|
|
|
152
|
-
// 1) внутри свечи t:
|
|
165
|
+
// 1) внутри свечи t: сначала проверяем защитный стоп и стоп отмены идеи (консервативный приоритет неблагоприятного движения), затем фиксацию прибыли
|
|
153
166
|
for (const [i, pos] of [...held]) {
|
|
154
167
|
if (pos.entryT > t) continue;
|
|
155
168
|
const tk = tokens[i];
|
|
@@ -159,19 +172,11 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
159
172
|
const favorable = pos.side === 'long' ? h : l;
|
|
160
173
|
const heldMinStart = (t - pos.entryT) * BAR_INTERVAL;
|
|
161
174
|
let done = false;
|
|
162
|
-
if (policy.profitLock) {
|
|
163
|
-
const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
|
|
164
|
-
if (lv) {
|
|
165
|
-
const gO = gainOf(pos, o) * 100;
|
|
166
|
-
if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
167
|
-
else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (done) continue;
|
|
171
175
|
const stopPx = pxOfGain(pos, -pos.stopPct / 100);
|
|
172
176
|
const levels = [[stopPx, 'stop']];
|
|
173
177
|
if (pos.thesisPx > 0) levels.push([pos.thesisPx, 'thesis']);
|
|
174
|
-
|
|
178
|
+
// При одновременном пробое нескольких стоп-уровней внутри свечи исполняем по более невыгодному (консервативно)
|
|
179
|
+
levels.sort((a, b) => (pos.side === 'long' ? a[0] - b[0] : b[0] - a[0]));
|
|
175
180
|
for (const [lvPx, kind] of levels) {
|
|
176
181
|
const hit = pos.side === 'long' ? adverse <= lvPx : adverse >= lvPx;
|
|
177
182
|
if (!hit) continue;
|
|
@@ -182,6 +187,15 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
182
187
|
break;
|
|
183
188
|
}
|
|
184
189
|
if (done) continue;
|
|
190
|
+
if (policy.profitLock) {
|
|
191
|
+
const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
|
|
192
|
+
if (lv) {
|
|
193
|
+
const gO = gainOf(pos, o) * 100;
|
|
194
|
+
if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
195
|
+
else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (done) continue;
|
|
185
199
|
pos.peakGain = Math.max(pos.peakGain, gainOf(pos, favorable));
|
|
186
200
|
if (policy.profitLock) {
|
|
187
201
|
const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart + BAR_INTERVAL, roundTripFeePct);
|
|
@@ -248,8 +262,18 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
248
262
|
if (kind === 'night') stats.night++; else if (kind === 'signal') stats.signalExits++; else if (kind === 'time') stats.timeExits++; else if (kind === 'regime') stats.regime++; else stats.div++;
|
|
249
263
|
}
|
|
250
264
|
|
|
251
|
-
// 3) входы
|
|
252
|
-
|
|
265
|
+
// 3) входы (блокируются до конца дня при срабатывании дневного стопа просадки, как в живом контуре)
|
|
266
|
+
if (maxDailyDdPct > 0 && !dayHalted && isStep) {
|
|
267
|
+
const curTotalRub = equity + mtmUnreal(t);
|
|
268
|
+
const dayDdPct = ((dayStartTotalRub - curTotalRub) / dayStartTotalRub) * 100;
|
|
269
|
+
if (dayDdPct >= maxDailyDdPct) {
|
|
270
|
+
dayDdBreachCount += 1;
|
|
271
|
+
if (dayDdBreachCount >= 2) dayHalted = true;
|
|
272
|
+
} else {
|
|
273
|
+
dayDdBreachCount = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
|
|
253
277
|
if (timeOk) {
|
|
254
278
|
const baseOk = (i) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i]) && (policy.tier2 !== false || isT1(i));
|
|
255
279
|
const candL = [];
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { buildFold, MODELS, FEATURE_WARMUP } from './models.js';
|
|
6
6
|
import { computeTokenEdgeMults } from './edge.js';
|
|
7
7
|
import { barsFromHours } from './dataset.js';
|
|
8
|
+
import { loadProfiles } from './profiles.js';
|
|
8
9
|
|
|
9
10
|
const DAY = 86400000;
|
|
10
11
|
const MSK = 3 * 3600000;
|
|
@@ -33,6 +34,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
|
|
|
33
34
|
const foldSigma = [];
|
|
34
35
|
const foldBeta = [];
|
|
35
36
|
const foldMult = [];
|
|
37
|
+
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
36
38
|
let fi = 0;
|
|
37
39
|
while (testStart < T - maxH - 2) {
|
|
38
40
|
if (signal?.aborted) throw new Error('Отменено');
|
|
@@ -40,7 +42,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
|
|
|
40
42
|
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
|
|
41
43
|
onProgress({ fold: fi + 1, folds, from: timeline[testStart], till: timeline[testEnd - 1] });
|
|
42
44
|
await tick();
|
|
43
|
-
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: 0, profiles
|
|
45
|
+
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight || 0, profiles, multiHorizon: autoAdapt });
|
|
44
46
|
foldSigma.push(Float64Array.from(f.sigma));
|
|
45
47
|
foldBeta.push(Float64Array.from(f.beta));
|
|
46
48
|
foldMult.push(computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Расписание торгов Мосбиржи и платформы «НейроАльфа» (МСК): утренняя, основная и вечерняя сессии.
|
|
3
|
+
// Используется ИИ-Стратегом для ответов о текущем состоянии торгов.
|
|
4
|
+
import { MSK } from './util.js';
|
|
5
|
+
|
|
6
|
+
export const SESSION_SCHEDULE = Object.freeze({
|
|
7
|
+
morningStart: 6 * 60 + 50, // 06:50
|
|
8
|
+
mainStart: 9 * 60 + 50, // 09:50
|
|
9
|
+
mainEnd: 18 * 60 + 50, // 18:50
|
|
10
|
+
eveningStart: 19 * 60, // 19:00
|
|
11
|
+
eveningEnd: 23 * 60 + 50, // 23:50
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Возвращает { open, kind, text } для текущего момента (или переданного ts).
|
|
15
|
+
// open=true — платформа может торговать; kind: morning | main | evening | break | before | after | weekend.
|
|
16
|
+
export function tradingSessionStatus(ts = Date.now()) {
|
|
17
|
+
const d = new Date(ts + MSK);
|
|
18
|
+
const dow = d.getUTCDay();
|
|
19
|
+
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
20
|
+
const s = SESSION_SCHEDULE;
|
|
21
|
+
|
|
22
|
+
if (dow === 0 || dow === 6) {
|
|
23
|
+
return { open: false, kind: 'weekend', text: 'выходной день (торги закрыты; следующая сессия — в понедельник в 06:50 МСК)' };
|
|
24
|
+
}
|
|
25
|
+
if (mins >= s.morningStart && mins < s.mainStart) {
|
|
26
|
+
return { open: true, kind: 'morning', text: 'идёт утренняя торговая сессия Мосбиржи (06:50–09:50 МСК)' };
|
|
27
|
+
}
|
|
28
|
+
if (mins >= s.mainStart && mins <= s.mainEnd) {
|
|
29
|
+
return { open: true, kind: 'main', text: 'идёт основная торговая сессия Мосбиржи (09:50–18:50 МСК)' };
|
|
30
|
+
}
|
|
31
|
+
if (mins > s.mainEnd && mins < s.eveningStart) {
|
|
32
|
+
return { open: false, kind: 'break', text: 'межсессионный перерыв (основная сессия закрыта, вечерняя откроется в 19:00 МСК)' };
|
|
33
|
+
}
|
|
34
|
+
if (mins >= s.eveningStart && mins <= s.eveningEnd) {
|
|
35
|
+
return { open: true, kind: 'evening', text: 'идёт вечерняя торговая сессия Мосбиржи (19:00–23:50 МСК)' };
|
|
36
|
+
}
|
|
37
|
+
if (mins > s.eveningEnd) {
|
|
38
|
+
const next = dow === 5 ? 'в понедельник в 06:50 МСК' : 'завтра в 06:50 МСК';
|
|
39
|
+
return { open: false, kind: 'after', text: `торги закрыты (вечерняя сессия завершена; следующая откроется ${next})` };
|
|
40
|
+
}
|
|
41
|
+
return { open: false, kind: 'before', text: 'торги ещё не открылись (утренняя сессия откроется сегодня в 06:50 МСК)' };
|
|
42
|
+
}
|
|
@@ -7,6 +7,7 @@ import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
|
7
7
|
import { hub } from '../hub.js';
|
|
8
8
|
import { getSettings } from '../settings.js';
|
|
9
9
|
import { brokersState } from '../brokers/index.js';
|
|
10
|
+
import { tradingSessionStatus } from '../session.js';
|
|
10
11
|
import { registerChatHandlers } from '../notify/index.js';
|
|
11
12
|
import {
|
|
12
13
|
currentConversation, newConversation, getConversation, setConversationTitle, listConversations,
|
|
@@ -21,6 +22,10 @@ fs.mkdirSync(ARCHIVE_ROOT, { recursive: true });
|
|
|
21
22
|
const MAX_STEPS = 20;
|
|
22
23
|
const MSK = 3 * 3600000;
|
|
23
24
|
const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
|
|
25
|
+
const fmtClockMsk = (ts) => new Date(ts + MSK).toISOString().slice(11, 16);
|
|
26
|
+
// Общие вопросы о состоянии торгов («как дела», «что происходит») требуют проверки фактических данных, а не ответа по календарю
|
|
27
|
+
const STATUS_QUESTION_RE = /^(?:ну\s+)?(?:как\s+(?:дела|делишки|торговля|идут\s+торги|сч[её]т|успехи)|что\s+(?:происходит|нового))(?:[\s!?.,]|$)/i;
|
|
28
|
+
const isStatusQuestion = (text = '') => STATUS_QUESTION_RE.test(String(text || '').trim());
|
|
24
29
|
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
|
|
25
30
|
|
|
26
31
|
// Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
|
|
@@ -157,14 +162,6 @@ function systemPrompt() {
|
|
|
157
162
|
const s = getSettings();
|
|
158
163
|
const newsFilterState = s.newsFilter ? 'включён (блокирует покупки на сильном негативе и шорты на сильном позитиве за 6 ч)' : 'выключен';
|
|
159
164
|
|
|
160
|
-
let brkSummary = '(брокерские счета пока не подключены)';
|
|
161
|
-
try {
|
|
162
|
-
const bAccs = brokersState().accounts || [];
|
|
163
|
-
if (bAccs.length) {
|
|
164
|
-
brkSummary = bAccs.map((a) => `- «${a.name}» (${a.broker}, субсчёт ${a.accountId || '—'}, режим ${a.mode}${a.sandbox ? ', ПЕСОЧНИЦА' : ''}, старый портфель: ${a.legacyMode || 'isolate'}, лимит: ${a.maxCapitalRub} ₽, баланс: ${a.lastSync?.totalRub ?? '—'} ₽, свободно: ${a.lastSync?.cashRub ?? '—'} ₽, бумаг: ${a.lastSync?.positions?.length ?? 0}${a.haltedReason ? `, ПАУЗА: ${a.haltedReason}` : ''})`).join('\n');
|
|
165
|
-
}
|
|
166
|
-
} catch { /* ignore */ }
|
|
167
|
-
|
|
168
165
|
const noTools = !activeModelInfo().tools;
|
|
169
166
|
const nowTs = Date.now();
|
|
170
167
|
const nowMsk = new Date(nowTs + MSK);
|
|
@@ -174,14 +171,46 @@ function systemPrompt() {
|
|
|
174
171
|
const fmtRuDate = (iso) => iso.split('-').reverse().join('.');
|
|
175
172
|
const nowClock = nowMsk.toISOString().slice(11, 16);
|
|
176
173
|
const dow = nowMsk.getUTCDay();
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
174
|
+
const session = tradingSessionStatus(nowTs);
|
|
175
|
+
|
|
176
|
+
let brkSummary = '(брокерские счета пока не подключены)';
|
|
177
|
+
let liveTradingSummary = '- Брокерские счета пока не подключены — фактических сделок по ним нет.';
|
|
178
|
+
try {
|
|
179
|
+
const bAccs = brokersState().accounts || [];
|
|
180
|
+
if (bAccs.length) {
|
|
181
|
+
brkSummary = bAccs.map((a) => `- «${a.name}» (${a.broker}, субсчёт ${a.accountId || '—'}, режим ${a.mode}${a.sandbox ? ', ПЕСОЧНИЦА' : ''}, старый портфель: ${a.legacyMode || 'isolate'}, лимит: ${a.maxCapitalRub} ₽, баланс: ${a.lastSync?.totalRub ?? '—'} ₽, свободно: ${a.lastSync?.cashRub ?? '—'} ₽, бумаг: ${a.lastSync?.positions?.length ?? 0}, снимок: ${a.lastSync?.at ? fmtTs(a.lastSync.at) : '—'}${a.haltedReason ? `, ПАУЗА: ${a.haltedReason}` : ''})`).join('\n');
|
|
182
|
+
const todayOps = [];
|
|
183
|
+
const todayStartTs = Date.parse(`${todayIso}T00:00:00+03:00`);
|
|
184
|
+
let lastSyncAt = 0;
|
|
185
|
+
for (const a of bAccs) {
|
|
186
|
+
lastSyncAt = Math.max(lastSyncAt, Number(a.lastSync?.at) || 0);
|
|
187
|
+
for (const op of a.lastHistory?.operations || []) {
|
|
188
|
+
if (!op.date) continue;
|
|
189
|
+
if (op.date < todayStartTs) break; // операции отсортированы от новых к старым
|
|
190
|
+
if (op.kind !== 'buy' && op.kind !== 'sell') continue;
|
|
191
|
+
todayOps.push(op);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
todayOps.sort((x, y) => (x.date || 0) - (y.date || 0));
|
|
195
|
+
const lastOp = todayOps[todayOps.length - 1];
|
|
196
|
+
liveTradingSummary = [
|
|
197
|
+
lastSyncAt ? `- Последняя синхронизация портфеля: ${fmtTs(lastSyncAt)} МСК.` : null,
|
|
198
|
+
todayOps.length
|
|
199
|
+
? `- Сегодня уже исполнено сделок: ${todayOps.length}; последняя — ${fmtClockMsk(lastOp.date)} МСК (${lastOp.kind === 'buy' ? 'покупка' : 'продажа'} ${lastOp.symbol || '—'}).`
|
|
200
|
+
: '- По загруженной истории за сегодня исполненных сделок пока нет (история может обновляться с задержкой до минуты).',
|
|
201
|
+
].filter(Boolean).join('\n');
|
|
202
|
+
}
|
|
203
|
+
} catch { /* ignore */ }
|
|
204
|
+
|
|
205
|
+
let statusQuestionHint = '';
|
|
206
|
+
try {
|
|
207
|
+
const lastUser = getMessages(conv().id, 20).filter((m) => m.kind === 'user' && !m.data?.hidden).slice(-1)[0];
|
|
208
|
+
if (!noTools && isStatusQuestion(lastUser?.content)) {
|
|
209
|
+
statusQuestionHint = `\n## Указание для текущего запроса
|
|
210
|
+
Пользователь задал общий вопрос о состоянии. Прежде чем отвечать, **ОБЯЗАТЕЛЬНО** вызови \`get_brokers_status\` и \`get_broker_operations\` с \`{"period": "today"}\` (если счета подключены). Ответь по фактическим данным: идут ли торги, какие позиции открыты, какие сделки уже были сегодня и с каким результатом. Не говори «торги ещё не начались», если в данных есть сделки.
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
} catch { /* ignore */ }
|
|
185
214
|
|
|
186
215
|
const modeText = noTools
|
|
187
216
|
? 'Выбранная модель ИИ не поддерживает вызов инструментов — ты отвечаешь и консультируешь по данным из контекста ниже.'
|
|
@@ -193,12 +222,20 @@ function systemPrompt() {
|
|
|
193
222
|
ЗАПРЕЩЕНО обещать или прогнозировать доходность («X% годовых», «гарантированно», «без риска»), называть цифры бэктеста ожидаемым доходом и давать индивидуальные инвестиционные рекомендации. Если спрашивают про доходность — объясни честно: на истории (бэктест с реальными издержками ~0,1% на сторону, задержкой исполнения и ограничением по ликвидности) стратегия давала порядка десятков процентов годовых при просадках до ~30%, но прошлые результаты не гарантируют будущих, возможны убытки, а при росте издержек выше ~0,17% на сторону стратегия убыточна.
|
|
194
223
|
|
|
195
224
|
## Текущее время и календарь Мосбиржи (МСК, UTC+3)
|
|
196
|
-
- **Сейчас:** ${DAYS_RU[dow]}, **${fmtRuDate(todayIso)}** (${todayIso}), **${nowClock} МСК** — ${
|
|
225
|
+
- **Сейчас:** ${DAYS_RU[dow]}, **${fmtRuDate(todayIso)}** (${todayIso}), **${nowClock} МСК** — ${session.text}.
|
|
226
|
+
- **Расписание торгов платформы:** по будням **06:50–23:50 МСК**: утренняя сессия **06:50–09:50**, основная **09:50–18:50**, вечерняя **19:00–23:50**. Утренняя сессия — это уже реальные торги: робот сопровождает позиции и торгует с 06:50 (в 06:50–07:25 действует защитное окно на новые входы).
|
|
197
227
|
- **Сегодняшняя дата:** ${fmtRuDate(todayIso)} (${todayIso}).
|
|
198
228
|
- **Вчерашняя дата:** ${fmtRuDate(yesterdayIso)} (${yesterdayIso}).
|
|
229
|
+
|
|
230
|
+
## Фактическое состояние торгов (по последней синхронизации)
|
|
231
|
+
${liveTradingSummary}
|
|
232
|
+
|
|
199
233
|
- **ВАЖНОЕ ПРАВИЛО ПО ВРЕМЕНИ И ТОРГАМ:**
|
|
234
|
+
- **Никогда не утверждай, что «торги ещё не начались» или «торги не идут», опираясь только на время или календарь.** Утренняя сессия начинается в 06:50 МСК. Если в блоке «Фактическое состояние торгов» есть сделки за сегодня или пользователь говорит, что торговля идёт, — торговля действительно идёт.
|
|
200
235
|
- Инструмент \`get_brokers_status\` показывает **только текущий снимок портфеля прямо сейчас** (какие бумаги лежат на счёте в эту минуту и какие заявки висят в стакане сейчас). Никогда не выдавай текущий портфель из \`get_brokers_status\` за «итоги вчерашних торгов»!
|
|
201
236
|
- Если пользователь спрашивает **«как прошли вчерашние торги»**, **«какие сделки были вчера / сегодня / на неделе»** или просит отчёт по прошлым сделкам — **ОБЯЗАТЕЛЬНО вызывай инструмент \`get_broker_operations\`** (например \`{"period": "yesterday"}\` для вчерашнего дня или \`{"period": "today"}\` для сегодняшнего дня)!
|
|
237
|
+
- На общие вопросы **«как дела», «что происходит», «как торговля», «как идут торги», «как счёт»** — **ОБЯЗАТЕЛЬНО** сначала вызывай \`get_brokers_status\`, затем \`get_broker_operations\` с \`{"period": "today"}\`, и только после этого отвечай. Не ограничивайся общими словами и не говори «торги ещё не начались» без проверки.
|
|
238
|
+
- Если данные инструментов расходятся с календарём или временем — **верь данным инструментов**: это фактические позиции и исполненные сделки.
|
|
202
239
|
|
|
203
240
|
## Как устроено автоматическое ядро платформы «НейроАльфа» (Рынок как язык)
|
|
204
241
|
- **Словарь:** ~100 самых ликвидных акций индекса широкого рынка Мосбиржи (MOEXBMI) + индекс IMOEX. Свечи 10-минутные, дивидендные отсечки скорректированы со сверкой по реальному гэпу цены, объёмы нормированы по 24-часовому суточному профилю (valRatio по часам МСК).
|
|
@@ -240,10 +277,11 @@ ${brkSummary}
|
|
|
240
277
|
## Доступные тебе инструменты
|
|
241
278
|
${modeText}
|
|
242
279
|
- Чтобы разобрать текущий портфель пользователя прямо сейчас, свободные рубли или активные заявки в стакане — вызови \`get_brokers_status\`.
|
|
280
|
+
- На вопросы «как дела / что происходит / как идут торги» — вызови \`get_brokers_status\` и \`get_broker_operations\` с \`{"period": "today"}\`, затем ответь по фактическим данным.
|
|
243
281
|
- Чтобы посмотреть, как прошли торги вчера, сегодня или на неделе (история исполненных сделок, покупок, продаж, комиссий и оборота по времени МСК) — вызови \`get_broker_operations\` (например \`{"period": "yesterday"}\` или \`{"period": "today"}\`).
|
|
244
282
|
- Чтобы объяснить текущую ситуацию на рынке — вызови \`model_forecast\` и \`get_news\`.
|
|
245
283
|
- Чтобы помочь подключить счёт — используй \`broker_onboarding_guide\`, \`connect_broker_account\` и \`discover_broker_accounts\` (по id уже сохранённого счёта). НИКОГДА не проси прислать токен брокера в чат: токен пользователь вставляет только в форму счёта («+ Добавить счёт» → поле «Токен»). Если пользователь всё же прислал токен в чат — не повторяй его в ответе, посоветуй перевыпустить токен у брокера и вставить новый в форму. Полностью поддерживается Т-Банк; Финам, БКС, Алор, Цифра и ITI — бета, только с подтверждением заявок; СберИнвестиции, ВТБ и Альфа сейчас не подключаются.
|
|
246
|
-
|
|
284
|
+
${statusQuestionHint}
|
|
247
285
|
## Как отвечать
|
|
248
286
|
- Размышляй и пиши по-русски, доброжелательно, чётко и простым языком без лишнего жаргона.
|
|
249
287
|
- Используй списки и выделение жирным для удобного чтения.`;
|
|
@@ -7,6 +7,7 @@ import { forecastNow } from '../model/forecast.js';
|
|
|
7
7
|
import { listNews } from '../news/index.js';
|
|
8
8
|
import { BROKER_META, brokersState, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders, fetchBrokerHistory } from '../brokers/index.js';
|
|
9
9
|
import { fmtBarMsk, fmtDayMsk, MSK, DAY, round } from '../util.js';
|
|
10
|
+
import { tradingSessionStatus } from '../session.js';
|
|
10
11
|
|
|
11
12
|
export const OOS_LIMIT = 3;
|
|
12
13
|
const AI_LOCKED = PARAMS.filter((p) => p.ai === false).map((p) => p.key);
|
|
@@ -78,9 +79,12 @@ export const TOOLS = {
|
|
|
78
79
|
заявки: a.pendingBatch.orders || [],
|
|
79
80
|
} : null,
|
|
80
81
|
}));
|
|
82
|
+
const session = tradingSessionStatus();
|
|
81
83
|
return {
|
|
82
84
|
result: {
|
|
83
85
|
текущее_время_мск: fmtBarMsk(Date.now()),
|
|
86
|
+
статус_сессии: session.text,
|
|
87
|
+
торги_идут: session.open,
|
|
84
88
|
подключено_счетов: accs.length,
|
|
85
89
|
счета: accs,
|
|
86
90
|
},
|