neuroalpha 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -1076,6 +1076,10 @@ async function loadForecast(force, silent = false) {
1076
1076
  reasons.push(`Фильтр новостей (сильный позитив): ${r.shortBlockedByNews}`);
1077
1077
  if (!label) label = '⛔ новости';
1078
1078
  }
1079
+ if (r.shortBlockedByTime) {
1080
+ reasons.push('Вне внутридневного окна открытия новых шортов (утренний старт или конец сессии)');
1081
+ if (!label) label = '⛔ вне окна';
1082
+ }
1079
1083
  } else {
1080
1084
  const dip = Boolean(r.buyBlockedByDip || (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5));
1081
1085
  if (dip) {
@@ -2964,7 +2968,8 @@ function renderBrokers() {
2964
2968
  const isPaused = a.autoSchedule === false || a.mode === 'readonly';
2965
2969
  const isEditingThis = editingBrokerId === a.id;
2966
2970
  const isSpecsHidden = hideBrokerSpecsById.has(a.id);
2967
- const 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
2968
2973
  ? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b>${ls.marginInfo ? ` · Марж. запас: <b class="pos">${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</b>` : ''} · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b>${a.executionAudit?.periods ? ` · 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub >= 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b>` : ''}</span>`
2969
2974
  : '';
2970
2975
 
@@ -3005,13 +3010,15 @@ function renderBrokers() {
3005
3010
  <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · плечо <b>${fmtNum(a.maxLeverage || 1, 1)}×</b> · стоп по общей просадке <b>${a.maxDrawdownPct > 0 ? `−${fmtNum(a.maxDrawdownPct, 0)}%` : 'выкл.'}</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
3006
3011
  ${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
3007
3012
  </div>
3008
- ${ls?.ok ? `<div class="kpis">
3013
+ ${hasPortfolioSnapshot ? `<div class="kpis">
3009
3014
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${visiblePositions.length} поз.${hideNonTradableAssets && nonTradableCount ? `, скрыто: ${nonTradableCount}` : ''})</div>${ls.marginInfo ? `<div class="sub">марж. запас: ${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</div>` : ''}</div>
3010
3015
  <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div>${ls.marginInfo?.startingMarginRub > 0 ? `<div class="sub">занято маржи: ${fmtNum(ls.marginInfo.startingMarginRub, 0)} ₽</div>` : ''}</div>
3011
3016
  <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div>${a.executionAudit?.periods ? `<div class="sub">за 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b></div>` : ''}</div>
3012
3017
  <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div>${a.executionAudit?.periods ? `<div class="sub">сегодня: <b class="${cls(a.executionAudit.periods.day.netRub)}">${a.executionAudit.periods.day.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.day.netRub, 0)} ₽</b></div>` : ''}</div>
3013
3018
  </div>` : ''}
3014
- ${ls && !ls.ok ? `<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>` : '')}
3015
3022
  <div class="brk-subtabs" style="align-items:center">
3016
3023
  <button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${visiblePositions.length})</button>
3017
3024
  <button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
@@ -4226,6 +4233,8 @@ function buildStockModalHeaderHtml(d) {
4226
4233
  ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4227
4234
  : fc?.shortBlockedByBroker
4228
4235
  ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4236
+ : fc?.shortBlockedByTime
4237
+ ? '<span class="tag blocked" title="Вне внутридневного окна открытия новых шортов">⛔ вне окна шорта</span>'
4229
4238
  : fc?.buyBlockedByDip
4230
4239
  ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4231
4240
  : fc?.buyBlockedByOverheat
@@ -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;
@@ -1716,10 +1816,10 @@ const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
1716
1816
  };
1717
1817
 
1718
1818
  async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1719
- if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1720
1819
  for (const [k, v] of entryImpulseWatch) {
1721
1820
  if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
1722
1821
  }
1822
+ if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1723
1823
  const ready = [];
1724
1824
  const deferred = [];
1725
1825
  for (const r of cands) {
@@ -1826,7 +1926,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1826
1926
  let list = loadRawAccounts();
1827
1927
  let acc = list.find((x) => x.id === id);
1828
1928
  if (!acc) throw new Error('Счёт не найден');
1829
- const exitsOnly = isDailyStopHalt(acc);
1929
+ let exitsOnly = isDailyStopHalt(acc);
1830
1930
  if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1831
1931
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1832
1932
  if (onlyEntries && (edgeWarn || exitsOnly)) {
@@ -1848,6 +1948,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1848
1948
  list = loadRawAccounts();
1849
1949
  acc = list.find((x) => x.id === id) || acc;
1850
1950
  }
1951
+ exitsOnly = isDailyStopHalt(acc);
1952
+ if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
1953
+ if (onlyEntries && exitsOnly) return brokersState();
1851
1954
  const params = normalizeParams(getSettings());
1852
1955
  const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
1853
1956
  forecastNow(params),
@@ -1984,7 +2087,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1984
2087
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1985
2088
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1986
2089
  const divBlocked = [];
1987
- const entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
2090
+ const portfolioStale = Boolean(acc.lastSync?.stale);
2091
+ const entryTimeOk = timeOk && !edgeWarn && !exitsOnly && !portfolioStale;
1988
2092
  const canBuyRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol))
