neuroalpha 0.5.24 → 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 +29 -15
- package/server/model/dataset.js +59 -19
- package/server/model/forecast.js +187 -10
- 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.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
|
@@ -581,14 +581,16 @@ export function deleteBrokerAccount(id) {
|
|
|
581
581
|
|
|
582
582
|
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
583
583
|
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
|
|
584
|
-
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 });
|
|
585
585
|
|
|
586
586
|
function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
587
587
|
return new Promise((resolve, reject) => {
|
|
588
588
|
let settled = false;
|
|
589
|
+
let wallTimer = null;
|
|
589
590
|
const done = (fn, val) => {
|
|
590
591
|
if (settled) return;
|
|
591
592
|
settled = true;
|
|
593
|
+
if (wallTimer) clearTimeout(wallTimer);
|
|
592
594
|
fn(val);
|
|
593
595
|
};
|
|
594
596
|
const u = new URL(url);
|
|
@@ -623,6 +625,12 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
623
625
|
res.on('error', (err) => done(reject, err));
|
|
624
626
|
res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
|
|
625
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?.();
|
|
626
634
|
req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
|
|
627
635
|
req.on('error', (err) => done(reject, err));
|
|
628
636
|
req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
|
|
@@ -759,17 +767,18 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
|
|
|
759
767
|
// Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
|
|
760
768
|
const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
|
|
761
769
|
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,
|
|
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',
|
|
772
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
|
+
}
|
|
773
782
|
if (!r.ok) return null;
|
|
774
783
|
const j = await r.json();
|
|
775
784
|
const out = [];
|
|
@@ -1774,7 +1783,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1774
1783
|
if (aQty > 0) {
|
|
1775
1784
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1776
1785
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1777
|
-
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.
|
|
1786
|
+
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1778
1787
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1779
1788
|
const dg = divGuardOf(sym, curPx);
|
|
1780
1789
|
const divExit = Boolean(dg?.exitLong);
|
|
@@ -1799,7 +1808,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1799
1808
|
} else if (aQty < 0) {
|
|
1800
1809
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1801
1810
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1802
|
-
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.
|
|
1811
|
+
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
1803
1812
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1804
1813
|
const dg = divGuardOf(sym, curPx);
|
|
1805
1814
|
const divExit = Boolean(dg?.exitShort);
|
|
@@ -2749,15 +2758,17 @@ registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrder
|
|
|
2749
2758
|
// ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
|
|
2750
2759
|
let lastSeenBar = null;
|
|
2751
2760
|
let schedulerBusy = false;
|
|
2761
|
+
let schedulerBusySince = 0;
|
|
2752
2762
|
|
|
2753
2763
|
async function tickBrokerSchedule() {
|
|
2754
|
-
if (schedulerBusy) return;
|
|
2764
|
+
if (schedulerBusy && Date.now() - schedulerBusySince < 90000) return;
|
|
2755
2765
|
if (!tradingGate().allowNew) return;
|
|
2756
2766
|
const nowMsk = new Date(Date.now() + MSK);
|
|
2757
2767
|
const dow = nowMsk.getUTCDay();
|
|
2758
2768
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
2759
2769
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
|
|
2760
2770
|
schedulerBusy = true;
|
|
2771
|
+
schedulerBusySince = Date.now();
|
|
2761
2772
|
try {
|
|
2762
2773
|
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
2763
2774
|
const isNewBar = Boolean(latest && latest !== lastSeenBar);
|
|
@@ -3121,9 +3132,12 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3121
3132
|
|
|
3122
3133
|
// Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
|
|
3123
3134
|
let autoRefreshBusy = false;
|
|
3135
|
+
let autoRefreshBusySince = 0;
|
|
3124
3136
|
async function tickBrokerAutoRefresh() {
|
|
3125
|
-
|
|
3137
|
+
const now = Date.now();
|
|
3138
|
+
if ((autoRefreshBusy && now - autoRefreshBusySince < 90000) || (schedulerBusy && now - schedulerBusySince < 45000)) return;
|
|
3126
3139
|
autoRefreshBusy = true;
|
|
3140
|
+
autoRefreshBusySince = now;
|
|
3127
3141
|
try {
|
|
3128
3142
|
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
3129
3143
|
const users = db.prepare('SELECT * FROM users').all();
|
package/server/model/dataset.js
CHANGED
|
@@ -227,7 +227,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
227
227
|
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
228
228
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
229
229
|
for (const v of datasetCache.values()) {
|
|
230
|
-
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && v.fromTs
|
|
230
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
|
|
231
231
|
return v.ds;
|
|
232
232
|
}
|
|
233
233
|
}
|
|
@@ -255,19 +255,30 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
255
255
|
if (signal?.aborted) throw new Error('Отменено');
|
|
256
256
|
const symCtrl = new AbortController();
|
|
257
257
|
const effSignal = signal ? AbortSignal.any([signal, symCtrl.signal]) : symCtrl.signal;
|
|
258
|
-
|
|
258
|
+
let killer = null;
|
|
259
259
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
260
|
+
await Promise.race([
|
|
261
|
+
(async () => {
|
|
262
|
+
// акциям подгружаем дивиденды и сразу корректируем цены на полную доходность
|
|
263
|
+
const divs = useDividends && symbols.includes(s) ? await ensureDividends(s, { signal: effSignal, localOnly }) : null;
|
|
264
|
+
const rows = await ensureCandles(s, interval, fromTs, tillTs, { signal: effSignal, localOnly });
|
|
265
|
+
// объекты свечей отпускаются сразу, а не копятся до конца загрузки всех акций
|
|
266
|
+
raw[s] = compact(rows);
|
|
267
|
+
if (divs?.length) {
|
|
268
|
+
// DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
|
|
269
|
+
const st = adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
|
|
270
|
+
divStat.applied += st.applied;
|
|
271
|
+
divStat.skipped += st.skipped;
|
|
272
|
+
}
|
|
273
|
+
})(),
|
|
274
|
+
new Promise((_, rej) => {
|
|
275
|
+
killer = setTimeout(() => {
|
|
276
|
+
symCtrl.abort();
|
|
277
|
+
rej(new Error('Превышено время ожидания загрузки'));
|
|
278
|
+
}, WITHIN);
|
|
279
|
+
killer.unref?.();
|
|
280
|
+
}),
|
|
281
|
+
]);
|
|
271
282
|
} catch (e) {
|
|
272
283
|
if (signal?.aborted) throw new Error('Отменено');
|
|
273
284
|
raw[s] = EMPTY();
|
|
@@ -419,18 +430,47 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
419
430
|
}
|
|
420
431
|
// макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
|
|
421
432
|
const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
|
|
422
|
-
const
|
|
423
|
-
|
|
433
|
+
const modernTimeline = timeline.filter((t) => t >= 1705104000000);
|
|
434
|
+
const effTl = modernTimeline.length >= 1000 ? modernTimeline : timeline;
|
|
435
|
+
const days = new Set(effTl.map(dayKey)).size;
|
|
436
|
+
// ликвидность: в скольких из последних 20 свечей ОСНОВНОЙ сессии (10:00–18:50 МСК) были сделки.
|
|
437
|
+
// 1) Утренняя (до 10:00 МСК) и вечерняя (с 19:00 МСК) дополнительные сессии не списывают очки ликвидности за пустые 10-минутки
|
|
438
|
+
// (если на доп. сессии сделки есть — засчитываем 1 в окно, если нет — окно основной сессии не сдвигается и не штрафуется).
|
|
439
|
+
// 2) На самых свежих 4 свечах правого края шкалы (последние 40 минут), где возможен рассинхрон между мгновенными свечами T-Invest API
|
|
440
|
+
// и 20–30-минутной задержкой бесплатного MOEX ISS (или локального кэша SQLite), не штрафуем бумаги, у которых свеча ещё не подтянулась с биржи.
|
|
441
|
+
const isMainSessionBar = new Uint8Array(T);
|
|
442
|
+
for (let i = 0; i < T; i++) {
|
|
443
|
+
const h = hourMsk[i];
|
|
444
|
+
isMainSessionBar[i] = (h >= 10 && h < 19) ? 1 : 0;
|
|
445
|
+
}
|
|
446
|
+
const tailGraceStart = Math.max(0, T - 4);
|
|
424
447
|
for (const tk of tokens) {
|
|
425
448
|
tk.liq = new Uint8Array(T);
|
|
426
|
-
let
|
|
449
|
+
let lastRealIdx = -1;
|
|
450
|
+
for (let i = T - 1; i >= 0; i--) {
|
|
451
|
+
if (tk.has[i]) { lastRealIdx = i; break; }
|
|
452
|
+
}
|
|
453
|
+
// Если бумага торговалась сегодня/вчера и отстаёт от правого края шкалы не более чем на 12 свечей (2 часа рассинхрона кэша/MOEX ISS),
|
|
454
|
+
// считаем хвостовые свечи после её последнего полученного бара ожидающими догрузки, а не отсутствием ликвидности:
|
|
455
|
+
const tailPendingFrom = (lastRealIdx >= 0 && T - 1 - lastRealIdx <= 12) ? Math.max(lastRealIdx + 1, tailGraceStart) : T;
|
|
456
|
+
const win = new Uint8Array(20);
|
|
457
|
+
let wLen = 0, wPtr = 0, n = 0;
|
|
427
458
|
for (let i = 0; i < T; i++) {
|
|
428
|
-
|
|
429
|
-
if (i
|
|
459
|
+
const h = tk.has[i] || (i >= tailPendingFrom ? 1 : 0);
|
|
460
|
+
if (isMainSessionBar[i] || h) {
|
|
461
|
+
if (wLen < 20) {
|
|
462
|
+
win[wLen++] = h;
|
|
463
|
+
n += h;
|
|
464
|
+
} else {
|
|
465
|
+
n += h - win[wPtr];
|
|
466
|
+
win[wPtr] = h;
|
|
467
|
+
wPtr = (wPtr + 1) % 20;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
430
470
|
tk.liq[i] = n;
|
|
431
471
|
}
|
|
432
472
|
}
|
|
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(
|
|
473
|
+
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
474
|
datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
|
|
435
475
|
if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
|
|
436
476
|
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),
|
|
@@ -415,6 +417,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
415
417
|
weight: round(weight, 3),
|
|
416
418
|
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
417
419
|
...(shortAllowed !== null ? { shortAllowed } : {}),
|
|
420
|
+
...(isRegimeCrash ? { regimeCrash: true } : {}),
|
|
421
|
+
...(isRegimeSqueeze ? { regimeSqueeze: true } : {}),
|
|
418
422
|
...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
|
|
419
423
|
...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
|
|
420
424
|
...(inIdx ? {} : { inIndex: false }),
|
|
@@ -439,8 +443,8 @@ function withNews(result, p) {
|
|
|
439
443
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
440
444
|
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
441
445
|
const nowTs = Date.now();
|
|
442
|
-
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.
|
|
443
|
-
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.
|
|
446
|
+
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
|
|
447
|
+
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
|
|
444
448
|
if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
|
|
445
449
|
if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
|
|
446
450
|
for (const r of result.rows) {
|
|
@@ -461,13 +465,13 @@ function withNews(result, p) {
|
|
|
461
465
|
} else if (r.expectedPct >= 0) {
|
|
462
466
|
delete r.shortBlockedByBroker;
|
|
463
467
|
}
|
|
464
|
-
if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
|
|
468
|
+
if (r.expectedPct < 0 && (r.shortBlockedBySqueeze || r.regimeSqueeze || (r.lastMovePct ?? 0) > 4.5)) {
|
|
465
469
|
r.shortBlockedBySqueeze = true;
|
|
466
470
|
r.shortSignal = false;
|
|
467
471
|
} else {
|
|
468
472
|
delete r.shortBlockedBySqueeze;
|
|
469
473
|
}
|
|
470
|
-
if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
|
|
474
|
+
if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
|
|
471
475
|
r.buyBlockedByDip = true;
|
|
472
476
|
r.signal = false;
|
|
473
477
|
} else {
|
|
@@ -501,10 +505,156 @@ function withNews(result, p) {
|
|
|
501
505
|
} else {
|
|
502
506
|
delete r.shortBlockedByNews;
|
|
503
507
|
}
|
|
508
|
+
|
|
509
|
+
const effThr = r.expectedPct < 0 ? (r.shortThresholdPct || baseShortThr) : (r.buyThresholdPct || baseBuyThr);
|
|
510
|
+
const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
|
|
511
|
+
r.thresholdRatioPct = Math.round(ratio * 100);
|
|
512
|
+
r.crossedThreshold = ratio >= 1;
|
|
513
|
+
}
|
|
514
|
+
if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
|
|
515
|
+
const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
|
|
516
|
+
result.nearSignals = savedNear;
|
|
504
517
|
}
|
|
505
518
|
return result;
|
|
506
519
|
}
|
|
507
520
|
|
|
521
|
+
// Формирует и инкрементально обновляет 5-дневный журнал сигналов, заблокированных входов (≥100% порога) и почти-сигналов (75–99% порога)
|
|
522
|
+
async function updateNearSignalsJournal(ds, p, last, key) {
|
|
523
|
+
if (!ds?.timeline?.length || last < 0) return [];
|
|
524
|
+
const lastBarTs = ds.timeline[last] || Date.now();
|
|
525
|
+
const minTs = lastBarTs - 7 * DAY;
|
|
526
|
+
let startIdx = ds.timeline.findIndex((t) => t >= minTs);
|
|
527
|
+
if (startIdx < 0) startIdx = Math.max(0, last - 480);
|
|
528
|
+
|
|
529
|
+
if (nearSignalsCache.key !== key || nearSignalsCache.scannedBarTs < ds.timeline[startIdx]) {
|
|
530
|
+
const saved = kvGet('nearSignalsLog');
|
|
531
|
+
if (saved?.key === key && saved.scannedBarTs >= ds.timeline[startIdx] && Array.isArray(saved.entries)) {
|
|
532
|
+
nearSignalsCache = {
|
|
533
|
+
key,
|
|
534
|
+
scannedBarTs: saved.scannedBarTs,
|
|
535
|
+
episodesMap: new Map(saved.entries),
|
|
536
|
+
list: saved.list || [],
|
|
537
|
+
};
|
|
538
|
+
} else {
|
|
539
|
+
nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
for (const [epKey, ep] of nearSignalsCache.episodesMap) {
|
|
544
|
+
if (ep.lastBarTs < minTs) nearSignalsCache.episodesMap.delete(epKey);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let updated = false;
|
|
548
|
+
for (let idx = startIdx; idx <= last; idx++) {
|
|
549
|
+
const barTs = ds.timeline[idx];
|
|
550
|
+
if (barTs <= nearSignalsCache.scannedBarTs) continue;
|
|
551
|
+
const r = await predictAt(ds, p, idx, { explain: false, liveSnap: null });
|
|
552
|
+
const nb = p.newsFilter ? blockingNews(6, barTs) : null;
|
|
553
|
+
const barMskIso = new Date(barTs + MSK).toISOString();
|
|
554
|
+
const dayStr = barMskIso.slice(0, 10);
|
|
555
|
+
const timeStr = barMskIso.slice(11, 16);
|
|
556
|
+
|
|
557
|
+
for (const row of r.rows) {
|
|
558
|
+
if (row.inIndex === false) continue;
|
|
559
|
+
const side = row.expectedPct < 0 ? 'short' : 'long';
|
|
560
|
+
const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
|
|
561
|
+
const absExp = Math.abs(row.expectedPct || 0);
|
|
562
|
+
const ratio = thr > 0 ? absExp / thr : 0;
|
|
563
|
+
if (ratio < 0.75) continue;
|
|
564
|
+
|
|
565
|
+
const crossed = absExp >= thr;
|
|
566
|
+
const dg = p.modelUseDividends !== false ? liveDividendGuard(row.symbol, row.price, barTs) : null;
|
|
567
|
+
const bNews = nb?.get(row.symbol);
|
|
568
|
+
const reasons = [];
|
|
569
|
+
if (side === 'short') {
|
|
570
|
+
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
571
|
+
if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
|
|
572
|
+
if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
|
|
573
|
+
if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
574
|
+
if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
|
|
575
|
+
} else {
|
|
576
|
+
if (row.buyBlockedByDip || row.regimeCrash || (row.lastMovePct ?? 0) < -4.5) reasons.push('защита от обвала свечи');
|
|
577
|
+
if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
|
|
578
|
+
if (dg?.blockLong) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
579
|
+
if (bNews?.bad) reasons.push(`фильтр новостей (${bNews.bad.gist})`);
|
|
580
|
+
}
|
|
581
|
+
if (row.liquid === false) reasons.push('низкая ликвидность');
|
|
582
|
+
|
|
583
|
+
const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
|
|
584
|
+
const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
|
|
585
|
+
const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
|
|
586
|
+
|
|
587
|
+
const epKey = `${dayStr}:${row.symbol}:${side}`;
|
|
588
|
+
const prev = nearSignalsCache.episodesMap.get(epKey);
|
|
589
|
+
if (!prev) {
|
|
590
|
+
nearSignalsCache.episodesMap.set(epKey, {
|
|
591
|
+
day: dayStr,
|
|
592
|
+
firstBar: timeStr,
|
|
593
|
+
lastBar: timeStr,
|
|
594
|
+
firstBarTs: barTs,
|
|
595
|
+
lastBarTs: barTs,
|
|
596
|
+
peakBar: timeStr,
|
|
597
|
+
barsCount: 1,
|
|
598
|
+
symbol: row.symbol,
|
|
599
|
+
name: row.name,
|
|
600
|
+
side,
|
|
601
|
+
price: row.price,
|
|
602
|
+
peakExpectedPct: row.expectedPct,
|
|
603
|
+
thresholdPct: thr,
|
|
604
|
+
peakRatioPct: Math.round(ratio * 100),
|
|
605
|
+
status,
|
|
606
|
+
reasons,
|
|
607
|
+
});
|
|
608
|
+
} else {
|
|
609
|
+
prev.lastBar = timeStr;
|
|
610
|
+
prev.lastBarTs = barTs;
|
|
611
|
+
prev.barsCount += 1;
|
|
612
|
+
const pctNow = Math.round(ratio * 100);
|
|
613
|
+
if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow >= prev.peakRatioPct)) {
|
|
614
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
615
|
+
prev.thresholdPct = thr;
|
|
616
|
+
prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
|
|
617
|
+
prev.peakBar = timeStr;
|
|
618
|
+
prev.price = row.price;
|
|
619
|
+
prev.status = status;
|
|
620
|
+
if (reasons.length) prev.reasons = reasons;
|
|
621
|
+
} else if (pctNow > prev.peakRatioPct) {
|
|
622
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
623
|
+
prev.thresholdPct = thr;
|
|
624
|
+
prev.peakRatioPct = pctNow;
|
|
625
|
+
prev.peakBar = timeStr;
|
|
626
|
+
}
|
|
627
|
+
if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
nearSignalsCache.scannedBarTs = barTs;
|
|
631
|
+
updated = true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (updated || !nearSignalsCache.list.length) {
|
|
635
|
+
const list = [...nearSignalsCache.episodesMap.values()]
|
|
636
|
+
.sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
|
|
637
|
+
.slice(0, 40)
|
|
638
|
+
.map((ep) => {
|
|
639
|
+
const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
|
|
640
|
+
const reasonText = ep.status === 'signal'
|
|
641
|
+
? 'Порог пройден — сигнал сформирован'
|
|
642
|
+
: ep.status === 'blocked'
|
|
643
|
+
? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
|
|
644
|
+
: `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
|
|
645
|
+
return { ...ep, gapPct, reasonText };
|
|
646
|
+
});
|
|
647
|
+
nearSignalsCache.list = list;
|
|
648
|
+
kvSet('nearSignalsLog', {
|
|
649
|
+
key,
|
|
650
|
+
scannedBarTs: nearSignalsCache.scannedBarTs,
|
|
651
|
+
entries: [...nearSignalsCache.episodesMap.entries()],
|
|
652
|
+
list,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return nearSignalsCache.list;
|
|
656
|
+
}
|
|
657
|
+
|
|
508
658
|
// Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
|
|
509
659
|
function attachDeltas(rows, stateObj) {
|
|
510
660
|
const prevExp = stateObj.prevExpMap;
|
|
@@ -590,7 +740,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
590
740
|
// ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
|
|
591
741
|
// а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
|
|
592
742
|
if (!force && baseState && baseState.key === key) {
|
|
593
|
-
|
|
743
|
+
const inflightAge = now - (inflightStartedAt.get(key) || 0);
|
|
744
|
+
if (inflight.has(key) && inflightAge > 90000) {
|
|
745
|
+
inflight.delete(key);
|
|
746
|
+
inflightStartedAt.delete(key);
|
|
747
|
+
}
|
|
748
|
+
const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
|
|
749
|
+
const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
|
|
750
|
+
const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
|
|
751
|
+
if ((now - baseState.dsLoadedAt >= 4 * 60000 || barStale) && !inflight.has(key)) {
|
|
594
752
|
setImmediate(() => {
|
|
595
753
|
refreshDatasetStateInBackground(p, key).catch(() => {});
|
|
596
754
|
});
|
|
@@ -615,8 +773,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
615
773
|
}
|
|
616
774
|
const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
|
|
617
775
|
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.
|
|
776
|
+
const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.40;
|
|
777
|
+
const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.50;
|
|
620
778
|
const rows = (saved.result.rows || []).map((r) => {
|
|
621
779
|
const px = liveSnap?.prices?.get(r.symbol);
|
|
622
780
|
const mult = symbolEdgeMult(r.symbol);
|
|
@@ -646,11 +804,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
646
804
|
|
|
647
805
|
// Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
|
|
648
806
|
function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
|
|
649
|
-
|
|
807
|
+
const now = Date.now();
|
|
808
|
+
if (!force && inflight.has(key)) {
|
|
809
|
+
if (now - (inflightStartedAt.get(key) || 0) < 90000) return inflight.get(key);
|
|
810
|
+
inflight.delete(key);
|
|
811
|
+
inflightStartedAt.delete(key);
|
|
812
|
+
}
|
|
650
813
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
651
814
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
652
815
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
653
|
-
const now = Date.now();
|
|
654
816
|
const task = (async () => {
|
|
655
817
|
hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
|
|
656
818
|
// Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
|
|
@@ -666,6 +828,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
666
828
|
]);
|
|
667
829
|
const last = lastClosedIndex(ds, now);
|
|
668
830
|
const r = await predictAt(ds, p, last, { explain: true, liveSnap, forceTrain: Boolean(force) });
|
|
831
|
+
const nearSignals = await updateNearSignalsJournal(ds, p, last, key).catch(() => []);
|
|
669
832
|
const snap = r.snap || mapSnapshot(r.f);
|
|
670
833
|
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
671
834
|
const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
|
|
@@ -686,6 +849,7 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
686
849
|
shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
|
|
687
850
|
marketDown: r.marketDown,
|
|
688
851
|
rows: sortedRows,
|
|
852
|
+
nearSignals,
|
|
689
853
|
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
854
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
|
|
691
855
|
};
|
|
@@ -708,8 +872,21 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
708
872
|
};
|
|
709
873
|
kvSet('lastForecastSnap', { key, result });
|
|
710
874
|
hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
|
|
875
|
+
if (coldLocalFirst) {
|
|
876
|
+
setTimeout(() => {
|
|
877
|
+
if (!inflight.has(key)) {
|
|
878
|
+
refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
|
|
879
|
+
}
|
|
880
|
+
}, 2000);
|
|
881
|
+
}
|
|
711
882
|
return result;
|
|
712
|
-
})().finally(() =>
|
|
883
|
+
})().finally(() => {
|
|
884
|
+
if (inflight.get(key) === task) {
|
|
885
|
+
inflight.delete(key);
|
|
886
|
+
inflightStartedAt.delete(key);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
713
889
|
inflight.set(key, task);
|
|
890
|
+
inflightStartedAt.set(key, now);
|
|
714
891
|
return task;
|
|
715
892
|
}
|
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
|
};
|