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 +1 -1
- package/public/app.js +260 -151
- package/server/api/routes.js +42 -3
- package/server/brokers/index.js +230 -73
- package/server/model/dataset.js +15 -3
- package/server/model/forecast.js +50 -33
- package/server/notify/index.js +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
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':
|
|
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 = '
|
|
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 : '
|
|
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
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
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
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
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
|
|
4062
|
-
if (
|
|
4047
|
+
const rawT = chartTime(op.date);
|
|
4048
|
+
if (rawT >= minT && rawT <= maxT) {
|
|
4063
4049
|
markers.push({
|
|
4064
|
-
time:
|
|
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
|
|
4077
|
-
if (
|
|
4062
|
+
const rawT = chartTime(dtMs);
|
|
4063
|
+
if (rawT >= minT && rawT <= maxT) {
|
|
4078
4064
|
markers.push({
|
|
4079
|
-
time:
|
|
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
|
-
|
|
4090
|
-
|
|
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
|
-
|
|
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)} и ваши сделки на
|
|
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
|
-
|
|
4378
|
+
<div id="stock-modal-kpis">${buildStockModalKpiHtml(d)}</div>
|
|
4270
4379
|
${chartWrapHtml}
|
|
4271
|
-
|
|
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
|
-
|
|
4382
|
+
<div id="stock-modal-userbox">${buildStockModalUserBoxHtml(d)}</div>
|
|
4274
4383
|
${newsHtml}
|
|
4275
4384
|
`;
|
|
4276
4385
|
|
package/server/api/routes.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|