neuroalpha 0.5.28 → 0.5.30

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.30",
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
  });
@@ -297,8 +297,26 @@ export function brokerAccountsSummary(uid = 1) {
297
297
  }
298
298
 
299
299
  const candlePrevStmt = db.prepare('SELECT ts, open, close FROM candles WHERE symbol = ? AND interval = 10 AND ts <= ? ORDER BY ts DESC LIMIT 3');
300
+ const recentBarValStmt = db.prepare('SELECT value FROM candles WHERE symbol = ? AND interval = 10 AND value > 0 ORDER BY ts DESC LIMIT 48');
300
301
  const executionAuditCache = new Map(); // accountId -> { sig, val }
301
302
 
303
+ // Медианный/средний 10-минутный биржевой оборот акции (в рублях) за последние ~8 торговых часов —
304
+ // ограничивает размер заявки во 2-м эшелоне, чтобы не ставить в тонкий стакан крупную «плиту», заметную роботам-маркетмейкерам
305
+ function recentBarTurnoverRub(symbol) {
306
+ try {
307
+ const rows = recentBarValStmt.all(symbol);
308
+ if (!rows.length) return 0;
309
+ const vals = rows.map((r) => Number(r.value) || 0).filter((v) => v > 0).sort((a, b) => a - b);
310
+ if (!vals.length) return 0;
311
+ const mid = Math.floor(vals.length / 2);
312
+ const medianVal = vals.length % 2 ? vals[mid] : (vals[mid - 1] + vals[mid]) / 2;
313
+ const avgVal = vals.reduce((s, v) => s + v, 0) / vals.length;
314
+ return Math.round((medianVal * 0.6 + avgVal * 0.4));
315
+ } catch {
316
+ return 0;
317
+ }
318
+ }
319
+
302
320
  // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста (с кешированием по срезу истории)
303
321
  function computeExecutionAudit(a) {
304
322
  const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
@@ -505,6 +523,7 @@ export function saveBrokerAccount(input = {}) {
505
523
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
506
524
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
507
525
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
526
+ entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
508
527
  peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
509
528
  baseRunId: nextBaseRunId,
510
529
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
@@ -767,8 +786,8 @@ export async function fetchTbankRealtimePrices() {
767
786
  }
768
787
  }
769
788
 
770
- // Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
771
- export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
789
+ // Возвращает свежие свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
790
+ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false, intervalMin = 10 } = {}) {
772
791
  try {
773
792
  const rawAccs = loadRawAccounts();
774
793
  const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
@@ -780,15 +799,17 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
780
799
  const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
781
800
  const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
782
801
  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);
802
+ const is1m = Number(intervalMin) === 1;
803
+ // Для CANDLE_INTERVAL_1_MIN максимальный диапазон одного запроса в T-Invest API — 1 сутки, для CANDLE_INTERVAL_10_MIN — 3 суток
804
+ const maxSpanMs = is1m ? (86400000 - 60000) : (3 * 86400000 - 60000);
805
+ const safeFrom = Math.max(fromTs, tillTs - maxSpanMs);
785
806
  if (safeFrom >= tillTs) return null;
786
807
  const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
787
808
  const body = JSON.stringify({
788
809
  instrumentId: uid,
789
810
  from: new Date(safeFrom).toISOString(),
790
811
  to: new Date(tillTs).toISOString(),
791
- interval: 'CANDLE_INTERVAL_10_MIN',
812
+ interval: is1m ? 'CANDLE_INTERVAL_1_MIN' : 'CANDLE_INTERVAL_10_MIN',
792
813
  });
793
814
  let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
794
815
  if (r.status === 429 || r.status >= 500) {
@@ -981,10 +1002,13 @@ async function syncTbank(acc) {
981
1002
  }
982
1003
  }
983
1004
  }
