neuroalpha 0.5.28 → 0.5.29

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.5.28",
3
+ "version": "0.5.29",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -3630,8 +3630,13 @@ function connectWs() {
3630
3630
  case 'paper:fill':
3631
3631
  break;
3632
3632
  case 'news:update': loadNews(); break;
3633
- case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
3633
+ case 'brokers:update':
3634
+ state.brokers = m.state;
3635
+ renderBrokers();
3636
+ scheduleStockModalRefresh();
3637
+ break;
3634
3638
  case 'trading:pulse': {
3639
+ scheduleStockModalRefresh();
3635
3640
  if (m.kind === 'broker_calc') {
3636
3641
  triggerTradingPulse(m.text || `Пересчёт заявок брокера…`, 3000);
3637
3642
  } else if (m.kind === 'broker_done') {
@@ -3979,55 +3984,31 @@ $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#he
3979
3984
 
3980
3985
  // ---------- интерактивное досье акции (#stock-modal) ----------
3981
3986
  let stockModalChart = null;
3987
+ let stockModalAreaSeries = null;
3988
+ let stockModalLastTime = 0;
3989
+ let stockModalFirstValue = 0;
3982
3990
  let stockChartRo = null;
3983
3991
  let currentStockData = null;
3992
+ let stockModalRefreshing = false;
3993
+ let stockModalDebounceTimer = null;
3984
3994
  // Выбранный период и масштаб графика в досье акции сохраняются в браузере (масштаб — отдельно для каждого периода)
3985
- const STOCK_CHART_KEY = 'na_stock_chart_v1';
3995
+ const STOCK_CHART_KEY = 'na_stock_chart_v2';
3986
3996
  const loadStockChartPrefs = () => { try { return JSON.parse(localStorage.getItem(STOCK_CHART_KEY) || '{}') || {}; } catch { return {}; } };
3987
3997
  const saveStockChartPrefs = (patch) => { try { localStorage.setItem(STOCK_CHART_KEY, JSON.stringify({ ...loadStockChartPrefs(), ...patch })); } catch { /* ignore */ } };
3988
3998
  const STOCK_RANGES = ['1d', '1w', '1m', '1y', 'all'];
3989
- let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1m';
3990
-
3991
- function renderStockChart(d, range = currentStockRange) {
3992
- currentStockRange = range;
3993
- saveStockChartPrefs({ range });
3994
- const box = $('#stock-chart-box');
3995
- if (!box) return;
3996
- if (stockChartRo) {
3997
- try { stockChartRo.disconnect(); } catch { /* ignore */ }
3998
- stockChartRo = null;
3999
- }
4000
- if (stockModalChart) {
4001
- try { stockModalChart.remove(); } catch { /* ignore */ }
4002
- stockModalChart = null;
4003
- }
4004
- box.innerHTML = '';
4005
- const light = document.body.classList.contains('light');
4006
- const initW = Math.max(260, box.clientWidth || 720);
4007
- stockModalChart = LightweightCharts.createChart(box, {
4008
- width: initW,
4009
- height: 260,
4010
- layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
4011
- grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
4012
- rightPriceScale: { borderVisible: false },
4013
- timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
4014
- crosshair: { mode: 0 },
4015
- });
4016
- if (window.ResizeObserver) {
4017
- stockChartRo = new ResizeObserver(() => {
4018
- if (stockModalChart && box.clientWidth > 100) {
4019
- stockModalChart.applyOptions({ width: box.clientWidth });
4020
- }
4021
- });
4022
- stockChartRo.observe(box);
4023
- }
3999
+ let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1d';
4024
4000
 
4001
+ function buildStockChartSeriesAndMarkers(d, range = currentStockRange) {
4025
4002
  const nowTs = Date.now();
4026
4003
  let rawPts = [];
4027
4004
  if (range === '1d') {
4028
- const cut = nowTs - 2 * 86400000;
4029
- rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
4030
- if (rawPts.length < 5) rawPts = (d.intraday || []).slice(-95);
4005
+ if (d.intraday1m?.length >= 5) {
4006
+ rawPts = d.intraday1m;
4007
+ } else {
4008
+ const cut = nowTs - 2 * 86400000;
4009
+ rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
4010
+ if (rawPts.length < 5) rawPts = (d.intraday || []).slice(-95);
4011
+ }
4031
4012
  } else if (range === '1w') {
4032
4013
  rawPts = (d.intraday || []).filter((b) => b.ts >= nowTs - 8 * 86400000);
4033
4014
  } else if (range === '1m') {
@@ -4048,32 +4029,25 @@ function renderStockChart(d, range = currentStockRange) {
4048
4029
  }
4049
4030
  seriesData.sort((a, b) => a.time - b.time);
4050
4031
 
4051
- const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
4052
- const mainColor = isUp ? '#3fb950' : '#f85149';
4053
- const areaSeries = stockModalChart.addAreaSeries({
4054
- lineColor: mainColor,
4055
- topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
4056
- bottomColor: 'rgba(0, 0, 0, 0.0)',
4057
- lineWidth: 2,
4058
- priceFormat: {
4059
- type: 'price',
4060
- precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
4061
- minMove: d.specs?.minStep || 0.01,
4062
- },
4063
- });
4064
- areaSeries.setData(seriesData);
4032
+ const snapBarTime = (rawT) => {
4033
+ let best = seriesData[0].time;
4034
+ for (let i = 0; i < seriesData.length; i++) {
4035
+ if (seriesData[i].time <= rawT) best = seriesData[i].time;
4036
+ else break;
4037
+ }
4038
+ return best;
4039
+ };
4065
4040
 
4066
- // Маркеры сделок пользователя и дивидендов на графике
4067
4041
  const markers = [];
4068
4042
  if (seriesData.length > 0) {
4069
4043
  const minT = seriesData[0].time;
4070
- const maxT = seriesData[seriesData.length - 1].time;
4044
+ const maxT = seriesData[seriesData.length - 1].time + 120;
4071
4045
  for (const op of (d.userOperations || [])) {
4072
4046
  if (op.kind !== 'buy' && op.kind !== 'sell') continue;
4073
- const t = chartTime(op.date);
4074
- if (t >= minT && t <= maxT) {
4047
+ const rawT = chartTime(op.date);
4048
+ if (rawT >= minT && rawT <= maxT) {
4075
4049
  markers.push({
4076
- time: t,
4050
+ time: snapBarTime(rawT),
4077
4051
  position: op.kind === 'buy' ? 'belowBar' : 'aboveBar',
4078
4052
  color: op.kind === 'buy' ? '#3fb950' : '#f85149',
4079
4053
  shape: op.kind === 'buy' ? 'arrowUp' : 'arrowDown',
@@ -4085,10 +4059,10 @@ function renderStockChart(d, range = currentStockRange) {
4085
4059
  for (const dv of (d.dividends || [])) {
4086
4060
  const dtMs = Date.parse(`${dv.registry}T10:00:00Z`);
4087
4061
  if (!Number.isFinite(dtMs)) continue;
4088
- const t = chartTime(dtMs);
4089
- if (t >= minT && t <= maxT) {
4062
+ const rawT = chartTime(dtMs);
4063
+ if (rawT >= minT && rawT <= maxT) {
4090
4064
  markers.push({
4091
- time: t,
4065
+ time: snapBarTime(rawT),
4092
4066
  position: 'aboveBar',
4093
4067
  color: '#4f8cff',
4094
4068
  shape: 'circle',
@@ -4098,9 +4072,68 @@ function renderStockChart(d, range = currentStockRange) {
4098
4072
  }
4099
4073
  }
4100
4074
  markers.sort((a, b) => a.time - b.time);
4101
- if (markers.length) {
4102
- try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
4103
- }
4075
+ }
4076
+ const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
4077
+ return { seriesData, markers, isUp };
4078
+ }
4079
+
4080
+ function renderStockChart(d, range = currentStockRange) {
4081
+ currentStockRange = range;
4082
+ saveStockChartPrefs({ range });
4083
+ const box = $('#stock-chart-box');
4084
+ if (!box) return;
4085
+ if (stockChartRo) {
4086
+ try { stockChartRo.disconnect(); } catch { /* ignore */ }
4087
+ stockChartRo = null;
4088
+ }
4089
+ if (stockModalChart) {
4090
+ try { stockModalChart.remove(); } catch { /* ignore */ }
4091
+ stockModalChart = null;
4092
+ }
4093
+ stockModalAreaSeries = null;
4094
+ stockModalLastTime = 0;
4095
+ stockModalFirstValue = 0;
4096
+ box.innerHTML = '';
4097
+ const light = document.body.classList.contains('light');
4098
+ const initW = Math.max(260, box.clientWidth || 720);
4099
+ stockModalChart = LightweightCharts.createChart(box, {
4100
+ width: initW,
4101
+ height: 260,
4102
+ layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
4103
+ grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
4104
+ rightPriceScale: { borderVisible: false },
4105
+ timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
4106
+ crosshair: { mode: 0 },
4107
+ });
4108
+ if (window.ResizeObserver) {
4109
+ stockChartRo = new ResizeObserver(() => {
4110
+ if (stockModalChart && box.clientWidth > 100) {
4111
+ stockModalChart.applyOptions({ width: box.clientWidth });
4112
+ }
4113
+ });
4114
+ stockChartRo.observe(box);
4115
+ }
4116
+
4117
+ const { seriesData, markers, isUp } = buildStockChartSeriesAndMarkers(d, range);
4118
+ const mainColor = isUp ? '#3fb950' : '#f85149';
4119
+ const areaSeries = stockModalChart.addAreaSeries({
4120
+ lineColor: mainColor,
4121
+ topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
4122
+ bottomColor: 'rgba(0, 0, 0, 0.0)',
4123
+ lineWidth: 2,
4124
+ priceFormat: {
4125
+ type: 'price',
4126
+ precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
4127
+ minMove: d.specs?.minStep || 0.01,
4128
+ },
4129
+ });
4130
+ areaSeries.setData(seriesData);
4131
+ stockModalAreaSeries = areaSeries;
4132
+ stockModalLastTime = seriesData.length ? seriesData[seriesData.length - 1].time : 0;
4133
+ stockModalFirstValue = seriesData.length ? seriesData[0].value : 0;
4134
+
4135
+ if (markers.length) {
4136
+ try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
4104
4137
  }
4105
4138
  const ts = stockModalChart.timeScale();
4106
4139
  ts.fitContent();
@@ -4125,6 +4158,150 @@ function renderStockChart(d, range = currentStockRange) {
4125
4158
  });
4126
4159
  }
4127
4160
 
4161
+ function buildStockModalHeaderHtml(d) {
4162
+ const fc = d.forecast;
4163
+ const sigTag = fc?.signal
4164
+ ? '<span class="tag ok">сигнал: купить</span>'
4165
+ : fc?.shortSignal
4166
+ ? '<span class="tag no">сигнал: шорт</span>'
4167
+ : fc?.shortBlockedBySqueeze
4168
+ ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4169
+ : fc?.shortBlockedByBroker
4170
+ ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4171
+ : fc?.buyBlockedByDip
4172
+ ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4173
+ : fc?.buyBlockedByOverheat
4174
+ ? `<span class="tag blocked" title="Бумага уже перегрета (+${fmtNum(fc.buyBlockedByOverheat, 1)}% сверх рынка) — покупка на вершине пампа запрещена">⛔ перегрев (+${fmtNum(fc.buyBlockedByOverheat, 1)}%)</span>`
4175
+ : '<span class="tag">нейтрально (вне порога)</span>';
4176
+ const shortBadge = d.shortAllowedByBroker === true
4177
+ ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
4178
+ : d.shortAllowedByBroker === false
4179
+ ? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
4180
+ : '';
4181
+ return `<b>${esc(d.name)}</b> <span class="tag">${esc(d.symbol)}</span> <span class="tag ${d.tier === 1 ? 'ok' : ''}">${esc(d.tierLabel)}</span> ${sigTag} ${shortBadge}`;
4182
+ }
4183
+
4184
+ function buildStockModalKpiHtml(d) {
4185
+ const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
4186
+ const fc = d.forecast;
4187
+ const capStr = d.specs?.marketCapRub
4188
+ ? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
4189
+ : '—';
4190
+ return `<div class="kpis">
4191
+ <div class="kpi"><div class="v">${d.price ? `${fmtNum(d.price, pxDec)} ₽` : '—'}</div><div class="l">текущая цена (1 лот = ${fmtNum(d.specs?.lotSize || 1, 0)} шт. ≈ ${fmtNum(d.specs?.lotCostRub || 0, 1)} ₽)</div></div>
4192
+ <div class="kpi"><div class="v ${cls(d.dayChangePct)}">${fmtPct(d.dayChangePct, 2)}</div><div class="l">за день · за 30 дн.: <b class="${cls(d.monthChangePct)}">${fmtPct(d.monthChangePct, 1)}</b></div></div>
4193
+ <div class="kpi"><div class="v ${cls(fc?.expectedPct || 0)}">${fc ? fmtPct(fc.expectedPct, 2) : '—'}</div><div class="l">прогноз ИИ на ${fc?.horizonHours || 2.5} ч (порог +${fmtNum(d.buyThresholdPct, 2)}%${d.shortThresholdPct ? ` / −${fmtNum(d.shortThresholdPct, 2)}%` : ''})</div></div>
4194
+ <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
4195
+ <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
4196
+ </div>`;
4197
+ }
4198
+
4199
+ function buildStockModalInfHtml(d) {
4200
+ const fc = d.forecast;
4201
+ if (!fc?.influences?.length) return '';
4202
+ return `<div class="scard spadded">
4203
+ <div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
4204
+ <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (возврат к среднему после локального импульса): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
4205
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
4206
+ ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
4207
+ <div>
4208
+ <span class="sym-link" data-sym="${esc(inf.symbol)}" title="Перейти в досье ${esc(inf.symbol)}"><b>${esc(inf.symbol)}</b> <span class="muted small">${esc(inf.name || '')}</span></span>
4209
+ <div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
4210
+ </div>
4211
+ <div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
4212
+ </div>`).join('')}
4213
+ </div>
4214
+ </div>`;
4215
+ }
4216
+
4217
+ function buildStockModalUserBoxHtml(d) {
4218
+ const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
4219
+ const userPosBanner = d.userPositions?.length
4220
+ ? d.userPositions.map((p) => `<div style="padding:8px 12px;border:1px solid var(--accent);border-radius:8px;background:rgba(59,130,246,0.06);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
4221
+ <div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
4222
+ <div>Стоимость: <b>${fmtNum(p.valueRub, 0)} ₽</b> · Финрезультат: <b class="${cls(p.pnlRub)}">${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽ (${fmtPct(p.pnlPct, 2)})</b></div>
4223
+ </div>`).join('')
4224
+ : '';
4225
+
4226
+ const userOpsHtml = d.userOperations?.length
4227
+ ? `<table><thead><tr><th>Дата и время</th><th>Операция</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${d.userOperations.slice(0, 15).map((op) => `<tr>
4228
+ <td class="muted small">${fmtTs(op.date)}</td>
4229
+ <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
4230
+ <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
4231
+ <td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
4232
+ <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
4233
+ </tr>`).join('')}</tbody></table>`
4234
+ : '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
4235
+
4236
+ const btInfo = d.backtestStat
4237
+ ? `<div class="muted small" style="margin-top:8px">В эталонном Walk-Forward тесте (№${d.backtestStat.runId}): сделок по ${esc(d.symbol)}: <b>${d.backtestStat.tradesCount}</b> · плюсовых: <b>${d.backtestStat.winRate}%</b> · средний результат сделки: <b class="${cls(d.backtestStat.avgPnlPct)}">${fmtPct(d.backtestStat.avgPnlPct, 2)}</b></div>`
4238
+ : '';
4239
+
4240
+ return `<div class="scard spadded">
4241
+ <div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
4242
+ ${userPosBanner}
4243
+ ${userOpsHtml}
4244
+ ${btInfo}
4245
+ </div>`;
4246
+ }
4247
+
4248
+ async function refreshOpenStockModalLive() {
4249
+ const modal = $('#stock-modal');
4250
+ if (!modal || modal.hidden || !currentStockData?.symbol || stockModalRefreshing) return;
4251
+ const targetSym = currentStockData.symbol;
4252
+ stockModalRefreshing = true;
4253
+ try {
4254
+ const d = await api(`/api/stock/${encodeURIComponent(targetSym)}`);
4255
+ if (modal.hidden || currentStockData?.symbol !== targetSym) return;
4256
+ currentStockData = d;
4257
+ const titleEl = $('#stock-modal-title');
4258
+ if (titleEl) titleEl.innerHTML = buildStockModalHeaderHtml(d);
4259
+ const kpiEl = $('#stock-modal-kpis');
4260
+ if (kpiEl) kpiEl.innerHTML = buildStockModalKpiHtml(d);
4261
+ const infEl = $('#stock-modal-inf');
4262
+ if (infEl) infEl.innerHTML = buildStockModalInfHtml(d);
4263
+ const userBoxEl = $('#stock-modal-userbox');
4264
+ if (userBoxEl) userBoxEl.innerHTML = buildStockModalUserBoxHtml(d);
4265
+
4266
+ if (stockModalAreaSeries && stockModalChart) {
4267
+ const { seriesData, markers } = buildStockChartSeriesAndMarkers(d, currentStockRange);
4268
+ if (seriesData.length) {
4269
+ const firstVal = stockModalFirstValue || seriesData[0].value;
4270
+ const lastPt = seriesData[seriesData.length - 1];
4271
+ const isUp = lastPt.value >= firstVal;
4272
+ stockModalAreaSeries.applyOptions({
4273
+ lineColor: isUp ? '#3fb950' : '#f85149',
4274
+ topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
4275
+ });
4276
+ for (const pt of seriesData.slice(-2)) {
4277
+ if (pt.time >= stockModalLastTime) {
4278
+ try {
4279
+ stockModalAreaSeries.update(pt);
4280
+ if (pt.time > stockModalLastTime) stockModalLastTime = pt.time;
4281
+ } catch { /* ignore */ }
4282
+ }
4283
+ }
4284
+ try { stockModalAreaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
4285
+ }
4286
+ }
4287
+ } catch { /* ignore background live refresh error */ } finally {
4288
+ stockModalRefreshing = false;
4289
+ }
4290
+ }
4291
+
4292
+ function scheduleStockModalRefresh() {
4293
+ const modal = $('#stock-modal');
4294
+ if (!modal || modal.hidden || !currentStockData?.symbol) return;
4295
+ clearTimeout(stockModalDebounceTimer);
4296
+ stockModalDebounceTimer = setTimeout(() => {
4297
+ refreshOpenStockModalLive();
4298
+ }, 800);
4299
+ }
4300
+
4301
+ setInterval(() => {
4302
+ refreshOpenStockModalLive();
4303
+ }, 3000);
4304
+
4128
4305
  async function openStockModal(symbol) {
4129
4306
  const sym = String(symbol || '').toUpperCase().trim();
4130
4307
  if (!sym) return;
@@ -4140,46 +4317,12 @@ async function openStockModal(symbol) {
4140
4317
  const d = await api(`/api/stock/${encodeURIComponent(sym)}`);
4141
4318
  currentStockData = d;
4142
4319
  KNOWN_MOEX_TICKERS.add(d.symbol);
4143
- const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
4144
- const fc = d.forecast;
4145
- const sigTag = fc?.signal
4146
- ? '<span class="tag ok">сигнал: купить</span>'
4147
- : fc?.shortSignal
4148
- ? '<span class="tag no">сигнал: шорт</span>'
4149
- : fc?.shortBlockedBySqueeze
4150
- ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4151
- : fc?.shortBlockedByBroker
4152
- ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4153
- : fc?.buyBlockedByDip
4154
- ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4155
- : fc?.buyBlockedByOverheat
4156
- ? `<span class="tag blocked" title="Бумага уже перегрета (+${fmtNum(fc.buyBlockedByOverheat, 1)}% сверх рынка) — покупка на вершине пампа запрещена">⛔ перегрев (+${fmtNum(fc.buyBlockedByOverheat, 1)}%)</span>`
4157
- : '<span class="tag">нейтрально (вне порога)</span>';
4158
- const shortBadge = d.shortAllowedByBroker === true
4159
- ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
4160
- : d.shortAllowedByBroker === false
4161
- ? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
4162
- : '';
4163
-
4164
- titleEl.innerHTML = `<b>${esc(d.name)}</b> <span class="tag">${esc(d.symbol)}</span> <span class="tag ${d.tier === 1 ? 'ok' : ''}">${esc(d.tierLabel)}</span> ${sigTag} ${shortBadge}`;
4165
-
4166
- const capStr = d.specs?.marketCapRub
4167
- ? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
4168
- : '—';
4169
-
4170
- // 1. Верхние KPI
4171
- const kpiHtml = `<div class="kpis">
4172
- <div class="kpi"><div class="v">${d.price ? `${fmtNum(d.price, pxDec)} ₽` : '—'}</div><div class="l">текущая цена (1 лот = ${fmtNum(d.specs?.lotSize || 1, 0)} шт. ≈ ${fmtNum(d.specs?.lotCostRub || 0, 1)} ₽)</div></div>
4173
- <div class="kpi"><div class="v ${cls(d.dayChangePct)}">${fmtPct(d.dayChangePct, 2)}</div><div class="l">за день · за 30 дн.: <b class="${cls(d.monthChangePct)}">${fmtPct(d.monthChangePct, 1)}</b></div></div>
4174
- <div class="kpi"><div class="v ${cls(fc?.expectedPct || 0)}">${fc ? fmtPct(fc.expectedPct, 2) : '—'}</div><div class="l">прогноз ИИ на ${fc?.horizonHours || 2.5} ч (порог +${fmtNum(d.buyThresholdPct, 2)}%${d.shortThresholdPct ? ` / −${fmtNum(d.shortThresholdPct, 2)}%` : ''})</div></div>
4175
- <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
4176
- <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
4177
- </div>`;
4320
+ titleEl.innerHTML = buildStockModalHeaderHtml(d);
4178
4321
 
4179
4322
  // 2. График с выбором периода
4180
4323
  const chartWrapHtml = `<div class="scard spadded">
4181
4324
  <div class="stitle bar" style="margin:0 0 8px">
4182
- <span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
4325
+ <span>График цены ${esc(d.symbol)} и ваши сделки на счёте <span class="muted small" style="font-weight:400">· обновление в реальном времени</span></span>
4183
4326
  <div class="seg" id="stock-chart-range" style="margin:0">
4184
4327
  ${[['1d', '1Д'], ['1w', '1Н'], ['1m', '1М'], ['1y', '1Г'], ['all', 'Все']].map(([k, l]) => `<button type="button" data-srange="${k}"${k === currentStockRange ? ' class="active"' : ''}>${l}</button>`).join('')}
4185
4328
  </div>
@@ -4187,52 +4330,6 @@ async function openStockModal(symbol) {
4187
4330
  <div id="stock-chart-box" class="chart"></div>
4188
4331
  </div>`;
4189
4332
 
4190
- // 3. Соседи по карте рынка (кто влияет на акцию прямо сейчас)
4191
- const infHtml = fc?.influences?.length
4192
- ? `<div class="scard spadded">
4193
- <div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
4194
- <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (возврат к среднему после локального импульса): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
4195
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
4196
- ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
4197
- <div>
4198
- <span class="sym-link" data-sym="${esc(inf.symbol)}" title="Перейти в досье ${esc(inf.symbol)}"><b>${esc(inf.symbol)}</b> <span class="muted small">${esc(inf.name || '')}</span></span>
4199
- <div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
4200
- </div>
4201
- <div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
4202
- </div>`).join('')}
4203
- </div>
4204
- </div>`
4205
- : '';
4206
-
4207
- // 4. Позиция пользователя и история реальных сделок на брокерском счёте по этой бумаге
4208
- const userPosBanner = d.userPositions?.length
4209
- ? d.userPositions.map((p) => `<div style="padding:8px 12px;border:1px solid var(--accent);border-radius:8px;background:rgba(59,130,246,0.06);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
4210
- <div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
4211
- <div>Стоимость: <b>${fmtNum(p.valueRub, 0)} ₽</b> · Финрезультат: <b class="${cls(p.pnlRub)}">${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽ (${fmtPct(p.pnlPct, 2)})</b></div>
4212
- </div>`).join('')
4213
- : '';
4214
-
4215
- const userOpsHtml = d.userOperations?.length
4216
- ? `<table><thead><tr><th>Дата и время</th><th>Операция</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${d.userOperations.slice(0, 15).map((op) => `<tr>
4217
- <td class="muted small">${fmtTs(op.date)}</td>
4218
- <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
4219
- <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
4220
- <td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
4221
- <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
4222
- </tr>`).join('')}</tbody></table>`
4223
- : '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
4224
-
4225
- const btInfo = d.backtestStat
4226
- ? `<div class="muted small" style="margin-top:8px">В эталонном Walk-Forward тесте (№${d.backtestStat.runId}): сделок по ${esc(d.symbol)}: <b>${d.backtestStat.tradesCount}</b> · плюсовых: <b>${d.backtestStat.winRate}%</b> · средний результат сделки: <b class="${cls(d.backtestStat.avgPnlPct)}">${fmtPct(d.backtestStat.avgPnlPct, 2)}</b></div>`
4227
- : '';
4228
-
4229
- const userBoxHtml = `<div class="scard spadded">
4230
- <div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
4231
- ${userPosBanner}
4232
- ${userOpsHtml}
4233
- ${btInfo}
4234
- </div>`;
4235
-
4236
4333
  // 5. Фундаментальный бизнес-профиль (20 признаков) + Дивиденды (в 2 колонки)
4237
4334
  const topTraits = [...(d.traits || [])].filter((t) => t.value >= 0.15).sort((a, b) => b.value - a.value).slice(0, 10);
4238
4335
  const traitsColHtml = topTraits.length
@@ -4278,11 +4375,11 @@ async function openStockModal(symbol) {
4278
4375
  </div>`;
4279
4376
 
4280
4377
  bodyEl.innerHTML = `
4281
- ${kpiHtml}
4378
+ <div id="stock-modal-kpis">${buildStockModalKpiHtml(d)}</div>
4282
4379
  ${chartWrapHtml}
4283
- ${infHtml}
4380
+ <div id="stock-modal-inf">${buildStockModalInfHtml(d)}</div>
4284
4381
  <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">${traitsColHtml}${divsColHtml}</div>
4285
- ${userBoxHtml}
4382
+ <div id="stock-modal-userbox">${buildStockModalUserBoxHtml(d)}</div>
4286
4383
  ${newsHtml}
4287
4384
  `;
4288
4385
 
@@ -654,11 +654,12 @@ export function createRouter() {
654
654
  if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
655
655
 
656
656
  const nowTs = Date.now();
657
- const [fc, rawSpecs, divs, tbLiveBars] = await Promise.all([
657
+ const [fc, rawSpecs, divs, tbLiveBars, tbMinBars] = await Promise.all([
658
658
  forecastNow(getSettings()).catch(() => null),
659
659
  ensureBoardSpecs().catch(() => ({})),
660
660
  ensureDividends(sym).catch(() => []),
661
661
  fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
662
+ fetchTbankCandles(sym, nowTs - DAY + 5 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null),
662
663
  ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
663
664
  ]);
664
665
  if (tbLiveBars?.length) {
@@ -703,7 +704,38 @@ export function createRouter() {
703
704
  }
704
705
  const daily = [...dailyMap.values()];
705
706
 
706
- const curPrice = fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
707
+ const tbFresh1m = tbMinBars?.length ? tbMinBars[tbMinBars.length - 1] : null;
708
+ const tbFreshBar = tbLiveBars?.length ? tbLiveBars[tbLiveBars.length - 1] : null;
709
+ const curPrice = (tbFresh1m?.close > 0 ? tbFresh1m.close : 0) || (tbFreshBar?.close > 0 ? tbFreshBar.close : 0) || fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
710
+ const intraday1m = (tbMinBars || []).map((b) => ({
711
+ ts: b.ts,
712
+ o: b.open,
713
+ h: b.high,
714
+ l: b.low,
715
+ c: b.close,
716
+ v: b.value || 0,
717
+ }));
718
+ if (curPrice > 0 && intraday1m.length) {
719
+ const last1m = intraday1m[intraday1m.length - 1];
720
+ const liveMinSlotTs = Math.floor(nowTs / 60000) * 60000;
721
+ if (liveMinSlotTs > last1m.ts && liveMinSlotTs - last1m.ts <= 3600000) {
722
+ intraday1m.push({ ts: liveMinSlotTs, o: last1m.c, h: Math.max(last1m.c, curPrice), l: Math.min(last1m.c, curPrice), c: curPrice, v: 0 });
723
+ } else if (last1m.c !== curPrice) {
724
+ last1m.c = curPrice;
725
+ if (curPrice > last1m.h) last1m.h = curPrice;
726
+ if (last1m.l <= 0 || curPrice < last1m.l) last1m.l = curPrice;
727
+ }
728
+ }
729
+ if (tbFreshBar && !tbFreshBar.isComplete && intraday.length) {
730
+ const lastBar = intraday[intraday.length - 1];
731
+ if (tbFreshBar.ts > lastBar.ts) {
732
+ intraday.push({ ts: tbFreshBar.ts, o: tbFreshBar.open, h: tbFreshBar.high, l: tbFreshBar.low, c: curPrice, v: tbFreshBar.value || 0 });
733
+ } else if (tbFreshBar.ts === lastBar.ts) {
734
+ lastBar.c = curPrice;
735
+ if (tbFreshBar.high > lastBar.h) lastBar.h = tbFreshBar.high;
736
+ if (tbFreshBar.low > 0 && (lastBar.l <= 0 || tbFreshBar.low < lastBar.l)) lastBar.l = tbFreshBar.low;
737
+ }
738
+ }
707
739
  if (curPrice > 0 && intraday.length) {
708
740
  const lastBar = intraday[intraday.length - 1];
709
741
  const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
@@ -741,7 +773,13 @@ export function createRouter() {
741
773
  for (const acc of bState.accounts || []) {
742
774
  const pos = (acc.lastSync?.positions || []).find((p) => p.symbol === sym && p.qty !== 0);
743
775
  if (pos) {
744
- userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos });
776
+ const livePx = curPrice > 0 ? curPrice : pos.price;
777
+ const avgPx = Number(pos.avgPrice) || 0;
778
+ const valRub = round(pos.qty * livePx, 2);
779
+ const pnlRub = avgPx > 0 ? round(pos.qty * (livePx - avgPx), 2) : pos.pnlRub;
780
+ const costBasis = avgPx > 0 ? Math.abs(pos.qty) * avgPx : 0;
781
+ const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : pos.pnlPct;
782
+ userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos, price: livePx, valueRub: valRub, pnlRub, pnlPct });
745
783
  }
746
784
  for (const op of acc.lastHistory?.operations || []) {
747
785
  if (op.symbol === sym && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'div' || op.kind === 'fee')) {
@@ -822,6 +860,7 @@ export function createRouter() {
822
860
  userPositions,
823
861
  userOperations: userOperations.slice(0, 60),
824
862
  backtestStat,
863
+ intraday1m,
825
864
  intraday,
826
865
  daily,
827
866
  });
@@ -505,6 +505,7 @@ export function saveBrokerAccount(input = {}) {
505
505
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
506
506
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
507
507
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
508
+ entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
508
509
  peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
509
510
  baseRunId: nextBaseRunId,
510
511
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
@@ -767,8 +768,8 @@ export async function fetchTbankRealtimePrices() {
767
768
  }
768
769
  }
769
770
 
770
- // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
771
- export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
771
+ // Возвращает свежие свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
772
+ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false, intervalMin = 10 } = {}) {
772
773
  try {
773
774
  const rawAccs = loadRawAccounts();
774
775
  const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
@@ -780,15 +781,17 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
780
781
  const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
781
782
  const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
782
783
  const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
783
- // Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
784
- const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
784
+ const is1m = Number(intervalMin) === 1;
785
+ // Для CANDLE_INTERVAL_1_MIN максимальный диапазон одного запроса в T-Invest API — 1 сутки, для CANDLE_INTERVAL_10_MIN — 3 суток
786
+ const maxSpanMs = is1m ? (86400000 - 60000) : (3 * 86400000 - 60000);
787
+ const safeFrom = Math.max(fromTs, tillTs - maxSpanMs);
785
788
  if (safeFrom >= tillTs) return null;
786
789
  const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
787
790
  const body = JSON.stringify({
788
791
  instrumentId: uid,
789
792
  from: new Date(safeFrom).toISOString(),
790
793
  to: new Date(tillTs).toISOString(),
791
- interval: 'CANDLE_INTERVAL_10_MIN',
794
+ interval: is1m ? 'CANDLE_INTERVAL_1_MIN' : 'CANDLE_INTERVAL_10_MIN',
792
795
  });
793
796
  let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
794
797
  if (r.status === 429 || r.status >= 500) {
@@ -981,10 +984,13 @@ async function syncTbank(acc) {
981
984
  }
982
985
  }
983
986
  }
984
- let marginEnabled = false;
985
- let marginInfo = null;
987
+ const p = await rPort.json();
988
+ const hasShortPos = (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
989
+ let marginEnabled = Boolean(acc.lastSync?.marginEnabled || hasShortPos);
990
+ let marginInfo = acc.lastSync?.marginInfo || null;
986
991
  if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
987
992
  marginEnabled = false;
993
+ marginInfo = null;
988
994
  } else if (rMargin?.ok) {
989
995
  marginEnabled = true;
990
996
  const jMar = await rMargin.json().catch(() => null);
@@ -1006,9 +1012,11 @@ async function syncTbank(acc) {
1006
1012
  }
1007
1013
  } else if (rMargin) {
1008
1014
  const mTxt = await rMargin.text().catch(() => '');
1009
- marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
1015
+ if (mTxt.includes('30051') || /margin.*disabled/i.test(mTxt)) {
1016
+ marginEnabled = false;
1017
+ marginInfo = null;
1018
+ }
1010
1019
  }
1011
- const p = await rPort.json();
1012
1020
  const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
1013
1021
  let rawOrders = jOrd?.orders || [];
1014
1022
 
@@ -1480,10 +1488,17 @@ export async function syncBrokerAccount(id) {
1480
1488
  }
1481
1489
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
1482
1490
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
1483
- const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1491
+ const rawDdPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1492
+ // Защита от ложного срабатывания стопа (−99%) при утреннем клиринге брокера, когда API временно отдаёт totalAmountPortfolio ≈ cashRub без оценки бумаг
1493
+ const prevTotalRub = Number(acc.lastSync?.totalRub || 0);
1494
+ const isGlitchZeroPortfolio = rawDdPct >= 50 && prevTotalRub > 50000 && data.totalRub < prevTotalRub * 0.5 && (data.positions || []).some((p) => p.qty !== 0);
1495
+ if (isGlitchZeroPortfolio) {
1496
+ data.totalRub = prevTotalRub;
1497
+ }
1498
+ const ddPct = isGlitchZeroPortfolio ? (Number(acc.lastSync?.dayDrawdownPct) || 0) : rawDdPct;
1484
1499
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
1485
1500
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
1486
- } else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1501
+ } else if (!isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1487
1502
  // Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
1488
1503
  acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
1489
1504
  if (acc.ddBreachCount >= 2) {
@@ -1578,7 +1593,34 @@ export async function freezeBrokerBaseline(id) {
1578
1593
  return st;
1579
1594
  }
1580
1595
 
1581
- // ---------- предохранитель времени сессии (первые и последние 10 минут торгов МСК) ----------
1596
+ // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1597
+ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
1598
+ if (!(avgPx > 0 && curPx > 0)) return null;
1599
+ const isLong = side === 'long';
1600
+ const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
1601
+ const effPeakPx = peakPx > 0
1602
+ ? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
1603
+ : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1604
+ const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1605
+ const tierMult = symbolEdgeMult(sym);
1606
+ // 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
1607
+ // фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
1608
+ if (peakGainPct >= 1.20 * tierMult) {
1609
+ const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
1610
+ if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
1611
+ return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1612
+ }
1613
+ }
1614
+ // 2) Защита заработанного плюса / безубытка (от +0.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
1615
+ // не позволяем плюсовой сделке развернуться обратно в убыток
1616
+ if (peakGainPct >= 0.75 * tierMult) {
1617
+ const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
1618
+ if (curGainPct <= breakevenFloorPct) {
1619
+ return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1620
+ }
1621
+ }
1622
+ return null;
1623
+ }
1582
1624
  function sessionEdgeWarning(now = Date.now()) {
1583
1625
  const d = new Date(now + MSK);
1584
1626
  const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
@@ -1626,6 +1668,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1626
1668
  if (!acc) throw new Error('Счёт не найден');
1627
1669
  if (acc.haltedReason) throw new Error(acc.haltedReason);
1628
1670
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1671
+ if (onlyEntries && edgeWarn) {
1672
+ return brokersState();
1673
+ }
1629
1674
 
1630
1675
  const plan = effectivePlan(currentUser());
1631
1676
  if ((plan.maxBrokerAccounts ?? 0) < 1) {
@@ -1690,6 +1735,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1690
1735
  const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
1691
1736
  const entryTsMap = acc.entryTsBySymbol || {};
1692
1737
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1738
+ const entryFailMap = acc.entryFailCooldownBySymbol || {};
1693
1739
  // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
1694
1740
  // Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
1695
1741
  // что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
@@ -1699,7 +1745,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1699
1745
  const minShortEdgePct = round(baseShortNeedPct, 3);
1700
1746
  const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1701
1747
  const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1702
- const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1748
+ const isInCooldown = (sym) => Boolean(
1749
+ (autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
1750
+ (entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
1751
+ );
1703
1752
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1704
1753
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1705
1754
  // временно исключаем такую бумагу из новых входов!
@@ -1797,11 +1846,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1797
1846
  const smoothSellPool = [];
1798
1847
  const peakMap = acc.peakPriceBySymbol || {};
1799
1848
  const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
1800
- // 1) закрытие бумаг стратегии (покупок и шортов):
1849
+ // 1) закрытие бумаг стратегии (покупок и шортов) на плановом шаге (!onlyEntries):
1801
1850
  // • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
1802
1851
  // • либо если прогноз ослаб ниже порога выхода curExitThr.
1852
+ // В быстром цикле добора (onlyEntries) закрывающие заявки не формируются — сопровождение и выходы идут строго по плановому шагу (и защитным стопам).
1803
1853
  // Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
1804
- for (const [sym, pos] of heldMap) {
1854
+ for (const [sym, pos] of (onlyEntries ? [] : heldMap)) {
1805
1855
  if (!pos.qty) continue;
1806
1856
  const r = rowMap.get(sym);
1807
1857
  if (!r) continue;
@@ -1814,30 +1864,39 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1814
1864
  const lastHistEntryTs = lastHistEntryOp?.date || 0;
1815
1865
  const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
1816
1866
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1817
- // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1818
- // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95) с учётом эшелона
1867
+ const heldMin = heldMs / 60000;
1868
+ const horizonMin = Math.max(90, effHorizon * 60);
1869
+ // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
1819
1870
  const symMult = r.edgeMult || symbolEdgeMult(sym);
1820
- const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symMult, 3);
1871
+ const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
1872
+ const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
1873
+ const curExitThr = round(rampedExitThr * symMult, 3);
1874
+ const softExitThr = round(earlyExitThresholdPct * symMult, 3);
1821
1875
  const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
1822
1876
  const curPx = Number(r.price || pos.price) || 0;
1823
1877
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1824
1878
  if (aQty > 0) {
1825
1879
  const lots = Math.floor(aQty / sp.lotSize);
1826
1880
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1881
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1827
1882
  const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1828
- const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1883
+ // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1884
+ const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1885
+ const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
1829
1886
  const dg = divGuardOf(sym, curPx);
1830
1887
  const divExit = Boolean(dg?.exitLong);
1831
- const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1888
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
1832
1889
  if (lots >= 1 && !keep) {
1833
1890
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1834
1891
  const exitReason = hitHardStopLoss
1835
1892
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1836
- : divExit
1837
- ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1838
- : nightExit
1839
- ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1840
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1893
+ : profitLockReason
1894
+ ? profitLockReason
1895
+ : divExit
1896
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1897
+ : nightExit
1898
+ ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1899
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1841
1900
  orders.push({
1842
1901
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1843
1902
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1849,20 +1908,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1849
1908
  } else if (aQty < 0) {
1850
1909
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1851
1910
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1911
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1852
1912
  const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
1853
- const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1913
+ // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
1914
+ const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1915
+ const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
1854
1916
  const dg = divGuardOf(sym, curPx);
1855
1917
  const divExit = Boolean(dg?.exitShort);
1856
- const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1918
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
1857
1919
  if (lots >= 1 && !keep) {
1858
1920
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1859
1921
  const exitReason = hitHardStopLoss
1860
1922
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1861
- : divExit
1862
- ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1863
- : nightExit
1864
- ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1865
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1923
+ : profitLockReason
1924
+ ? profitLockReason
1925
+ : divExit
1926
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1927
+ : nightExit
1928
+ ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1929
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1866
1930
  orders.push({
1867
1931
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1868
1932
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -2169,12 +2233,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2169
2233
  }
2170
2234
 
2171
2235
  const batchNowTs = Date.now();
2236
+ const batchOrderSig = (arr) => (Array.isArray(arr) ? arr.map((o) => `${o.action}:${o.symbol}:${o.lots}`).join('|') : '');
2237
+ const prevPending = acc.pendingBatch;
2238
+ const curBatchSig = batchOrderSig(orders);
2239
+ const isDuplicatePending = Boolean(
2240
+ curBatchSig &&
2241
+ prevPending?.status === 'pending' &&
2242
+ batchOrderSig(prevPending.orders) === curBatchSig &&
2243
+ batchNowTs - (prevPending.createdAt || 0) < 2 * 3600000,
2244
+ );
2172
2245
  if (!onlyEntries) {
2173
2246
  acc.lastFullStepWallTs = batchNowTs;
2174
2247
  if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
2175
2248
  }
2176
2249
  acc.pendingBatch = {
2177
- id: `batch_${batchNowTs}`,
2250
+ id: isDuplicatePending && prevPending?.id ? prevPending.id : `batch_${batchNowTs}`,
2178
2251
  createdAt: batchNowTs,
2179
2252
  bar: fc.bar,
2180
2253
  budgetRub: budget,
@@ -2191,10 +2264,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2191
2264
  results: [],
2192
2265
  };
2193
2266
  saveRawAccounts(list);
2194
- notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
2195
2267
  if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
2196
2268
  return await executeBrokerOrders(id, { _lockHeld: true });
2197
2269
  }
2270
+ if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
2271
+ notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
2272
+ }
2198
2273
  const st = brokersState();
2199
2274
  hub.emit({ type: 'brokers:update', state: st });
2200
2275
  hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
@@ -2300,7 +2375,7 @@ async function sendOrderToBroker(acc, ord) {
2300
2375
  for (const ao of jActive?.orders || []) {
2301
2376
  const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
2302
2377
  const sameDir = String(ao.direction || '').includes(wantDir);
2303
- if (sameInst && sameDir && ao.orderId) {
2378
+ if (sameInst && (sameDir || !isEntry) && ao.orderId) {
2304
2379
  await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
2305
2380
  method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
2306
2381
  }).catch(() => null);
@@ -2544,8 +2619,10 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2544
2619
  saveRawAccounts(list);
2545
2620
  const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
2546
2621
  const results = [];
2622
+ const newlySentOrders = [];
2547
2623
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2548
2624
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2625
+ acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
2549
2626
  acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2550
2627
  const execNowTs = Date.now();
2551
2628
  const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
@@ -2573,11 +2650,21 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2573
2650
  }
2574
2651
  }
2575
2652
  const r = await sendOrderToBroker(acc, ord);
2653
+ if (r.lots > 0) {
2654
+ ord.lots = r.lots;
2655
+ ord.shares = r.lots * (ord.lotSize || 1);
2656
+ }
2657
+ if (r.limitPrice > 0) {
2658
+ ord.limitPrice = r.limitPrice;
2659
+ ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
2660
+ }
2576
2661
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2662
+ newlySentOrders.push(ord);
2577
2663
  if (ord.action === 'buy' || ord.action === 'short') {
2578
2664
  acc.entryTsBySymbol[ord.symbol] = execNowTs;
2579
2665
  acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2580
2666
  delete acc.exitCooldownBySymbol[ord.symbol];
2667
+ delete acc.entryFailCooldownBySymbol[ord.symbol];
2581
2668
  } else if (ord.action === 'sell' || ord.action === 'cover') {
2582
2669
  hadExits = true;
2583
2670
  delete acc.entryTsBySymbol[ord.symbol];
@@ -2590,6 +2677,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2590
2677
  ord.sendAttempts = (ord.sendAttempts || 0) + 1;
2591
2678
  if (e.definitive) ord.clientOrderId = crypto.randomUUID();
2592
2679
  else ord.uncertainSend = true;
2680
+ if (ord.action === 'buy' || ord.action === 'short') {
2681
+ acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
2682
+ }
2593
2683
  results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2594
2684
  logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
2595
2685
  }
@@ -2598,6 +2688,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2598
2688
  batch.results = results;
2599
2689
  batch.executedAt = Date.now();
2600
2690
  saveRawAccounts(list);
2691
+ if (acc.mode === 'auto' && newlySentOrders.length > 0) {
2692
+ notifyBrokerBatch(currentUserId(), acc, { ...batch, orders: newlySentOrders }).catch(() => {});
2693
+ }
2601
2694
  await syncBrokerAccount(id).catch(() => {});
2602
2695
  if (acc.broker === 'tbank' && acc.token) {
2603
2696
  await fetchBrokerHistory(id).catch(() => {});
@@ -2860,7 +2953,7 @@ async function tickBrokerSchedule() {
2860
2953
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2861
2954
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2862
2955
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2863
- if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2956
+ if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2864
2957
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2865
2958
  }
2866
2959
  }
@@ -3090,8 +3183,7 @@ async function checkIntradayProfitLock(accId) {
3090
3183
  if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
3091
3184
  const aQty = algoQtyOf(sym, pos.qty);
3092
3185
  if (!aQty) continue;
3093
- // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
3094
- if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
3186
+ const hasBrokerHardStop = Boolean(acc.brokerStops && acc.brokerStopBySymbol?.[sym]);
3095
3187
  // Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
3096
3188
  const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
3097
3189
  if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
@@ -3108,26 +3200,30 @@ async function checkIntradayProfitLock(accId) {
3108
3200
  const lots = Math.floor(aQty / sp.lotSize);
3109
3201
  if (lots < 1) continue;
3110
3202
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3111
- const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT;
3112
- if (hitHardStopLoss) {
3203
+ const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3204
+ const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3205
+ if (hitHardStopLoss || profitLockReason) {
3113
3206
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
3114
3207
  lockOrders.push({
3115
3208
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
3116
3209
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
3117
- reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)`,
3210
+ reason: hitHardStopLoss ? `Авто-стоп позиции (${round(curGainPct, 2)}%)` : profitLockReason,
3211
+ isStopLoss: hitHardStopLoss,
3118
3212
  });
3119
3213
  }
3120
3214
  } else if (aQty < 0) {
3121
3215
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3122
3216
  if (lots < 1) continue;
3123
3217
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3124
- const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT;
3125
- if (hitHardStopLoss) {
3218
+ const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3219
+ const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3220
+ if (hitHardStopLoss || profitLockReason) {
3126
3221
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
3127
3222
  lockOrders.push({
3128
3223
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
3129
3224
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
3130
- reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)`,
3225
+ reason: hitHardStopLoss ? `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)` : profitLockReason,
3226
+ isStopLoss: hitHardStopLoss,
3131
3227
  });
3132
3228
  }
3133
3229
  }
@@ -3135,6 +3231,7 @@ async function checkIntradayProfitLock(accId) {
3135
3231
 
3136
3232
  if (!lockOrders.length) return;
3137
3233
  const results = [];
3234
+ const sentLockOrders = [];
3138
3235
  const execNowTs = Date.now();
3139
3236
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
3140
3237
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
@@ -3145,16 +3242,25 @@ async function checkIntradayProfitLock(accId) {
3145
3242
  await cancelBrokerOrder(accId, co.orderId).catch(() => {});
3146
3243
  }
3147
3244
  for (const ord of lockOrders) {
3148
- logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3245
+ logBrokerEvent(acc, ord.isStopLoss ? 'protective_stop' : 'profit_lock', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3149
3246
  try {
3150
3247
  if (acc.brokerStopBySymbol?.[ord.symbol]) {
3151
3248
  await closeBrokerStopBeforeExit(acc, ord.symbol);
3152
3249
  }
3153
3250
  const r = await sendOrderToBroker(acc, ord);
3251
+ if (r.lots > 0) {
3252
+ ord.lots = r.lots;
3253
+ ord.shares = r.lots * (ord.lotSize || 1);
3254
+ }
3255
+ if (r.limitPrice > 0) {
3256
+ ord.limitPrice = r.limitPrice;
3257
+ ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
3258
+ }
3154
3259
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
3260
+ sentLockOrders.push(ord);
3155
3261
  delete acc.entryTsBySymbol[ord.symbol];
3156
3262
  delete acc.peakPriceBySymbol[ord.symbol];
3157
- acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3263
+ if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3158
3264
  } catch (e) {
3159
3265
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
3160
3266
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
@@ -3165,12 +3271,15 @@ async function checkIntradayProfitLock(accId) {
3165
3271
  id: `batch_stop_${execNowTs}`,
3166
3272
  createdAt: execNowTs,
3167
3273
  executedAt: execNowTs,
3168
- bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
3274
+ bar: `${fmtBarMsk(execNowTs)} (Фиксация прибыли / Защита)`,
3169
3275
  orders: lockOrders,
3170
3276
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3171
3277
  results,
3172
3278
  };
3173
3279
  saveRawAccounts(list);
3280
+ if (sentLockOrders.length > 0) {
3281
+ notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
3282
+ }
3174
3283
  await syncBrokerAccount(accId).catch(() => {});
3175
3284
  const st = brokersState();
3176
3285
  hub.emit({ type: 'brokers:update', state: st });
@@ -3197,7 +3306,9 @@ async function tickBrokerAutoRefresh() {
3197
3306
  if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
3198
3307
  await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
3199
3308
  }
3200
- if (acc.broker === 'tbank' && (!acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 120000)) {
3309
+ const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3310
+ const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
3311
+ if (acc.broker === 'tbank' && historyNeedSync) {
3201
3312
  await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
3202
3313
  }
3203
3314
  // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
@@ -378,10 +378,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
378
378
  const p30m = tk.close[idx30m] || 0;
379
379
  const p1d = tk.close[idx1d] || 0;
380
380
  const p3d = tk.close[idx3d] || 0;
381
+ let maxPx1d = livePx;
382
+ for (let t = idx1d; t <= idx; t++) {
383
+ const c = tk.close[t];
384
+ if (c > maxPx1d) maxPx1d = c;
385
+ }
381
386
  const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
382
387
  const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
388
+ const peakExcess1d = (p1d > 0 && maxPx1d > 0 ? (maxPx1d / p1d - 1) : 0) - m1d;
383
389
  const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
384
- const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
390
+ const isOverheated = excess1d >= 0.045 || peakExcess1d >= 0.045 || excess3d >= 0.09;
385
391
  const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
386
392
  let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
387
393
  if (isOverheated && selfRaw > 0) {
@@ -192,9 +192,17 @@ export async function notifyUser(userId, text, buttons = null) {
192
192
 
193
193
  export async function notifyBrokerBatch(userId, acc, batch) {
194
194
  if (!batch?.orders?.length) return;
195
- const lines = batch.orders.map((o) => `• ${o.action === 'buy' ? 'Купить' : 'Продать'} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽`);
196
- const text = `НейроАльфа · Счёт «${acc.name}»\nСформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):\n${lines.join('\n')}`;
197
- const buttons = acc.mode === 'confirm' ? [
195
+ const actionLabel = (a) => (a === 'buy' ? 'Купить' : a === 'cover' ? 'Закрыть шорт (купить)' : a === 'short' ? 'Шорт (продать)' : 'Продать');
196
+ const lines = batch.orders.map((o) => {
197
+ const why = o.reason ? `\n ↳ ${o.reason}` : '';
198
+ return `• ${actionLabel(o.action)} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽${why}`;
199
+ });
200
+ const isExecuted = batch.status === 'executed' || batch.status === 'partial';
201
+ const header = isExecuted
202
+ ? `Выставлены лимитные заявки (свеча ${batch.bar || '—'}):`
203
+ : `Сформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):`;
204
+ const text = `НейроАльфа · Счёт «${acc.name}»\n${header}\n${lines.join('\n')}`;
205
+ const buttons = acc.mode === 'confirm' && !isExecuted ? [
198
206
  { label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}`, action: 'exec', accountId: acc.id, batchId: batch.id || '' },
199
207
  { label: '✕ Отклонить', data: `rej:${userId}:${acc.id}`, action: 'rej', accountId: acc.id, batchId: batch.id || '' },
200
208
  ] : null;