neuroalpha 0.5.22 → 0.5.27
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 +54 -6
- package/server/api/routes.js +2 -2
- package/server/brokers/index.js +92 -41
- package/server/data/moex.js +29 -20
- package/server/db.js +4 -0
- package/server/model/dataset.js +59 -19
- package/server/model/edge.js +39 -7
- package/server/model/forecast.js +246 -28
- package/server/model/live.js +30 -8
- package/server/model/models.js +27 -5
- package/server/model/research.js +124 -37
- package/server/news/index.js +2 -2
- package/server/strategist/index.js +3 -3
- package/server/strategist/tools.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.27",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1079,20 +1079,36 @@ async function loadForecast(force, silent = false) {
|
|
|
1079
1079
|
}
|
|
1080
1080
|
return reasons.length ? { blocked: true, label, title: reasons.join(' · ') } : { blocked: false, label: '', title: '' };
|
|
1081
1081
|
};
|
|
1082
|
-
const sigBadge = (r, blk) => {
|
|
1082
|
+
const sigBadge = (r, blk, crossed, pctOfThr, thrText, thrVal) => {
|
|
1083
1083
|
if (r.signal) return '<span class="tag ok">купить</span>';
|
|
1084
1084
|
if (r.shortSignal) return '<span class="tag no">шорт</span>';
|
|
1085
|
-
if (blk?.blocked
|
|
1086
|
-
|
|
1085
|
+
if (blk?.blocked && crossed) {
|
|
1086
|
+
return `<span class="tag blocked" title="${esc(`Порог ${thrText} пройден (ожидание ${fmtPct(r.expectedPct, 2)}, ${pctOfThr}% порога), но вход заблокирован: ${blk.title}`)}">${esc(blk.label)}</span>`;
|
|
1087
|
+
}
|
|
1088
|
+
if (blk?.blocked && !crossed) {
|
|
1089
|
+
const shortTag = blk.label.replace(/^⛔\s*/, '');
|
|
1090
|
+
return `<span class="muted small" title="${esc(`Ожидание ${fmtPct(r.expectedPct, 2)} ниже порога ${thrText} (${pctOfThr}% от порога) — сигнала нет. Дополнительно по бумаге активен фильтр: ${blk.title}`)}">ниже порога · ${esc(shortTag)}</span>`;
|
|
1091
|
+
}
|
|
1092
|
+
if (pctOfThr >= 75) {
|
|
1093
|
+
const gap = Math.max(0, (Number(thrVal) || 0) - Math.abs(Number(r.expectedPct) || 0));
|
|
1094
|
+
return `<span class="tag mid" title="${esc(`Почти сигнал: ожидание ${fmtPct(r.expectedPct, 2)} достигло ${pctOfThr}% от порога ${thrText} (не хватает ${fmtNum(gap, 2)}%)`)}">почти (${pctOfThr}%)</span>`;
|
|
1095
|
+
}
|
|
1096
|
+
return `<span class="muted small" title="${esc(`Ожидание ${fmtPct(r.expectedPct, 2)} ниже порога ${thrText} (${pctOfThr}% от порога)`)}">—</span>`;
|
|
1087
1097
|
};
|
|
1088
1098
|
const row = (r, side) => {
|
|
1089
1099
|
const blk = !r.signal && !r.shortSignal ? blockInfoOf(r, side) : { blocked: false };
|
|
1090
|
-
const trCls = (r.signal || r.shortSignal) ? ' class="best"' : blk.blocked ? ` class="fc-blocked" title="${esc(blk.title)}"` : '';
|
|
1091
1100
|
const isShort = side === 'short' || (side !== 'long' && r.expectedPct < 0);
|
|
1092
1101
|
const isT1 = r.tier ? r.tier === 1 : TIER1_SET.has(r.symbol);
|
|
1093
1102
|
const thrVal = isShort
|
|
1094
1103
|
? (r.shortThresholdPct ?? (isT1 ? shortThr : shortThrT2))
|
|
1095
1104
|
: (r.buyThresholdPct ?? (isT1 ? buyThr : buyThrT2));
|
|
1105
|
+
const pctOfThr = thrVal > 0 ? Math.round((Math.abs(Number(r.expectedPct) || 0) / thrVal) * 100) : 0;
|
|
1106
|
+
const crossed = thrVal > 0 && Math.abs(Number(r.expectedPct) || 0) >= thrVal;
|
|
1107
|
+
const trCls = (r.signal || r.shortSignal)
|
|
1108
|
+
? ' class="best"'
|
|
1109
|
+
: (blk.blocked && crossed)
|
|
1110
|
+
? ` class="fc-blocked" title="${esc(`Порог пройден, но вход заблокирован: ${blk.title}`)}"`
|
|
1111
|
+
: '';
|
|
1096
1112
|
const thrFmt = Number(thrVal || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
1097
1113
|
const thrText = isShort ? `−${thrFmt}%` : `+${thrFmt}%`;
|
|
1098
1114
|
const multStr = r.edgeMult ? `×${fmtNum(r.edgeMult, 2)}` : (isT1 ? '×1,00' : '×1,20');
|
|
@@ -1106,7 +1122,7 @@ async function loadForecast(force, silent = false) {
|
|
|
1106
1122
|
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
|
|
1107
1123
|
<td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
|
|
1108
1124
|
<td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
|
|
1109
|
-
<td style="text-align:center;white-space:nowrap">${sigBadge(r, blk)}</td>
|
|
1125
|
+
<td style="text-align:center;white-space:nowrap">${sigBadge(r, blk, crossed, pctOfThr, thrText, thrVal)}</td>
|
|
1110
1126
|
<td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => `<span class="sym-link" data-sym="${esc(x.symbol)}" title="Сосед по карте рынка: вклад ${x.contribPct > 0 ? '+' : ''}${x.contribPct}%, близость ${x.closeness}">${esc(x.symbol)}</span>`).join(', ')}</td>
|
|
1111
1127
|
<td class="num muted small" style="white-space:nowrap" title="${esc(thrTitle)}">${thrText}</td>
|
|
1112
1128
|
</tr>`;
|
|
@@ -1119,9 +1135,41 @@ async function loadForecast(force, silent = false) {
|
|
|
1119
1135
|
].slice(0, 4);
|
|
1120
1136
|
// общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
|
|
1121
1137
|
const fcTable = (title, rows, extraStyle = '', side = 'long') => `<table class="fc-table" style="table-layout:fixed;width:100%${extraStyle}">${colgroup}<thead><tr><th>${title}</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th><th class="num" title="Индивидуальный порог входа с учётом ликвидности бумаги (1-й или 2–3-й эшелон) и тренда индекса">Порог</th></tr></thead><tbody>${rows.map((r) => row(r, side)).join('')}</tbody></table>`;
|
|
1138
|
+
const nearList = Array.isArray(f.nearSignals) ? f.nearSignals : [];
|
|
1139
|
+
const prevNearEl = $('#fc-near-box');
|
|
1140
|
+
const nearOpenAttr = (!prevNearEl || prevNearEl.open) ? ' open' : '';
|
|
1141
|
+
const nearHtml = nearList.length ? `<details id="fc-near-box"${nearOpenAttr} style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
|
|
1142
|
+
<summary style="font-weight:500">Журнал сигналов и почти-сигналов за неделю (≥75% порога) · ${nearList.filter((x) => x.status === 'signal').length} сигн., ${nearList.filter((x) => x.status === 'blocked').length} блок., ${nearList.filter((x) => x.status === 'near').length} почти</summary>
|
|
1143
|
+
<div style="overflow-x:auto;margin-top:8px">
|
|
1144
|
+
<table class="fc-table" style="width:100%">
|
|
1145
|
+
<thead><tr><th>Дата · время МСК</th><th>Бумага</th><th>Направление</th><th class="num">Пик ожидания</th><th class="num">Порог</th><th class="num">К порогу</th><th>Статус и причина</th></tr></thead>
|
|
1146
|
+
<tbody>${nearList.slice(0, 18).map((ep) => {
|
|
1147
|
+
const dStr = ep.day ? `${ep.day.slice(8, 10)}.${ep.day.slice(5, 7)}` : '';
|
|
1148
|
+
const tRange = ep.firstBar && ep.lastBar && ep.firstBar !== ep.lastBar ? `${ep.firstBar}–${ep.lastBar} (${ep.barsCount} св.)` : `${ep.peakBar || ep.lastBar || ''}`;
|
|
1149
|
+
const isSh = ep.side === 'short';
|
|
1150
|
+
const thrStr = `${isSh ? '−' : '+'}${fmtNum(ep.thresholdPct, 2)}%`;
|
|
1151
|
+
const stBadge = ep.status === 'signal'
|
|
1152
|
+
? `<span class="tag ok">${isSh ? '✓ сигнал шорт' : '✓ сигнал купить'}</span>`
|
|
1153
|
+
: ep.status === 'blocked'
|
|
1154
|
+
? '<span class="tag blocked">⛔ блок входа</span>'
|
|
1155
|
+
: `<span class="tag mid">почти (${ep.peakRatioPct}%)</span>`;
|
|
1156
|
+
const rowCls = ep.status === 'signal' ? ' class="best"' : ep.status === 'blocked' ? ' class="fc-blocked"' : '';
|
|
1157
|
+
return `<tr${rowCls}>
|
|
1158
|
+
<td class="muted small" style="white-space:nowrap">${esc(dStr)} ${esc(tRange)}</td>
|
|
1159
|
+
<td style="white-space:nowrap"><span class="sym-link" data-sym="${esc(ep.symbol)}" title="Открыть досье акции ${esc(ep.symbol)}"><b>${esc(ep.symbol)}</b> <span class="muted small">${esc(ep.name || '')}</span></span></td>
|
|
1160
|
+
<td class="${isSh ? 'neg' : 'pos'} small" style="white-space:nowrap">${isSh ? 'В шорт' : 'В покупку'}</td>
|
|
1161
|
+
<td class="num ${cls(ep.peakExpectedPct)}" style="white-space:nowrap"><b>${fmtPct(ep.peakExpectedPct, 2)}</b></td>
|
|
1162
|
+
<td class="num muted small" style="white-space:nowrap">${thrStr}</td>
|
|
1163
|
+
<td class="num small" style="white-space:nowrap"><b>${ep.peakRatioPct}%</b></td>
|
|
1164
|
+
<td class="small" style="white-space:normal">${stBadge} <span class="muted">${esc(ep.reasonText || '')}</span></td>
|
|
1165
|
+
</tr>`;
|
|
1166
|
+
}).join('')}</tbody>
|
|
1167
|
+
</table>
|
|
1168
|
+
</div>
|
|
1169
|
+
</details>` : '';
|
|
1122
1170
|
box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6), '', 'long')}
|
|
1123
1171
|
${fcTable('Слабее рынка', shortRows, ';margin-top:14px', 'short')}</div>
|
|
1124
|
-
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Точный индивидуальный порог каждой акции рассчитывается в колонке <b>«Порог»</b> с учётом её ликвидности (оборота торгов), волатильности свечи и тренда индекса. <span style="color:var(--blocked-fg)">Розовым</span>
|
|
1172
|
+
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Точный индивидуальный порог каждой акции рассчитывается в колонке <b>«Порог»</b> с учётом её ликвидности (оборота торгов), волатильности свечи и тренда индекса. <span style="color:var(--blocked-fg)">Розовым</span> выделяются только те бумаги, где ожидание <b>уже пробило порог</b>, но сделка остановлена защитным фильтром (если ожидание ещё ниже порога, фильтр указывается справочно рядом с «ниже порога»).</p>${nearHtml}`;
|
|
1125
1173
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
1126
1174
|
} catch (e) {
|
|
1127
1175
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
package/server/api/routes.js
CHANGED
|
@@ -783,8 +783,8 @@ export function createRouter() {
|
|
|
783
783
|
tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
|
|
784
784
|
edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
|
|
785
785
|
sigmaPct: fcRow?.sigmaPct ?? null,
|
|
786
|
-
buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.
|
|
787
|
-
shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.
|
|
786
|
+
buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.40) * tierMult, 0.30), 3),
|
|
787
|
+
shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.50) * tierMult, 0.31), 3),
|
|
788
788
|
shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
|
|
789
789
|
specs: {
|
|
790
790
|
lotSize: sp.lotSize,
|
package/server/brokers/index.js
CHANGED
|
@@ -118,15 +118,15 @@ export function roundToStep(price, minStep = 0.01, decimals = 2) {
|
|
|
118
118
|
return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// Снайперский расчёт лимитной
|
|
122
|
-
// отталкивается от живой секундной цены basePx и
|
|
123
|
-
//
|
|
121
|
+
// Снайперский расчёт лимитной цены:
|
|
122
|
+
// отталкивается от живой секундной цены basePx и задаёт защитный предел исполнения по offsetPct (но не менее 1 минимального шага цены minStep),
|
|
123
|
+
// а в момент фактической отправки в стакан sendOrderToBroker дополнительно подстраивает цену под лучший bid/ask живого стакана 1×1!
|
|
124
124
|
export function sniperLimitPrice(basePx, dir, offsetPct = 0.02, sp = { minStep: 0.01, decimals: 2 }) {
|
|
125
125
|
const step = sp.minStep > 0 ? sp.minStep : 0.01;
|
|
126
|
-
const maxOffRatio = Math.max(0, Math.min(0.
|
|
126
|
+
const maxOffRatio = Math.max(0, Math.min(0.001, (Number(offsetPct) || 0.02) / 100));
|
|
127
127
|
const byPct = basePx * (1 + dir * maxOffRatio);
|
|
128
128
|
const byOneTick = basePx + dir * step;
|
|
129
|
-
const rawLimit = dir > 0 ? Math.
|
|
129
|
+
const rawLimit = dir > 0 ? Math.max(byPct, byOneTick) : Math.min(byPct, byOneTick);
|
|
130
130
|
return roundToStep(rawLimit, step, sp.decimals);
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -498,6 +498,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
498
498
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
499
499
|
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
500
500
|
lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
|
|
501
|
+
lastFullStepWallTs: strategyOrCapChanged ? 0 : (prev.lastFullStepWallTs || 0),
|
|
501
502
|
// дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
|
|
502
503
|
haltedReason: prev.haltedReason || null,
|
|
503
504
|
ddOverrideDay: prev.ddOverrideDay || null,
|
|
@@ -580,14 +581,16 @@ export function deleteBrokerAccount(id) {
|
|
|
580
581
|
|
|
581
582
|
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
582
583
|
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
|
|
583
|
-
const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
|
|
584
|
+
const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, keepAliveInitialDelay: 10000, maxSockets: 6, timeout: 20000, ca: TRUSTED_CA });
|
|
584
585
|
|
|
585
586
|
function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
586
587
|
return new Promise((resolve, reject) => {
|
|
587
588
|
let settled = false;
|
|
589
|
+
let wallTimer = null;
|
|
588
590
|
const done = (fn, val) => {
|
|
589
591
|
if (settled) return;
|
|
590
592
|
settled = true;
|
|
593
|
+
if (wallTimer) clearTimeout(wallTimer);
|
|
591
594
|
fn(val);
|
|
592
595
|
};
|
|
593
596
|
const u = new URL(url);
|
|
@@ -622,6 +625,12 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
622
625
|
res.on('error', (err) => done(reject, err));
|
|
623
626
|
res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
|
|
624
627
|
});
|
|
628
|
+
wallTimer = setTimeout(() => {
|
|
629
|
+
const err = new Error('Превышено время ожидания ответа от брокера');
|
|
630
|
+
req.destroy(err);
|
|
631
|
+
done(reject, err);
|
|
632
|
+
}, Math.max(1500, timeoutMs));
|
|
633
|
+
wallTimer.unref?.();
|
|
625
634
|
req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
|
|
626
635
|
req.on('error', (err) => done(reject, err));
|
|
627
636
|
req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
|
|
@@ -710,8 +719,9 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
710
719
|
// Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
|
|
711
720
|
export async function fetchTbankRealtimePrices() {
|
|
712
721
|
try {
|
|
713
|
-
// только токен владельца текущих данных — токены других пользователей не используются никогда
|
|
714
|
-
const
|
|
722
|
+
// только токен владельца текущих данных — токены других пользователей не используются никогда (предпочитаем боевой контур песочнице)
|
|
723
|
+
const rawAccs = loadRawAccounts();
|
|
724
|
+
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
715
725
|
if (!tbAcc?.token) return null;
|
|
716
726
|
await ensureTbankShortableMap(tbAcc);
|
|
717
727
|
const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
|
|
@@ -744,7 +754,8 @@ export async function fetchTbankRealtimePrices() {
|
|
|
744
754
|
// Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
|
|
745
755
|
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
|
|
746
756
|
try {
|
|
747
|
-
const
|
|
757
|
+
const rawAccs = loadRawAccounts();
|
|
758
|
+
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
748
759
|
if (!tbAcc?.token) return null;
|
|
749
760
|
await ensureTbankShortableMap(tbAcc);
|
|
750
761
|
const shortMap = kvGet('tbankShortableMap');
|
|
@@ -756,17 +767,18 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
|
|
|
756
767
|
// Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
|
|
757
768
|
const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
|
|
758
769
|
if (safeFrom >= tillTs) return null;
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
to: new Date(tillTs).toISOString(),
|
|
766
|
-
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
767
|
-
}),
|
|
768
|
-
timeoutMs: 5000,
|
|
770
|
+
const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
|
|
771
|
+
const body = JSON.stringify({
|
|
772
|
+
instrumentId: uid,
|
|
773
|
+
from: new Date(safeFrom).toISOString(),
|
|
774
|
+
to: new Date(tillTs).toISOString(),
|
|
775
|
+
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
769
776
|
});
|
|
777
|
+
let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
|
|
778
|
+
if (r.status === 429 || r.status >= 500) {
|
|
779
|
+
await new Promise((res) => setTimeout(res, 350 + Math.floor(Math.random() * 400)));
|
|
780
|
+
r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
|
|
781
|
+
}
|
|
770
782
|
if (!r.ok) return null;
|
|
771
783
|
const j = await r.json();
|
|
772
784
|
const out = [];
|
|
@@ -1771,7 +1783,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1771
1783
|
if (aQty > 0) {
|
|
1772
1784
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1773
1785
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1774
|
-
const hitHardStopLoss = curGainPct <= -5.0;
|
|
1786
|
+
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1775
1787
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1776
1788
|
const dg = divGuardOf(sym, curPx);
|
|
1777
1789
|
const divExit = Boolean(dg?.exitLong);
|
|
@@ -1796,7 +1808,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1796
1808
|
} else if (aQty < 0) {
|
|
1797
1809
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1798
1810
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1799
|
-
const hitHardStopLoss = curGainPct <= -4.5;
|
|
1811
|
+
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
1800
1812
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1801
1813
|
const dg = divGuardOf(sym, curPx);
|
|
1802
1814
|
const divExit = Boolean(dg?.exitShort);
|
|
@@ -1899,7 +1911,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1899
1911
|
}
|
|
1900
1912
|
}
|
|
1901
1913
|
const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
1902
|
-
const
|
|
1914
|
+
const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
|
|
1915
|
+
const openShortRub = [...heldMap.values()]
|
|
1916
|
+
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
1917
|
+
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
1918
|
+
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
|
|
1903
1919
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
1904
1920
|
|
|
1905
1921
|
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
@@ -2111,9 +2127,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2111
2127
|
return brokersState();
|
|
2112
2128
|
}
|
|
2113
2129
|
|
|
2130
|
+
const batchNowTs = Date.now();
|
|
2131
|
+
if (!onlyEntries) {
|
|
2132
|
+
acc.lastFullStepWallTs = batchNowTs;
|
|
2133
|
+
}
|
|
2114
2134
|
acc.pendingBatch = {
|
|
2115
|
-
id: `batch_${
|
|
2116
|
-
createdAt:
|
|
2135
|
+
id: `batch_${batchNowTs}`,
|
|
2136
|
+
createdAt: batchNowTs,
|
|
2117
2137
|
bar: fc.bar,
|
|
2118
2138
|
budgetRub: budget,
|
|
2119
2139
|
availRub: round(availRub, 0),
|
|
@@ -2190,19 +2210,35 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2190
2210
|
if (bestBid > 0 && bestAsk >= bestBid) {
|
|
2191
2211
|
const midPx = (bestBid + bestAsk) / 2;
|
|
2192
2212
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
2213
|
+
const step = sp.minStep || 0.01;
|
|
2193
2214
|
const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
|
|
2194
2215
|
if (isEntry && spreadPct > maxSpreadPct) {
|
|
2195
2216
|
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
|
|
2196
2217
|
throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
|
|
2197
2218
|
}
|
|
2198
|
-
if (
|
|
2199
|
-
// Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
|
|
2200
|
-
const passivePx = isBuy
|
|
2201
|
-
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
|
|
2202
|
-
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
|
|
2203
|
-
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2204
|
-
} else if (ord.symbol === 'LQDT') {
|
|
2219
|
+
if (ord.symbol === 'LQDT') {
|
|
2205
2220
|
finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
|
|
2221
|
+
} else if (!isEntry) {
|
|
2222
|
+
// Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
|
|
2223
|
+
// чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
|
|
2224
|
+
if (spreadPct <= 0.80) {
|
|
2225
|
+
const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2226
|
+
if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
|
|
2227
|
+
}
|
|
2228
|
+
} else {
|
|
2229
|
+
// Входящие заявки (buy / short):
|
|
2230
|
+
// • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
|
|
2231
|
+
// • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
|
|
2232
|
+
const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
|
|
2233
|
+
if (tightSpread) {
|
|
2234
|
+
const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2235
|
+
if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
|
|
2236
|
+
} else {
|
|
2237
|
+
const passivePx = isBuy
|
|
2238
|
+
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
|
|
2239
|
+
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
|
|
2240
|
+
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2241
|
+
}
|
|
2206
2242
|
}
|
|
2207
2243
|
}
|
|
2208
2244
|
}
|
|
@@ -2722,15 +2758,17 @@ registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrder
|
|
|
2722
2758
|
// ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
|
|
2723
2759
|
let lastSeenBar = null;
|
|
2724
2760
|
let schedulerBusy = false;
|
|
2761
|
+
let schedulerBusySince = 0;
|
|
2725
2762
|
|
|
2726
2763
|
async function tickBrokerSchedule() {
|
|
2727
|
-
if (schedulerBusy) return;
|
|
2764
|
+
if (schedulerBusy && Date.now() - schedulerBusySince < 90000) return;
|
|
2728
2765
|
if (!tradingGate().allowNew) return;
|
|
2729
2766
|
const nowMsk = new Date(Date.now() + MSK);
|
|
2730
2767
|
const dow = nowMsk.getUTCDay();
|
|
2731
2768
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
2732
2769
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
|
|
2733
2770
|
schedulerBusy = true;
|
|
2771
|
+
schedulerBusySince = Date.now();
|
|
2734
2772
|
try {
|
|
2735
2773
|
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
2736
2774
|
const isNewBar = Boolean(latest && latest !== lastSeenBar);
|
|
@@ -2751,14 +2789,21 @@ async function tickBrokerSchedule() {
|
|
|
2751
2789
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
2752
2790
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
2753
2791
|
const minStepMs = stepBars * BAR_MS;
|
|
2754
|
-
const
|
|
2792
|
+
const lastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
2793
|
+
const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
|
|
2794
|
+
const hasOpenAlgoPos = (acc.lastSync?.positions || []).some((p) => p.qty && p.symbol !== 'LQDT' && p.symbol !== 'TMON@' && p.symbol !== 'TMON' && p.symbol !== 'SBMM' && algoQtyFor(acc, p.symbol, p.qty) !== 0);
|
|
2795
|
+
const nowTs = Date.now();
|
|
2796
|
+
const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
|
|
2755
2797
|
const dueFullStep = Boolean(
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2798
|
+
nightExitDue || (
|
|
2799
|
+
latest &&
|
|
2800
|
+
(latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
|
|
2801
|
+
!(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
|
|
2802
|
+
),
|
|
2759
2803
|
);
|
|
2760
2804
|
if (dueFullStep) {
|
|
2761
|
-
acc.lastScheduledBarTs = latest;
|
|
2805
|
+
acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
|
|
2806
|
+
acc.lastFullStepWallTs = nowTs;
|
|
2762
2807
|
saveRawAccounts(list);
|
|
2763
2808
|
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
2764
2809
|
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
@@ -2769,7 +2814,7 @@ async function tickBrokerSchedule() {
|
|
|
2769
2814
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
2770
2815
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
2771
2816
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
2772
|
-
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 &&
|
|
2817
|
+
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2773
2818
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
2774
2819
|
}
|
|
2775
2820
|
}
|
|
@@ -3056,6 +3101,9 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3056
3101
|
for (const ord of lockOrders) {
|
|
3057
3102
|
logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
3058
3103
|
try {
|
|
3104
|
+
if (acc.brokerStopBySymbol?.[ord.symbol]) {
|
|
3105
|
+
await closeBrokerStopBeforeExit(acc, ord.symbol);
|
|
3106
|
+
}
|
|
3059
3107
|
const r = await sendOrderToBroker(acc, ord);
|
|
3060
3108
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
3061
3109
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
@@ -3068,10 +3116,10 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3068
3116
|
}
|
|
3069
3117
|
acc.pendingBatch = {
|
|
3070
3118
|
...(acc.pendingBatch || {}),
|
|
3071
|
-
id: `
|
|
3119
|
+
id: `batch_stop_${execNowTs}`,
|
|
3072
3120
|
createdAt: execNowTs,
|
|
3073
3121
|
executedAt: execNowTs,
|
|
3074
|
-
bar: `${fmtBarMsk(execNowTs)} (
|
|
3122
|
+
bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
|
|
3075
3123
|
orders: lockOrders,
|
|
3076
3124
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3077
3125
|
results,
|
|
@@ -3084,9 +3132,12 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3084
3132
|
|
|
3085
3133
|
// Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
|
|
3086
3134
|
let autoRefreshBusy = false;
|
|
3135
|
+
let autoRefreshBusySince = 0;
|
|
3087
3136
|
async function tickBrokerAutoRefresh() {
|
|
3088
|
-
|
|
3137
|
+
const now = Date.now();
|
|
3138
|
+
if ((autoRefreshBusy && now - autoRefreshBusySince < 90000) || (schedulerBusy && now - schedulerBusySince < 45000)) return;
|
|
3089
3139
|
autoRefreshBusy = true;
|
|
3140
|
+
autoRefreshBusySince = now;
|
|
3090
3141
|
try {
|
|
3091
3142
|
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
3092
3143
|
const users = db.prepare('SELECT * FROM users').all();
|
package/server/data/moex.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
|
|
2
|
+
import { saveCandles, loadCandles, candleRange, candleMaxBetween, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { DAY, MSK } from '../util.js';
|
|
4
4
|
|
|
5
5
|
let realtimeCandleFetcher = null;
|
|
@@ -80,9 +80,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
80
80
|
const now = Date.now();
|
|
81
81
|
const pKey = `candProbe:${symbol}:${interval}`;
|
|
82
82
|
const probe = kvGet(pKey);
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
83
|
+
// Метка v3: fromTs — до какой даты левый край подтверждён (свечи есть в базе или на бирже раньше их нет).
|
|
84
|
+
// Метки v1/v2 не доверяем: старое условие range.n < 500 записывало fromTs без скачивания, и у бумаг с накопленной
|
|
85
|
+
// историей левый край (например, 2022 год) так и не догружался — датасет зависел от того, когда создана база.
|
|
86
|
+
const verifiedFrom = probe?.v === 3 ? probe.fromTs : Infinity;
|
|
87
|
+
let leftFill = probe?.v === 3 ? probe.leftFill || null : null;
|
|
86
88
|
|
|
87
89
|
if (!range.n) {
|
|
88
90
|
// В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
|
|
@@ -90,28 +92,35 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
90
92
|
signal,
|
|
91
93
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
92
94
|
});
|
|
93
|
-
kvSet(pKey, { v:
|
|
95
|
+
kvSet(pKey, { v: 3, fromTs });
|
|
94
96
|
lastTailCheckAt.set(`${symbol}:${interval}`, now);
|
|
95
97
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
// 1) ЛЕВЫЙ КРАЙ:
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
100
|
+
// 1) ЛЕВЫЙ КРАЙ: история докачивается один раз, дальше работаем только по SQLite.
|
|
101
|
+
// Докачка идёт от ранних свечей к поздним; пока она не завершена, в метке хранится leftFill,
|
|
102
|
+
// чтобы после обрыва продолжить с последней сохранённой свечи, а не оставить дыру в середине истории.
|
|
103
|
+
let probeFrom = Math.min(verifiedFrom, range.min);
|
|
104
|
+
if (!leftFill && range.min > fromTs + 5 * DAY && verifiedFrom > fromTs + 5 * DAY) {
|
|
102
105
|
const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
|
|
103
106
|
if (!first || first.ts >= range.min - DAY) {
|
|
104
|
-
|
|
107
|
+
probeFrom = fromTs; // раньше имеющихся свечей на бирже истории нет
|
|
105
108
|
} else {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
signal,
|
|
109
|
-
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
110
|
-
});
|
|
111
|
-
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
|
|
109
|
+
leftFill = { from: fromTs, till: range.min };
|
|
110
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, leftFill, tailCheckedAt: probe?.tailCheckedAt });
|
|
112
111
|
}
|
|
113
|
-
}
|
|
114
|
-
|
|
112
|
+
}
|
|
113
|
+
if (leftFill) {
|
|
114
|
+
const resumeTs = candleMaxBetween(symbol, interval, leftFill.from, leftFill.till - 1) ?? leftFill.from;
|
|
115
|
+
await fetchCandles(symbol, interval, tsToDate(resumeTs), tsToDate(leftFill.till + DAY), {
|
|
116
|
+
signal,
|
|
117
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
118
|
+
});
|
|
119
|
+
probeFrom = Math.min(probeFrom, leftFill.from);
|
|
120
|
+
leftFill = null;
|
|
121
|
+
}
|
|
122
|
+
if (probe?.v !== 3 || probe.fromTs !== probeFrom || probe.leftFill) {
|
|
123
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: probe?.tailCheckedAt });
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
// 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
|
|
@@ -137,7 +146,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
137
146
|
// В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
|
|
138
147
|
let filledByTbank = false;
|
|
139
148
|
if (isTbankEligible) {
|
|
140
|
-
const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now -
|
|
149
|
+
const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 3 * DAY + 120000), Math.min(tillTs, now)).catch(() => null);
|
|
141
150
|
if (tbRows?.length) {
|
|
142
151
|
saveCandles(symbol, interval, tbRows);
|
|
143
152
|
filledByTbank = true;
|
|
@@ -149,7 +158,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
149
158
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
150
159
|
});
|
|
151
160
|
}
|
|
152
|
-
kvSet(pKey, { v:
|
|
161
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: now });
|
|
153
162
|
}
|
|
154
163
|
|
|
155
164
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
package/server/db.js
CHANGED
|
@@ -343,6 +343,10 @@ export function candleRange(symbol, interval) {
|
|
|
343
343
|
return db.prepare('SELECT MIN(ts) AS min, MAX(ts) AS max, COUNT(*) AS n FROM candles WHERE symbol = ? AND interval = ?').get(symbol, interval);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
export function candleMaxBetween(symbol, interval, fromTs, tillTs) {
|
|
347
|
+
return db.prepare('SELECT MAX(ts) AS max FROM candles WHERE symbol = ? AND interval = ? AND ts >= ? AND ts <= ?').get(symbol, interval, fromTs, tillTs)?.max ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
346
350
|
export function createRun(mode, params, uid = getUid()) {
|
|
347
351
|
const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
|
|
348
352
|
return Number(r.lastInsertRowid);
|