neuroalpha 0.6.4 → 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 +6 -3
- package/server/brokers/index.js +217 -93
- 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
|
@@ -2968,7 +2968,8 @@ function renderBrokers() {
|
|
|
2968
2968
|
const isPaused = a.autoSchedule === false || a.mode === 'readonly';
|
|
2969
2969
|
const isEditingThis = editingBrokerId === a.id;
|
|
2970
2970
|
const isSpecsHidden = hideBrokerSpecsById.has(a.id);
|
|
2971
|
-
const
|
|
2971
|
+
const hasPortfolioSnapshot = Boolean(ls && (ls.ok || ls.totalRub > 0 || (ls.positions && ls.positions.length > 0)));
|
|
2972
|
+
const collapsedMetricsHtml = !isOpen && hasPortfolioSnapshot
|
|
2972
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>`
|
|
2973
2974
|
: '';
|
|
2974
2975
|
|
|
@@ -3009,13 +3010,15 @@ function renderBrokers() {
|
|
|
3009
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>
|
|
3010
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>` : ''}
|
|
3011
3012
|
</div>
|
|
3012
|
-
${
|
|
3013
|
+
${hasPortfolioSnapshot ? `<div class="kpis">
|
|
3013
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>
|
|
3014
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>
|
|
3015
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>
|
|
3016
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>
|
|
3017
3018
|
</div>` : ''}
|
|
3018
|
-
${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>` : '')}
|
|
3019
3022
|
<div class="brk-subtabs" style="align-items:center">
|
|
3020
3023
|
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${visiblePositions.length})</button>
|
|
3021
3024
|
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
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;
|
|
@@ -1987,7 +2087,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1987
2087
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1988
2088
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1989
2089
|
const divBlocked = [];
|
|
1990
|
-
const
|
|
2090
|
+
const portfolioStale = Boolean(acc.lastSync?.stale);
|
|
2091
|
+
const entryTimeOk = timeOk && !edgeWarn && !exitsOnly && !portfolioStale;
|
|
1991
2092
|
const canBuyRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol))
|
|
1992
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);
|
|
1993
2094
|
const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
|
|
@@ -2414,6 +2515,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2414
2515
|
if (!orders.length) {
|
|
2415
2516
|
if (exitsOnly) {
|
|
2416
2517
|
diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
|
|
2518
|
+
} else if (portfolioStale) {
|
|
2519
|
+
diagNote = `Сервис портфеля брокера временно отвечает с задержкой (${acc.lastSync?.staleReason || 'таймаут'}) — открытие новых позиций отложено до подтверждения остатков, текущие позиции и заявки под контролем.`;
|
|
2417
2520
|
} else if (!timeOk) {
|
|
2418
2521
|
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2419
2522
|
} else if (edgeWarn) {
|
|
@@ -3076,26 +3179,40 @@ export async function fetchBrokerHistory(id, opts = {}) {
|
|
|
3076
3179
|
const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
|
|
3077
3180
|
if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
|
|
3078
3181
|
|
|
3079
|
-
const
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
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
|
+
}
|
|
3092
3203
|
}
|
|
3204
|
+
return { ok: false, fromMs, toMs, ops: [] };
|
|
3093
3205
|
}));
|
|
3094
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
|
+
|
|
3095
3212
|
const seenIds = new Set();
|
|
3096
3213
|
const rawOps = [];
|
|
3097
|
-
for (const
|
|
3098
|
-
for (const op of
|
|
3214
|
+
for (const chunk of chunkOutcomes) {
|
|
3215
|
+
for (const op of chunk.ops) {
|
|
3099
3216
|
const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
|
|
3100
3217
|
if (seenIds.has(key)) continue;
|
|
3101
3218
|
seenIds.add(key);
|
|
@@ -3148,13 +3265,17 @@ export async function fetchBrokerHistory(id, opts = {}) {
|
|
|
3148
3265
|
};
|
|
3149
3266
|
}).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
|
|
3150
3267
|
|
|
3151
|
-
// Сохраняем операции других годов,
|
|
3152
|
-
const
|
|
3153
|
-
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) => {
|
|
3154
3272
|
const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
|
|
3155
|
-
|
|
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);
|
|
3156
3277
|
});
|
|
3157
|
-
const mergedOps = [...fetchedOps, ...
|
|
3278
|
+
const mergedOps = [...fetchedOps, ...preservedOps]
|
|
3158
3279
|
.sort((a, b) => (b.date || 0) - (a.date || 0))
|
|
3159
3280
|
.slice(0, 6000);
|
|
3160
3281
|
|
|
@@ -3640,14 +3761,17 @@ async function tickBrokerAutoRefresh() {
|
|
|
3640
3761
|
if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
|
|
3641
3762
|
await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
|
|
3642
3763
|
}
|
|
3643
|
-
const
|
|
3644
|
-
const
|
|
3645
|
-
|
|
3646
|
-
|
|
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(() => {});
|
|
3647
3771
|
}
|
|
3648
|
-
// Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
|
|
3649
|
-
await forecastNow(getSettings()).catch(() => {});
|
|
3650
3772
|
}
|
|
3773
|
+
// Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
|
|
3774
|
+
await forecastNow(getSettings()).catch(() => {});
|
|
3651
3775
|
});
|
|
3652
3776
|
}
|
|
3653
3777
|
} finally {
|
|
@@ -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
|
},
|