neuroalpha 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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 collapsedMetricsHtml = !isOpen && ls?.ok
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
- ${ls?.ok ? `<div class="kpis">
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 ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
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>
@@ -13,7 +13,7 @@ import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
13
13
  import { forecastNow } from '../model/forecast.js';
14
14
  import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
15
15
  import { liveDividendGuard } from '../model/dividends.js';
16
- import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
16
+ import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
17
17
  import { MODELS } from '../model/models.js';
18
18
  import { blockingNews } from '../news/index.js';
19
19
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
@@ -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
- const rAcc = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetAccounts`, {
1022
- method: 'POST', headers, body: JSON.stringify({}), timeoutMs: 15000,
1023
- }).catch(() => null);
1024
- if (rAcc?.ok) {
1025
- const jAcc = await rAcc.json();
1026
- const openAccs = (jAcc.accounts || []).filter((a) => !a.status || a.status === 'ACCOUNT_STATUS_OPEN');
1027
- if (!accountId) accountId = (openAccs[0] || jAcc.accounts?.[0])?.id;
1028
- const matched = (jAcc.accounts || []).find((a) => a.id === accountId);
1029
- if (matched?.name) accountName = matched.name;
1030
- if (matched?.type) accountType = matched.type;
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 [rPort, rOrd, rMargin, rRecentOps] = await Promise.all([
1035
- brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
1036
- method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), timeoutMs: 15000,
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
- brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
1045
- method: 'POST',
1046
- headers,
1047
- body: JSON.stringify({
1048
- accountId,
1049
- from: new Date(Date.now() - 35 * DAY).toISOString(),
1050
- to: new Date().toISOString(),
1051
- state: 'OPERATION_STATE_EXECUTED',
1052
- }),
1053
- timeoutMs: 10000,
1054
- }).catch(() => null),
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
- if (!rPort.ok) throw new Error(`T-Invest GetPortfolio: HTTP ${rPort.status}`);
1057
- let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(accountName);
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 hasShortPos = (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
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 totalRub = round(qVal(p.totalAmountPortfolio), 2);
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
- for (const s of Object.keys(entryMap)) {
1641
- // Не удаляем метку времени входа, если лимитная заявка по бумаге ещё стоит в стакане
1642
- // или была отправлена менее 20 минут назад (чтобы не сбросить 65-минутный таймер до исполнения лимитки!)
1643
- const isRecentEntryOrder = nowTs - entryMap[s] < 20 * 60000;
1644
- if (!curSymbols.has(s) && !pendingOrderSymbols.has(s) && !isRecentEntryOrder) {
1645
- delete entryMap[s];
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 entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
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))
@@ -2046,10 +2147,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2046
2147
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
2047
2148
  const heldMin = heldMs / 60000;
2048
2149
  const horizonMin = Math.max(90, effHorizon * 60);
2049
- // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
2150
+ // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к динамическому плотному (0.95 × clamp(realizedBarPct / sigmaBarPct, 0.75, 1.25) к концу горизонта 2.5 ч)
2050
2151
  const symMult = r.edgeMult || symbolEdgeMult(sym);
2051
- const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
2052
- const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
2152
+ const holdRatio = exitHoldRatio(heldMin, horizonMin, {
2153
+ sigmaBarPct: Number(r.day?.sigmaBarPct || r.sigmaPct) || 0,
2154
+ realizedBarPct: Number(r.day?.realizedBarPct) || 0,
2155
+ baseEndRatio: exitRatio,
2156
+ autoAdapt,
2157
+ });
2158
+ const rampedExitThr = round((fc.thresholdPct || 0) * holdRatio, 3);
2053
2159
  const curExitThr = round(rampedExitThr * symMult, 3);
2054
2160
  const softExitThr = round(earlyExitThresholdPct * symMult, 3);
2055
2161
  const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
@@ -2414,6 +2520,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2414
2520
  if (!orders.length) {
2415
2521
  if (exitsOnly) {
2416
2522
  diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
2523
+ } else if (portfolioStale) {
2524
+ diagNote = `Сервис портфеля брокера временно отвечает с задержкой (${acc.lastSync?.staleReason || 'таймаут'}) — открытие новых позиций отложено до подтверждения остатков, текущие позиции и заявки под контролем.`;
2417
2525
  } else if (!timeOk) {
2418
2526
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2419
2527
  } else if (edgeWarn) {
@@ -3076,26 +3184,40 @@ export async function fetchBrokerHistory(id, opts = {}) {
3076
3184
  const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
3077
3185
  if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
3078
3186
 
3079
- const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
3080
- try {
3081
- const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
3082
- method: 'POST',
3083
- headers,
3084
- body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
3085
- timeoutMs: 20000,
3086
- });
3087
- if (!r.ok) return [];
3088
- const j = await r.json();
3089
- return j.operations || [];
3090
- } catch {
3091
- return [];
3187
+ const chunkOutcomes = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
3188
+ const fromMs = Date.parse(fromIso);
3189
+ const toMs = Date.parse(toIso);
3190
+ const callChunk = (timeoutMs) => brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
3191
+ method: 'POST',
3192
+ headers,
3193
+ body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
3194
+ timeoutMs,
3195
+ });
3196
+ for (let attempt = 0; attempt < 2; attempt++) {
3197
+ try {
3198
+ if (attempt > 0) await new Promise((res) => setTimeout(res, 1000));
3199
+ const r = await callChunk(attempt === 0 ? 15000 : 18000);
3200
+ if (r.ok) {
3201
+ const j = await r.json();
3202
+ return { ok: true, fromMs, toMs, ops: j.operations || [] };
3203
+ }
3204
+ if (r.status !== 429 && r.status < 500) break;
3205
+ } catch {
3206
+ // retry once on timeout / transient socket error
3207
+ }
3092
3208
  }
3209
+ return { ok: false, fromMs, toMs, ops: [] };
3093
3210
  }));
