neuroalpha 0.5.20 → 0.5.22
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 +36 -7
- package/server/api/routes.js +28 -5
- package/server/brokers/index.js +73 -4
- package/server/data/moex.js +31 -9
- package/server/model/dataset.js +32 -12
- package/server/model/forecast.js +28 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.22",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1060,6 +1060,10 @@ async function loadForecast(force, silent = false) {
|
|
|
1060
1060
|
reasons.push(`Аварийная защита от обвала свечи: бумага падает на текущей свече (${fmtNum(r.lastMovePct, 2)}% < −4,5%), покупка запрещена`);
|
|
1061
1061
|
label = '⛔ провал';
|
|
1062
1062
|
}
|
|
1063
|
+
if (r.buyBlockedByOverheat) {
|
|
1064
|
+
reasons.push(`Защита от покупки на вершине пампа: бумага уже перегрета (+${fmtNum(r.buyBlockedByOverheat, 1)}% сверх рынка) — высок риск отката`);
|
|
1065
|
+
if (!label) label = '⛔ перегрев';
|
|
1066
|
+
}
|
|
1063
1067
|
if (r.buyBlockedByDiv) {
|
|
1064
1068
|
reasons.push(`Защита от дивидендного гэпа: ${r.buyBlockedByDiv}`);
|
|
1065
1069
|
if (!label) label = '⛔ отсечка';
|
|
@@ -2442,6 +2446,11 @@ function toggleBrokerSpecs(id) {
|
|
|
2442
2446
|
else hideBrokerSpecsById.add(id);
|
|
2443
2447
|
saveNavPatch({ hideBrokerSpecs: [...hideBrokerSpecsById] });
|
|
2444
2448
|
}
|
|
2449
|
+
let hideNonTradableAssets = loadSavedNav().hideNonTradableAssets !== false;
|
|
2450
|
+
function setHideNonTradableAssets(val) {
|
|
2451
|
+
hideNonTradableAssets = Boolean(val);
|
|
2452
|
+
saveNavPatch({ hideNonTradableAssets });
|
|
2453
|
+
}
|
|
2445
2454
|
let editingBrokerId = null;
|
|
2446
2455
|
let brokerPollBusy = false;
|
|
2447
2456
|
let lastBrokerPollTs = 0;
|
|
@@ -2527,7 +2536,12 @@ function renderBrokers() {
|
|
|
2527
2536
|
return p.qty;
|
|
2528
2537
|
};
|
|
2529
2538
|
|
|
2539
|
+
const isNonTradablePos = (p) => !(Number(p.price) > 0 && Math.abs(Number(p.valueRub)) > 0) || Boolean(p.blocked);
|
|
2540
|
+
|
|
2530
2541
|
const posStatus = (p) => {
|
|
2542
|
+
if (isNonTradablePos(p)) {
|
|
2543
|
+
return '<span class="tag" title="Бумага снята с торгов, проходит редомициляцию или заблокирована (рыночная оценка 0 ₽). Стратегия её не затрагивает">не торгуется (0 ₽)</span>';
|
|
2544
|
+
}
|
|
2531
2545
|
if (p.instrumentType && p.instrumentType !== 'share') {
|
|
2532
2546
|
const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
|
|
2533
2547
|
return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
|
|
@@ -2577,6 +2591,9 @@ function renderBrokers() {
|
|
|
2577
2591
|
const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
|
|
2578
2592
|
|
|
2579
2593
|
// Вкладка 1: Активы портфеля
|
|
2594
|
+
const allPositions = ls?.positions || [];
|
|
2595
|
+
const nonTradableCount = allPositions.filter(isNonTradablePos).length;
|
|
2596
|
+
const visiblePositions = hideNonTradableAssets ? allPositions.filter((p) => !isNonTradablePos(p)) : allPositions;
|
|
2580
2597
|
const fmtOwnership = (p) => {
|
|
2581
2598
|
if (!(p.issueSize > 0) || !(p.qty > 0)) return '<span class="muted small">—</span>';
|
|
2582
2599
|
const pct = (p.qty / p.issueSize) * 100;
|
|
@@ -2585,8 +2602,8 @@ function renderBrokers() {
|
|
|
2585
2602
|
const tip = `Доля владения в компании ${p.name || p.symbol}: ${fmtNum(p.qty, 0)} из ${fmtNum(p.issueSize, 0)} акций всего выпуска на Мосбирже`;
|
|
2586
2603
|
return `<span title="${esc(tip)}" style="white-space:nowrap"><b>${pctStr}%</b></span>`;
|
|
2587
2604
|
};
|
|
2588
|
-
const posHtml =
|
|
2589
|
-
? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num" title="Доля ваших акций в общем выпуске ценных бумаг компании-эмитента на Мосбирже (ISSUESIZE)">Доля владения</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${
|
|
2605
|
+
const posHtml = visiblePositions.length
|
|
2606
|
+
? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num" title="Доля ваших акций в общем выпуске ценных бумаг компании-эмитента на Мосбирже (ISSUESIZE)">Доля владения</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${visiblePositions.map((p) => {
|
|
2590
2607
|
const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
|
|
2591
2608
|
const fcRow = fcRowMap?.get(p.symbol);
|
|
2592
2609
|
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
@@ -2601,7 +2618,7 @@ function renderBrokers() {
|
|
|
2601
2618
|
<td>${posStatus(p)}${fcBadge}</td>
|
|
2602
2619
|
</tr>`;
|
|
2603
2620
|
}).join('')}</tbody></table>`
|
|
2604
|
-
: `<p class="muted small">${ls?.ok ? 'Открытых позиций на счёте сейчас нет (все средства в рублях).' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и активы с брокера.'}</p>`;
|
|
2621
|
+
: `<p class="muted small">${ls?.ok ? (nonTradableCount ? `Торгуемых позиций на счёте сейчас нет (скрыто неторгуемых активов с нулевой оценкой: ${nonTradableCount}).` : 'Открытых позиций на счёте сейчас нет (все средства в рублях).') : 'Нажмите «Обновить портфель», чтобы загрузить баланс и активы с брокера.'}</p>`;
|
|
2605
2622
|
|
|
2606
2623
|
// Вкладка 2: Торговля и заявки стратегии
|
|
2607
2624
|
const activeOrdHtml = ls?.activeOrders?.length
|
|
@@ -2877,16 +2894,20 @@ function renderBrokers() {
|
|
|
2877
2894
|
${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
|
|
2878
2895
|
</div>
|
|
2879
2896
|
${ls?.ok ? `<div class="kpis">
|
|
2880
|
-
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${
|
|
2897
|
+
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${visiblePositions.length} поз.${hideNonTradableAssets && nonTradableCount ? `, скрыто: ${nonTradableCount}` : ''})</div>${ls.marginInfo ? `<div class="sub">марж. запас: ${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</div>` : ''}</div>
|
|
2881
2898
|
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div>${ls.marginInfo?.startingMarginRub > 0 ? `<div class="sub">занято маржи: ${fmtNum(ls.marginInfo.startingMarginRub, 0)} ₽</div>` : ''}</div>
|
|
2882
2899
|
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div>${a.executionAudit?.periods ? `<div class="sub">за 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b></div>` : ''}</div>
|
|
2883
2900
|
<div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div>${a.executionAudit?.periods ? `<div class="sub">сегодня: <b class="${cls(a.executionAudit.periods.day.netRub)}">${a.executionAudit.periods.day.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.day.netRub, 0)} ₽</b></div>` : ''}</div>
|
|
2884
2901
|
</div>` : ''}
|
|
2885
2902
|
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2886
|
-
<div class="brk-subtabs">
|
|
2887
|
-
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${
|
|
2903
|
+
<div class="brk-subtabs" style="align-items:center">
|
|
2904
|
+
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${visiblePositions.length})</button>
|
|
2888
2905
|
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
|
2889
2906
|
<button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
|
|
2907
|
+
<label style="margin-left:auto;display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted);cursor:pointer;user-select:none;padding:2px 4px" title="Скрывать из портфеля делистингованные, заблокированные и неторгуемые ценные бумаги с нулевой рыночной оценкой (0 ₽)">
|
|
2908
|
+
<input type="checkbox" data-bhide-nontradable="1" ${hideNonTradableAssets ? 'checked' : ''} style="margin:0;cursor:pointer">
|
|
2909
|
+
<span>Скрывать неторгуемые активы${nonTradableCount ? ` (${nonTradableCount})` : ''}</span>
|
|
2910
|
+
</label>
|
|
2890
2911
|
</div>
|
|
2891
2912
|
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2892
2913
|
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
@@ -3243,6 +3264,12 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
3243
3264
|
});
|
|
3244
3265
|
|
|
3245
3266
|
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
3267
|
+
const hideChk = e.target.closest('input[data-bhide-nontradable]');
|
|
3268
|
+
if (hideChk) {
|
|
3269
|
+
setHideNonTradableAssets(hideChk.checked);
|
|
3270
|
+
renderBrokers();
|
|
3271
|
+
return;
|
|
3272
|
+
}
|
|
3246
3273
|
const fltBtn = e.target.closest('button[data-hfilter]');
|
|
3247
3274
|
if (fltBtn) {
|
|
3248
3275
|
toggleBrokerHistFilter(fltBtn.dataset.bid, fltBtn.dataset.hfilter);
|
|
@@ -4065,7 +4092,9 @@ async function openStockModal(symbol) {
|
|
|
4065
4092
|
? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
|
|
4066
4093
|
: fc?.buyBlockedByDip
|
|
4067
4094
|
? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
|
|
4068
|
-
:
|
|
4095
|
+
: fc?.buyBlockedByOverheat
|
|
4096
|
+
? `<span class="tag blocked" title="Бумага уже перегрета (+${fmtNum(fc.buyBlockedByOverheat, 1)}% сверх рынка) — покупка на вершине пампа запрещена">⛔ перегрев (+${fmtNum(fc.buyBlockedByOverheat, 1)}%)</span>`
|
|
4097
|
+
: '<span class="tag">нейтрально (вне порога)</span>';
|
|
4069
4098
|
const shortBadge = d.shortAllowedByBroker === true
|
|
4070
4099
|
? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
|
|
4071
4100
|
: d.shortAllowedByBroker === false
|
package/server/api/routes.js
CHANGED
|
@@ -5,7 +5,7 @@ import express from 'express';
|
|
|
5
5
|
import { ROOT, strategyDefaults } from '../config.js';
|
|
6
6
|
import { activeModelInfo } from '../llm/client.js';
|
|
7
7
|
import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
|
|
8
|
-
import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
|
|
8
|
+
import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles, saveCandles } from '../db.js';
|
|
9
9
|
import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
|
|
10
10
|
import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
|
|
11
11
|
import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
|
|
@@ -14,9 +14,9 @@ import { getSettings, updateSettings, resetSettings, revertSettingsChange } from
|
|
|
14
14
|
import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
|
|
15
15
|
import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
|
|
16
16
|
import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
|
|
17
|
-
import { ensureDividends } from '../data/moex.js';
|
|
17
|
+
import { ensureDividends, ensureCandles } from '../data/moex.js';
|
|
18
18
|
import { live } from '../model/live.js';
|
|
19
|
-
import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading } from '../brokers/index.js';
|
|
19
|
+
import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading, fetchTbankCandles } from '../brokers/index.js';
|
|
20
20
|
import { listNews } from '../news/index.js';
|
|
21
21
|
import * as strategist from '../strategist/index.js';
|
|
22
22
|
import { round, MSK, DAY } from '../util.js';
|
|
@@ -653,11 +653,18 @@ export function createRouter() {
|
|
|
653
653
|
const sym = String(req.params.symbol || '').toUpperCase().trim();
|
|
654
654
|
if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
|
|
655
655
|
|
|
656
|
-
const
|
|
656
|
+
const nowTs = Date.now();
|
|
657
|
+
const [fc, rawSpecs, divs, tbLiveBars] = await Promise.all([
|
|
657
658
|
forecastNow(getSettings()).catch(() => null),
|
|
658
659
|
ensureBoardSpecs().catch(() => ({})),
|
|
659
660
|
ensureDividends(sym).catch(() => []),
|
|
661
|
+
fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
|
|
662
|
+
ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
|
|
660
663
|
]);
|
|
664
|
+
if (tbLiveBars?.length) {
|
|
665
|
+
const doneBars = tbLiveBars.filter((b) => b.isComplete);
|
|
666
|
+
if (doneBars.length) saveCandles(sym, 10, doneBars);
|
|
667
|
+
}
|
|
661
668
|
const tbMap = kvGet('tbankShortableMap') || {};
|
|
662
669
|
const tbSpecs = tbMap.specMap || {};
|
|
663
670
|
const shortableMap = tbMap.map || {};
|
|
@@ -669,7 +676,6 @@ export function createRouter() {
|
|
|
669
676
|
const tierMult = symbolEdgeMult(sym);
|
|
670
677
|
|
|
671
678
|
// 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
|
|
672
|
-
const nowTs = Date.now();
|
|
673
679
|
const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
|
|
674
680
|
const intraday = rawBars30d.slice(-1500).map((b) => ({
|
|
675
681
|
ts: b.ts,
|
|
@@ -698,6 +704,23 @@ export function createRouter() {
|
|
|
698
704
|
const daily = [...dailyMap.values()];
|
|
699
705
|
|
|
700
706
|
const curPrice = fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
|
|
707
|
+
if (curPrice > 0 && intraday.length) {
|
|
708
|
+
const lastBar = intraday[intraday.length - 1];
|
|
709
|
+
const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
|
|
710
|
+
if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
|
|
711
|
+
intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
|
|
712
|
+
} else if (lastBar.c !== curPrice) {
|
|
713
|
+
lastBar.c = curPrice;
|
|
714
|
+
if (curPrice > lastBar.h) lastBar.h = curPrice;
|
|
715
|
+
if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
|
|
716
|
+
}
|
|
717
|
+
if (daily.length) {
|
|
718
|
+
const lastDay = daily[daily.length - 1];
|
|
719
|
+
lastDay.c = curPrice;
|
|
720
|
+
if (curPrice > lastDay.h) lastDay.h = curPrice;
|
|
721
|
+
if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
701
724
|
const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
|
|
702
725
|
const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
|
|
703
726
|
const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
|
package/server/brokers/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
|
|
|
5
5
|
import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
|
|
6
6
|
import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
|
|
7
7
|
import { getSettings, normalizeParams } from '../settings.js';
|
|
8
|
-
import { latestBarTs } from '../data/moex.js';
|
|
8
|
+
import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
|
|
9
9
|
import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
10
10
|
import { forecastNow } from '../model/forecast.js';
|
|
11
11
|
import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
|
|
@@ -579,9 +579,17 @@ export function deleteBrokerAccount(id) {
|
|
|
579
579
|
}
|
|
580
580
|
|
|
581
581
|
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
582
|
-
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.)
|
|
582
|
+
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
|
|
583
|
+
const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
|
|
584
|
+
|
|
583
585
|
function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
584
586
|
return new Promise((resolve, reject) => {
|
|
587
|
+
let settled = false;
|
|
588
|
+
const done = (fn, val) => {
|
|
589
|
+
if (settled) return;
|
|
590
|
+
settled = true;
|
|
591
|
+
fn(val);
|
|
592
|
+
};
|
|
585
593
|
const u = new URL(url);
|
|
586
594
|
const payload = body === null || body === undefined ? null : (typeof body === 'string' ? body : body.toString());
|
|
587
595
|
const reqHeaders = { ...headers };
|
|
@@ -594,6 +602,7 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
594
602
|
path: u.pathname + u.search,
|
|
595
603
|
method,
|
|
596
604
|
headers: reqHeaders,
|
|
605
|
+
agent: BROKER_HTTPS_AGENT,
|
|
597
606
|
ca: TRUSTED_CA,
|
|
598
607
|
rejectUnauthorized: true,
|
|
599
608
|
timeout: timeoutMs,
|
|
@@ -603,16 +612,19 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
603
612
|
res.on('end', () => {
|
|
604
613
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
605
614
|
const status = res.statusCode || 0;
|
|
606
|
-
resolve
|
|
615
|
+
done(resolve, {
|
|
607
616
|
ok: status >= 200 && status < 300,
|
|
608
617
|
status,
|
|
609
618
|
text: async () => text,
|
|
610
619
|
json: async () => (text ? JSON.parse(text) : {}),
|
|
611
620
|
});
|
|
612
621
|
});
|
|
622
|
+
res.on('error', (err) => done(reject, err));
|
|
623
|
+
res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
|
|
613
624
|
});
|
|
614
625
|
req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
|
|
615
|
-
req.on('error', reject);
|
|
626
|
+
req.on('error', (err) => done(reject, err));
|
|
627
|
+
req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
|
|
616
628
|
if (payload !== null) req.write(payload);
|
|
617
629
|
req.end();
|
|
618
630
|
});
|
|
@@ -729,6 +741,63 @@ export async function fetchTbankRealtimePrices() {
|
|
|
729
741
|
}
|
|
730
742
|
}
|
|
731
743
|
|
|
744
|
+
// Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
|
|
745
|
+
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
|
|
746
|
+
try {
|
|
747
|
+
const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
|
|
748
|
+
if (!tbAcc?.token) return null;
|
|
749
|
+
await ensureTbankShortableMap(tbAcc);
|
|
750
|
+
const shortMap = kvGet('tbankShortableMap');
|
|
751
|
+
const uid = shortMap?.uidMap?.[symbol];
|
|
752
|
+
if (!uid) return null;
|
|
753
|
+
const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
|
|
754
|
+
const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
755
|
+
const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
|
|
756
|
+
// Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
|
|
757
|
+
const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
|
|
758
|
+
if (safeFrom >= tillTs) return null;
|
|
759
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`, {
|
|
760
|
+
method: 'POST',
|
|
761
|
+
headers,
|
|
762
|
+
body: JSON.stringify({
|
|
763
|
+
instrumentId: uid,
|
|
764
|
+
from: new Date(safeFrom).toISOString(),
|
|
765
|
+
to: new Date(tillTs).toISOString(),
|
|
766
|
+
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
767
|
+
}),
|
|
768
|
+
timeoutMs: 5000,
|
|
769
|
+
});
|
|
770
|
+
if (!r.ok) return null;
|
|
771
|
+
const j = await r.json();
|
|
772
|
+
const out = [];
|
|
773
|
+
for (const c of j.candles || []) {
|
|
774
|
+
if (!includeIncomplete && c.isComplete === false) continue;
|
|
775
|
+
const ts = Date.parse(c.time);
|
|
776
|
+
const open = qVal(c.open);
|
|
777
|
+
const high = qVal(c.high);
|
|
778
|
+
const low = qVal(c.low);
|
|
779
|
+
const close = qVal(c.close);
|
|
780
|
+
if (!(ts > 0 && close > 0)) continue;
|
|
781
|
+
const volShares = (Number(c.volume) || 0) * lotSize;
|
|
782
|
+
const avgPx = (open + high + low + close) / 4;
|
|
783
|
+
out.push({
|
|
784
|
+
ts,
|
|
785
|
+
open,
|
|
786
|
+
high,
|
|
787
|
+
low,
|
|
788
|
+
close,
|
|
789
|
+
volume: volShares,
|
|
790
|
+
value: Math.round(volShares * avgPx),
|
|
791
|
+
isComplete: c.isComplete !== false,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
return out.length ? out : null;
|
|
795
|
+
} catch {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
registerRealtimeCandleFetcher(fetchTbankCandles);
|
|
800
|
+
|
|
732
801
|
// Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
|
|
733
802
|
async function ensureTbankShortableMap(acc) {
|
|
734
803
|
if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
|
package/server/data/moex.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { DAY, MSK } from '../util.js';
|
|
4
4
|
|
|
5
|
+
let realtimeCandleFetcher = null;
|
|
6
|
+
export function registerRealtimeCandleFetcher(fn) {
|
|
7
|
+
realtimeCandleFetcher = fn;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
const ISS = 'https://iss.moex.com/iss';
|
|
6
11
|
const INDEXES = new Set(['IMOEX', 'RGBI', 'MOEXBMI']);
|
|
7
12
|
// макро-инструменты: валюта (CETS) и фьючерсы (FORTS/RFUD)
|
|
@@ -121,18 +126,30 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
121
126
|
const mskHour = mskDate.getUTCHours();
|
|
122
127
|
const mskMins = mskHour * 60 + mskDate.getUTCMinutes();
|
|
123
128
|
const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskMins >= 6 * 60 + 50 && mskHour < 24;
|
|
124
|
-
const
|
|
125
|
-
const
|
|
129
|
+
const isTbankEligible = Boolean(realtimeCandleFetcher && Number(interval) === 10 && now - range.max <= 3 * DAY && !INDEXES.has(symbol) && !CURRENCY.has(symbol) && !FUTURES.test(symbol));
|
|
130
|
+
const isExpiredFuture = FUTURES.test(symbol) && (now - range.max > 10 * DAY);
|
|
131
|
+
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - barMs(interval)) / barMs(interval)) * barMs(interval);
|
|
132
|
+
const checkCooldownMs = isExpiredFuture ? DAY : isTradingHours ? (isTbankEligible ? 45000 : 4 * 60000) : 4 * 3600000;
|
|
133
|
+
const dbIsAlreadyFresh = isExpiredFuture || (range.max >= latestClosedSlotTs) || (!isTradingHours && now - range.max < 18 * 3600000);
|
|
126
134
|
|
|
127
|
-
if (!dbIsAlreadyFresh && range.max <
|
|
128
|
-
// Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
|
|
129
|
-
// чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
|
|
135
|
+
if (!dbIsAlreadyFresh && range.max < latestClosedSlotTs && (now - lastCheck >= checkCooldownMs)) {
|
|
130
136
|
lastTailCheckAt.set(tailKey, now);
|
|
137
|
+
// В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
|
|
138
|
+
let filledByTbank = false;
|
|
139
|
+
if (isTbankEligible) {
|
|
140
|
+
const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 2 * DAY), Math.min(tillTs, now)).catch(() => null);
|
|
141
|
+
if (tbRows?.length) {
|
|
142
|
+
saveCandles(symbol, interval, tbRows);
|
|
143
|
+
filledByTbank = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!filledByTbank) {
|
|
147
|
+
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
|
|
148
|
+
signal,
|
|
149
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
131
152
|
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
|
|
132
|
-
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
|
|
133
|
-
signal,
|
|
134
|
-
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
135
|
-
});
|
|
136
153
|
}
|
|
137
154
|
|
|
138
155
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
@@ -140,6 +157,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
140
157
|
|
|
141
158
|
// Последняя свеча инструмента прямо с биржи (без кэша) — чтобы быстро понять, закрылась ли новая свеча
|
|
142
159
|
export async function latestBarTs(symbol, interval = 60) {
|
|
160
|
+
if (realtimeCandleFetcher && Number(interval) === 10) {
|
|
161
|
+
const tbSym = INDEXES.has(symbol) ? 'SBER' : symbol;
|
|
162
|
+
const tbRows = await realtimeCandleFetcher(tbSym, Date.now() - 6 * 3600000, Date.now()).catch(() => null);
|
|
163
|
+
if (tbRows?.length) return tbRows[tbRows.length - 1].ts;
|
|
164
|
+
}
|
|
143
165
|
const from = tsToDate(Date.now() - 5 * DAY);
|
|
144
166
|
const rows = await fetchCandles(symbol, interval, from, null);
|
|
145
167
|
return rows.length ? rows[rows.length - 1].ts : null;
|
package/server/model/dataset.js
CHANGED
|
@@ -121,35 +121,37 @@ const MACRO = [
|
|
|
121
121
|
|
|
122
122
|
const MCODES = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z']; // месяцы в кодах фьючерсов
|
|
123
123
|
|
|
124
|
-
// Список существующих контрактов BR с их периодами (кэш на
|
|
125
|
-
async function brentContracts(fromTs, tillTs, signal) {
|
|
124
|
+
// Список существующих контрактов BR с их периодами (кэш на 7 суток, чтобы не пробовать все коды каждый раз)
|
|
125
|
+
async function brentContracts(fromTs, tillTs, signal, { localOnly = false } = {}) {
|
|
126
126
|
const cached = kvGet('brContracts');
|
|
127
127
|
let list = cached?.list;
|
|
128
|
-
if (!cached || Date.now() - cached.at > DAY) {
|
|
128
|
+
if ((!cached || Date.now() - cached.at > 7 * DAY) && !(localOnly && list?.length)) {
|
|
129
129
|
list = [];
|
|
130
130
|
const codes = [];
|
|
131
|
-
const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs +
|
|
131
|
+
const y0 = new Date(fromTs - 40 * DAY).getUTCFullYear(), y1 = new Date(tillTs + 50 * DAY).getUTCFullYear();
|
|
132
132
|
for (let y = y0; y <= y1; y++) for (const m of MCODES) codes.push(`BR${m}${y % 10}`);
|
|
133
|
-
await pool(codes,
|
|
133
|
+
await pool(codes, 4, async (code) => {
|
|
134
134
|
try {
|
|
135
|
-
const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal });
|
|
135
|
+
const rows = await ensureCandles(code, 24, fromTs - 35 * DAY, tillTs + 35 * DAY, { signal, localOnly });
|
|
136
136
|
if (rows.length) list.push({ code, first: rows[0].ts, last: rows[rows.length - 1].ts });
|
|
137
137
|
} catch { /* контракта нет */ }
|
|
138
138
|
});
|
|
139
139
|
list.sort((a, b) => a.first - b.first);
|
|
140
|
-
kvSet('brContracts', { at: Date.now(), list });
|
|
140
|
+
if (list.length) kvSet('brContracts', { at: Date.now(), list });
|
|
141
141
|
}
|
|
142
|
-
return list.filter((c) => c.last >= fromTs && c.first <= tillTs);
|
|
142
|
+
return (list || []).filter((c) => c.last >= fromTs && c.first <= tillTs);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// Непрерывный ряд цены нефти: в каждый день — контракт с наибольшим оборотом дня,
|
|
146
146
|
// на стыке контрактов цена продолжается без скачка (доходность стыка = 0)
|
|
147
|
-
async function loadBrent(fromTs, tillTs, signal) {
|
|
148
|
-
const list = await brentContracts(fromTs, tillTs, signal);
|
|
147
|
+
async function loadBrent(fromTs, tillTs, signal, { localOnly = false } = {}) {
|
|
148
|
+
const list = await brentContracts(fromTs, tillTs, signal, { localOnly });
|
|
149
149
|
const raw = [];
|
|
150
|
+
const now = Date.now();
|
|
150
151
|
await pool(list, 4, async (c) => {
|
|
151
152
|
try {
|
|
152
|
-
const
|
|
153
|
+
const isExpired = c.last < now - 10 * DAY;
|
|
154
|
+
const rows = await ensureCandles(c.code, BAR_INTERVAL, fromTs, tillTs, { signal, localOnly: localOnly || isExpired });
|
|
153
155
|
if (rows.length) raw.push(compact(rows));
|
|
154
156
|
} catch { /* пропускаем */ }
|
|
155
157
|
});
|
|
@@ -280,7 +282,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
280
282
|
if (useMacro) {
|
|
281
283
|
try {
|
|
282
284
|
onProgress({ symbol: 'BR', done: done, total: all.length });
|
|
283
|
-
raw.BR = await loadBrent(fromTs, tillTs, signal);
|
|
285
|
+
raw.BR = await loadBrent(fromTs, tillTs, signal, { localOnly });
|
|
284
286
|
} catch (e) {
|
|
285
287
|
if (signal?.aborted) throw new Error('Отменено');
|
|
286
288
|
raw.BR = EMPTY();
|
|
@@ -397,6 +399,24 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
397
399
|
}
|
|
398
400
|
}
|
|
399
401
|
const mret = lret(mk.close);
|
|
402
|
+
// На самых свежих барах текущего дня (последние 2 часа), когда 10-минутные свечи акций уже получены в реальном времени из Т-Банка,
|
|
403
|
+
// а индекс IMOEX в бесплатном MOEX ISS ещё отстаёт на 20 минут, достраиваем mret и mclose по средневзвешенной доходности акций индекса:
|
|
404
|
+
for (let i = Math.max(1, T - 12); i < T; i++) {
|
|
405
|
+
if (!mk.has[i] && mk.close[i - 1] > 0) {
|
|
406
|
+
let wSum = 0, rSum = 0;
|
|
407
|
+
for (const tk of tokens) {
|
|
408
|
+
if (tk.has[i] && (!tk.inIndex || tk.inIndex[i])) {
|
|
409
|
+
const w = tk.weight > 0 ? tk.weight : 1;
|
|
410
|
+
rSum += w * tk.ret[i];
|
|
411
|
+
wSum += w;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (wSum > 0) {
|
|
415
|
+
mret[i] = rSum / wSum;
|
|
416
|
+
mk.close[i] = mk.close[i - 1] * Math.exp(mret[i]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
400
420
|
// макро-ряды: лог-доходности с переносом цены (вне торгов — 0)
|
|
401
421
|
const macro = macroRaw.map((m) => ({ id: m.id, label: m.label, ret: lret(m.s.close), has: m.s.has }));
|
|
402
422
|
const days = new Set(timeline.map(dayKey)).size;
|
package/server/model/forecast.js
CHANGED
|
@@ -315,11 +315,34 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
315
315
|
const avgInv = nInv ? sInv / nInv : 0;
|
|
316
316
|
const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
|
|
317
317
|
const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
|
|
318
|
+
const bpd = ds.barsPerDay || 54;
|
|
319
|
+
const idx1d = Math.max(0, idx - bpd);
|
|
320
|
+
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
321
|
+
const mNow = liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0);
|
|
322
|
+
const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
|
|
323
|
+
const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
|
|
318
324
|
const rows = ds.tokens.map((tk, i) => {
|
|
319
|
-
|
|
325
|
+
let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
320
326
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
321
327
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
322
328
|
const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
|
|
329
|
+
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
330
|
+
const p1d = tk.close[idx1d] || 0;
|
|
331
|
+
const p3d = tk.close[idx3d] || 0;
|
|
332
|
+
const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
|
|
333
|
+
const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
|
|
334
|
+
const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
|
|
335
|
+
const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
|
|
336
|
+
let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
|
|
337
|
+
if (isOverheated && selfRaw > 0) {
|
|
338
|
+
// Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
|
|
339
|
+
// ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — обнуляем ложный отскок вверх:
|
|
340
|
+
exp -= selfRaw;
|
|
341
|
+
selfRaw = 0;
|
|
342
|
+
}
|
|
343
|
+
const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
|
|
344
|
+
? round((excess1d >= 0.06 ? excess1d : excess3d) * 100, 1)
|
|
345
|
+
: 0;
|
|
323
346
|
// Индивидуальный множитель порога каждой акции по её фактическому обороту в рублях и волатильности свечи:
|
|
324
347
|
const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
|
|
325
348
|
const symNeedL = Math.max(needL * tierMult, 0.0030);
|
|
@@ -327,7 +350,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
327
350
|
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
|
|
328
351
|
// обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
|
|
329
352
|
const EXTREME_BAR_MOVE_PCT = 4.5;
|
|
330
|
-
const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT);
|
|
353
|
+
const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT) && !buyBlockedByOverheat;
|
|
331
354
|
const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
|
|
332
355
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
333
356
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
@@ -352,6 +375,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
352
375
|
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
353
376
|
...(shortAllowed !== null ? { shortAllowed } : {}),
|
|
354
377
|
...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
|
|
378
|
+
...(buyBlockedByOverheat ? { buyBlockedByOverheat } : {}),
|
|
355
379
|
...(inIdx ? {} : { inIndex: false }),
|
|
356
380
|
};
|
|
357
381
|
if (dir !== 'long') {
|
|
@@ -359,10 +383,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
359
383
|
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
360
384
|
if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
|
|
361
385
|
}
|
|
362
|
-
if (explain) {
|
|
363
|
-
const ex = explainAttention(fitted.attention, f, u, i);
|
|
386
|
+
if (explain && ex) {
|
|
364
387
|
row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
|
|
365
|
-
row.selfPct = round(
|
|
388
|
+
row.selfPct = round(selfRaw * 100, 3);
|
|
366
389
|
}
|
|
367
390
|
return row;
|
|
368
391
|
});
|