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