neuroalpha 0.5.28 → 0.5.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +247 -150
- package/server/api/routes.js +42 -3
- package/server/brokers/index.js +159 -48
- package/server/model/forecast.js +7 -1
- 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
|
@@ -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':
|
|
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 = '
|
|
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 : '
|
|
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
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
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
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
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
|
|
4074
|
-
if (
|
|
4047
|
+
const rawT = chartTime(op.date);
|
|
4048
|
+
if (rawT >= minT && rawT <= maxT) {
|
|
4075
4049
|
markers.push({
|
|
4076
|
-
time:
|
|
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
|
|
4089
|
-
if (
|
|
4062
|
+
const rawT = chartTime(dtMs);
|
|
4063
|
+
if (rawT >= minT && rawT <= maxT) {
|
|
4090
4064
|
markers.push({
|
|
4091
|
-
time:
|
|
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
|
-
|
|
4102
|
-
|
|
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
|
-
|
|
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)} и ваши сделки на
|
|
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
|
-
|
|
4378
|
+
<div id="stock-modal-kpis">${buildStockModalKpiHtml(d)}</div>
|
|
4282
4379
|
${chartWrapHtml}
|
|
4283
|
-
|
|
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
|
-
|
|
4382
|
+
<div id="stock-modal-userbox">${buildStockModalUserBoxHtml(d)}</div>
|
|
4286
4383
|
${newsHtml}
|
|
4287
4384
|
`;
|
|
4288
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
|
});
|
package/server/brokers/index.js
CHANGED
|
@@ -505,6 +505,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
505
505
|
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
506
506
|
entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
|
|
507
507
|
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
508
|
+
entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
|
|
508
509
|
peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
|
|
509
510
|
baseRunId: nextBaseRunId,
|
|
510
511
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
@@ -767,8 +768,8 @@ export async function fetchTbankRealtimePrices() {
|
|
|
767
768
|
}
|
|
768
769
|
}
|
|
769
770
|
|
|
770
|
-
// Возвращает свежие
|
|
771
|
-
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
|
|
771
|
+
// Возвращает свежие свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
|
|
772
|
+
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false, intervalMin = 10 } = {}) {
|
|
772
773
|
try {
|
|
773
774
|
const rawAccs = loadRawAccounts();
|
|
774
775
|
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
@@ -780,15 +781,17 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
|
|
|
780
781
|
const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
|
|
781
782
|
const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
782
783
|
const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
|
|
783
|
-
|
|
784
|
-
|
|
784
|
+
const is1m = Number(intervalMin) === 1;
|
|
785
|
+
// Для CANDLE_INTERVAL_1_MIN максимальный диапазон одного запроса в T-Invest API — 1 сутки, для CANDLE_INTERVAL_10_MIN — 3 суток
|
|
786
|
+
const maxSpanMs = is1m ? (86400000 - 60000) : (3 * 86400000 - 60000);
|
|
787
|
+
const safeFrom = Math.max(fromTs, tillTs - maxSpanMs);
|
|
785
788
|
if (safeFrom >= tillTs) return null;
|
|
786
789
|
const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
|
|
787
790
|
const body = JSON.stringify({
|
|
788
791
|
instrumentId: uid,
|
|
789
792
|
from: new Date(safeFrom).toISOString(),
|
|
790
793
|
to: new Date(tillTs).toISOString(),
|
|
791
|
-
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
794
|
+
interval: is1m ? 'CANDLE_INTERVAL_1_MIN' : 'CANDLE_INTERVAL_10_MIN',
|
|
792
795
|
});
|
|
793
796
|
let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
|
|
794
797
|
if (r.status === 429 || r.status >= 500) {
|
|
@@ -981,10 +984,13 @@ async function syncTbank(acc) {
|
|
|
981
984
|
}
|
|
982
985
|
}
|
|
983
986
|
}
|
|
984
|
-
|
|
985
|
-
|
|
987
|
+
const p = await rPort.json();
|
|
988
|
+
const hasShortPos = (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
|
|
989
|
+
let marginEnabled = Boolean(acc.lastSync?.marginEnabled || hasShortPos);
|
|
990
|
+
let marginInfo = acc.lastSync?.marginInfo || null;
|
|
986
991
|
if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
|
|
987
992
|
marginEnabled = false;
|
|
993
|
+
marginInfo = null;
|
|
988
994
|
} else if (rMargin?.ok) {
|
|
989
995
|
marginEnabled = true;
|
|
990
996
|
const jMar = await rMargin.json().catch(() => null);
|
|
@@ -1006,9 +1012,11 @@ async function syncTbank(acc) {
|
|
|
1006
1012
|
}
|
|
1007
1013
|
} else if (rMargin) {
|
|
1008
1014
|
const mTxt = await rMargin.text().catch(() => '');
|
|
1009
|
-
|
|
1015
|
+
if (mTxt.includes('30051') || /margin.*disabled/i.test(mTxt)) {
|
|
1016
|
+
marginEnabled = false;
|
|
1017
|
+
marginInfo = null;
|
|
1018
|
+
}
|
|
1010
1019
|
}
|
|
1011
|
-
const p = await rPort.json();
|
|
1012
1020
|
const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
|
|
1013
1021
|
let rawOrders = jOrd?.orders || [];
|
|
1014
1022
|
|
|
@@ -1480,10 +1488,17 @@ export async function syncBrokerAccount(id) {
|
|
|
1480
1488
|
}
|
|
1481
1489
|
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
1482
1490
|
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
1483
|
-
const
|
|
1491
|
+
const rawDdPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
|
|
1492
|
+
// Защита от ложного срабатывания стопа (−99%) при утреннем клиринге брокера, когда API временно отдаёт totalAmountPortfolio ≈ cashRub без оценки бумаг
|
|
1493
|
+
const prevTotalRub = Number(acc.lastSync?.totalRub || 0);
|
|
1494
|
+
const isGlitchZeroPortfolio = rawDdPct >= 50 && prevTotalRub > 50000 && data.totalRub < prevTotalRub * 0.5 && (data.positions || []).some((p) => p.qty !== 0);
|
|
1495
|
+
if (isGlitchZeroPortfolio) {
|
|
1496
|
+
data.totalRub = prevTotalRub;
|
|
1497
|
+
}
|
|
1498
|
+
const ddPct = isGlitchZeroPortfolio ? (Number(acc.lastSync?.dayDrawdownPct) || 0) : rawDdPct;
|
|
1484
1499
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
1485
1500
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
1486
|
-
} else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1501
|
+
} else if (!isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1487
1502
|
// Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
|
|
1488
1503
|
acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
|
|
1489
1504
|
if (acc.ddBreachCount >= 2) {
|
|
@@ -1578,7 +1593,34 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1578
1593
|
return st;
|
|
1579
1594
|
}
|
|
1580
1595
|
|
|
1581
|
-
//
|
|
1596
|
+
// Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
|
|
1597
|
+
function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
|
|
1598
|
+
if (!(avgPx > 0 && curPx > 0)) return null;
|
|
1599
|
+
const isLong = side === 'long';
|
|
1600
|
+
const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
|
|
1601
|
+
const effPeakPx = peakPx > 0
|
|
1602
|
+
? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
|
|
1603
|
+
: (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
|
|
1604
|
+
const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
|
|
1605
|
+
const tierMult = symbolEdgeMult(sym);
|
|
1606
|
+
// 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
|
|
1607
|
+
// фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
|
|
1608
|
+
if (peakGainPct >= 1.20 * tierMult) {
|
|
1609
|
+
const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
|
|
1610
|
+
if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
|
|
1611
|
+
return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
// 2) Защита заработанного плюса / безубытка (от +0.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
|
|
1615
|
+
// не позволяем плюсовой сделке развернуться обратно в убыток
|
|
1616
|
+
if (peakGainPct >= 0.75 * tierMult) {
|
|
1617
|
+
const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
|
|
1618
|
+
if (curGainPct <= breakevenFloorPct) {
|
|
1619
|
+
return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1582
1624
|
function sessionEdgeWarning(now = Date.now()) {
|
|
1583
1625
|
const d = new Date(now + MSK);
|
|
1584
1626
|
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
@@ -1626,6 +1668,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1626
1668
|
if (!acc) throw new Error('Счёт не найден');
|
|
1627
1669
|
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
1628
1670
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1671
|
+
if (onlyEntries && edgeWarn) {
|
|
1672
|
+
return brokersState();
|
|
1673
|
+
}
|
|
1629
1674
|
|
|
1630
1675
|
const plan = effectivePlan(currentUser());
|
|
1631
1676
|
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
@@ -1690,6 +1735,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1690
1735
|
const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
|
|
1691
1736
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1692
1737
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1738
|
+
const entryFailMap = acc.entryFailCooldownBySymbol || {};
|
|
1693
1739
|
// Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
|
|
1694
1740
|
// Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
|
|
1695
1741
|
// что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
|
|
@@ -1699,7 +1745,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1699
1745
|
const minShortEdgePct = round(baseShortNeedPct, 3);
|
|
1700
1746
|
const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
|
|
1701
1747
|
const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
|
|
1702
|
-
const isInCooldown = (sym) =>
|
|
1748
|
+
const isInCooldown = (sym) => Boolean(
|
|
1749
|
+
(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
|
|
1750
|
+
(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
|
|
1751
|
+
);
|
|
1703
1752
|
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
1704
1753
|
// если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
|
|
1705
1754
|
// временно исключаем такую бумагу из новых входов!
|
|
@@ -1797,11 +1846,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1797
1846
|
const smoothSellPool = [];
|
|
1798
1847
|
const peakMap = acc.peakPriceBySymbol || {};
|
|
1799
1848
|
const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
|
|
1800
|
-
// 1) закрытие бумаг стратегии (покупок и шортов):
|
|
1849
|
+
// 1) закрытие бумаг стратегии (покупок и шортов) на плановом шаге (!onlyEntries):
|
|
1801
1850
|
// • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
|
|
1802
1851
|
// • либо если прогноз ослаб ниже порога выхода curExitThr.
|
|
1852
|
+
// В быстром цикле добора (onlyEntries) закрывающие заявки не формируются — сопровождение и выходы идут строго по плановому шагу (и защитным стопам).
|
|
1803
1853
|
// Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
|
|
1804
|
-
for (const [sym, pos] of heldMap) {
|
|
1854
|
+
for (const [sym, pos] of (onlyEntries ? [] : heldMap)) {
|
|
1805
1855
|
if (!pos.qty) continue;
|
|
1806
1856
|
const r = rowMap.get(sym);
|
|
1807
1857
|
if (!r) continue;
|
|
@@ -1814,30 +1864,39 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1814
1864
|
const lastHistEntryTs = lastHistEntryOp?.date || 0;
|
|
1815
1865
|
const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
|
|
1816
1866
|
const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
|
|
1817
|
-
|
|
1818
|
-
|
|
1867
|
+
const heldMin = heldMs / 60000;
|
|
1868
|
+
const horizonMin = Math.max(90, effHorizon * 60);
|
|
1869
|
+
// Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
|
|
1819
1870
|
const symMult = r.edgeMult || symbolEdgeMult(sym);
|
|
1820
|
-
const
|
|
1871
|
+
const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
|
|
1872
|
+
const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
|
|
1873
|
+
const curExitThr = round(rampedExitThr * symMult, 3);
|
|
1874
|
+
const softExitThr = round(earlyExitThresholdPct * symMult, 3);
|
|
1821
1875
|
const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
|
|
1822
1876
|
const curPx = Number(r.price || pos.price) || 0;
|
|
1823
1877
|
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
|
|
1824
1878
|
if (aQty > 0) {
|
|
1825
1879
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1826
1880
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1881
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
1827
1882
|
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1828
|
-
|
|
1883
|
+
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
|
|
1884
|
+
const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
1885
|
+
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
|
|
1829
1886
|
const dg = divGuardOf(sym, curPx);
|
|
1830
1887
|
const divExit = Boolean(dg?.exitLong);
|
|
1831
|
-
const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
|
|
1888
|
+
const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
|
|
1832
1889
|
if (lots >= 1 && !keep) {
|
|
1833
1890
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
1834
1891
|
const exitReason = hitHardStopLoss
|
|
1835
1892
|
? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
|
|
1836
|
-
:
|
|
1837
|
-
?
|
|
1838
|
-
:
|
|
1839
|
-
?
|
|
1840
|
-
:
|
|
1893
|
+
: profitLockReason
|
|
1894
|
+
? profitLockReason
|
|
1895
|
+
: divExit
|
|
1896
|
+
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
|
|
1897
|
+
: nightExit
|
|
1898
|
+
? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
|
|
1899
|
+
: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
|
|
1841
1900
|
orders.push({
|
|
1842
1901
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1843
1902
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -1849,20 +1908,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1849
1908
|
} else if (aQty < 0) {
|
|
1850
1909
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1851
1910
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1911
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
1852
1912
|
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
1853
|
-
|
|
1913
|
+
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
|
|
1914
|
+
const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
1915
|
+
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
|
|
1854
1916
|
const dg = divGuardOf(sym, curPx);
|
|
1855
1917
|
const divExit = Boolean(dg?.exitShort);
|
|
1856
|
-
const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
|
|
1918
|
+
const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
|
|
1857
1919
|
if (lots >= 1 && !keep) {
|
|
1858
1920
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
1859
1921
|
const exitReason = hitHardStopLoss
|
|
1860
1922
|
? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
|
|
1861
|
-
:
|
|
1862
|
-
?
|
|
1863
|
-
:
|
|
1864
|
-
?
|
|
1865
|
-
:
|
|
1923
|
+
: profitLockReason
|
|
1924
|
+
? profitLockReason
|
|
1925
|
+
: divExit
|
|
1926
|
+
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
|
|
1927
|
+
: nightExit
|
|
1928
|
+
? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
|
|
1929
|
+
: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
|
|
1866
1930
|
orders.push({
|
|
1867
1931
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1868
1932
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -2169,12 +2233,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2169
2233
|
}
|
|
2170
2234
|
|
|
2171
2235
|
const batchNowTs = Date.now();
|
|
2236
|
+
const batchOrderSig = (arr) => (Array.isArray(arr) ? arr.map((o) => `${o.action}:${o.symbol}:${o.lots}`).join('|') : '');
|
|
2237
|
+
const prevPending = acc.pendingBatch;
|
|
2238
|
+
const curBatchSig = batchOrderSig(orders);
|
|
2239
|
+
const isDuplicatePending = Boolean(
|
|
2240
|
+
curBatchSig &&
|
|
2241
|
+
prevPending?.status === 'pending' &&
|
|
2242
|
+
batchOrderSig(prevPending.orders) === curBatchSig &&
|
|
2243
|
+
batchNowTs - (prevPending.createdAt || 0) < 2 * 3600000,
|
|
2244
|
+
);
|
|
2172
2245
|
if (!onlyEntries) {
|
|
2173
2246
|
acc.lastFullStepWallTs = batchNowTs;
|
|
2174
2247
|
if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
|
|
2175
2248
|
}
|
|
2176
2249
|
acc.pendingBatch = {
|
|
2177
|
-
id: `batch_${batchNowTs}`,
|
|
2250
|
+
id: isDuplicatePending && prevPending?.id ? prevPending.id : `batch_${batchNowTs}`,
|
|
2178
2251
|
createdAt: batchNowTs,
|
|
2179
2252
|
bar: fc.bar,
|
|
2180
2253
|
budgetRub: budget,
|
|
@@ -2191,10 +2264,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2191
2264
|
results: [],
|
|
2192
2265
|
};
|
|
2193
2266
|
saveRawAccounts(list);
|
|
2194
|
-
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
2195
2267
|
if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
|
|
2196
2268
|
return await executeBrokerOrders(id, { _lockHeld: true });
|
|
2197
2269
|
}
|
|
2270
|
+
if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
|
|
2271
|
+
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
2272
|
+
}
|
|
2198
2273
|
const st = brokersState();
|
|
2199
2274
|
hub.emit({ type: 'brokers:update', state: st });
|
|
2200
2275
|
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
@@ -2300,7 +2375,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2300
2375
|
for (const ao of jActive?.orders || []) {
|
|
2301
2376
|
const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
|
|
2302
2377
|
const sameDir = String(ao.direction || '').includes(wantDir);
|
|
2303
|
-
if (sameInst && sameDir && ao.orderId) {
|
|
2378
|
+
if (sameInst && (sameDir || !isEntry) && ao.orderId) {
|
|
2304
2379
|
await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
2305
2380
|
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
|
|
2306
2381
|
}).catch(() => null);
|
|
@@ -2544,8 +2619,10 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2544
2619
|
saveRawAccounts(list);
|
|
2545
2620
|
const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
|
|
2546
2621
|
const results = [];
|
|
2622
|
+
const newlySentOrders = [];
|
|
2547
2623
|
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
2548
2624
|
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
2625
|
+
acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
|
|
2549
2626
|
acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
|
|
2550
2627
|
const execNowTs = Date.now();
|
|
2551
2628
|
const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
|
|
@@ -2573,11 +2650,21 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2573
2650
|
}
|
|
2574
2651
|
}
|
|
2575
2652
|
const r = await sendOrderToBroker(acc, ord);
|
|
2653
|
+
if (r.lots > 0) {
|
|
2654
|
+
ord.lots = r.lots;
|
|
2655
|
+
ord.shares = r.lots * (ord.lotSize || 1);
|
|
2656
|
+
}
|
|
2657
|
+
if (r.limitPrice > 0) {
|
|
2658
|
+
ord.limitPrice = r.limitPrice;
|
|
2659
|
+
ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
|
|
2660
|
+
}
|
|
2576
2661
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
|
|
2662
|
+
newlySentOrders.push(ord);
|
|
2577
2663
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2578
2664
|
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
2579
2665
|
acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
|
|
2580
2666
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2667
|
+
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2581
2668
|
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
2582
2669
|
hadExits = true;
|
|
2583
2670
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
@@ -2590,6 +2677,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2590
2677
|
ord.sendAttempts = (ord.sendAttempts || 0) + 1;
|
|
2591
2678
|
if (e.definitive) ord.clientOrderId = crypto.randomUUID();
|
|
2592
2679
|
else ord.uncertainSend = true;
|
|
2680
|
+
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2681
|
+
acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2682
|
+
}
|
|
2593
2683
|
results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
|
|
2594
2684
|
logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
|
|
2595
2685
|
}
|
|
@@ -2598,6 +2688,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2598
2688
|
batch.results = results;
|
|
2599
2689
|
batch.executedAt = Date.now();
|
|
2600
2690
|
saveRawAccounts(list);
|
|
2691
|
+
if (acc.mode === 'auto' && newlySentOrders.length > 0) {
|
|
2692
|
+
notifyBrokerBatch(currentUserId(), acc, { ...batch, orders: newlySentOrders }).catch(() => {});
|
|
2693
|
+
}
|
|
2601
2694
|
await syncBrokerAccount(id).catch(() => {});
|
|
2602
2695
|
if (acc.broker === 'tbank' && acc.token) {
|
|
2603
2696
|
await fetchBrokerHistory(id).catch(() => {});
|
|
@@ -2860,7 +2953,7 @@ async function tickBrokerSchedule() {
|
|
|
2860
2953
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
2861
2954
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
2862
2955
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
2863
|
-
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2956
|
+
if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2864
2957
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
2865
2958
|
}
|
|
2866
2959
|
}
|
|
@@ -3090,8 +3183,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3090
3183
|
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
3091
3184
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
3092
3185
|
if (!aQty) continue;
|
|
3093
|
-
|
|
3094
|
-
if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
|
|
3186
|
+
const hasBrokerHardStop = Boolean(acc.brokerStops && acc.brokerStopBySymbol?.[sym]);
|
|
3095
3187
|
// Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
|
|
3096
3188
|
const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
|
|
3097
3189
|
if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
|
|
@@ -3108,26 +3200,30 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3108
3200
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
3109
3201
|
if (lots < 1) continue;
|
|
3110
3202
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
3111
|
-
const
|
|
3112
|
-
|
|
3203
|
+
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3204
|
+
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
3205
|
+
if (hitHardStopLoss || profitLockReason) {
|
|
3113
3206
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
3114
3207
|
lockOrders.push({
|
|
3115
3208
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
3116
3209
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
3117
|
-
reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)
|
|
3210
|
+
reason: hitHardStopLoss ? `Авто-стоп позиции (${round(curGainPct, 2)}%)` : profitLockReason,
|
|
3211
|
+
isStopLoss: hitHardStopLoss,
|
|
3118
3212
|
});
|
|
3119
3213
|
}
|
|
3120
3214
|
} else if (aQty < 0) {
|
|
3121
3215
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
3122
3216
|
if (lots < 1) continue;
|
|
3123
3217
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
3124
|
-
const
|
|
3125
|
-
|
|
3218
|
+
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3219
|
+
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
3220
|
+
if (hitHardStopLoss || profitLockReason) {
|
|
3126
3221
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
3127
3222
|
lockOrders.push({
|
|
3128
3223
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
3129
3224
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
3130
|
-
reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)
|
|
3225
|
+
reason: hitHardStopLoss ? `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)` : profitLockReason,
|
|
3226
|
+
isStopLoss: hitHardStopLoss,
|
|
3131
3227
|
});
|
|
3132
3228
|
}
|
|
3133
3229
|
}
|
|
@@ -3135,6 +3231,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3135
3231
|
|
|
3136
3232
|
if (!lockOrders.length) return;
|
|
3137
3233
|
const results = [];
|
|
3234
|
+
const sentLockOrders = [];
|
|
3138
3235
|
const execNowTs = Date.now();
|
|
3139
3236
|
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
3140
3237
|
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
@@ -3145,16 +3242,25 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3145
3242
|
await cancelBrokerOrder(accId, co.orderId).catch(() => {});
|
|
3146
3243
|
}
|
|
3147
3244
|
for (const ord of lockOrders) {
|
|
3148
|
-
logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
3245
|
+
logBrokerEvent(acc, ord.isStopLoss ? 'protective_stop' : 'profit_lock', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
3149
3246
|
try {
|
|
3150
3247
|
if (acc.brokerStopBySymbol?.[ord.symbol]) {
|
|
3151
3248
|
await closeBrokerStopBeforeExit(acc, ord.symbol);
|
|
3152
3249
|
}
|
|
3153
3250
|
const r = await sendOrderToBroker(acc, ord);
|
|
3251
|
+
if (r.lots > 0) {
|
|
3252
|
+
ord.lots = r.lots;
|
|
3253
|
+
ord.shares = r.lots * (ord.lotSize || 1);
|
|
3254
|
+
}
|
|
3255
|
+
if (r.limitPrice > 0) {
|
|
3256
|
+
ord.limitPrice = r.limitPrice;
|
|
3257
|
+
ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
|
|
3258
|
+
}
|
|
3154
3259
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
3260
|
+
sentLockOrders.push(ord);
|
|
3155
3261
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
3156
3262
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
3157
|
-
acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
3263
|
+
if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
3158
3264
|
} catch (e) {
|
|
3159
3265
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
3160
3266
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
|
@@ -3165,12 +3271,15 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3165
3271
|
id: `batch_stop_${execNowTs}`,
|
|
3166
3272
|
createdAt: execNowTs,
|
|
3167
3273
|
executedAt: execNowTs,
|
|
3168
|
-
bar: `${fmtBarMsk(execNowTs)} (
|
|
3274
|
+
bar: `${fmtBarMsk(execNowTs)} (Фиксация прибыли / Защита)`,
|
|
3169
3275
|
orders: lockOrders,
|
|
3170
3276
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3171
3277
|
results,
|
|
3172
3278
|
};
|
|
3173
3279
|
saveRawAccounts(list);
|
|
3280
|
+
if (sentLockOrders.length > 0) {
|
|
3281
|
+
notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
|
|
3282
|
+
}
|
|
3174
3283
|
await syncBrokerAccount(accId).catch(() => {});
|
|
3175
3284
|
const st = brokersState();
|
|
3176
3285
|
hub.emit({ type: 'brokers:update', state: st });
|
|
@@ -3197,7 +3306,9 @@ async function tickBrokerAutoRefresh() {
|
|
|
3197
3306
|
if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
|
|
3198
3307
|
await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
|
|
3199
3308
|
}
|
|
3200
|
-
|
|
3309
|
+
const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
|
|
3310
|
+
const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
|
|
3311
|
+
if (acc.broker === 'tbank' && historyNeedSync) {
|
|
3201
3312
|
await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
|
|
3202
3313
|
}
|
|
3203
3314
|
// Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
|
package/server/model/forecast.js
CHANGED
|
@@ -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) {
|
package/server/notify/index.js
CHANGED
|
@@ -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
|
|
196
|
-
const
|
|
197
|
-
|
|
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;
|