3094
3211
 
3212
+ const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3213
+ if (intervals.length > 0 && !chunkOutcomes.some((c) => c.ok)) {
3214
+ if (prevOps.length > 0) return brokersState();
3215
+ }
3216
+
3095
3217
  const seenIds = new Set();
3096
3218
  const rawOps = [];
3097
- for (const arr of chunkResults) {
3098
- for (const op of arr) {
3219
+ for (const chunk of chunkOutcomes) {
3220
+ for (const op of chunk.ops) {
3099
3221
  const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
3100
3222
  if (seenIds.has(key)) continue;
3101
3223
  seenIds.add(key);
@@ -3148,13 +3270,17 @@ export async function fetchBrokerHistory(id, opts = {}) {
3148
3270
  };
3149
3271
  }).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
3150
3272
 
3151
- // Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
3152
- const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3153
- const otherYearsOps = prevOps.filter((op) => {
3273
+ // Сохраняем операции других годов, а также операции из тех полугодий targetYear, которые временно не ответили (504 / таймаут)
3274
+ const failedChunks = chunkOutcomes.filter((c) => !c.ok);
3275
+ const fetchedKeys = new Set(fetchedOps.map((op) => op.id || `${op.date}_${op.kind}_${op.symbol}_${op.paymentRub}`));
3276
+ const preservedOps = prevOps.filter((op) => {
3154
3277
  const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
3155
- return y !== targetYear;
3278
+ if (y !== targetYear) return true;
3279
+ const opKey = op.id || `${op.date}_${op.kind}_${op.symbol}_${op.paymentRub}`;
3280
+ if (fetchedKeys.has(opKey)) return false;
3281
+ return failedChunks.some((c) => op.date >= c.fromMs && op.date <= c.toMs);
3156
3282
  });
3157
- const mergedOps = [...fetchedOps, ...otherYearsOps]
3283
+ const mergedOps = [...fetchedOps, ...preservedOps]
3158
3284
  .sort((a, b) => (b.date || 0) - (a.date || 0))
3159
3285
  .slice(0, 6000);
3160
3286
 
@@ -3640,14 +3766,17 @@ async function tickBrokerAutoRefresh() {
3640
3766
  if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
3641
3767
  await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
3642
3768
  }
3643
- const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3644
- const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
3645
- if (acc.broker === 'tbank' && historyNeedSync) {
3646
- await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
3769
+ const freshAcc = loadRawAccounts().find((x) => x.id === acc.id) || acc;
3770
+ const latestEntryTs = Math.max(0, ...Object.values(freshAcc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3771
+ const hasCurYearOps = (freshAcc.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).getUTCFullYear() === curYear);
3772
+ const historyNeedSync = !freshAcc.lastHistory?.at || !hasCurYearOps || Date.now() - freshAcc.lastHistory.at >= 90000 || (latestEntryTs > (freshAcc.lastHistory.at || 0));
3773
+ const operationsServiceHealthy = freshAcc.lastSync?.ok && !freshAcc.lastSync?.stale;
3774
+ if (freshAcc.broker === 'tbank' && historyNeedSync && (operationsServiceHealthy || !hasCurYearOps)) {
3775
+ await fetchBrokerHistory(freshAcc.id, { year: curYear }).catch(() => {});
3647
3776
  }
3648
- // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
3649
- await forecastNow(getSettings()).catch(() => {});
3650
3777
  }
3778
+ // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
3779
+ await forecastNow(getSettings()).catch(() => {});
3651
3780
  });
3652
3781
  }
3653
3782
  } finally {
@@ -34,6 +34,10 @@ export const STRATEGY_POLICY = Object.freeze({
34
34
  // паузы повторного входа после выхода, минуты
35
35
  cooldownMin: 30,
36
36
  stopCooldownMin: 60,
37
+ // динамическая подстройка верхнего порога удержания (0.95) по фактической внутридневной волатильности акции (realizedBarPct / sigmaBarPct):
38
+ // в спокойный день (множитель до 0.75×) не вытряхивает позицию раньше времени, в турбулентный день (до 1.25×) быстрее фиксирует выход при затухании прогноза.
39
+ // Проверка 09.11.2026 (06.2024–11.2026, плечо 3×): CAGR 58,6%/52,1% по половинам (весь 55,1%, PF 1,97, DD 6,4%) против 57,5%/48,1% (весь 52,6%, PF 1,89, DD 6,8%).
40
+ exitVolClamp: Object.freeze([0.75, 1.25]),
37
41
  });
38
42
 
39
43
  // ---------- окна начала и конца сессий (широкий спред, гэпы, аукционы) ----------
@@ -95,6 +99,22 @@ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, h
95
99
  return side === 'long' ? (st.longPct ?? 7.0) : (st.shortPct ?? 7.0);
96
100
  }
97
101
 
102
+ // ---------- порог удержания (выхода по ослаблению прогноза) ----------
103
+ // Первые 45 минут действует мягкий порог 0.35, затем к концу горизонта (horizonMin) он плавно поднимается к baseEndRatio (0.95),
104
+ // масштабированному по отношению фактического сегодняшнего размаха свечей бумаги (realizedBarPct) к её обычной волатильности (sigmaBarPct) в коридоре [0.75..1.25].
105
+ export function exitHoldRatio(heldMin, horizonMin, { sigmaBarPct = 0, realizedBarPct = 0, baseEndRatio = 0.95, autoAdapt = true } = {}, policy = STRATEGY_POLICY) {
106
+ if (!autoAdapt) return baseEndRatio;
107
+ const startRatio = 0.35;
108
+ const ramp = heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / Math.max(1, horizonMin - 45));
109
+ let endRatio = baseEndRatio;
110
+ const clamp = policy?.exitVolClamp;
111
+ if (Array.isArray(clamp) && clamp.length === 2 && sigmaBarPct > 0 && realizedBarPct > 0) {
112
+ const vr = Math.max(clamp[0], Math.min(clamp[1], realizedBarPct / sigmaBarPct));
113
+ endRatio = baseEndRatio * vr;
114
+ }
115
+ return startRatio + (endRatio - startRatio) * ramp;
116
+ }
117
+
98
118
  // ---------- плечо и множители размера по эшелонам ----------