1989
2093
  || (isOnImpulseWatch(acc.id, r.symbol, 'buy', nowTs) && r.liquid && r.inIndex !== false && !r.buyBlockedByDip && !r.buyBlockedByOverheat && !r.buyBlockedByDiv && !r.buyBlockedByNews && r.expectedPct >= reqBuyEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
1990
2094
  const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
@@ -2411,6 +2515,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2411
2515
  if (!orders.length) {
2412
2516
  if (exitsOnly) {
2413
2517
  diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
2518
+ } else if (portfolioStale) {
2519
+ diagNote = `Сервис портфеля брокера временно отвечает с задержкой (${acc.lastSync?.staleReason || 'таймаут'}) — открытие новых позиций отложено до подтверждения остатков, текущие позиции и заявки под контролем.`;
2414
2520
  } else if (!timeOk) {
2415
2521
  diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2416
2522
  } else if (edgeWarn) {
@@ -2866,23 +2972,25 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2866
2972
  if (!acc) throw new Error('Счёт не найден');
2867
2973
  if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2868
2974
  const gate = tradingGate();
2869
- if (!gate.allowNew) throw new Error(gate.reason);
2975
+ if (!gate.allowNew && !gate.allowProtective) throw new Error(gate.reason);
2870
2976
  const batch = acc.pendingBatch;
2871
2977
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2872
2978
  if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
2873
- if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2874
- // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов или в окне начала сессии не отправляем
2979
+ if (acc.haltedReason && !isDailyStopHalt(acc)) throw new Error(acc.haltedReason);
2980
+ // Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов, при дневном стопе или в окне начала сессии не отправляем (выходы сохраняются!)
2875
2981
  const execCheckTs = Date.now();
2876
2982
  const staleWhy = (() => {
2983
+ if (!gate.allowNew) return gate.reason;
2984
+ if (isDailyStopHalt(acc)) return acc.haltedReason;
2877
2985
  if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
2878
2986
  if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
2879
2987
  if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
2880
- if (isDailyStopHalt(acc)) return acc.haltedReason;
2881
2988
  return null;
2882
2989
  })();
2883
2990
  if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
2884
- const dropped = batch.orders.filter((o) => !isExitOrder(o) && o.symbol !== 'LQDT');
2885
- batch.orders = batch.orders.filter((o) => isExitOrder(o) || o.symbol === 'LQDT');
2991
+ const keepLqdt = !isDailyStopHalt(acc) && gate.allowNew;
2992
+ const dropped = batch.orders.filter((o) => !isExitOrder(o) && !(keepLqdt && o.symbol === 'LQDT'));
2993
+ batch.orders = batch.orders.filter((o) => isExitOrder(o) || (keepLqdt && o.symbol === 'LQDT'));
2886
2994
  logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
2887
2995
  if (!batch.orders.length) {
2888
2996
  batch.status = 'expired';
@@ -2892,6 +3000,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2892
3000
  throw new Error(`Входы не отправлены: ${staleWhy}`);
2893
3001
  }
2894
3002
  }
3003
+ if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
2895
3004
  // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2896
3005
  // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2897
3006
  for (const ord of batch.orders) {
@@ -2944,8 +3053,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2944
3053
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2945
3054
  newlySentOrders.push(ord);
2946
3055
  if (ord.action === 'buy' || ord.action === 'short') {
2947
- acc.entryTsBySymbol[ord.symbol] = execNowTs;
2948
- acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
3056
+ if (!acc.entryTsBySymbol[ord.symbol]) acc.entryTsBySymbol[ord.symbol] = execNowTs;
3057
+ const ordPx = Number(ord.limitPrice || ord.refPrice) || 0;
3058
+ const prevPeak = Number(acc.peakPriceBySymbol[ord.symbol]) || 0;
3059
+ acc.peakPriceBySymbol[ord.symbol] = prevPeak > 0 && ordPx > 0
3060
+ ? (ord.action === 'buy' ? Math.max(prevPeak, ordPx) : Math.min(prevPeak, ordPx))
3061
+ : (ordPx || prevPeak);
2949
3062
  delete acc.exitCooldownBySymbol[ord.symbol];
2950
3063
  delete acc.entryFailCooldownBySymbol[ord.symbol];
2951
3064
  delete acc.entryFailReasonBySymbol[ord.symbol];
@@ -3066,26 +3179,40 @@ export async function fetchBrokerHistory(id, opts = {}) {
3066
3179
  const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
3067
3180
  if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
3068
3181
 
3069
- const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
3070
- try {
3071
- const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
3072
- method: 'POST',
3073
- headers,
3074
- body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
3075
- timeoutMs: 20000,
3076
- });
3077
- if (!r.ok) return [];
3078
- const j = await r.json();
3079
- return j.operations || [];
3080
- } catch {
3081
- return [];
3182
+ const chunkOutcomes = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
3183
+ const fromMs = Date.parse(fromIso);
3184
+ const toMs = Date.parse(toIso);
3185
+ const callChunk = (timeoutMs) => brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
3186
+ method: 'POST',
3187
+ headers,
3188
+ body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
3189
+ timeoutMs,
3190
+ });
3191
+ for (let attempt = 0; attempt < 2; attempt++) {
3192
+ try {
3193
+ if (attempt > 0) await new Promise((res) => setTimeout(res, 1000));
3194
+ const r = await callChunk(attempt === 0 ? 15000 : 18000);
3195
+ if (r.ok) {
3196
+ const j = await r.json();
3197
+ return { ok: true, fromMs, toMs, ops: j.operations || [] };
3198
+ }
3199
+ if (r.status !== 429 && r.status < 500) break;
3200
+ } catch {
3201
+ // retry once on timeout / transient socket error
3202
+ }
3082
3203
  }
3204
+ return { ok: false, fromMs, toMs, ops: [] };
3083
3205
  }));
