neuroalpha 0.5.27 → 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.27",
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
@@ -2575,8 +2575,10 @@ function renderBrokers() {
2575
2575
  const baseMap = a.baselineQty || {};
2576
2576
  const curSubTab = brokerTabById.get(a.id) || 'assets';
2577
2577
 
2578
+ const isSweepLqdt = (p) => Boolean(a.cashSweep && a.broker === 'tbank' && p.symbol === 'LQDT');
2579
+
2578
2580
  const algoQtyOf = (p) => {
2579
- if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
2581
+ if (!isSweepLqdt(p) && ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol)))) return 0;
2580
2582
  if (lMode === 'takeover') return p.qty;
2581
2583
  const bq = Number(baseMap[p.symbol] || 0);
2582
2584
  if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
@@ -2590,6 +2592,16 @@ function renderBrokers() {
2590
2592
  if (isNonTradablePos(p)) {
2591
2593
  return '<span class="tag" title="Бумага снята с торгов, проходит редомициляцию или заблокирована (рыночная оценка 0 ₽). Стратегия её не затрагивает">не торгуется (0 ₽)</span>';
2592
2594
  }
2595
+ if (isSweepLqdt(p)) {
2596
+ const aq = algoQtyOf(p);
2597
+ const bq = Number(baseMap[p.symbol] || 0);
2598
+ if (aq > 0 && bq > 0 && lMode !== 'takeover') {
2599
+ return `<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически продаётся под новые сигналы">старый: ${fmtNum(bq, 0)} шт. · резерв стратегии: ${fmtNum(aq, 0)} шт.</span>`;
2600
+ }
2601
+ if (aq > 0) {
2602
+ return '<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически высвобождается под новые сигналы покупки и шорта">резерв стратегии (кэш в LQDT)</span>';
2603
+ }
2604
+ }
2593
2605
  if (p.instrumentType && p.instrumentType !== 'share') {
2594
2606
  const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
2595
2607
  return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
@@ -3618,8 +3630,13 @@ function connectWs() {
3618
3630
  case 'paper:fill':
3619
3631
  break;
3620
3632
  case 'news:update': loadNews(); break;
3621
- case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
3633
+ case 'brokers:update':
3634
+ state.brokers = m.state;
3635
+ renderBrokers();
3636
+ scheduleStockModalRefresh();
3637
+ break;
3622
3638
  case 'trading:pulse': {
3639
+ scheduleStockModalRefresh();
3623
3640
  if (m.kind === 'broker_calc') {
3624
3641
  triggerTradingPulse(m.text || `Пересчёт заявок брокера…`, 3000);
3625
3642
  } else if (m.kind === 'broker_done') {
@@ -3967,55 +3984,31 @@ $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#he
3967
3984
 
3968
3985
  // ---------- интерактивное досье акции (#stock-modal) ----------
3969
3986
  let stockModalChart = null;
3987
+ let stockModalAreaSeries = null;
3988
+ let stockModalLastTime = 0;
3989
+ let stockModalFirstValue = 0;
3970
3990
  let stockChartRo = null;
3971
3991
  let currentStockData = null;
3992
+ let stockModalRefreshing = false;
3993
+ let stockModalDebounceTimer = null;
3972
3994
  // Выбранный период и масштаб графика в досье акции сохраняются в браузере (масштаб — отдельно для каждого периода)
3973
- const STOCK_CHART_KEY = 'na_stock_chart_v1';
3995
+ const STOCK_CHART_KEY = 'na_stock_chart_v2';
3974
3996
  const loadStockChartPrefs = () => { try { return JSON.parse(localStorage.getItem(STOCK_CHART_KEY) || '{}') || {}; } catch { return {}; } };
3975
3997
  const saveStockChartPrefs = (patch) => { try { localStorage.setItem(STOCK_CHART_KEY, JSON.stringify({ ...loadStockChartPrefs(), ...patch })); } catch { /* ignore */ } };
3976
3998
  const STOCK_RANGES = ['1d', '1w', '1m', '1y', 'all'];
3977
- let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1m';
3978
-
3979
- function renderStockChart(d, range = currentStockRange) {
3980
- currentStockRange = range;
3981
- saveStockChartPrefs({ range });
3982
- const box = $('#stock-chart-box');
3983
- if (!box) return;
3984
- if (stockChartRo) {
3985
- try { stockChartRo.disconnect(); } catch { /* ignore */ }
3986
- stockChartRo = null;
3987
- }
3988
- if (stockModalChart) {
3989
- try { stockModalChart.remove(); } catch { /* ignore */ }
3990
- stockModalChart = null;
3991
- }
3992
- box.innerHTML = '';
3993
- const light = document.body.classList.contains('light');
3994
- const initW = Math.max(260, box.clientWidth || 720);
3995
- stockModalChart = LightweightCharts.createChart(box, {
3996
- width: initW,
3997
- height: 260,
3998
- layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
3999
- grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
4000
- rightPriceScale: { borderVisible: false },
4001
- timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
4002
- crosshair: { mode: 0 },
4003
- });
4004
- if (window.ResizeObserver) {
4005
- stockChartRo = new ResizeObserver(() => {
4006
- if (stockModalChart && box.clientWidth > 100) {
4007
- stockModalChart.applyOptions({ width: box.clientWidth });
4008
- }
4009
- });
4010
- stockChartRo.observe(box);
4011
- }
3999
+ let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1d';
4012
4000
 
4001
+ function buildStockChartSeriesAndMarkers(d, range = currentStockRange) {
4013
4002
  const nowTs = Date.now();
4014
4003
  let rawPts = [];
4015
4004
  if (range === '1d') {
4016
- const cut = nowTs - 2 * 86400000;
4017
- rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
4018
- 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
+ }
4019
4012
  } else if (range === '1w') {
4020
4013
  rawPts = (d.intraday || []).filter((b) => b.ts >= nowTs - 8 * 86400000);
4021
4014
  } else if (range === '1m') {
@@ -4036,32 +4029,25 @@ function renderStockChart(d, range = currentStockRange) {
4036
4029
  }
4037
4030
  seriesData.sort((a, b) => a.time - b.time);
4038
4031
 
4039
- const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
4040
- const mainColor = isUp ? '#3fb950' : '#f85149';
4041
- const areaSeries = stockModalChart.addAreaSeries({
4042
- lineColor: mainColor,
4043
- topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
4044
- bottomColor: 'rgba(0, 0, 0, 0.0)',
4045
- lineWidth: 2,
4046
- priceFormat: {
4047
- type: 'price',
4048
- precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
4049
- minMove: d.specs?.minStep || 0.01,
4050
- },
4051
- });
4052
- 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
+ };
4053
4040
 
4054
- // Маркеры сделок пользователя и дивидендов на графике
4055
4041
  const markers = [];
4056
4042
  if (seriesData.length > 0) {
4057
4043
  const minT = seriesData[0].time;
4058
- const maxT = seriesData[seriesData.length - 1].time;
4044
+ const maxT = seriesData[seriesData.length - 1].time + 120;
4059
4045
  for (const op of (d.userOperations || [])) {
4060
4046
  if (op.kind !== 'buy' && op.kind !== 'sell') continue;
4061
- const t = chartTime(op.date);
4062
- if (t >= minT && t <= maxT) {
4047
+ const rawT = chartTime(op.date);
4048
+ if (rawT >= minT && rawT <= maxT) {
4063
4049
  markers.push({
4064
- time: t,
4050
+ time: snapBarTime(rawT),
4065
4051
  position: op.kind === 'buy' ? 'belowBar' : 'aboveBar',
4066
4052
  color: op.kind === 'buy' ? '#3fb950' : '#f85149',
4067
4053
  shape: op.kind === 'buy' ? 'arrowUp' : 'arrowDown',
@@ -4073,10 +4059,10 @@ function renderStockChart(d, range = currentStockRange) {
4073
4059
  for (const dv of (d.dividends || [])) {
4074
4060
  const dtMs = Date.parse(`${dv.registry}T10:00:00Z`);
4075
4061
  if (!Number.isFinite(dtMs)) continue;
4076
- const t = chartTime(dtMs);
4077
- if (t >= minT && t <= maxT) {
4062
+ const rawT = chartTime(dtMs);
4063
+ if (rawT >= minT && rawT <= maxT) {
4078
4064
  markers.push({
4079
- time: t,
4065
+ time: snapBarTime(rawT),
4080
4066
  position: 'aboveBar',
4081
4067
  color: '#4f8cff',
4082
4068
  shape: 'circle',
@@ -4086,9 +4072,68 @@ function renderStockChart(d, range = currentStockRange) {
4086
4072
  }
4087
4073
  }
4088
4074
  markers.sort((a, b) => a.time - b.time);
4089
- if (markers.length) {
4090
- try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
4091
- }
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 */ }
4092
4137
  }
4093
4138
  const ts = stockModalChart.timeScale();
4094
4139
  ts.fitContent();
@@ -4113,6 +4158,150 @@ function renderStockChart(d, range = currentStockRange) {
4113
4158
  });
4114
4159
  }
4115
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
+
4116
4305
  async function openStockModal(symbol) {
4117
4306
  const sym = String(symbol || '').toUpperCase().trim();
4118
4307
  if (!sym) return;
@@ -4128,46 +4317,12 @@ async function openStockModal(symbol) {
4128
4317
  const d = await api(`/api/stock/${encodeURIComponent(sym)}`);
4129
4318
  currentStockData = d;
4130
4319
  KNOWN_MOEX_TICKERS.add(d.symbol);
4131
- const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
4132
- const fc = d.forecast;
4133
- const sigTag = fc?.signal
4134
- ? '<span class="tag ok">сигнал: купить</span>'
4135
- : fc?.shortSignal
4136
- ? '<span class="tag no">сигнал: шорт</span>'
4137
- : fc?.shortBlockedBySqueeze
4138
- ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4139
- : fc?.shortBlockedByBroker
4140
- ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4141
- : fc?.buyBlockedByDip
4142
- ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4143
- : fc?.buyBlockedByOverheat
4144
- ? `<span class="tag blocked" title="Бумага уже перегрета (+${fmtNum(fc.buyBlockedByOverheat, 1)}% сверх рынка) — покупка на вершине пампа запрещена">⛔ перегрев (+${fmtNum(fc.buyBlockedByOverheat, 1)}%)</span>`
4145
- : '<span class="tag">нейтрально (вне порога)</span>';
4146
- const shortBadge = d.shortAllowedByBroker === true
4147
- ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
4148
- : d.shortAllowedByBroker === false
4149
- ? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
4150
- : '';
4151
-
4152
- 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}`;
4153
-
4154
- const capStr = d.specs?.marketCapRub
4155
- ? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
4156
- : '—';
4157
-
4158
- // 1. Верхние KPI
4159
- const kpiHtml = `<div class="kpis">
4160
- <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>
4161
- <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>
4162
- <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>
4163
- <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
4164
- <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
4165
- </div>`;
4320
+ titleEl.innerHTML = buildStockModalHeaderHtml(d);
4166
4321
 
4167
4322
  // 2. График с выбором периода
4168
4323
  const chartWrapHtml = `<div class="scard spadded">
4169
4324
  <div class="stitle bar" style="margin:0 0 8px">
4170
- <span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
4325
+ <span>График цены ${esc(d.symbol)} и ваши сделки на счёте <span class="muted small" style="font-weight:400">· обновление в реальном времени</span></span>
4171
4326
  <div class="seg" id="stock-chart-range" style="margin:0">
4172
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('')}
4173
4328
  </div>
@@ -4175,52 +4330,6 @@ async function openStockModal(symbol) {
4175
4330
  <div id="stock-chart-box" class="chart"></div>
4176
4331
  </div>`;
4177
4332
 
4178
- // 3. Соседи по карте рынка (кто влияет на акцию прямо сейчас)
4179
- const infHtml = fc?.influences?.length
4180
- ? `<div class="scard spadded">
4181
- <div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
4182
- <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (возврат к среднему после локального импульса): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
4183
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
4184
- ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
4185
- <div>
4186
- <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>
4187
- <div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
4188
- </div>
4189
- <div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
4190
- </div>`).join('')}
4191
- </div>
4192
- </div>`
4193
- : '';
4194
-
4195
- // 4. Позиция пользователя и история реальных сделок на брокерском счёте по этой бумаге
4196
- const userPosBanner = d.userPositions?.length
4197
- ? 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">
4198
- <div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
4199
- <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>
4200
- </div>`).join('')
4201
- : '';
4202
-
4203
- const userOpsHtml = d.userOperations?.length
4204
- ? `<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>
4205
- <td class="muted small">${fmtTs(op.date)}</td>
4206
- <td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
4207
- <td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
4208
- <td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
4209
- <td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
4210
- </tr>`).join('')}</tbody></table>`
4211
- : '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
4212
-
4213
- const btInfo = d.backtestStat
4214
- ? `<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>`
4215
- : '';
4216
-
4217
- const userBoxHtml = `<div class="scard spadded">
4218
- <div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
4219
- ${userPosBanner}
4220
- ${userOpsHtml}
4221
- ${btInfo}
4222
- </div>`;
4223
-
4224
4333
  // 5. Фундаментальный бизнес-профиль (20 признаков) + Дивиденды (в 2 колонки)
4225
4334
  const topTraits = [...(d.traits || [])].filter((t) => t.value >= 0.15).sort((a, b) => b.value - a.value).slice(0, 10);
4226
4335
  const traitsColHtml = topTraits.length
@@ -4266,11 +4375,11 @@ async function openStockModal(symbol) {
4266
4375
  </div>`;
4267
4376
 
4268
4377
  bodyEl.innerHTML = `
4269
- ${kpiHtml}
4378
+ <div id="stock-modal-kpis">${buildStockModalKpiHtml(d)}</div>
4270
4379
  ${chartWrapHtml}
4271
- ${infHtml}
4380
+ <div id="stock-modal-inf">${buildStockModalInfHtml(d)}</div>
4272
4381
  <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">${traitsColHtml}${divsColHtml}</div>
4273
- ${userBoxHtml}
4382
+ <div id="stock-modal-userbox">${buildStockModalUserBoxHtml(d)}</div>
4274
4383
  ${newsHtml}
4275
4384
  `;
4276
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
  });