99
119
  export const MAX_LEVERAGE = 7;
100
120
  export function tierSizeMult(symbol, leverage = 1, policy = STRATEGY_POLICY) {
@@ -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, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
20
+ import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, exitHoldRatio, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
21
21
 
22
22
  // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
23
  export function alignSeries(timeline, rows) {
@@ -245,8 +245,10 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
245
245
  if (pos.side === 'long' ? fl.crash : fl.squeeze) { exitsAt.push([i, 'Защита от сквиза/обвала', 'regime']); continue; }
246
246
  if (policy.signalExit === false) continue;
247
247
  const heldMin = barsHeldNext * BAR_INTERVAL;
248
- const ramp = heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45));
249
- const thr = (0.35 + (0.95 - 0.35) * ramp) * need * mult[i] * tierScale(i);
248
+ const sigmaBarPct = sg[i] * 100;
249
+ const ctx = dayContext(ds, i, t, sigmaBarPct);
250
+ const holdRatio = exitHoldRatio(heldMin, horizonMin, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, baseEndRatio: 0.95, autoAdapt: true }, policy);
251
+ const thr = holdRatio * need * mult[i] * tierScale(i);
250
252
  const soft = 0.35 * need * mult[i] * tierScale(i);
251
253
  const e = edge(i);
252
254
  const signed = pos.side === 'long' ? e : -e;
@@ -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 mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
178
- const moexStatus = (dow === 0 || dow === 6)
179
- ? 'выходной день (основные торги закрыты)'
180
- : (mins >= 9 * 60 + 50 && mins <= 18 * 60 + 50)
181
- ? 'идёт основная торговая сессия Мосбиржи (09:50–18:50 МСК)'
182
- : (mins > 18 * 60 + 50 && mins <= 23 * 60 + 50)
183
- ? 'идёт вечерняя торговая сессия Мосбиржи (19:00–23:50 МСК)'
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} МСК** — ${moexStatus}.
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
  },