3084
3206
 
3207
+ const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3208
+ if (intervals.length > 0 && !chunkOutcomes.some((c) => c.ok)) {
3209
+ if (prevOps.length > 0) return brokersState();
3210
+ }
3211
+
3085
3212
  const seenIds = new Set();
3086
3213
  const rawOps = [];
3087
- for (const arr of chunkResults) {
3088
- for (const op of arr) {
3214
+ for (const chunk of chunkOutcomes) {
3215
+ for (const op of chunk.ops) {
3089
3216
  const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
3090
3217
  if (seenIds.has(key)) continue;
3091
3218
  seenIds.add(key);
@@ -3138,13 +3265,17 @@ export async function fetchBrokerHistory(id, opts = {}) {
3138
3265
  };
3139
3266
  }).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
3140
3267
 
3141
- // Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
3142
- const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3143
- const otherYearsOps = prevOps.filter((op) => {
3268
+ // Сохраняем операции других годов, а также операции из тех полугодий targetYear, которые временно не ответили (504 / таймаут)
3269
+ const failedChunks = chunkOutcomes.filter((c) => !c.ok);
3270
+ const fetchedKeys = new Set(fetchedOps.map((op) => op.id || `${op.date}_${op.kind}_${op.symbol}_${op.paymentRub}`));
3271
+ const preservedOps = prevOps.filter((op) => {
3144
3272
  const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
3145
- return y !== targetYear;
3273
+ if (y !== targetYear) return true;
3274
+ const opKey = op.id || `${op.date}_${op.kind}_${op.symbol}_${op.paymentRub}`;
3275
+ if (fetchedKeys.has(opKey)) return false;
3276
+ return failedChunks.some((c) => op.date >= c.fromMs && op.date <= c.toMs);
3146
3277
  });
3147
- const mergedOps = [...fetchedOps, ...otherYearsOps]
3278
+ const mergedOps = [...fetchedOps, ...preservedOps]
3148
3279
  .sort((a, b) => (b.date || 0) - (a.date || 0))
3149
3280
  .slice(0, 6000);
3150
3281
 
@@ -3248,7 +3379,8 @@ async function tickBrokerSchedule() {
3248
3379
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3249
3380
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3250
3381
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3251
- if (acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3382
+ const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
3383
+ if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3252
3384
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3253
3385
  }
3254
3386
  }
@@ -3372,6 +3504,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
3372
3504
  const headers = tbankHeaders(acc);
3373
3505
  const want = new Map();
3374
3506
  const activeOrders = acc.lastSync?.activeOrders || [];
3507
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
3375
3508
  for (const pos of acc.lastSync.positions || []) {
3376
3509
  const sym = pos.symbol;
3377
3510
  if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
@@ -3386,7 +3519,8 @@ async function reconcileBrokerStopsLocked(list, acc) {
3386
3519
  const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
3387
3520
  const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
3388
3521
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
3389
- const avgPx = Number(pos.avgPrice) || 0;
3522
+ const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenDir && op.date > 0);
3523
+ const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || Number(pos.price) || 0);
3390
3524
  if (lots < 1 || avgPx <= 0) continue;
3391
3525
  const side = aQty > 0 ? 'long' : 'short';
3392
3526
  const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
@@ -3452,16 +3586,19 @@ async function reconcileBrokerStopsLocked(list, acc) {
3452
3586
  }
3453
3587
  }
3454
3588
 
3455
- // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 2 минуты между шагами расписания
3589
+ // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 25 секунд между шагами расписания
3456
3590
  async function checkIntradayProfitLock(accId) {
3457
- const list = loadRawAccounts();
3458
- const acc = list.find((x) => x.id === accId);
3459
- if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3460
3591
  if (!tradingGate().allowProtective) return;
3461
3592
  const nowMsk = new Date(Date.now() + MSK);
3462
3593
  const dow = nowMsk.getUTCDay();
3463
3594
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
3464
3595
  if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
3596
+ const lockToken = acquireAccountLock(accId);
3597
+ if (!lockToken) return;
3598
+ try {
3599
+ const list = loadRawAccounts();
3600
+ const acc = list.find((x) => x.id === accId);
3601
+ if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
3465
3602
  // В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
3466
3603
  const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
3467
3604
 
@@ -3578,7 +3715,7 @@ async function checkIntradayProfitLock(accId) {
3578
3715
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
3579
3716
  }
3580
3717
  }
3581
- acc.pendingBatch = {
3718
+ const stopBatchObj = {
3582
3719
  ...(acc.pendingBatch || {}),
3583
3720
  id: `batch_stop_${execNowTs}`,
3584
3721
  createdAt: execNowTs,
@@ -3588,13 +3725,19 @@ async function checkIntradayProfitLock(accId) {
3588
3725
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3589
3726
  results,
3590
3727
  };
3728
+ if (acc.pendingBatch?.status !== 'pending' && acc.pendingBatch?.status !== 'executing') {
3729
+ acc.pendingBatch = stopBatchObj;
3730
+ }
3591
3731
  saveRawAccounts(list);
3592
3732
  if (sentLockOrders.length > 0) {
3593
- notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
3733
+ notifyBrokerBatch(currentUserId(), acc, { ...stopBatchObj, orders: sentLockOrders }).catch(() => {});
3594
3734
  }
3595
3735
  await syncBrokerAccount(accId).catch(() => {});
3596
3736
  const st = brokersState();
3597
3737
  hub.emit({ type: 'brokers:update', state: st });
3738
+ } finally {
3739
+ releaseAccountLock(accId, lockToken);
3740
+ }
3598
3741
  }
3599
3742
 
3600
3743
  // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
@@ -3618,14 +3761,17 @@ async function tickBrokerAutoRefresh() {
3618
3761
  if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
3619
3762
  await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
3620
3763
  }
3621
- const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3622
- const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
3623
- if (acc.broker === 'tbank' && historyNeedSync) {
3624
- await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
3764
+ const freshAcc = loadRawAccounts().find((x) => x.id === acc.id) || acc;
3765
+ const latestEntryTs = Math.max(0, ...Object.values(freshAcc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3766
+ const hasCurYearOps = (freshAcc.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).getUTCFullYear() === curYear);
3767
+ const historyNeedSync = !freshAcc.lastHistory?.at || !hasCurYearOps || Date.now() - freshAcc.lastHistory.at >= 90000 || (latestEntryTs > (freshAcc.lastHistory.at || 0));
3768
+ const operationsServiceHealthy = freshAcc.lastSync?.ok && !freshAcc.lastSync?.stale;
3769
+ if (freshAcc.broker === 'tbank' && historyNeedSync && (operationsServiceHealthy || !hasCurYearOps)) {
3770
+ await fetchBrokerHistory(freshAcc.id, { year: curYear }).catch(() => {});
3625
3771
  }
3626
- // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
3627
- await forecastNow(getSettings()).catch(() => {});
3628
3772
  }
3773
+ // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
3774
+ await forecastNow(getSettings()).catch(() => {});
3629
3775
  });
3630
3776
  }
3631
3777
  } finally {
@@ -230,8 +230,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
230
230
  const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
231
231
  const isCacheFreshForSlot = (entry) => {
232
232
  if (!isTradingNow || now - entry.at < 50000) return true;
233
- const lastBar = entry.ds?.timeline?.[entry.ds.T - 1] || 0;
234
- return lastBar >= latestClosedSlotTs;
233
+ if (entry.at < latestClosedSlotTs + bMs) return false;
234
+ const tl = entry.ds?.timeline;
235
+ if (!tl?.length) return false;
236
+ let lastClosedBar = 0;
237
+ for (let i = tl.length - 1; i >= 0; i--) {
238
+ if (tl[i] + bMs <= now) { lastClosedBar = tl[i]; break; }
239
+ }
240
+ return lastClosedBar >= latestClosedSlotTs;
235
241
  };
236
242
  const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
237
243
  const hit = datasetCache.get(key);
@@ -82,7 +82,6 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
82
82
  vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
83
83
  }
84
84
  }