984
- let marginEnabled = false;
985
- let marginInfo = null;
1005
+ const p = await rPort.json();
1006
+ const hasShortPos = (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
1007
+ let marginEnabled = Boolean(acc.lastSync?.marginEnabled || hasShortPos);
1008
+ let marginInfo = acc.lastSync?.marginInfo || null;
986
1009
  if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
987
1010
  marginEnabled = false;
1011
+ marginInfo = null;
988
1012
  } else if (rMargin?.ok) {
989
1013
  marginEnabled = true;
990
1014
  const jMar = await rMargin.json().catch(() => null);
@@ -1006,9 +1030,11 @@ async function syncTbank(acc) {
1006
1030
  }
1007
1031
  } else if (rMargin) {
1008
1032
  const mTxt = await rMargin.text().catch(() => '');
1009
- marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
1033
+ if (mTxt.includes('30051') || /margin.*disabled/i.test(mTxt)) {
1034
+ marginEnabled = false;
1035
+ marginInfo = null;
1036
+ }
1010
1037
  }
1011
- const p = await rPort.json();
1012
1038
  const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
1013
1039
  let rawOrders = jOrd?.orders || [];
1014
1040
 
@@ -1480,10 +1506,17 @@ export async function syncBrokerAccount(id) {
1480
1506
  }
