neuroalpha 0.5.24 → 0.5.28
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 +67 -7
- package/server/api/routes.js +2 -2
- package/server/brokers/index.js +100 -40
- package/server/model/dataset.js +73 -21
- package/server/model/forecast.js +230 -42
- package/server/model/live.js +2 -2
- package/server/model/research.js +11 -10
- package/server/news/index.js +2 -2
- package/server/strategist/index.js +3 -3
- package/server/strategist/tools.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
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>`;
|
|
@@ -2527,8 +2575,10 @@ function renderBrokers() {
|
|
|
2527
2575
|
const baseMap = a.baselineQty || {};
|
|
2528
2576
|
const curSubTab = brokerTabById.get(a.id) || 'assets';
|
|
2529
2577
|
|
|
2578
|
+
const isSweepLqdt = (p) => Boolean(a.cashSweep && a.broker === 'tbank' && p.symbol === 'LQDT');
|
|
2579
|
+
|
|
2530
2580
|
const algoQtyOf = (p) => {
|
|
2531
|
-
if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
|
|
2581
|
+
if (!isSweepLqdt(p) && ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol)))) return 0;
|
|
2532
2582
|
if (lMode === 'takeover') return p.qty;
|
|
2533
2583
|
const bq = Number(baseMap[p.symbol] || 0);
|
|
2534
2584
|
if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
|
|
@@ -2542,6 +2592,16 @@ function renderBrokers() {
|
|
|
2542
2592
|
if (isNonTradablePos(p)) {
|
|
2543
2593
|
return '<span class="tag" title="Бумага снята с торгов, проходит редомициляцию или заблокирована (рыночная оценка 0 ₽). Стратегия её не затрагивает">не торгуется (0 ₽)</span>';
|
|
2544
2594
|
}
|
|
2595
|
+
if (isSweepLqdt(p)) {
|
|
2596
|
+
const aq = algoQtyOf(p);
|
|
2597
|
+
const bq = Number(baseMap[p.symbol] || 0);
|
|
2598
|
+
if (aq > 0 && bq > 0 && lMode !== 'takeover') {
|
|
2599
|
+
return `<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически продаётся под новые сигналы">старый: ${fmtNum(bq, 0)} шт. · резерв стратегии: ${fmtNum(aq, 0)} шт.</span>`;
|
|
2600
|
+
}
|
|
2601
|
+
if (aq > 0) {
|
|
2602
|
+
return '<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически высвобождается под новые сигналы покупки и шорта">резерв стратегии (кэш в LQDT)</span>';
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2545
2605
|
if (p.instrumentType && p.instrumentType !== 'share') {
|
|
2546
2606
|
const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
|
|
2547
2607
|
return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</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
|
@@ -79,13 +79,29 @@ export const BROKER_META = [
|
|
|
79
79
|
|
|
80
80
|
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
81
81
|
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
82
|
+
|
|
83
|
+
function withWallTimeout(promise, ms, label = 'Превышено время ожидания') {
|
|
84
|
+
let timer = null;
|
|
85
|
+
return Promise.race([
|
|
86
|
+
promise,
|
|
87
|
+
new Promise((_, reject) => {
|
|
88
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
}),
|
|
91
|
+
]).finally(() => {
|
|
92
|
+
if (timer) clearTimeout(timer);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
export async function ensureBoardSpecs() {
|
|
83
97
|
const cached = kvGet('moexSpecs');
|
|
84
98
|
if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
|
|
85
99
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
const j = await withWallTimeout((async () => {
|
|
101
|
+
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
102
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
103
|
+
return r.json();
|
|
104
|
+
})(), 26000, 'Таймаут загрузки справочника TQBR');
|
|
89
105
|
const sc = j.securities.columns, mc = j.marketdata.columns;
|
|
90
106
|
const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
|
|
91
107
|
const prices = new Map();
|
|
@@ -581,14 +597,16 @@ export function deleteBrokerAccount(id) {
|
|
|
581
597
|
|
|
582
598
|
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
583
599
|
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
|
|
584
|
-
const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
|
|
600
|
+
const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, keepAliveInitialDelay: 10000, maxSockets: 6, timeout: 20000, ca: TRUSTED_CA });
|
|
585
601
|
|
|
586
602
|
function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
587
603
|
return new Promise((resolve, reject) => {
|
|
588
604
|
let settled = false;
|
|
605
|
+
let wallTimer = null;
|
|
589
606
|
const done = (fn, val) => {
|
|
590
607
|
if (settled) return;
|
|
591
608
|
settled = true;
|
|
609
|
+
if (wallTimer) clearTimeout(wallTimer);
|
|
592
610
|
fn(val);
|
|
593
611
|
};
|
|
594
612
|
const u = new URL(url);
|
|
@@ -623,6 +641,12 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
623
641
|
res.on('error', (err) => done(reject, err));
|
|
624
642
|
res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
|
|
625
643
|
});
|
|
644
|
+
wallTimer = setTimeout(() => {
|
|
645
|
+
const err = new Error('Превышено время ожидания ответа от брокера');
|
|
646
|
+
req.destroy(err);
|
|
647
|
+
done(reject, err);
|
|
648
|
+
}, Math.max(1500, timeoutMs));
|
|
649
|
+
wallTimer.unref?.();
|
|
626
650
|
req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
|
|
627
651
|
req.on('error', (err) => done(reject, err));
|
|
628
652
|
req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
|
|
@@ -759,17 +783,18 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
|
|
|
759
783
|
// Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
|
|
760
784
|
const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
|
|
761
785
|
if (safeFrom >= tillTs) return null;
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
to: new Date(tillTs).toISOString(),
|
|
769
|
-
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
770
|
-
}),
|
|
771
|
-
timeoutMs: 5000,
|
|
786
|
+
const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
|
|
787
|
+
const body = JSON.stringify({
|
|
788
|
+
instrumentId: uid,
|
|
789
|
+
from: new Date(safeFrom).toISOString(),
|
|
790
|
+
to: new Date(tillTs).toISOString(),
|
|
791
|
+
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
772
792
|
});
|
|
793
|
+
let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
|
|
794
|
+
if (r.status === 429 || r.status >= 500) {
|
|
795
|
+
await new Promise((res) => setTimeout(res, 350 + Math.floor(Math.random() * 400)));
|
|
796
|
+
r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
|
|
797
|
+
}
|
|
773
798
|
if (!r.ok) return null;
|
|
774
799
|
const j = await r.json();
|
|
775
800
|
const out = [];
|
|
@@ -1565,11 +1590,36 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
1565
1590
|
}
|
|
1566
1591
|
|
|
1567
1592
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1568
|
-
const
|
|
1593
|
+
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
1594
|
+
const accountBusyLocks = new Map(); // id -> { since, token }
|
|
1569
1595
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1596
|
+
function isAccountBusy(id) {
|
|
1597
|
+
const cur = accountBusyLocks.get(id);
|
|
1598
|
+
if (!cur) return false;
|
|
1599
|
+
if (Date.now() - cur.since >= ACCOUNT_LOCK_TTL_MS) {
|
|
1600
|
+
accountBusyLocks.delete(id);
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function acquireAccountLock(id) {
|
|
1607
|
+
if (isAccountBusy(id)) return null;
|
|
1608
|
+
const token = Symbol(id);
|
|
1609
|
+
accountBusyLocks.set(id, { since: Date.now(), token });
|
|
1610
|
+
return token;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function releaseAccountLock(id, token) {
|
|
1614
|
+
const cur = accountBusyLocks.get(id);
|
|
1615
|
+
if (cur && (!token || cur.token === token)) {
|
|
1616
|
+
accountBusyLocks.delete(id);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBarTs = 0 } = {}) {
|
|
1621
|
+
const lockToken = acquireAccountLock(id);
|
|
1622
|
+
if (!lockToken) return brokersState();
|
|
1573
1623
|
try {
|
|
1574
1624
|
let list = loadRawAccounts();
|
|
1575
1625
|
let acc = list.find((x) => x.id === id);
|
|
@@ -1593,11 +1643,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1593
1643
|
acc = list.find((x) => x.id === id) || acc;
|
|
1594
1644
|
}
|
|
1595
1645
|
const params = normalizeParams(getSettings());
|
|
1596
|
-
const [fc, rawSpecs, shortableMap] = await Promise.all([
|
|
1646
|
+
const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
|
|
1597
1647
|
forecastNow(params),
|
|
1598
1648
|
ensureBoardSpecs(),
|
|
1599
1649
|
ensureTbankShortableMap(acc),
|
|
1600
|
-
]);
|
|
1650
|
+
]), 55000, 'Превышено время ожидания расчёта прогноза');
|
|
1601
1651
|
const specs = { ...rawSpecs };
|
|
1602
1652
|
if (acc.broker === 'tbank') {
|
|
1603
1653
|
const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
|
|
@@ -1774,7 +1824,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1774
1824
|
if (aQty > 0) {
|
|
1775
1825
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1776
1826
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1777
|
-
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.
|
|
1827
|
+
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1778
1828
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1779
1829
|
const dg = divGuardOf(sym, curPx);
|
|
1780
1830
|
const divExit = Boolean(dg?.exitLong);
|
|
@@ -1799,7 +1849,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1799
1849
|
} else if (aQty < 0) {
|
|
1800
1850
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1801
1851
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1802
|
-
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.
|
|
1852
|
+
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
1803
1853
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1804
1854
|
const dg = divGuardOf(sym, curPx);
|
|
1805
1855
|
const divExit = Boolean(dg?.exitShort);
|
|
@@ -2121,6 +2171,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2121
2171
|
const batchNowTs = Date.now();
|
|
2122
2172
|
if (!onlyEntries) {
|
|
2123
2173
|
acc.lastFullStepWallTs = batchNowTs;
|
|
2174
|
+
if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
|
|
2124
2175
|
}
|
|
2125
2176
|
acc.pendingBatch = {
|
|
2126
2177
|
id: `batch_${batchNowTs}`,
|
|
@@ -2149,7 +2200,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2149
2200
|
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
2150
2201
|
return st;
|
|
2151
2202
|
} finally {
|
|
2152
|
-
|
|
2203
|
+
releaseAccountLock(id, lockToken);
|
|
2153
2204
|
}
|
|
2154
2205
|
}
|
|
2155
2206
|
|
|
@@ -2465,12 +2516,13 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2465
2516
|
}
|
|
2466
2517
|
|
|
2467
2518
|
export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
2519
|
+
let lockToken = null;
|
|
2468
2520
|
if (!_lockHeld) {
|
|
2469
2521
|
// Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
|
|
2470
2522
|
const waitUntil = Date.now() + 15000;
|
|
2471
|
-
while (
|
|
2472
|
-
|
|
2473
|
-
|
|
2523
|
+
while (isAccountBusy(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
|
|
2524
|
+
lockToken = acquireAccountLock(id);
|
|
2525
|
+
if (!lockToken) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
|
|
2474
2526
|
}
|
|
2475
2527
|
try {
|
|
2476
2528
|
const list = loadRawAccounts();
|
|
@@ -2554,7 +2606,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2554
2606
|
hub.emit({ type: 'brokers:update', state: st });
|
|
2555
2607
|
return st;
|
|
2556
2608
|
} finally {
|
|
2557
|
-
if (!_lockHeld)
|
|
2609
|
+
if (!_lockHeld && lockToken) releaseAccountLock(id, lockToken);
|
|
2558
2610
|
}
|
|
2559
2611
|
}
|
|
2560
2612
|
|
|
@@ -2749,15 +2801,17 @@ registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrder
|
|
|
2749
2801
|
// ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
|
|
2750
2802
|
let lastSeenBar = null;
|
|
2751
2803
|
let schedulerBusy = false;
|
|
2804
|
+
let schedulerBusySince = 0;
|
|
2752
2805
|
|
|
2753
2806
|
async function tickBrokerSchedule() {
|
|
2754
|
-
if (schedulerBusy) return;
|
|
2807
|
+
if (schedulerBusy && Date.now() - schedulerBusySince < 90000) return;
|
|
2755
2808
|
if (!tradingGate().allowNew) return;
|
|
2756
2809
|
const nowMsk = new Date(Date.now() + MSK);
|
|
2757
2810
|
const dow = nowMsk.getUTCDay();
|
|
2758
2811
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
2759
2812
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
|
|
2760
2813
|
schedulerBusy = true;
|
|
2814
|
+
schedulerBusySince = Date.now();
|
|
2761
2815
|
try {
|
|
2762
2816
|
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
2763
2817
|
const isNewBar = Boolean(latest && latest !== lastSeenBar);
|
|
@@ -2778,24 +2832,27 @@ async function tickBrokerSchedule() {
|
|
|
2778
2832
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
2779
2833
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
2780
2834
|
const minStepMs = stepBars * BAR_MS;
|
|
2781
|
-
const
|
|
2782
|
-
const
|
|
2835
|
+
const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
2836
|
+
const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
|
|
2837
|
+
const lastAnyWallTs = acc.pendingBatch?.executedAt || batchCreatedTs || 0;
|
|
2838
|
+
// Если отметка шага была записана без фактического обновления pendingBatch, не считаем такой шаг состоявшимся
|
|
2839
|
+
const lastFullWallTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? rawLastFullWallTs : batchCreatedTs;
|
|
2840
|
+
const lastSchedBarTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? (acc.lastScheduledBarTs || 0) : 0;
|
|
2783
2841
|
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);
|
|
2784
2842
|
const nowTs = Date.now();
|
|
2785
2843
|
const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
|
|
2786
2844
|
const dueFullStep = Boolean(
|
|
2787
2845
|
nightExitDue || (
|
|
2788
2846
|
latest &&
|
|
2789
|
-
(latest -
|
|
2847
|
+
(latest - lastSchedBarTs >= minStepMs) &&
|
|
2790
2848
|
!(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
|
|
2791
2849
|
),
|
|
2792
2850
|
);
|
|
2793
2851
|
if (dueFullStep) {
|
|
2794
|
-
|
|
2795
|
-
acc.
|
|
2796
|
-
saveRawAccounts(list);
|
|
2852
|
+
if (isAccountBusy(acc.id)) continue;
|
|
2853
|
+
const stepBarTs = latest || acc.lastScheduledBarTs || nowTs;
|
|
2797
2854
|
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
2798
|
-
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
2855
|
+
await prepareBrokerOrders(acc.id, { scheduledBarTs: stepBarTs }).catch(() => {});
|
|
2799
2856
|
continue;
|
|
2800
2857
|
}
|
|
2801
2858
|
// Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
|
|
@@ -2909,12 +2966,12 @@ async function reconcileBrokerStops(accId) {
|
|
|
2909
2966
|
return;
|
|
2910
2967
|
}
|
|
2911
2968
|
if (!acc.lastSync?.ok) return;
|
|
2912
|
-
|
|
2913
|
-
|
|
2969
|
+
const lockToken = acquireAccountLock(accId);
|
|
2970
|
+
if (!lockToken) return; // идёт исполнение пакета — сверимся в следующий раз
|
|
2914
2971
|
try {
|
|
2915
|
-
await reconcileBrokerStopsLocked(list, acc);
|
|
2972
|
+
await withWallTimeout(reconcileBrokerStopsLocked(list, acc), 35000, 'Превышено время сверки стоп-заявок');
|
|
2916
2973
|
} finally {
|
|
2917
|
-
|
|
2974
|
+
releaseAccountLock(accId, lockToken);
|
|
2918
2975
|
}
|
|
2919
2976
|
}
|
|
2920
2977
|
|
|
@@ -3121,9 +3178,12 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3121
3178
|
|
|
3122
3179
|
// Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
|
|
3123
3180
|
let autoRefreshBusy = false;
|
|
3181
|
+
let autoRefreshBusySince = 0;
|
|
3124
3182
|
async function tickBrokerAutoRefresh() {
|
|
3125
|
-
|
|
3183
|
+
const now = Date.now();
|
|
3184
|
+
if ((autoRefreshBusy && now - autoRefreshBusySince < 90000) || (schedulerBusy && now - schedulerBusySince < 45000)) return;
|
|
3126
3185
|
autoRefreshBusy = true;
|
|
3186
|
+
autoRefreshBusySince = now;
|
|
3127
3187
|
try {
|
|
3128
3188
|
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
3129
3189
|
const users = db.prepare('SELECT * FROM users').all();
|
package/server/model/dataset.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
|
|
2
|
+
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate, barMs } from '../data/moex.js';
|
|
3
3
|
import { ensureVocab, ensureIndexHistory } from './vocab.js';
|
|
4
4
|
import { kvGet, kvSet } from '../db.js';
|
|
5
5
|
|
|
@@ -221,13 +221,25 @@ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {
|
|
|
221
221
|
// цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
|
|
222
222
|
// Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
|
|
223
223
|
export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, useMacro = false, useDividends = true, localOnly = false, onProgress = () => {}, signal }) {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const mskDate = new Date(now + MSK);
|
|
226
|
+
const mskDow = mskDate.getUTCDay();
|
|
227
|
+
const mskMins = mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes();
|
|
228
|
+
const isTradingNow = !localOnly && mskDow >= 1 && mskDow <= 5 && mskMins >= 7 * 60 && mskMins <= 23 * 60 + 50 && tillTs >= now - 2 * 60000;
|
|
229
|
+
const bMs = barMs(interval);
|
|
230
|
+
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
|
|
231
|
+
const isCacheFreshForSlot = (entry) => {
|
|
232
|
+
if (!isTradingNow || now - entry.at < 50000) return true;
|
|
233
|
+
const lastBar = entry.ds?.timeline?.[entry.ds.T - 1] || 0;
|
|
234
|
+
return lastBar >= latestClosedSlotTs;
|
|
235
|
+
};
|
|
224
236
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
225
237
|
const hit = datasetCache.get(key);
|
|
226
|
-
if (hit &&
|
|
238
|
+
if (hit && now - hit.at < 4 * 60000 && isCacheFreshForSlot(hit)) return hit.ds;
|
|
227
239
|
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
228
240
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
229
241
|
for (const v of datasetCache.values()) {
|
|
230
|
-
if (v.modeKey === modeKey && (!v.localOnly || localOnly) &&
|
|
242
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
|
|
231
243
|
return v.ds;
|
|
232
244
|
}
|
|
233
245
|
}
|
|
@@ -255,19 +267,30 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
255
267
|
if (signal?.aborted) throw new Error('Отменено');
|
|
256
268
|
const symCtrl = new AbortController();
|
|
257
269
|
const effSignal = signal ? AbortSignal.any([signal, symCtrl.signal]) : symCtrl.signal;
|
|
258
|
-
|
|
270
|
+
let killer = null;
|
|
259
271
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
await Promise.race([
|
|
273
|
+
(async () => {
|
|
274
|
+
// акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
|
|
275
|
+
const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
|
|
276
|
+
const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
|
|
277
|
+
// объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
|
|
278
|
+
raw[s] = compact(rows);
|
|
279
|
+
if (divs?.length) {
|
|
280
|
+
// DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
|
|
281
|
+
const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
|
|
282
|
+
divStat.applied += st.applied;
|
|
283
|
+
divStat.skipped += st.skipped;
|
|
284
|
+
}
|
|
285
|
+
})(),
|
|
286
|
+
new Promise((_, rej) => {
|
|
287
|
+
killer = setTimeout(() => {
|
|
288
|
+
symCtrl.abort();
|
|
289
|
+
rej(new Error('Превышено время ожидания загрузки'));
|
|
290
|
+
}, WITHIN);
|
|
291
|
+
killer.unref?.();
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
271
294
|
} catch (e) {
|
|
272
295
|
if (signal?.aborted) throw new Error('Отменено');
|
|
273
296
|
raw[s] = EMPTY();
|
|
@@ -419,18 +442,47 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
419
442
|
}
|
|
420
443
|
// макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
|
|
421
444
|
const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
|
|
422
|
-
const
|
|
423
|
-
|
|
445
|
+
const modernTimeline = timeline.filter((t) => t >= 1705104000000);
|
|
446
|
+
const effTl = modernTimeline.length >= 1000 ? modernTimeline : timeline;
|
|
447
|
+
const days = new Set(effTl.map(dayKey)).size;
|
|
448
|
+
// ликвидность: в скольких из последних 20 свечей ОСНОВНОЙ сессии (10:00–18:50 МСК) были сделки.
|
|
449
|
+
// 1) Утренняя (до 10:00 МСК) и вечерняя (с 19:00 МСК) дополнительные сессии не списывают очки ликвидности за пустые 10-минутки
|
|
450
|
+
// (если на доп. сессии сделки есть — засчитываем 1 в окно, если нет — окно основной сессии не сдвигается и не штрафуется).
|
|
451
|
+
// 2) На самых свежих 4 свечах правого края шкалы (последние 40 минут), где возможен рассинхрон между мгновенными свечами T-Invest API
|
|
452
|
+
// и 20–30-минутной задержкой бесплатного MOEX ISS (или локального кэша SQLite), не штрафуем бумаги, у которых свеча ещё не подтянулась с биржи.
|
|
453
|
+
const isMainSessionBar = new Uint8Array(T);
|
|
454
|
+
for (let i = 0; i < T; i++) {
|
|
455
|
+
const h = hourMsk[i];
|
|
456
|
+
isMainSessionBar[i] = (h >= 10 && h < 19) ? 1 : 0;
|
|
457
|
+
}
|
|
458
|
+
const tailGraceStart = Math.max(0, T - 4);
|
|
424
459
|
for (const tk of tokens) {
|
|
425
460
|
tk.liq = new Uint8Array(T);
|
|
426
|
-
let
|
|
461
|
+
let lastRealIdx = -1;
|
|
462
|
+
for (let i = T - 1; i >= 0; i--) {
|
|
463
|
+
if (tk.has[i]) { lastRealIdx = i; break; }
|
|
464
|
+
}
|
|
465
|
+
// Если бумага торговалась сегодня/вчера и отстаёт от правого края шкалы не более чем на 12 свечей (2 часа рассинхрона кэша/MOEX ISS),
|
|
466
|
+
// считаем хвостовые свечи после её последнего полученного бара ожидающими догрузки, а не отсутствием ликвидности:
|
|
467
|
+
const tailPendingFrom = (lastRealIdx >= 0 && T - 1 - lastRealIdx <= 12) ? Math.max(lastRealIdx + 1, tailGraceStart) : T;
|
|
468
|
+
const win = new Uint8Array(20);
|
|
469
|
+
let wLen = 0, wPtr = 0, n = 0;
|
|
427
470
|
for (let i = 0; i < T; i++) {
|
|
428
|
-
|
|
429
|
-
if (i
|
|
471
|
+
const h = tk.has[i] || (i >= tailPendingFrom ? 1 : 0);
|
|
472
|
+
if (isMainSessionBar[i] || h) {
|
|
473
|
+
if (wLen < 20) {
|
|
474
|
+
win[wLen++] = h;
|
|
475
|
+
n += h;
|
|
476
|
+
} else {
|
|
477
|
+
n += h - win[wPtr];
|
|
478
|
+
win[wPtr] = h;
|
|
479
|
+
wPtr = (wPtr + 1) % 20;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
430
482
|
tk.liq[i] = n;
|
|
431
483
|
}
|
|
432
484
|
}
|
|
433
|
-
const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(
|
|
485
|
+
const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(effTl.length / days) : 54 };
|
|
434
486
|
datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
|
|
435
487
|
if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
|
|
436
488
|
return ds;
|
package/server/model/forecast.js
CHANGED
|
@@ -16,6 +16,8 @@ import { hub } from '../hub.js';
|
|
|
16
16
|
const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
|
|
17
17
|
let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
|
|
18
18
|
const inflight = new Map();
|
|
19
|
+
const inflightStartedAt = new Map();
|
|
20
|
+
let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
19
21
|
|
|
20
22
|
// Синхронная живая доходность индекса относительно закрытия свечи idx-1:
|
|
21
23
|
// если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
|
|
@@ -161,42 +163,53 @@ export const isModelTrainedToday = (p, now = Date.now()) => {
|
|
|
161
163
|
// в первую очередь берёт секундные цены реального времени из T-Invest API (GetLastPrices, 0 секунд задержки!),
|
|
162
164
|
// а недостающие тикеры и индекс IMOEX дополняет из MOEX ISS
|
|
163
165
|
async function fetchLiveMoexSnapshot() {
|
|
166
|
+
let timer = null;
|
|
164
167
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
return await Promise.race([
|
|
169
|
+
(async () => {
|
|
170
|
+
const [tbPrices, rShares, rIdx] = await Promise.all([
|
|
171
|
+
fetchTbankRealtimePrices().catch(() => null),
|
|
172
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }).catch(() => null),
|
|
173
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/index/boards/SNDX/securities/IMOEX.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,CURRENTVALUE,LASTVALUE', { signal: AbortSignal.timeout(6000) }).catch(() => null),
|
|
174
|
+
]);
|
|
175
|
+
const prices = new Map();
|
|
176
|
+
if (rShares?.ok) {
|
|
177
|
+
const j = await rShares.json().catch(() => null);
|
|
178
|
+
const cols = j?.marketdata?.columns || [];
|
|
179
|
+
const iSec = cols.indexOf('SECID');
|
|
180
|
+
const iLast = cols.indexOf('LAST');
|
|
181
|
+
const iMkt = cols.indexOf('MARKETPRICE');
|
|
182
|
+
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
183
|
+
for (const row of j?.marketdata?.data || []) {
|
|
184
|
+
const sym = row[iSec];
|
|
185
|
+
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
186
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (tbPrices?.size) {
|
|
190
|
+
for (const [sym, px] of tbPrices) {
|
|
191
|
+
if (px > 0) prices.set(sym, px);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!prices.size) return null;
|
|
195
|
+
let imoexPrice = 0;
|
|
196
|
+
if (rIdx?.ok) {
|
|
197
|
+
const ji = await rIdx.json().catch(() => null);
|
|
198
|
+
const ic = ji?.marketdata?.columns || [];
|
|
199
|
+
const ir = ji?.marketdata?.data?.[0];
|
|
200
|
+
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
201
|
+
}
|
|
202
|
+
return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
|
|
203
|
+
})(),
|
|
204
|
+
new Promise((resolve) => {
|
|
205
|
+
timer = setTimeout(() => resolve(null), 7000);
|
|
206
|
+
timer.unref?.();
|
|
207
|
+
}),
|
|
169
208
|
]);
|
|
170
|
-
const prices = new Map();
|
|
171
|
-
if (rShares?.ok) {
|
|
172
|
-
const j = await rShares.json().catch(() => null);
|
|
173
|
-
const cols = j?.marketdata?.columns || [];
|
|
174
|
-
const iSec = cols.indexOf('SECID');
|
|
175
|
-
const iLast = cols.indexOf('LAST');
|
|
176
|
-
const iMkt = cols.indexOf('MARKETPRICE');
|
|
177
|
-
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
178
|
-
for (const row of j?.marketdata?.data || []) {
|
|
179
|
-
const sym = row[iSec];
|
|
180
|
-
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
181
|
-
if (sym && px > 0) prices.set(sym, px);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
if (tbPrices?.size) {
|
|
185
|
-
for (const [sym, px] of tbPrices) {
|
|
186
|
-
if (px > 0) prices.set(sym, px);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (!prices.size) return null;
|
|
190
|
-
let imoexPrice = 0;
|
|
191
|
-
if (rIdx?.ok) {
|
|
192
|
-
const ji = await rIdx.json().catch(() => null);
|
|
193
|
-
const ic = ji?.marketdata?.columns || [];
|
|
194
|
-
const ir = ji?.marketdata?.data?.[0];
|
|
195
|
-
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
196
|
-
}
|
|
197
|
-
return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
|
|
198
209
|
} catch {
|
|
199
210
|
return null;
|
|
211
|
+
} finally {
|
|
212
|
+
if (timer) clearTimeout(timer);
|
|
200
213
|
}
|
|
201
214
|
}
|
|
202
215
|
|
|
@@ -415,6 +428,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
415
428
|
weight: round(weight, 3),
|
|
416
429
|
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
417
430
|
...(shortAllowed !== null ? { shortAllowed } : {}),
|
|
431
|
+
...(isRegimeCrash ? { regimeCrash: true } : {}),
|
|
432
|
+
...(isRegimeSqueeze ? { regimeSqueeze: true } : {}),
|
|
418
433
|
...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
|
|
419
434
|
...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
|
|
420
435
|
...(inIdx ? {} : { inIndex: false }),
|
|
@@ -439,8 +454,8 @@ function withNews(result, p) {
|
|
|
439
454
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
440
455
|
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
441
456
|
const nowTs = Date.now();
|
|
442
|
-
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.
|
|
443
|
-
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.
|
|
457
|
+
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
|
|
458
|
+
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
|
|
444
459
|
if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
|
|
445
460
|
if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
|
|
446
461
|
for (const r of result.rows) {
|
|
@@ -461,13 +476,13 @@ function withNews(result, p) {
|
|
|
461
476
|
} else if (r.expectedPct >= 0) {
|
|
462
477
|
delete r.shortBlockedByBroker;
|
|
463
478
|
}
|
|
464
|
-
if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
|
|
479
|
+
if (r.expectedPct < 0 && (r.shortBlockedBySqueeze || r.regimeSqueeze || (r.lastMovePct ?? 0) > 4.5)) {
|
|
465
480
|
r.shortBlockedBySqueeze = true;
|
|
466
481
|
r.shortSignal = false;
|
|
467
482
|
} else {
|
|
468
483
|
delete r.shortBlockedBySqueeze;
|
|
469
484
|
}
|
|
470
|
-
if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
|
|
485
|
+
if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
|
|
471
486
|
r.buyBlockedByDip = true;
|
|
472
487
|
r.signal = false;
|
|
473
488
|
} else {
|
|
@@ -501,10 +516,156 @@ function withNews(result, p) {
|
|
|
501
516
|
} else {
|
|
502
517
|
delete r.shortBlockedByNews;
|
|
503
518
|
}
|
|
519
|
+
|
|
520
|
+
const effThr = r.expectedPct < 0 ? (r.shortThresholdPct || baseShortThr) : (r.buyThresholdPct || baseBuyThr);
|
|
521
|
+
const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
|
|
522
|
+
r.thresholdRatioPct = Math.round(ratio * 100);
|
|
523
|
+
r.crossedThreshold = ratio >= 1;
|
|
524
|
+
}
|
|
525
|
+
if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
|
|
526
|
+
const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
|
|
527
|
+
result.nearSignals = savedNear;
|
|
504
528
|
}
|
|
505
529
|
return result;
|
|
506
530
|
}
|
|
507
531
|
|
|
532
|
+
// Формирует и инкрементально обновляет 5-дневный журнал сигналов, заблокированных входов (≥100% порога) и почти-сигналов (75–99% порога)
|
|
533
|
+
async function updateNearSignalsJournal(ds, p, last, key) {
|
|
534
|
+
if (!ds?.timeline?.length || last < 0) return [];
|
|
535
|
+
const lastBarTs = ds.timeline[last] || Date.now();
|
|
536
|
+
const minTs = lastBarTs - 7 * DAY;
|
|
537
|
+
let startIdx = ds.timeline.findIndex((t) => t >= minTs);
|
|
538
|
+
if (startIdx < 0) startIdx = Math.max(0, last - 480);
|
|
539
|
+
|
|
540
|
+
if (nearSignalsCache.key !== key || nearSignalsCache.scannedBarTs < ds.timeline[startIdx]) {
|
|
541
|
+
const saved = kvGet('nearSignalsLog');
|
|
542
|
+
if (saved?.key === key && saved.scannedBarTs >= ds.timeline[startIdx] && Array.isArray(saved.entries)) {
|
|
543
|
+
nearSignalsCache = {
|
|
544
|
+
key,
|
|
545
|
+
scannedBarTs: saved.scannedBarTs,
|
|
546
|
+
episodesMap: new Map(saved.entries),
|
|
547
|
+
list: saved.list || [],
|
|
548
|
+
};
|
|
549
|
+
} else {
|
|
550
|
+
nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for (const [epKey, ep] of nearSignalsCache.episodesMap) {
|
|
555
|
+
if (ep.lastBarTs < minTs) nearSignalsCache.episodesMap.delete(epKey);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let updated = false;
|
|
559
|
+
for (let idx = startIdx; idx <= last; idx++) {
|
|
560
|
+
const barTs = ds.timeline[idx];
|
|
561
|
+
if (barTs <= nearSignalsCache.scannedBarTs) continue;
|
|
562
|
+
const r = await predictAt(ds, p, idx, { explain: false, liveSnap: null });
|
|
563
|
+
const nb = p.newsFilter ? blockingNews(6, barTs) : null;
|
|
564
|
+
const barMskIso = new Date(barTs + MSK).toISOString();
|
|
565
|
+
const dayStr = barMskIso.slice(0, 10);
|
|
566
|
+
const timeStr = barMskIso.slice(11, 16);
|
|
567
|
+
|
|
568
|
+
for (const row of r.rows) {
|
|
569
|
+
if (row.inIndex === false) continue;
|
|
570
|
+
const side = row.expectedPct < 0 ? 'short' : 'long';
|
|
571
|
+
const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
|
|
572
|
+
const absExp = Math.abs(row.expectedPct || 0);
|
|
573
|
+
const ratio = thr > 0 ? absExp / thr : 0;
|
|
574
|
+
if (ratio < 0.75) continue;
|
|
575
|
+
|
|
576
|
+
const crossed = absExp >= thr;
|
|
577
|
+
const dg = p.modelUseDividends !== false ? liveDividendGuard(row.symbol, row.price, barTs) : null;
|
|
578
|
+
const bNews = nb?.get(row.symbol);
|
|
579
|
+
const reasons = [];
|
|
580
|
+
if (side === 'short') {
|
|
581
|
+
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
582
|
+
if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
|
|
583
|
+
if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
|
|
584
|
+
if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
585
|
+
if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
|
|
586
|
+
} else {
|
|
587
|
+
if (row.buyBlockedByDip || row.regimeCrash || (row.lastMovePct ?? 0) < -4.5) reasons.push('защита от обвала свечи');
|
|
588
|
+
if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
|
|
589
|
+
if (dg?.blockLong) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
590
|
+
if (bNews?.bad) reasons.push(`фильтр новостей (${bNews.bad.gist})`);
|
|
591
|
+
}
|
|
592
|
+
if (row.liquid === false) reasons.push('низкая ликвидность');
|
|
593
|
+
|
|
594
|
+
const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
|
|
595
|
+
const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
|
|
596
|
+
const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
|
|
597
|
+
|
|
598
|
+
const epKey = `${dayStr}:${row.symbol}:${side}`;
|
|
599
|
+
const prev = nearSignalsCache.episodesMap.get(epKey);
|
|
600
|
+
if (!prev) {
|
|
601
|
+
nearSignalsCache.episodesMap.set(epKey, {
|
|
602
|
+
day: dayStr,
|
|
603
|
+
firstBar: timeStr,
|
|
604
|
+
lastBar: timeStr,
|
|
605
|
+
firstBarTs: barTs,
|
|
606
|
+
lastBarTs: barTs,
|
|
607
|
+
peakBar: timeStr,
|
|
608
|
+
barsCount: 1,
|
|
609
|
+
symbol: row.symbol,
|
|
610
|
+
name: row.name,
|
|
611
|
+
side,
|
|
612
|
+
price: row.price,
|
|
613
|
+
peakExpectedPct: row.expectedPct,
|
|
614
|
+
thresholdPct: thr,
|
|
615
|
+
peakRatioPct: Math.round(ratio * 100),
|
|
616
|
+
status,
|
|
617
|
+
reasons,
|
|
618
|
+
});
|
|
619
|
+
} else {
|
|
620
|
+
prev.lastBar = timeStr;
|
|
621
|
+
prev.lastBarTs = barTs;
|
|
622
|
+
prev.barsCount += 1;
|
|
623
|
+
const pctNow = Math.round(ratio * 100);
|
|
624
|
+
if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow >= prev.peakRatioPct)) {
|
|
625
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
626
|
+
prev.thresholdPct = thr;
|
|
627
|
+
prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
|
|
628
|
+
prev.peakBar = timeStr;
|
|
629
|
+
prev.price = row.price;
|
|
630
|
+
prev.status = status;
|
|
631
|
+
if (reasons.length) prev.reasons = reasons;
|
|
632
|
+
} else if (pctNow > prev.peakRatioPct) {
|
|
633
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
634
|
+
prev.thresholdPct = thr;
|
|
635
|
+
prev.peakRatioPct = pctNow;
|
|
636
|
+
prev.peakBar = timeStr;
|
|
637
|
+
}
|
|
638
|
+
if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
nearSignalsCache.scannedBarTs = barTs;
|
|
642
|
+
updated = true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (updated || !nearSignalsCache.list.length) {
|
|
646
|
+
const list = [...nearSignalsCache.episodesMap.values()]
|
|
647
|
+
.sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
|
|
648
|
+
.slice(0, 40)
|
|
649
|
+
.map((ep) => {
|
|
650
|
+
const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
|
|
651
|
+
const reasonText = ep.status === 'signal'
|
|
652
|
+
? 'Порог пройден — сигнал сформирован'
|
|
653
|
+
: ep.status === 'blocked'
|
|
654
|
+
? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
|
|
655
|
+
: `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
|
|
656
|
+
return { ...ep, gapPct, reasonText };
|
|
657
|
+
});
|
|
658
|
+
nearSignalsCache.list = list;
|
|
659
|
+
kvSet('nearSignalsLog', {
|
|
660
|
+
key,
|
|
661
|
+
scannedBarTs: nearSignalsCache.scannedBarTs,
|
|
662
|
+
entries: [...nearSignalsCache.episodesMap.entries()],
|
|
663
|
+
list,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return nearSignalsCache.list;
|
|
667
|
+
}
|
|
668
|
+
|
|
508
669
|
// Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
|
|
509
670
|
function attachDeltas(rows, stateObj) {
|
|
510
671
|
const prevExp = stateObj.prevExpMap;
|
|
@@ -590,7 +751,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
590
751
|
// ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
|
|
591
752
|
// а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
|
|
592
753
|
if (!force && baseState && baseState.key === key) {
|
|
593
|
-
|
|
754
|
+
const inflightAge = now - (inflightStartedAt.get(key) || 0);
|
|
755
|
+
if (inflight.has(key) && inflightAge > 90000) {
|
|
756
|
+
inflight.delete(key);
|
|
757
|
+
inflightStartedAt.delete(key);
|
|
758
|
+
}
|
|
759
|
+
const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
|
|
760
|
+
const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
|
|
761
|
+
const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
|
|
762
|
+
if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
|
|
594
763
|
setImmediate(() => {
|
|
595
764
|
refreshDatasetStateInBackground(p, key).catch(() => {});
|
|
596
765
|
});
|
|
@@ -615,8 +784,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
615
784
|
}
|
|
616
785
|
const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
|
|
617
786
|
const liveTs = liveSnap?.ts || now;
|
|
618
|
-
const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.
|
|
619
|
-
const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.
|
|
787
|
+
const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.40;
|
|
788
|
+
const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.50;
|
|
620
789
|
const rows = (saved.result.rows || []).map((r) => {
|
|
621
790
|
const px = liveSnap?.prices?.get(r.symbol);
|
|
622
791
|
const mult = symbolEdgeMult(r.symbol);
|
|
@@ -646,11 +815,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
646
815
|
|
|
647
816
|
// Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
|
|
648
817
|
function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
|
|
649
|
-
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
if (!force && inflight.has(key)) {
|
|
820
|
+
if (now - (inflightStartedAt.get(key) || 0) < 90000) return inflight.get(key);
|
|
821
|
+
inflight.delete(key);
|
|
822
|
+
inflightStartedAt.delete(key);
|
|
823
|
+
}
|
|
650
824
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
651
825
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
652
826
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
653
|
-
const now = Date.now();
|
|
654
827
|
const task = (async () => {
|
|
655
828
|
hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
|
|
656
829
|
// Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
|
|
@@ -666,6 +839,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
666
839
|
]);
|
|
667
840
|
const last = lastClosedIndex(ds, now);
|
|
668
841
|
const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
|
|
842
|
+
const nearSignals = await updateNearSignalsJournal(ds, p, last, key).catch(() => []);
|
|
669
843
|
const snap = r.snap || mapSnapshot(r.f);
|
|
670
844
|
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
671
845
|
const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
|
|
@@ -686,6 +860,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
686
860
|
shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
|
|
687
861
|
marketDown: r.marketDown,
|
|
688
862
|
rows: sortedRows,
|
|
863
|
+
nearSignals,
|
|
689
864
|
map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
|
|
690
865
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
|
|
691
866
|
};
|
|
@@ -708,8 +883,21 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
708
883
|
};
|
|
709
884
|
kvSet('lastForecastSnap', { key, result });
|
|
710
885
|
hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
|
|
886
|
+
if (coldLocalFirst) {
|
|
887
|
+
setTimeout(() => {
|
|
888
|
+
if (!inflight.has(key)) {
|
|
889
|
+
refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
|
|
890
|
+
}
|
|
891
|
+
}, 2000);
|
|
892
|
+
}
|
|
711
893
|
return result;
|
|
712
|
-
})().finally(() =>
|
|
894
|
+
})().finally(() => {
|
|
895
|
+
if (inflight.get(key) === task) {
|
|
896
|
+
inflight.delete(key);
|
|
897
|
+
inflightStartedAt.delete(key);
|
|
898
|
+
}
|
|
899
|
+
});
|
|
713
900
|
inflight.set(key, task);
|
|
901
|
+
inflightStartedAt.set(key, now);
|
|
714
902
|
return task;
|
|
715
903
|
}
|
package/server/model/live.js
CHANGED
|
@@ -6,7 +6,7 @@ import { hub } from '../hub.js';
|
|
|
6
6
|
import { latestBarTs } from '../data/moex.js';
|
|
7
7
|
import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
|
|
8
8
|
import { predictAt, lastClosedIndex } from './forecast.js';
|
|
9
|
-
import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
|
|
9
|
+
import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
|
|
10
10
|
import { computeReport, downsample } from './metrics.js';
|
|
11
11
|
import { MODELS } from './models.js';
|
|
12
12
|
import { blockingNews } from '../news/index.js';
|
|
@@ -336,7 +336,7 @@ class LiveEngine {
|
|
|
336
336
|
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
337
337
|
const invVol = s.params.modelAllocMode === 'invvol';
|
|
338
338
|
const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
|
|
339
|
-
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
|
|
339
|
+
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct) * ENTRY_EDGE_MULT;
|
|
340
340
|
const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
|
|
341
341
|
if (autoAdapt && buyOrders.length > 1) {
|
|
342
342
|
buyOrders.sort((a, b) => Math.abs(b.expectedPct || 0) - Math.abs(a.expectedPct || 0));
|
package/server/model/research.js
CHANGED
|
@@ -42,7 +42,8 @@ export function isMarketDown(ds, t) {
|
|
|
42
42
|
// 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
|
|
43
43
|
// 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
|
|
44
44
|
// 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
|
|
45
|
-
|
|
45
|
+
// 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
|
|
46
|
+
const DATA_VERSION = 14;
|
|
46
47
|
|
|
47
48
|
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
48
49
|
function reuseParamsKey(params) {
|
|
@@ -160,13 +161,14 @@ async function run(runId, p, state) {
|
|
|
160
161
|
// НИКОГДА не сдвигала fromTs и не вызывала повторную загрузку котировок!
|
|
161
162
|
const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
|
|
162
163
|
const rawResearchFrom = todayStartMsk - p.modelHistoryDays * DAY;
|
|
163
|
-
// Стабильная привязка
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
const
|
|
164
|
+
// Стабильная привязка к современному режиму торгов Мосбиржи (утренняя + основная + вечерняя сессии, 94 свечи/день с 18.01.2024)
|
|
165
|
+
// и якорю сетки 20-дневных окон после завершения волны редомициляции ключевых эмитентов (1718272800000 = 13.06.2024 13:00 МСК):
|
|
166
|
+
// смена календарного дня запуска или наличие в SQLite архивных свечей 2022–2023 гг. НИКОГДА не сдвигает сетку фолдов и не искажает barsPerDay!
|
|
167
|
+
const MODERN_REGIME_START_TS = 1705560600000;
|
|
168
|
+
const EPOCH_ANCHOR_TS = 1718272800000;
|
|
167
169
|
const gridBlockMs = 20 * DAY;
|
|
168
|
-
const researchFrom = EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs;
|
|
169
|
-
const fromTs =
|
|
170
|
+
const researchFrom = Math.max(EPOCH_ANCHOR_TS, EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs);
|
|
171
|
+
const fromTs = MODERN_REGIME_START_TS;
|
|
170
172
|
|
|
171
173
|
emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
|
|
172
174
|
const ds = await loadDataset({
|
|
@@ -179,7 +181,7 @@ async function run(runId, p, state) {
|
|
|
179
181
|
const bpd = ds.barsPerDay;
|
|
180
182
|
const trainLen = Math.round(p.modelTrainDays * bpd);
|
|
181
183
|
const testLen = Math.round(20 * bpd);
|
|
182
|
-
let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom),
|
|
184
|
+
let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom), FEATURE_WARMUP + maxH + 100);
|
|
183
185
|
if (testStart < 0 || testStart >= T - H - 10) throw new Error('Недостаточно истории: увеличьте период исследования или уменьшите окно обучения');
|
|
184
186
|
const firstTest = testStart;
|
|
185
187
|
|
|
@@ -999,8 +1001,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
999
1001
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
1000
1002
|
const H = barsFromHours(effHorizon);
|
|
1001
1003
|
const tillTs = Date.now();
|
|
1002
|
-
const
|
|
1003
|
-
const fromTs = todayStartMsk - 1650 * DAY;
|
|
1004
|
+
const fromTs = 1705560600000;
|
|
1004
1005
|
const periodTill = runRow.metrics?.period?.till || tillTs;
|
|
1005
1006
|
const emitStab = (m) => { if (!st.cancelled) hub.emit({ type: 'stability:progress', runId: targetId, ...m }); };
|
|
1006
1007
|
emitStab({ pct: 2, text: 'Проверка устойчивости: чтение локальных цен…' });
|
package/server/news/index.js
CHANGED
|
@@ -280,9 +280,9 @@ export function newsForSymbols(hours = 24) {
|
|
|
280
280
|
|
|
281
281
|
// Для живой проверки с newsFilter: самые важные свежие новости (важность ≥ 0.6, |тон| ≥ 0.4)
|
|
282
282
|
// по тикерам: symbol → { bad: { gist, imp, tone, ts }, good: {…} }
|
|
283
|
-
export function blockingNews(hours = 6) {
|
|
283
|
+
export function blockingNews(hours = 6, atTs = Date.now()) {
|
|
284
284
|
const rows = db.prepare(`SELECT ts, title, gist, imp, tags FROM news
|
|
285
|
-
WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ?`).all(
|
|
285
|
+
WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(atTs - hours * 3600000, atTs);
|
|
286
286
|
const m = new Map();
|
|
287
287
|
for (const r of rows) {
|
|
288
288
|
for (const [sym, tone] of parseTags(r)) {
|
|
@@ -205,9 +205,9 @@ function systemPrompt() {
|
|
|
205
205
|
- **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
|
|
206
206
|
- **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
|
|
207
207
|
- **Пороги входа и удержания (индивидуальные по эшелонам ликвидности):**
|
|
208
|
-
- Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,
|
|
209
|
-
- Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до
|
|
210
|
-
- Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от +0,
|
|
208
|
+
- Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,60 (запас над реальными издержками по стресс-тесту и фактическим сделкам). Для высоколиквидных голубых фишек 1-го эшелона (SBER, LKOH, GAZP, T, YDEX, GMKN, FLOT и др.) при издержках 0,05% за сделку это +0,40% (с индивидуальной подстройкой под оборот и волатильность бумаги).
|
|
209
|
+
- Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до ~+0,48% (с индивидуальной поправкой на рублёвую ликвидность стакана).
|
|
210
|
+
- Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от базового порога (+0,40% 1-й эшелон и +0,48% 2–3-й эшелон), а контртрендовые шорты ужесточаются в 1,25× (до −0,50% для 1-го эшелона и −0,60% для 2–3-го эшелона). На падающем рынке — наоборот. Всегда называй пользователю именно индивидуальный порог конкретной бумаги (\`порог_покупки_проц\` / \`порог_шорта_проц\`).
|
|
211
211
|
- Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от порога входа), затем — плотный порог фиксации прибыли.
|
|
212
212
|
- Реальные издержки на счёте Т-Банка (по фактическим сделкам): комиссия ~0,04% + отклонение цены исполнения от цены сигнала ~0,03–0,08% на сторону.
|
|
213
213
|
- **Dynamic Top-K (modelTopK = 0 Авто) и Conviction Sizing:** фиксированного числа акций нет — робот держит от 0 до 4 лонгов и до 4 шортов в зависимости от силы сигналов и концентрирует слот в лучших лидерах.
|
|
@@ -279,6 +279,8 @@ export const TOOLS = {
|
|
|
279
279
|
ожидание_проц: x.expectedPct,
|
|
280
280
|
порог_покупки_проц: symBuyThr,
|
|
281
281
|
...(x.shortSignal !== undefined ? { порог_шорта_проц: -symShortThr } : {}),
|
|
282
|
+
доля_порога_проц: x.thresholdRatioPct ?? 0,
|
|
283
|
+
порог_пройден: Boolean(x.crossedThreshold),
|
|
282
284
|
импульс_текущей_свечи_проц: x.lastMovePct ?? 0,
|
|
283
285
|
динамика_проц: x.deltaPct || 0,
|
|
284
286
|
сигнал_покупки: !!x.signal,
|
|
@@ -310,6 +312,7 @@ export const TOOLS = {
|
|
|
310
312
|
...(targetRow ? { выбранная_акция: pick(targetRow) } : {}),
|
|
311
313
|
сильнее_рынка: r.rows.slice(0, top).map(pick),
|
|
312
314
|
слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
|
|
315
|
+
журнал_почти_сигналов_за_неделю: (r.nearSignals || []).slice(0, 12),
|
|
313
316
|
},
|
|
314
317
|
summary: `Прогноз на свече ${r.bar}: сигналов покупки ${r.rows.filter((x) => x.signal).length}, шорта ${r.rows.filter((x) => x.shortSignal).length}`,
|
|
315
318
|
};
|