85
- vr[(uClosed + 1) * N] = 1;
86
85
  for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
87
86
 
88
87
  if (liveSnap?.prices?.size > 0) {
@@ -125,10 +124,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
125
124
  if (dm) {
126
125
  const msig = mTrain.msig || 1e-3;
127
126
  for (const uk of [uClosed, uClosed + 1]) {
127
+ const baseT = Math.min(idx, lo + uk);
128
128
  SCALES.forEach((sc, si) => {
129
129
  let sum = 0;
130
130
  for (let k = 0; k < sc.len; k++) {
131
- const t = Math.min(idx, lo + uk - sc.from - k);
131
+ const t = baseT - sc.from - k;
132
132
  if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
133
133
  }
134
134
  zs[si * W + uk] = sum / Math.sqrt(sc.len);
@@ -316,8 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
316
316
  const chosen = p.modelType in MODELS ? p.modelType : 'attention';
317
317
  const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
318
318
  const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
319
+ const dayFitKey = `${fitKey}:${barDayMsk}`;
319
320
 
320
- const cached = trainedModelCache.get(fitKey);
321
+ const cached = trainedModelCache.get(dayFitKey);
321
322
  if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
322
323
  const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
323
324
  return {
@@ -331,7 +332,6 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
331
332
  const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
332
333
  const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
333
334
  const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
334
- const u = idx - f.lo;
335
335
  // Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
336
336
  // не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
337
337
  const list = [...new Set(['attention', chosen])];
@@ -356,7 +356,11 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
356
356
  ...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
357
357
  };
358
358
  const trainedAt = Date.now();
359
- trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
359
+ if (trainedModelCache.size >= 16) {
360
+ const firstKey = trainedModelCache.keys().next().value;
361
+ if (firstKey) trainedModelCache.delete(firstKey);
362
+ }
363
+ trainedModelCache.set(dayFitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
360
364
 
361
365
  const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
362
366
  return {
@@ -389,6 +393,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
389
393
  const idx1d = Math.max(0, idx - bpd);
390
394
  const idx3d = Math.max(0, idx - 3 * bpd);
391
395
  const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
396
+ const liveScale = liveSnap?.prices?.size > 0 ? (1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap))) : 1;
392
397
  const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
393
398
  const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
394
399
  const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
@@ -403,8 +408,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
403
408
  const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
404
409
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
405
410
  const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
406
- const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
407
- const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
411
+ const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * liveScale * 100;
412
+ const rawPriceMovePct = ((livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100)) * liveScale;
408
413
  const lastMovePct = round(rawResPct, 3);
409
414
  // Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
410
415
  const p30m = tk.close[idx30m] || 0;
@@ -446,15 +451,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
446
451
  const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
447
452
  const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
448
453
  const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
449
- const isExtremeSpike = Boolean(isRegimeSqueeze || isMorningOpenShortBlocked || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
454
+ const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
450
455
  const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
451
- const liveShortOk = !isExtremeSpike;
456
+ const liveShortOk = !isExtremeSpike && !isMorningOpenShortBlocked;
452
457
  // сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
453
458
  const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
454
459
  const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
455
460
  const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
456
461
  const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
457
462
  const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
463
+ const shortBlockedByTime = Boolean(exp < 0 && !isExtremeSpike && isMorningOpenShortBlocked);
458
464
  const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
459
465
  const row = {
460
466
  i, symbol: tk.symbol, name: tk.name,
@@ -481,6 +487,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
481
487
  row.shortSignal = rawShortHit && !shortBlockedByBroker;
482
488
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
483
489
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
490
+ if (shortBlockedByTime) row.shortBlockedByTime = true;
484
491
  }
485
492
  // контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
486
493
  const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
@@ -531,6 +538,11 @@ function withNews(result, p) {
531
538
  } else {
532
539
  delete r.shortBlockedBySqueeze;
533
540
  }
541
+ if (r.expectedPct < 0 && r.shortBlockedByTime) {
542
+ r.shortSignal = false;
543
+ } else if (r.expectedPct >= 0) {
544
+ delete r.shortBlockedByTime;
545
+ }
534
546
  if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
535
547
  r.buyBlockedByDip = true;
536
548
  r.signal = false;
@@ -648,6 +660,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
648
660
  if (side === 'short') {
649
661
  if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
650
662
  if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
663
+ if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
651
664
  if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
652
665
  if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
653
666
  if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
@@ -89,10 +89,10 @@ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, h
89
89
  if (st.mode === 'vol' && sigmaBarPct > 0) {
90
90
  const vol = Math.max(sigmaBarPct, st.useRealized ? realizedBarPct : 0);
91
91
  const raw = st.k * vol * Math.sqrt(horizonBars);
92
- const maxPct = side === 'long' ? (st.maxLongPct ?? 5.0) : (st.maxShortPct ?? 4.5);
92
+ const maxPct = side === 'long' ? (st.maxLongPct ?? 7.0) : (st.maxShortPct ?? 7.0);
93
93
  return Math.max(st.minPct ?? 1.0, Math.min(maxPct, raw));
94
94
  }
95
- return side === 'long' ? (st.longPct ?? 5.0) : (st.shortPct ?? 4.5);
95
+ return side === 'long' ? (st.longPct ?? 7.0) : (st.shortPct ?? 7.0);
96
96
  }
97
97
 
98
98
  // ---------- плечо и множители размера по эшелонам ----------
@@ -17,7 +17,7 @@ import {
17
17
  isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
18
18
  } from './edge.js';
19
19
  import { nextDividendAt, dividendRules } from './dividends.js';
20
- import { STRATEGY_POLICY, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
20
+ import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
21
21
 
22
22
  // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
23
  export function alignSeries(timeline, rows) {
@@ -52,7 +52,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
52
52
  const slotW = policy.slotPct ?? (plan === 'owner' ? 0.45 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
53
53
  const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
54
54
  const convK = plan === 'owner' ? 0.30 : 0.25;
55
- const lev = Math.max(1, Number(leverage) || 1);
55
+ const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
56
56
  const maxGross = policy.maxGross ?? 0.99 * lev;
57
57
  const capScale = policy.capScale ?? 1;
58
58
  const tierScale = (i) => (isT1(i) ? (policy.edgeScaleT1 ?? 1) : (policy.edgeScaleT2 ?? 1));
@@ -106,7 +106,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
106
106
  }
107
107
  held.delete(i);
108
108
  cooldownUntil[i] = exitT + (kind === 'stop' || kind === 'thesis' ? stopCooldownBars : cooldownBars);
109
- stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * realCost);
109
+ stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * c);
110
110
  stockCnt[i] += 1;
111
111
  };
112
112
  const mtmUnreal = (t) => {
@@ -124,13 +124,26 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
124
124
  if (firstK < 0) firstK = wf.testTs.length;
125
125
  let lastT = -1;
126
126
  let prevT = -1;
127
+ const maxDailyDdPct = policy.maxDailyDrawdownPct ?? 3.0;
128
+ let curDayMsk = -1;
129
+ let dayStartTotalRub = capital;
130
+ let dayDdBreachCount = 0;
131
+ let dayHalted = false;
127
132
  for (let k = firstK; k < wf.testTs.length; k++) {
128
133
  const t = wf.testTs[k];
129
134
  if (timeline[t] > tillTs || t + 1 >= T) break;
130
135
  lastT = t;
131
136
  const fo = wf.foldOf[k];
132
137
  const pr = wf.pred[k], sg = wf.foldSigma[fo], mult = wf.foldMult[fo], beta = wf.foldBeta[fo];
138
+ if (!pr || !sg || !mult || !beta) continue;
133
139
  const decTs = timeline[t] + BAR_MS;
140
+ const barDayMsk = Math.floor((timeline[t] + 3 * 3600000) / 86400000);
141
+ if (barDayMsk !== curDayMsk) {
142
+ curDayMsk = barDayMsk;
143
+ dayStartTotalRub = Math.max(1, equity + mtmUnreal(t));
144
+ dayDdBreachCount = 0;
145
+ dayHalted = false;
146
+ }
134
147
  const isStep = (k - firstK) % stepBars === 0;
135
148
  if (isStep) {
136
149
  for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
@@ -149,7 +162,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
149
162
  }
150
163
  prevT = t;
151
164
 
152
- // 1) внутри свечи t: фиксация прибыли, стоп отмены идеи и защитный стоп
165
+ // 1) внутри свечи t: сначала проверяем защитный стоп и стоп отмены идеи (консервативный приоритет неблагоприятного движения), затем фиксацию прибыли
153
166
  for (const [i, pos] of [...held]) {
154
167
  if (pos.entryT > t) continue;
155
168
  const tk = tokens[i];
@@ -159,19 +172,11 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
159
172
  const favorable = pos.side === 'long' ? h : l;
160
173
  const heldMinStart = (t - pos.entryT) * BAR_INTERVAL;
161
174
  let done = false;
162
- if (policy.profitLock) {
163
- const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
164
- if (lv) {
165
- const gO = gainOf(pos, o) * 100;
166
- if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
167
- else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
168
- }
169
- }
170
- if (done) continue;
171
175
  const stopPx = pxOfGain(pos, -pos.stopPct / 100);
172
176
  const levels = [[stopPx, 'stop']];
173
177
  if (pos.thesisPx > 0) levels.push([pos.thesisPx, 'thesis']);
174
- levels.sort((a, b) => (pos.side === 'long' ? b[0] - a[0] : a[0] - b[0]));
178
+ // При одновременном пробое нескольких стоп-уровней внутри свечи исполняем по более невыгодному (консервативно)
179
+ levels.sort((a, b) => (pos.side === 'long' ? a[0] - b[0] : b[0] - a[0]));
175
180
  for (const [lvPx, kind] of levels) {
176
181
  const hit = pos.side === 'long' ? adverse <= lvPx : adverse >= lvPx;
177
182
  if (!hit) continue;
@@ -182,6 +187,15 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
182
187
  break;
183
188
  }
184
189
  if (done) continue;
190
+ if (policy.profitLock) {
191
+ const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
192
+ if (lv) {
193
+ const gO = gainOf(pos, o) * 100;
194
+ if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
195
+ else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
196
+ }
197
+ }
198
+ if (done) continue;
185
199
  pos.peakGain = Math.max(pos.peakGain, gainOf(pos, favorable));
186
200
  if (policy.profitLock) {
187
201
  const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart + BAR_INTERVAL, roundTripFeePct);
@@ -248,8 +262,18 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
248
262
  if (kind === 'night') stats.night++; else if (kind === 'signal') stats.signalExits++; else if (kind === 'time') stats.timeExits++; else if (kind === 'regime') stats.regime++; else stats.div++;
249
263
  }
250
264
 
251
- // 3) входы
252
- const timeOk = canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
265
+ // 3) входы (блокируются до конца дня при срабатывании дневного стопа просадки, как в живом контуре)
266
+ if (maxDailyDdPct > 0 && !dayHalted && isStep) {
267
+ const curTotalRub = equity + mtmUnreal(t);
268
+ const dayDdPct = ((dayStartTotalRub - curTotalRub) / dayStartTotalRub) * 100;
269
+ if (dayDdPct >= maxDailyDdPct) {
270
+ dayDdBreachCount += 1;
271
+ if (dayDdBreachCount >= 2) dayHalted = true;
272
+ } else {
273
+ dayDdBreachCount = 0;
274
+ }
275
+ }
276
+ const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
253
277
  if (timeOk) {
254
278
  const baseOk = (i) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i]) && (policy.tier2 !== false || isT1(i));
255
279
  const candL = [];
@@ -5,6 +5,7 @@
5
5
  import { buildFold, MODELS, FEATURE_WARMUP } from './models.js';
6
6
  import { computeTokenEdgeMults } from './edge.js';
7
7
  import { barsFromHours } from './dataset.js';
8
+ import { loadProfiles } from './profiles.js';
8
9
 
9
10
  const DAY = 86400000;
10
11
  const MSK = 3 * 3600000;
@@ -33,6 +34,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
33
34
  const foldSigma = [];
34
35
  const foldBeta = [];
35
36
  const foldMult = [];
37
+ const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
36
38
  let fi = 0;
37
39
  while (testStart < T - maxH - 2) {
38
40
  if (signal?.aborted) throw new Error('Отменено');
@@ -40,7 +42,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
40
42
  const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
41
43
  onProgress({ fold: fi + 1, folds, from: timeline[testStart], till: timeline[testEnd - 1] });
42
44
  await tick();
43
- const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: 0, profiles: null, multiHorizon: autoAdapt });
45
+ const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight || 0, profiles, multiHorizon: autoAdapt });
44
46
  foldSigma.push(Float64Array.from(f.sigma));
45
47
  foldBeta.push(Float64Array.from(f.beta));
46
48
  foldMult.push(computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart));
@@ -0,0 +1,42 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Расписание торгов Мосбиржи и платформы «НейроАльфа» (МСК): утренняя, основная и вечерняя сессии.
3
+ // Используется ИИ-Стратегом для ответов о текущем состоянии торгов.
4
+ import { MSK } from './util.js';
5
+
6
+ export const SESSION_SCHEDULE = Object.freeze({
7
+ morningStart: 6 * 60 + 50, // 06:50
8
+ mainStart: 9 * 60 + 50, // 09:50
9
+ mainEnd: 18 * 60 + 50, // 18:50
10
+ eveningStart: 19 * 60, // 19:00
11
+ eveningEnd: 23 * 60 + 50, // 23:50
12
+ });
13
+
14
+ // Возвращает { open, kind, text } для текущего момента (или переданного ts).
15
+ // open=true — платформа может торговать; kind: morning | main | evening | break | before | after | weekend.
16
+ export function tradingSessionStatus(ts = Date.now()) {
17
+ const d = new Date(ts + MSK);
18
+ const dow = d.getUTCDay();
19
+ const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
20
+ const s = SESSION_SCHEDULE;
21
+
22
+ if (dow === 0 || dow === 6) {
23
+ return { open: false, kind: 'weekend', text: 'выходной день (торги закрыты; следующая сессия — в понедельник в 06:50 МСК)' };
24
+ }
25
+ if (mins >= s.morningStart && mins < s.mainStart) {
26
+ return { open: true, kind: 'morning', text: 'идёт утренняя торговая сессия Мосбиржи (06:50–09:50 МСК)' };
27
+ }
28
+ if (mins >= s.mainStart && mins <= s.mainEnd) {
29
+ return { open: true, kind: 'main', text: 'идёт основная торговая сессия Мосбиржи (09:50–18:50 МСК)' };
30
+ }
31
+ if (mins > s.mainEnd && mins < s.eveningStart) {
32
+ return { open: false, kind: 'break', text: 'межсессионный перерыв (основная сессия закрыта, вечерняя откроется в 19:00 МСК)' };
33
+ }
34
+ if (mins >= s.eveningStart && mins <= s.eveningEnd) {
35
+ return { open: true, kind: 'evening', text: 'идёт вечерняя торговая сессия Мосбиржи (19:00–23:50 МСК)' };
36
+ }
37
+ if (mins > s.eveningEnd) {
38
+ const next = dow === 5 ? 'в понедельник в 06:50 МСК' : 'завтра в 06:50 МСК';
39
+ return { open: false, kind: 'after', text: `торги закрыты (вечерняя сессия завершена; следующая откроется ${next})` };
40
+ }
41
+ return { open: false, kind: 'before', text: 'торги ещё не открылись (утренняя сессия откроется сегодня в 06:50 МСК)' };
42
+ }
@@ -7,6 +7,7 @@ import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
7
7
  import { hub } from '../hub.js';
8
8
  import { getSettings } from '../settings.js';
9
9
  import { brokersState } from '../brokers/index.js';
10
+ import { tradingSessionStatus } from '../session.js';
10
11
  import { registerChatHandlers } from '../notify/index.js';
11
12
  import {
12
13
  currentConversation, newConversation, getConversation, setConversationTitle, listConversations,
@@ -21,6 +22,10 @@ fs.mkdirSync(ARCHIVE_ROOT, { recursive: true });
21
22
  const MAX_STEPS = 20;
22
23
  const MSK = 3 * 3600000;
23
24
  const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
25
+ const fmtClockMsk = (ts) => new Date(ts + MSK).toISOString().slice(11, 16);
26
+ // Общие вопросы о состоянии торгов («как дела», «что происходит») требуют проверки фактических данных, а не ответа по календарю
27
+ const STATUS_QUESTION_RE = /^(?:ну\s+)?(?:как\s+(?:дела|делишки|торговля|идут\s+торги|сч[её]т|успехи)|что\s+(?:происходит|нового))(?:[\s!?.,]|$)/i;
28
+ const isStatusQuestion = (text = '') => STATUS_QUESTION_RE.test(String(text || '').trim());
24
29
  const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
25
30
 
26
31
  // Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
@@ -157,14 +162,6 @@ function systemPrompt() {
157
162
  const s = getSettings();
158
163
  const newsFilterState = s.newsFilter ? 'включён (блокирует покупки на сильном негативе и шорты на сильном позитиве за 6 ч)' : 'выключен';
159
164
 
160
- let brkSummary = '(брокерские счета пока не подключены)';
161
- try {
162
- const bAccs = brokersState().accounts || [];
163
- if (bAccs.length) {
164
- brkSummary = bAccs.map((a) => `- «${a.name}» (${a.broker}, субсчёт ${a.accountId || '—'}, режим ${a.mode}${a.sandbox ? ', ПЕСОЧНИЦА' : ''}, старый портфель: ${a.legacyMode || 'isolate'}, лимит: ${a.maxCapitalRub} ₽, баланс: ${a.lastSync?.totalRub ?? '—'} ₽, свободно: ${a.lastSync?.cashRub ?? '—'} ₽, бумаг: ${a.lastSync?.positions?.length ?? 0}${a.haltedReason ? `, ПАУЗА: ${a.haltedReason}` : ''})`).join('\n');
165
- }
166
- } catch { /* ignore */ }
167
-
168
165
  const noTools = !activeModelInfo().tools;
169
166
  const nowTs = Date.now();
170
167
  const nowMsk = new Date(nowTs + MSK);
@@ -174,14 +171,46 @@ function systemPrompt() {
174
171
  const fmtRuDate = (iso) => iso.split('-').reverse().join('.');
175
172
  const nowClock = nowMsk.toISOString().slice(11, 16);
176
173
  const dow = nowMsk.getUTCDay();
177
- const 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
  },