1481
1507
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
1482
1508
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
1483
- const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1509
+ const rawDdPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1510
+ // Защита от ложного срабатывания стопа (−99%) при утреннем клиринге брокера, когда API временно отдаёт totalAmountPortfolio ≈ cashRub без оценки бумаг
1511
+ const prevTotalRub = Number(acc.lastSync?.totalRub || 0);
1512
+ const isGlitchZeroPortfolio = rawDdPct >= 50 && prevTotalRub > 50000 && data.totalRub < prevTotalRub * 0.5 && (data.positions || []).some((p) => p.qty !== 0);
1513
+ if (isGlitchZeroPortfolio) {
1514
+ data.totalRub = prevTotalRub;
1515
+ }
1516
+ const ddPct = isGlitchZeroPortfolio ? (Number(acc.lastSync?.dayDrawdownPct) || 0) : rawDdPct;
1484
1517
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
1485
1518
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
1486
- } else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1519
+ } else if (!isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1487
1520
  // Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
1488
1521
  acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
1489
1522
  if (acc.ddBreachCount >= 2) {
@@ -1578,7 +1611,34 @@ export async function freezeBrokerBaseline(id) {
1578
1611
  return st;
1579
1612
  }
1580
1613
 
1581
- // ---------- предохранитель времени сессии (первые и последние 10 минут торгов МСК) ----------
1614
+ // Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
1615
+ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
1616
+ if (!(avgPx > 0 && curPx > 0)) return null;
1617
+ const isLong = side === 'long';
1618
+ const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
1619
+ const effPeakPx = peakPx > 0
1620
+ ? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
1621
+ : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
1622
+ const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
1623
+ const tierMult = symbolEdgeMult(sym);
1624
+ // 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
1625
+ // фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
1626
+ if (peakGainPct >= 1.20 * tierMult) {
1627
+ const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
1628
+ if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
1629
+ return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
1630
+ }
1631
+ }
1632
+ // 2) Защита заработанного плюса / безубытка (от +0.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
1633
+ // не позволяем плюсовой сделке развернуться обратно в убыток
1634
+ if (peakGainPct >= 0.75 * tierMult) {
1635
+ const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
1636
+ if (curGainPct <= breakevenFloorPct) {
1637
+ return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
1638
+ }
1639
+ }
1640
+ return null;
1641
+ }
1582
1642
  function sessionEdgeWarning(now = Date.now()) {
1583
1643
  const d = new Date(now + MSK);
1584
1644
  const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
@@ -1626,6 +1686,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1626
1686
  if (!acc) throw new Error('Счёт не найден');
1627
1687
  if (acc.haltedReason) throw new Error(acc.haltedReason);
1628
1688
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
1689
+ if (onlyEntries && edgeWarn) {
1690
+ return brokersState();
1691
+ }
1629
1692
 
1630
1693
  const plan = effectivePlan(currentUser());
1631
1694
  if ((plan.maxBrokerAccounts ?? 0) < 1) {
@@ -1690,6 +1753,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1690
1753
  const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
1691
1754
  const entryTsMap = acc.entryTsBySymbol || {};
1692
1755
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1756
+ const entryFailMap = acc.entryFailCooldownBySymbol || {};
1693
1757
  // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
1694
1758
  // Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
1695
1759
  // что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
@@ -1699,7 +1763,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1699
1763
  const minShortEdgePct = round(baseShortNeedPct, 3);
1700
1764
  const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1701
1765
  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);
1766
+ const isInCooldown = (sym) => Boolean(
1767
+ (autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
1768
+ (entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
1769
+ );
1703
1770
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1704
1771
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1705
1772
  // временно исключаем такую бумагу из новых входов!
@@ -1797,11 +1864,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1797
1864
  const smoothSellPool = [];
1798
1865
  const peakMap = acc.peakPriceBySymbol || {};
1799
1866
  const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
1800
- // 1) закрытие бумаг стратегии (покупок и шортов):
1867
+ // 1) закрытие бумаг стратегии (покупок и шортов) на плановом шаге (!onlyEntries):
1801
1868
  // • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
1802
1869
  // • либо если прогноз ослаб ниже порога выхода curExitThr.
1870
+ // В быстром цикле добора (onlyEntries) закрывающие заявки не формируются — сопровождение и выходы идут строго по плановому шагу (и защитным стопам).
1803
1871
  // Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
1804
- for (const [sym, pos] of heldMap) {
1872
+ for (const [sym, pos] of (onlyEntries ? [] : heldMap)) {
1805
1873
  if (!pos.qty) continue;
1806
1874
  const r = rowMap.get(sym);
1807
1875
  if (!r) continue;
@@ -1814,30 +1882,39 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1814
1882
  const lastHistEntryTs = lastHistEntryOp?.date || 0;
1815
1883
  const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
1816
1884
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1817
- // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1818
- // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95) с учётом эшелона
1885
+ const heldMin = heldMs / 60000;
1886
+ const horizonMin = Math.max(90, effHorizon * 60);
1887
+ // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
1819
1888
  const symMult = r.edgeMult || symbolEdgeMult(sym);
1820
- const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symMult, 3);
1889
+ const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
1890
+ const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
1891
+ const curExitThr = round(rampedExitThr * symMult, 3);
1892
+ const softExitThr = round(earlyExitThresholdPct * symMult, 3);
1821
1893
  const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
1822
1894
  const curPx = Number(r.price || pos.price) || 0;
1823
1895
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1824
1896
  if (aQty > 0) {
1825
1897
  const lots = Math.floor(aQty / sp.lotSize);
1826
1898
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1899
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1827
1900
  const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1828
- const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1901
+ // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1902
+ const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1903
+ const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
1829
1904
  const dg = divGuardOf(sym, curPx);
1830
1905
  const divExit = Boolean(dg?.exitLong);
1831
- const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1906
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
1832
1907
  if (lots >= 1 && !keep) {
1833
1908
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1834
1909
  const exitReason = hitHardStopLoss
1835
1910
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1836
- : divExit
1837
- ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1838
- : nightExit
1839
- ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1840
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1911
+ : profitLockReason
1912
+ ? profitLockReason
1913
+ : divExit
1914
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1915
+ : nightExit
1916
+ ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1917
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1841
1918
  orders.push({
1842
1919
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1843
1920
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1849,20 +1926,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1849
1926
  } else if (aQty < 0) {
1850
1927
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1851
1928
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1929
+ const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
1852
1930
  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);
1931
+ // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
1932
+ const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
1933
+ const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
1854
1934
  const dg = divGuardOf(sym, curPx);
1855
1935
  const divExit = Boolean(dg?.exitShort);
1856
- const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1936
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
1857
1937
  if (lots >= 1 && !keep) {
1858
1938
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1859
1939
  const exitReason = hitHardStopLoss
1860
1940
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1861
- : divExit
1862
- ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1863
- : nightExit
1864
- ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1865
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1941
+ : profitLockReason
1942
+ ? profitLockReason
1943
+ : divExit
1944
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1945
+ : nightExit
1946
+ ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1947
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1866
1948
  orders.push({
1867
1949
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1868
1950
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -2037,8 +2119,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2037
2119
  const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
2038
2120
  const neededForNewRub = combinedNew.reduce((s, { r, side }) => {
2039
2121
  const isT1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2040
- const cap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
2041
- return s + Math.min(cap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2122
+ const rawCap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
2123
+ const barTurnRub = !isT1 ? recentBarTurnoverRub(r.symbol) : 0;
2124
+ const liqCap = (!isT1 && barTurnRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnRub * 0.12)) : rawCap;
2125
+ return s + Math.min(liqCap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2042
2126
  }, 0);
2043
2127
  const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
2044
2128
  if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
@@ -2076,9 +2160,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2076
2160
  remWeightSum = Math.max(0, remWeightSum - wCur);
2077
2161
  remNew--;
2078
2162
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2079
- const sideCapRub = side === 'short'
2163
+ const rawSideCapRub = side === 'short'
2080
2164
  ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
2081
2165
  : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
2166
+ // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
2167
+ // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2168
+ const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2169
+ const sideCapRub = (!isTier1 && barTurnoverRub > 0)
2170
+ ? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
2171
+ : rawSideCapRub;
2082
2172
  const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2083
2173
  const sum = Math.min(rawSum, propShare, availRub);
2084
2174
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
@@ -2169,12 +2259,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2169
2259
  }
2170
2260
 
2171
2261
  const batchNowTs = Date.now();
2262
+ const batchOrderSig = (arr) => (Array.isArray(arr) ? arr.map((o) => `${o.action}:${o.symbol}:${o.lots}`).join('|') : '');
2263
+ const prevPending = acc.pendingBatch;
2264
+ const curBatchSig = batchOrderSig(orders);
2265
+ const isDuplicatePending = Boolean(
2266
+ curBatchSig &&
2267
+ prevPending?.status === 'pending' &&
2268
+ batchOrderSig(prevPending.orders) === curBatchSig &&
2269
+ batchNowTs - (prevPending.createdAt || 0) < 2 * 3600000,
2270
+ );
2172
2271
  if (!onlyEntries) {
2173
2272
  acc.lastFullStepWallTs = batchNowTs;
2174
2273
  if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
2175
2274
  }
2176
2275
  acc.pendingBatch = {
2177
- id: `batch_${batchNowTs}`,
2276
+ id: isDuplicatePending && prevPending?.id ? prevPending.id : `batch_${batchNowTs}`,
2178
2277
  createdAt: batchNowTs,
2179
2278
  bar: fc.bar,
2180
2279
  budgetRub: budget,
@@ -2191,10 +2290,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2191
2290
  results: [],
2192
2291
  };
2193
2292
  saveRawAccounts(list);
2194
- notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
2195
2293
  if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
2196
2294
  return await executeBrokerOrders(id, { _lockHeld: true });
2197
2295
  }
2296
+ if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
2297
+ notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
2298
+ }
2198
2299
  const st = brokersState();
2199
2300
  hub.emit({ type: 'brokers:update', state: st });
2200
2301
  hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
@@ -2241,9 +2342,10 @@ async function sendOrderToBroker(acc, ord) {
2241
2342
  // • для входа (buy/short) и для LQDT ставим лимитку по лучшей цене своей стороны или середине спреда (не переплачиваем за пересечение спреда!);
2242
2343
  // • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
2243
2344
  let finalLimitPx = Number(ord.limitPrice) || 0;
2345
+ let lotsToOrder = Number(ord.lots) || 0;
2244
2346
  const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
2245
2347
  const rBook = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetOrderBook`, {
2246
- method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 1 }), timeoutMs: 3500,
2348
+ method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 10 }), timeoutMs: 3500,
2247
2349
  }).catch(() => null);
2248
2350
  if (rBook?.ok) {
2249
2351
  const jBook = await rBook.json().catch(() => null);
@@ -2258,14 +2360,35 @@ async function sendOrderToBroker(acc, ord) {
2258
2360
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2259
2361
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2260
2362
  }
2363
+ // Контроль видимой глубины стакана при входе во 2-й эшелон: не выставляем заявку крупнее 65% суммарного объёма 5 лучших уровней стакана,
2364
+ // чтобы не светить крупную «плиту» перед стаканными роботами
2365
+ if (isEntry && ord.symbol !== 'LQDT' && !TIER1_LIQUID_SYMBOLS.has(ord.symbol) && lotsToOrder > 1) {
2366
+ const topBookSide = (isBuy ? jBook?.asks : jBook?.bids) || [];
2367
+ const top5Lots = topBookSide.slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2368
+ const minViableLots = Math.max(1, Math.ceil(30000 / Math.max(1, midPx * (ord.lotSize || 1))));
2369
+ if (top5Lots >= minViableLots) {
2370
+ const depthCapLots = Math.max(minViableLots, Math.floor(top5Lots * 0.65));
2371
+ if (lotsToOrder > depthCapLots) lotsToOrder = depthCapLots;
2372
+ }
2373
+ }
2261
2374
  if (ord.symbol === 'LQDT') {
2262
2375
  finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2263
2376
  } else if (!isEntry) {
2264
- // Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
2265
- // чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
2266
- if (spreadPct <= 0.80) {
2377
+ // Закрывающие заявки (sell / cover):
2378
+ // • при экстренном стоп-лоссе (isStopLoss) закрываемся гарантированно в пределах защитного лимита;
2379
+ // • при штатном выходе (Take-Profit или остывание прогноза) НЕ пробиваем несколько уровней тонкого стакана против себя:
2380
+ // при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
2381
+ // а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
2382
+ const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
2383
+ if (ord.isStopLoss && spreadPct <= 0.80) {
2267
2384
  const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2268
2385
  if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2386
+ } else if (tightExitSpread) {
2387
+ const cleanExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2388
+ if (cleanExitPx > 0) finalLimitPx = cleanExitPx;
2389
+ } else if (spreadPct <= 0.80) {
2390
+ const midExitPx = roundToStep(midPx, step, sp.decimals);
2391
+ if (midExitPx > 0) finalLimitPx = isBuy ? Math.min(bestAsk, midExitPx) : Math.max(bestBid, midExitPx);
2269
2392
  }
2270
2393
  } else {
2271
2394
  // Входящие заявки (buy / short):
@@ -2286,7 +2409,6 @@ async function sendOrderToBroker(acc, ord) {
2286
2409
  }
2287
2410
  const units = Math.trunc(finalLimitPx);
2288
2411
  const nano = Math.round((finalLimitPx - units) * 1e9);
2289
- let lotsToOrder = Number(ord.lots) || 0;
2290
2412
 
2291
2413
  // Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
2292
2414
  // чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
@@ -2300,7 +2422,7 @@ async function sendOrderToBroker(acc, ord) {
2300
2422
  for (const ao of jActive?.orders || []) {
2301
2423
  const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
2302
2424
  const sameDir = String(ao.direction || '').includes(wantDir);
2303
- if (sameInst && sameDir && ao.orderId) {
2425
+ if (sameInst && (sameDir || !isEntry) && ao.orderId) {
2304
2426
  await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
2305
2427
  method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
2306
2428
  }).catch(() => null);
@@ -2544,8 +2666,10 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2544
2666
  saveRawAccounts(list);
2545
2667
  const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
2546
2668
  const results = [];
2669
+ const newlySentOrders = [];
2547
2670
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2548
2671
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2672
+ acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
2549
2673
  acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2550
2674
  const execNowTs = Date.now();
2551
2675
  const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
@@ -2573,11 +2697,21 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2573
2697
  }
2574
2698
  }
2575
2699
  const r = await sendOrderToBroker(acc, ord);
2700
+ if (r.lots > 0) {
2701
+ ord.lots = r.lots;
2702
+ ord.shares = r.lots * (ord.lotSize || 1);
2703
+ }
2704
+ if (r.limitPrice > 0) {
2705
+ ord.limitPrice = r.limitPrice;
2706
+ ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
2707
+ }
2576
2708
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2709
+ newlySentOrders.push(ord);
2577
2710
  if (ord.action === 'buy' || ord.action === 'short') {
2578
2711
  acc.entryTsBySymbol[ord.symbol] = execNowTs;
2579
2712
  acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2580
2713
  delete acc.exitCooldownBySymbol[ord.symbol];
2714
+ delete acc.entryFailCooldownBySymbol[ord.symbol];
2581
2715
  } else if (ord.action === 'sell' || ord.action === 'cover') {
2582
2716
  hadExits = true;
2583
2717
  delete acc.entryTsBySymbol[ord.symbol];
@@ -2590,6 +2724,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2590
2724
  ord.sendAttempts = (ord.sendAttempts || 0) + 1;
2591
2725
  if (e.definitive) ord.clientOrderId = crypto.randomUUID();
2592
2726
  else ord.uncertainSend = true;
2727
+ if (ord.action === 'buy' || ord.action === 'short') {
2728
+ acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
2729
+ }
2593
2730
  results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2594
2731
  logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
2595
2732
  }
@@ -2598,6 +2735,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2598
2735
  batch.results = results;
2599
2736
  batch.executedAt = Date.now();
2600
2737
  saveRawAccounts(list);
2738
+ if (acc.mode === 'auto' && newlySentOrders.length > 0) {
2739
+ notifyBrokerBatch(currentUserId(), acc, { ...batch, orders: newlySentOrders }).catch(() => {});
2740
+ }
2601
2741
  await syncBrokerAccount(id).catch(() => {});
2602
2742
  if (acc.broker === 'tbank' && acc.token) {
2603
2743
  await fetchBrokerHistory(id).catch(() => {});
@@ -2860,7 +3000,7 @@ async function tickBrokerSchedule() {
2860
3000
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2861
3001
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2862
3002
  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) {
3003
+ if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
2864
3004
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2865
3005
  }
2866
3006
  }
@@ -3090,8 +3230,7 @@ async function checkIntradayProfitLock(accId) {
3090
3230
  if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
3091
3231
  const aQty = algoQtyOf(sym, pos.qty);
3092
3232
  if (!aQty) continue;
3093
- // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
3094
- if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
3233
+ const hasBrokerHardStop = Boolean(acc.brokerStops && acc.brokerStopBySymbol?.[sym]);
3095
3234
  // Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
3096
3235
  const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
3097
3236
  if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
@@ -3108,26 +3247,30 @@ async function checkIntradayProfitLock(accId) {
3108
3247
  const lots = Math.floor(aQty / sp.lotSize);
3109
3248
  if (lots < 1) continue;
3110
3249
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3111
- const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT;
3112
- if (hitHardStopLoss) {
3250
+ const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3251
+ const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3252
+ if (hitHardStopLoss || profitLockReason) {
3113
3253
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
3114
3254
  lockOrders.push({
3115
3255
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
3116
3256
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
3117
- reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)`,
3257
+ reason: hitHardStopLoss ? `Авто-стоп позиции (${round(curGainPct, 2)}%)` : profitLockReason,
3258
+ isStopLoss: hitHardStopLoss,
3118
3259
  });
3119
3260
  }
3120
3261
  } else if (aQty < 0) {
3121
3262
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3122
3263
  if (lots < 1) continue;
3123
3264
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3124
- const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT;
3125
- if (hitHardStopLoss) {
3265
+ const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
3266
+ const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3267
+ if (hitHardStopLoss || profitLockReason) {
3126
3268
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
3127
3269
  lockOrders.push({
3128
3270
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
3129
3271
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
3130
- reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)`,
3272
+ reason: hitHardStopLoss ? `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)` : profitLockReason,
3273
+ isStopLoss: hitHardStopLoss,
3131
3274
  });
3132
3275
  }
3133
3276
  }
@@ -3135,6 +3278,7 @@ async function checkIntradayProfitLock(accId) {
3135
3278
 
3136
3279
  if (!lockOrders.length) return;
3137
3280
  const results = [];
3281
+ const sentLockOrders = [];
3138
3282
  const execNowTs = Date.now();
3139
3283
  acc.entryTsBySymbol = acc.entryTsBySymbol || {};
3140
3284
  acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
@@ -3145,16 +3289,25 @@ async function checkIntradayProfitLock(accId) {
3145
3289
  await cancelBrokerOrder(accId, co.orderId).catch(() => {});
3146
3290
  }
3147
3291
  for (const ord of lockOrders) {
3148
- logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3292
+ logBrokerEvent(acc, ord.isStopLoss ? 'protective_stop' : 'profit_lock', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
3149
3293
  try {
3150
3294
  if (acc.brokerStopBySymbol?.[ord.symbol]) {
3151
3295
  await closeBrokerStopBeforeExit(acc, ord.symbol);
3152
3296
  }
3153
3297
  const r = await sendOrderToBroker(acc, ord);
3298
+ if (r.lots > 0) {
3299
+ ord.lots = r.lots;
3300
+ ord.shares = r.lots * (ord.lotSize || 1);
3301
+ }
3302
+ if (r.limitPrice > 0) {
3303
+ ord.limitPrice = r.limitPrice;
3304
+ ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
3305
+ }
3154
3306
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
3307
+ sentLockOrders.push(ord);
3155
3308
  delete acc.entryTsBySymbol[ord.symbol];
3156
3309
  delete acc.peakPriceBySymbol[ord.symbol];
3157
- acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3310
+ if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3158
3311
  } catch (e) {
3159
3312
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
3160
3313
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
@@ -3165,12 +3318,15 @@ async function checkIntradayProfitLock(accId) {
3165
3318
  id: `batch_stop_${execNowTs}`,
3166
3319
  createdAt: execNowTs,
3167
3320
  executedAt: execNowTs,
3168
- bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
3321
+ bar: `${fmtBarMsk(execNowTs)} (Фиксация прибыли / Защита)`,
3169
3322
  orders: lockOrders,
3170
3323
  status: results.every((r) => r.ok) ? 'executed' : 'partial',
3171
3324
  results,
3172
3325
  };
3173
3326
  saveRawAccounts(list);
3327
+ if (sentLockOrders.length > 0) {
3328
+ notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
3329
+ }
3174
3330
  await syncBrokerAccount(accId).catch(() => {});
3175
3331
  const st = brokersState();
3176
3332
  hub.emit({ type: 'brokers:update', state: st });
@@ -3197,7 +3353,9 @@ async function tickBrokerAutoRefresh() {
3197
3353
  if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
3198
3354
  await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
3199
3355
  }
3200
- if (acc.broker === 'tbank' && (!acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 120000)) {
3356
+ const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
3357
+ const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
3358
+ if (acc.broker === 'tbank' && historyNeedSync) {
3201
3359
  await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
3202
3360
  }
3203
3361
  // Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
@@ -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;