neuroalpha 0.5.22 → 0.5.24
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/server/brokers/index.js +65 -28
- package/server/data/moex.js +29 -20
- package/server/db.js +4 -0
- package/server/model/edge.js +39 -7
- package/server/model/forecast.js +59 -18
- package/server/model/live.js +29 -7
- package/server/model/models.js +27 -5
- package/server/model/research.js +114 -28
- package/server/strategist/tools.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.24",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/server/brokers/index.js
CHANGED
|
@@ -118,15 +118,15 @@ export function roundToStep(price, minStep = 0.01, decimals = 2) {
|
|
|
118
118
|
return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// Снайперский расчёт лимитной
|
|
122
|
-
// отталкивается от живой секундной цены basePx и
|
|
123
|
-
//
|
|
121
|
+
// Снайперский расчёт лимитной цены:
|
|
122
|
+
// отталкивается от живой секундной цены basePx и задаёт защитный предел исполнения по offsetPct (но не менее 1 минимального шага цены minStep),
|
|
123
|
+
// а в момент фактической отправки в стакан sendOrderToBroker дополнительно подстраивает цену под лучший bid/ask живого стакана 1×1!
|
|
124
124
|
export function sniperLimitPrice(basePx, dir, offsetPct = 0.02, sp = { minStep: 0.01, decimals: 2 }) {
|
|
125
125
|
const step = sp.minStep > 0 ? sp.minStep : 0.01;
|
|
126
|
-
const maxOffRatio = Math.max(0, Math.min(0.
|
|
126
|
+
const maxOffRatio = Math.max(0, Math.min(0.001, (Number(offsetPct) || 0.02) / 100));
|
|
127
127
|
const byPct = basePx * (1 + dir * maxOffRatio);
|
|
128
128
|
const byOneTick = basePx + dir * step;
|
|
129
|
-
const rawLimit = dir > 0 ? Math.
|
|
129
|
+
const rawLimit = dir > 0 ? Math.max(byPct, byOneTick) : Math.min(byPct, byOneTick);
|
|
130
130
|
return roundToStep(rawLimit, step, sp.decimals);
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -498,6 +498,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
498
498
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
499
499
|
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
500
500
|
lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
|
|
501
|
+
lastFullStepWallTs: strategyOrCapChanged ? 0 : (prev.lastFullStepWallTs || 0),
|
|
501
502
|
// дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
|
|
502
503
|
haltedReason: prev.haltedReason || null,
|
|
503
504
|
ddOverrideDay: prev.ddOverrideDay || null,
|
|
@@ -710,8 +711,9 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
710
711
|
// Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
|
|
711
712
|
export async function fetchTbankRealtimePrices() {
|
|
712
713
|
try {
|
|
713
|
-
// только токен владельца текущих данных — токены других пользователей не используются никогда
|
|
714
|
-
const
|
|
714
|
+
// только токен владельца текущих данных — токены других пользователей не используются никогда (предпочитаем боевой контур песочнице)
|
|
715
|
+
const rawAccs = loadRawAccounts();
|
|
716
|
+
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
715
717
|
if (!tbAcc?.token) return null;
|
|
716
718
|
await ensureTbankShortableMap(tbAcc);
|
|
717
719
|
const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
|
|
@@ -744,7 +746,8 @@ export async function fetchTbankRealtimePrices() {
|
|
|
744
746
|
// Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
|
|
745
747
|
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
|
|
746
748
|
try {
|
|
747
|
-
const
|
|
749
|
+
const rawAccs = loadRawAccounts();
|
|
750
|
+
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
748
751
|
if (!tbAcc?.token) return null;
|
|
749
752
|
await ensureTbankShortableMap(tbAcc);
|
|
750
753
|
const shortMap = kvGet('tbankShortableMap');
|
|
@@ -1771,7 +1774,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1771
1774
|
if (aQty > 0) {
|
|
1772
1775
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1773
1776
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1774
|
-
const hitHardStopLoss = curGainPct <= -5.0;
|
|
1777
|
+
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.buyBlockedByDip);
|
|
1775
1778
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1776
1779
|
const dg = divGuardOf(sym, curPx);
|
|
1777
1780
|
const divExit = Boolean(dg?.exitLong);
|
|
@@ -1796,7 +1799,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1796
1799
|
} else if (aQty < 0) {
|
|
1797
1800
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1798
1801
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1799
|
-
const hitHardStopLoss = curGainPct <= -4.5;
|
|
1802
|
+
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.shortBlockedBySqueeze);
|
|
1800
1803
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1801
1804
|
const dg = divGuardOf(sym, curPx);
|
|
1802
1805
|
const divExit = Boolean(dg?.exitShort);
|
|
@@ -1899,7 +1902,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1899
1902
|
}
|
|
1900
1903
|
}
|
|
1901
1904
|
const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
1902
|
-
const
|
|
1905
|
+
const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
|
|
1906
|
+
const openShortRub = [...heldMap.values()]
|
|
1907
|
+
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
1908
|
+
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
1909
|
+
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
|
|
1903
1910
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
1904
1911
|
|
|
1905
1912
|
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
@@ -2111,9 +2118,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2111
2118
|
return brokersState();
|
|
2112
2119
|
}
|
|
2113
2120
|
|
|
2121
|
+
const batchNowTs = Date.now();
|
|
2122
|
+
if (!onlyEntries) {
|
|
2123
|
+
acc.lastFullStepWallTs = batchNowTs;
|
|
2124
|
+
}
|
|
2114
2125
|
acc.pendingBatch = {
|
|
2115
|
-
id: `batch_${
|
|
2116
|
-
createdAt:
|
|
2126
|
+
id: `batch_${batchNowTs}`,
|
|
2127
|
+
createdAt: batchNowTs,
|
|
2117
2128
|
bar: fc.bar,
|
|
2118
2129
|
budgetRub: budget,
|
|
2119
2130
|
availRub: round(availRub, 0),
|
|
@@ -2190,19 +2201,35 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2190
2201
|
if (bestBid > 0 && bestAsk >= bestBid) {
|
|
2191
2202
|
const midPx = (bestBid + bestAsk) / 2;
|
|
2192
2203
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
2204
|
+
const step = sp.minStep || 0.01;
|
|
2193
2205
|
const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
|
|
2194
2206
|
if (isEntry && spreadPct > maxSpreadPct) {
|
|
2195
2207
|
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
|
|
2196
2208
|
throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
|
|
2197
2209
|
}
|
|
2198
|
-
if (
|
|
2199
|
-
// Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
|
|
2200
|
-
const passivePx = isBuy
|
|
2201
|
-
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
|
|
2202
|
-
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
|
|
2203
|
-
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2204
|
-
} else if (ord.symbol === 'LQDT') {
|
|
2210
|
+
if (ord.symbol === 'LQDT') {
|
|
2205
2211
|
finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
|
|
2212
|
+
} else if (!isEntry) {
|
|
2213
|
+
// Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
|
|
2214
|
+
// чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
|
|
2215
|
+
if (spreadPct <= 0.80) {
|
|
2216
|
+
const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2217
|
+
if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
|
|
2218
|
+
}
|
|
2219
|
+
} else {
|
|
2220
|
+
// Входящие заявки (buy / short):
|
|
2221
|
+
// • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
|
|
2222
|
+
// • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
|
|
2223
|
+
const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
|
|
2224
|
+
if (tightSpread) {
|
|
2225
|
+
const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2226
|
+
if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
|
|
2227
|
+
} else {
|
|
2228
|
+
const passivePx = isBuy
|
|
2229
|
+
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
|
|
2230
|
+
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
|
|
2231
|
+
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2232
|
+
}
|
|
2206
2233
|
}
|
|
2207
2234
|
}
|
|
2208
2235
|
}
|
|
@@ -2751,14 +2778,21 @@ async function tickBrokerSchedule() {
|
|
|
2751
2778
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
2752
2779
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
2753
2780
|
const minStepMs = stepBars * BAR_MS;
|
|
2754
|
-
const
|
|
2781
|
+
const lastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
2782
|
+
const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
|
|
2783
|
+
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
|
+
const nowTs = Date.now();
|
|
2785
|
+
const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
|
|
2755
2786
|
const dueFullStep = Boolean(
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2787
|
+
nightExitDue || (
|
|
2788
|
+
latest &&
|
|
2789
|
+
(latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
|
|
2790
|
+
!(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
|
|
2791
|
+
),
|
|
2759
2792
|
);
|
|
2760
2793
|
if (dueFullStep) {
|
|
2761
|
-
acc.lastScheduledBarTs = latest;
|
|
2794
|
+
acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
|
|
2795
|
+
acc.lastFullStepWallTs = nowTs;
|
|
2762
2796
|
saveRawAccounts(list);
|
|
2763
2797
|
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
2764
2798
|
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
@@ -2769,7 +2803,7 @@ async function tickBrokerSchedule() {
|
|
|
2769
2803
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
2770
2804
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
2771
2805
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
2772
|
-
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 &&
|
|
2806
|
+
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2773
2807
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
2774
2808
|
}
|
|
2775
2809
|
}
|
|
@@ -3056,6 +3090,9 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3056
3090
|
for (const ord of lockOrders) {
|
|
3057
3091
|
logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
3058
3092
|
try {
|
|
3093
|
+
if (acc.brokerStopBySymbol?.[ord.symbol]) {
|
|
3094
|
+
await closeBrokerStopBeforeExit(acc, ord.symbol);
|
|
3095
|
+
}
|
|
3059
3096
|
const r = await sendOrderToBroker(acc, ord);
|
|
3060
3097
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
3061
3098
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
@@ -3068,10 +3105,10 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3068
3105
|
}
|
|
3069
3106
|
acc.pendingBatch = {
|
|
3070
3107
|
...(acc.pendingBatch || {}),
|
|
3071
|
-
id: `
|
|
3108
|
+
id: `batch_stop_${execNowTs}`,
|
|
3072
3109
|
createdAt: execNowTs,
|
|
3073
3110
|
executedAt: execNowTs,
|
|
3074
|
-
bar: `${fmtBarMsk(execNowTs)} (
|
|
3111
|
+
bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
|
|
3075
3112
|
orders: lockOrders,
|
|
3076
3113
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3077
3114
|
results,
|
package/server/data/moex.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
|
|
2
|
+
import { saveCandles, loadCandles, candleRange, candleMaxBetween, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { DAY, MSK } from '../util.js';
|
|
4
4
|
|
|
5
5
|
let realtimeCandleFetcher = null;
|
|
@@ -80,9 +80,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
80
80
|
const now = Date.now();
|
|
81
81
|
const pKey = `candProbe:${symbol}:${interval}`;
|
|
82
82
|
const probe = kvGet(pKey);
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
83
|
+
// Метка v3: fromTs — до какой даты левый край подтверждён (свечи есть в базе или на бирже раньше их нет).
|
|
84
|
+
// Метки v1/v2 не доверяем: старое условие range.n < 500 записывало fromTs без скачивания, и у бумаг с накопленной
|
|
85
|
+
// историей левый край (например, 2022 год) так и не догружался — датасет зависел от того, когда создана база.
|
|
86
|
+
const verifiedFrom = probe?.v === 3 ? probe.fromTs : Infinity;
|
|
87
|
+
let leftFill = probe?.v === 3 ? probe.leftFill || null : null;
|
|
86
88
|
|
|
87
89
|
if (!range.n) {
|
|
88
90
|
// В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
|
|
@@ -90,28 +92,35 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
90
92
|
signal,
|
|
91
93
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
92
94
|
});
|
|
93
|
-
kvSet(pKey, { v:
|
|
95
|
+
kvSet(pKey, { v: 3, fromTs });
|
|
94
96
|
lastTailCheckAt.set(`${symbol}:${interval}`, now);
|
|
95
97
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
// 1) ЛЕВЫЙ КРАЙ:
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
100
|
+
// 1) ЛЕВЫЙ КРАЙ: история докачивается один раз, дальше работаем только по SQLite.
|
|
101
|
+
// Докачка идёт от ранних свечей к поздним; пока она не завершена, в метке хранится leftFill,
|
|
102
|
+
// чтобы после обрыва продолжить с последней сохранённой свечи, а не оставить дыру в середине истории.
|
|
103
|
+
let probeFrom = Math.min(verifiedFrom, range.min);
|
|
104
|
+
if (!leftFill && range.min > fromTs + 5 * DAY && verifiedFrom > fromTs + 5 * DAY) {
|
|
102
105
|
const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
|
|
103
106
|
if (!first || first.ts >= range.min - DAY) {
|
|
104
|
-
|
|
107
|
+
probeFrom = fromTs; // раньше имеющихся свечей на бирже истории нет
|
|
105
108
|
} else {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
signal,
|
|
109
|
-
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
110
|
-
});
|
|
111
|
-
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
|
|
109
|
+
leftFill = { from: fromTs, till: range.min };
|
|
110
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, leftFill, tailCheckedAt: probe?.tailCheckedAt });
|
|
112
111
|
}
|
|
113
|
-
}
|
|
114
|
-
|
|
112
|
+
}
|
|
113
|
+
if (leftFill) {
|
|
114
|
+
const resumeTs = candleMaxBetween(symbol, interval, leftFill.from, leftFill.till - 1) ?? leftFill.from;
|
|
115
|
+
await fetchCandles(symbol, interval, tsToDate(resumeTs), tsToDate(leftFill.till + DAY), {
|
|
116
|
+
signal,
|
|
117
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
118
|
+
});
|
|
119
|
+
probeFrom = Math.min(probeFrom, leftFill.from);
|
|
120
|
+
leftFill = null;
|
|
121
|
+
}
|
|
122
|
+
if (probe?.v !== 3 || probe.fromTs !== probeFrom || probe.leftFill) {
|
|
123
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: probe?.tailCheckedAt });
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
// 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
|
|
@@ -137,7 +146,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
137
146
|
// В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
|
|
138
147
|
let filledByTbank = false;
|
|
139
148
|
if (isTbankEligible) {
|
|
140
|
-
const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now -
|
|
149
|
+
const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 3 * DAY + 120000), Math.min(tillTs, now)).catch(() => null);
|
|
141
150
|
if (tbRows?.length) {
|
|
142
151
|
saveCandles(symbol, interval, tbRows);
|
|
143
152
|
filledByTbank = true;
|
|
@@ -149,7 +158,7 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
149
158
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
150
159
|
});
|
|
151
160
|
}
|
|
152
|
-
kvSet(pKey, { v:
|
|
161
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: now });
|
|
153
162
|
}
|
|
154
163
|
|
|
155
164
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
package/server/db.js
CHANGED
|
@@ -343,6 +343,10 @@ export function candleRange(symbol, interval) {
|
|
|
343
343
|
return db.prepare('SELECT MIN(ts) AS min, MAX(ts) AS max, COUNT(*) AS n FROM candles WHERE symbol = ? AND interval = ?').get(symbol, interval);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
export function candleMaxBetween(symbol, interval, fromTs, tillTs) {
|
|
347
|
+
return db.prepare('SELECT MAX(ts) AS max FROM candles WHERE symbol = ? AND interval = ? AND ts >= ? AND ts <= ?').get(symbol, interval, fromTs, tillTs)?.max ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
346
350
|
export function createRun(mode, params, uid = getUid()) {
|
|
347
351
|
const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
|
|
348
352
|
return Number(r.lastInsertRowid);
|
package/server/model/edge.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
|
|
6
6
|
// • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
|
|
7
7
|
// • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
|
|
8
|
-
export const ENTRY_EDGE_MULT = 1.
|
|
8
|
+
export const ENTRY_EDGE_MULT = 1.60;
|
|
9
9
|
export const TIER2_EDGE_MULT = 1.20;
|
|
10
10
|
|
|
11
11
|
// Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
|
|
@@ -20,13 +20,45 @@ export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub =
|
|
|
20
20
|
const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
|
|
21
21
|
const base = isT1 ? 1.0 : TIER2_EDGE_MULT;
|
|
22
22
|
if (!(sigma > 0 || avgBarValRub > 0)) return base;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
if (isT1) {
|
|
24
|
+
const refVal = 15e6;
|
|
25
|
+
const liqAdj = avgBarValRub > 0
|
|
26
|
+
? Math.max(-0.07, Math.min(0.10, -0.05 * Math.log10(Math.max(5e4, avgBarValRub) / refVal)))
|
|
27
|
+
: 0;
|
|
28
|
+
const sigRatio = (sigma > 0 && avgSigma > 0) ? (sigma / avgSigma) : 1;
|
|
29
|
+
const volAdj = Math.max(-0.05, Math.min(0.10, 0.12 * (sigRatio - 1)));
|
|
30
|
+
return Math.max(0.88, Math.min(1.45, base * (1 + liqAdj + volAdj)));
|
|
31
|
+
}
|
|
32
|
+
const liqAdj2 = avgBarValRub > 0
|
|
33
|
+
? Math.max(-0.015, Math.min(0.025, -0.012 * Math.log10(Math.max(1e5, avgBarValRub) / 1.5e6)))
|
|
26
34
|
: 0;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
return Math.round(base * (1 + liqAdj2) * 1000) / 1000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Пороги защиты от структурного слома режима (взрывного шорт-сквиза ≥20% за торговый день или отвесного обвала ≥9,5% за 30 минут):
|
|
39
|
+
export const REGIME_SHOCK_1D = 0.20;
|
|
40
|
+
export const REGIME_CRASH_30M = 0.095;
|
|
41
|
+
|
|
42
|
+
export function isRegimeShocked(ds, i, t, side = 'long') {
|
|
43
|
+
const tk = ds?.tokens?.[i];
|
|
44
|
+
if (!tk || t < 1) return false;
|
|
45
|
+
const bpd = Math.max(1, Math.round(ds.barsPerDay || 54));
|
|
46
|
+
const t1d = Math.max(0, t - bpd);
|
|
47
|
+
const p0 = tk.close[t1d], p1 = tk.close[t];
|
|
48
|
+
const m0 = ds.mclose?.[t1d], m1 = ds.mclose?.[t];
|
|
49
|
+
if (p0 > 0 && p1 > 0 && m0 > 0 && m1 > 0) {
|
|
50
|
+
const ex1d = (p1 / p0 - 1) - (m1 / m0 - 1);
|
|
51
|
+
if (side === 'short' ? ex1d > REGIME_SHOCK_1D : ex1d < -REGIME_SHOCK_1D) return true;
|
|
52
|
+
}
|
|
53
|
+
if (side === 'long' && t >= 3) {
|
|
54
|
+
const t3 = t - 3;
|
|
55
|
+
const pa = tk.close[t3], ma = ds.mclose?.[t3];
|
|
56
|
+
if (pa > 0 && p1 > 0 && ma > 0 && m1 > 0) {
|
|
57
|
+
const ex30m = (p1 / pa - 1) - (m1 / ma - 1);
|
|
58
|
+
if (ex30m < -REGIME_CRASH_30M) return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
30
62
|
}
|
|
31
63
|
|
|
32
64
|
export function computeTokenEdgeMults(tokens, sigma, fromIdx = 0, tillIdx = 0) {
|
package/server/model/forecast.js
CHANGED
|
@@ -5,7 +5,7 @@ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
|
|
|
5
5
|
import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
|
|
6
6
|
import { loadProfiles } from './profiles.js';
|
|
7
7
|
import { isMarketDown } from './research.js';
|
|
8
|
-
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults } from './edge.js';
|
|
8
|
+
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M } from './edge.js';
|
|
9
9
|
export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
|
|
10
10
|
import { newsForSymbols, blockingNews } from '../news/index.js';
|
|
11
11
|
import { liveDividendGuard } from './dividends.js';
|
|
@@ -17,9 +17,35 @@ const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, f
|
|
|
17
17
|
let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
|
|
18
18
|
const inflight = new Map();
|
|
19
19
|
|
|
20
|
+
// Синхронная живая доходность индекса относительно закрытия свечи idx-1:
|
|
21
|
+
// если есть живые котировки акций индекса (из Т-Банка с 0 сек задержки или из MOEX ISS),
|
|
22
|
+
// считаем средневзвешенную лог-доходность по весам индекса, чтобы не сравнивать секундные цены акций Т-Банка
|
|
23
|
+
// с отстающим на 15–20 минут значением IMOEX из бесплатного контура MOEX ISS!
|
|
24
|
+
function computeLiveMarketReturn(ds, idx, liveSnap) {
|
|
25
|
+
if (idx < 1 || idx >= ds.T) return 0;
|
|
26
|
+
if (liveSnap?.prices?.size > 0) {
|
|
27
|
+
let wSum = 0, rSum = 0, cnt = 0;
|
|
28
|
+
for (const tk of ds.tokens) {
|
|
29
|
+
if (tk.inIndex && !tk.inIndex[idx]) continue;
|
|
30
|
+
const prevPx = tk.close[idx - 1];
|
|
31
|
+
const livePx = liveSnap.prices.get(tk.symbol);
|
|
32
|
+
if (livePx > 0 && prevPx > 0) {
|
|
33
|
+
const w = tk.weight > 0 ? tk.weight : 1;
|
|
34
|
+
rSum += w * Math.log(livePx / prevPx);
|
|
35
|
+
wSum += w;
|
|
36
|
+
cnt++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (cnt >= 15 && wSum > 0) return rSum / wSum;
|
|
40
|
+
const mPrev = ds.mclose?.[idx - 1] || 0;
|
|
41
|
+
if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
|
|
42
|
+
}
|
|
43
|
+
return ds.mret[idx] || 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
20
46
|
// Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
|
|
21
47
|
function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
22
|
-
const { tokens, mret
|
|
48
|
+
const { tokens, mret } = ds;
|
|
23
49
|
const N = fTrain.N;
|
|
24
50
|
const { beta, sigma, cos } = fTrain;
|
|
25
51
|
const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
|
|
@@ -31,11 +57,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
31
57
|
const barTs = ds.timeline[idx] || 0;
|
|
32
58
|
const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
33
59
|
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
60
|
+
const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
|
|
34
61
|
for (let uk = 0; uk < W; uk++) {
|
|
35
62
|
const t = lo + uk;
|
|
36
63
|
const isLast = uk === u && liveSnap?.prices?.size > 0;
|
|
37
|
-
const
|
|
38
|
-
const mt = isLast && liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : mret[t];
|
|
64
|
+
const mt = isLast ? mLive : mret[t];
|
|
39
65
|
for (let i = 0; i < N; i++) {
|
|
40
66
|
const tk = tokens[i];
|
|
41
67
|
let rt = tk.ret[t];
|
|
@@ -180,8 +206,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
|
180
206
|
const idx = f.lo + u;
|
|
181
207
|
if (idx < 1 || idx >= ds.T) return;
|
|
182
208
|
const N = f.N;
|
|
183
|
-
const
|
|
184
|
-
const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
|
|
209
|
+
const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
185
210
|
const barTs = ds.timeline[idx] || 0;
|
|
186
211
|
const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
187
212
|
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
@@ -280,7 +305,7 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
280
305
|
emb: f.emb,
|
|
281
306
|
embN: f.embN,
|
|
282
307
|
eigenvalues: f.eigenvalues,
|
|
283
|
-
macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b })) } : null,
|
|
308
|
+
macro: f.macro ? { list: f.macro.list.map((m) => ({ id: m.id, label: m.label, b: m.b, msig: m.msig })) } : null,
|
|
284
309
|
};
|
|
285
310
|
const lightFitted = {
|
|
286
311
|
...fitted,
|
|
@@ -289,9 +314,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
289
314
|
const trainedAt = Date.now();
|
|
290
315
|
trainedModelCache.set(fitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
|
|
291
316
|
|
|
292
|
-
|
|
317
|
+
const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
|
|
293
318
|
return {
|
|
294
|
-
...buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
|
|
319
|
+
...buildPredictionRows(ds, p, idx, inf.f, inf.u, inf.fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap),
|
|
295
320
|
snap,
|
|
296
321
|
trainedAt,
|
|
297
322
|
};
|
|
@@ -316,19 +341,31 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
316
341
|
const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
|
|
317
342
|
const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
|
|
318
343
|
const bpd = ds.barsPerDay || 54;
|
|
344
|
+
const idx30m = Math.max(0, idx - 3);
|
|
319
345
|
const idx1d = Math.max(0, idx - bpd);
|
|
320
346
|
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
321
|
-
const
|
|
347
|
+
const mPrevBar = idx > 0 ? (ds.mclose?.[idx - 1] || 0) : 0;
|
|
348
|
+
const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
349
|
+
const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
|
|
350
|
+
? mPrevBar * Math.exp(mLiveRet)
|
|
351
|
+
: (liveSnap?.imoexPrice > 0 ? liveSnap.imoexPrice : (ds.mclose?.[idx] || 0));
|
|
352
|
+
const m30m = ds.mclose?.[idx30m] > 0 && mNow > 0 ? (mNow / ds.mclose[idx30m] - 1) : 0;
|
|
322
353
|
const m1d = ds.mclose?.[idx1d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx1d] - 1) : 0;
|
|
323
354
|
const m3d = ds.mclose?.[idx3d] > 0 && mNow > 0 ? (mNow / ds.mclose[idx3d] - 1) : 0;
|
|
324
355
|
const rows = ds.tokens.map((tk, i) => {
|
|
325
356
|
let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
326
357
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
358
|
+
const prevPx = idx > 0 ? (tk.close[idx - 1] || 0) : 0;
|
|
327
359
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
328
|
-
const
|
|
360
|
+
const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
|
|
361
|
+
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
|
|
362
|
+
const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
|
|
363
|
+
const lastMovePct = round(rawResPct, 3);
|
|
329
364
|
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
365
|
+
const p30m = tk.close[idx30m] || 0;
|
|
330
366
|
const p1d = tk.close[idx1d] || 0;
|
|
331
367
|
const p3d = tk.close[idx3d] || 0;
|
|
368
|
+
const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
|
|
332
369
|
const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
|
|
333
370
|
const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
|
|
334
371
|
const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
|
|
@@ -336,8 +373,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
336
373
|
let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
|
|
337
374
|
if (isOverheated && selfRaw > 0) {
|
|
338
375
|
// Если акция уже взлетела за день/3 дня (памп) и начала откатываться вниз, линейный «возврат к среднему»
|
|
339
|
-
// ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» —
|
|
340
|
-
exp
|
|
376
|
+
// ошибочно трактует начало обвала с пика как «перепроданность к отскоку вверх» — гасим ложный отскок вверх, не переворачивая в искусственный шорт:
|
|
377
|
+
if (exp > 0) exp = Math.max(0, exp - selfRaw);
|
|
341
378
|
selfRaw = 0;
|
|
342
379
|
}
|
|
343
380
|
const buyBlockedByOverheat = (exp > 0 && (excess1d >= 0.06 || excess3d >= 0.12))
|
|
@@ -347,18 +384,22 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
347
384
|
const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
|
|
348
385
|
const symNeedL = Math.max(needL * tierMult, 0.0030);
|
|
349
386
|
const symNeedS = Math.max(needS * tierMult, 0.0031);
|
|
350
|
-
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу
|
|
387
|
+
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
|
|
351
388
|
// обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
|
|
352
389
|
const EXTREME_BAR_MOVE_PCT = 4.5;
|
|
353
|
-
const
|
|
354
|
-
const
|
|
390
|
+
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M;
|
|
391
|
+
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D;
|
|
392
|
+
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
393
|
+
const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
394
|
+
const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
|
|
395
|
+
const liveShortOk = !isExtremeSpike;
|
|
355
396
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
356
397
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
357
398
|
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
358
399
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
359
400
|
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
360
|
-
const shortBlockedBySqueeze = Boolean(
|
|
361
|
-
const buyBlockedByDip = Boolean(
|
|
401
|
+
const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
|
|
402
|
+
const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
|
|
362
403
|
const row = {
|
|
363
404
|
i, symbol: tk.symbol, name: tk.name,
|
|
364
405
|
tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
|
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 } from './edge.js';
|
|
9
|
+
import { 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';
|
|
@@ -434,9 +434,14 @@ class LiveEngine {
|
|
|
434
434
|
closePos(sym, 'Закрытие перед ночным клирингом');
|
|
435
435
|
continue;
|
|
436
436
|
}
|
|
437
|
+
const tkIdx = bySym.get(sym);
|
|
438
|
+
if (autoAdapt && tkIdx !== undefined && isRegimeShocked(ds, tkIdx, idx, h.side || 'long')) {
|
|
439
|
+
closePos(sym, 'Защита от экстремального сквиза/обвала');
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
437
442
|
const x = row.get(sym);
|
|
438
443
|
const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
|
|
439
|
-
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * symbolEdgeMult(sym);
|
|
444
|
+
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * (x?.edgeMult || symbolEdgeMult(sym));
|
|
440
445
|
if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
|
|
441
446
|
}
|
|
442
447
|
const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
|
|
@@ -454,16 +459,33 @@ class LiveEngine {
|
|
|
454
459
|
}
|
|
455
460
|
return out;
|
|
456
461
|
};
|
|
462
|
+
const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
|
|
463
|
+
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
464
|
+
const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
465
|
+
const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
|
|
466
|
+
if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
467
|
+
const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
|
|
468
|
+
const exitNeed = r.threshold * exitRatio;
|
|
469
|
+
if (bestNewEdge >= r.threshold * 2.2) {
|
|
470
|
+
let weakestSym = null, weakestE = Infinity;
|
|
471
|
+
for (const [sym, h] of s.held) {
|
|
472
|
+
if (closing.has(sym)) continue;
|
|
473
|
+
const xCur = row.get(sym);
|
|
474
|
+
const curSignedE = xCur ? (h.side === 'short' ? -xCur.expected : xCur.expected) : 0;
|
|
475
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(r.threshold, curSignedE) * 2.4) {
|
|
476
|
+
weakestE = curSignedE;
|
|
477
|
+
weakestSym = sym;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (weakestSym) closePos(weakestSym, 'Ротация в более сильного лидера');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
457
483
|
let entries;
|
|
458
484
|
if (dir === 'long') {
|
|
459
485
|
const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
|
|
460
|
-
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol) && !closing.has(x.symbol)).sort((a, b) => b.expected - a.expected);
|
|
461
486
|
if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
462
487
|
entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
|
|
463
488
|
} else {
|
|
464
|
-
const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
|
|
465
|
-
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
466
|
-
const candS = !timeOk ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
467
489
|
if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
468
490
|
const nL = K - sideHeld('long').length;
|
|
469
491
|
const nS = K - sideHeld('short').length;
|
|
@@ -472,7 +494,7 @@ class LiveEngine {
|
|
|
472
494
|
for (const [x, action] of entries) {
|
|
473
495
|
const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
|
|
474
496
|
const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
|
|
475
|
-
const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(
|
|
497
|
+
const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(effHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
|
|
476
498
|
s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
|
|
477
499
|
step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
|
|
478
500
|
}
|
package/server/model/models.js
CHANGED
|
@@ -262,7 +262,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
|
|
|
262
262
|
out[u] = a >= 0 ? sum / Math.sqrt(sc.len) : 0;
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
|
-
list.push({ id: m.id, label: m.label, b, zs });
|
|
265
|
+
list.push({ id: m.id, label: m.label, b, msig, zs });
|
|
266
266
|
}
|
|
267
267
|
if (list.length) macro = { list };
|
|
268
268
|
}
|
|
@@ -505,15 +505,37 @@ export const modelNames = () => Object.keys(MODELS);
|
|
|
505
505
|
// Объяснение прогноза «внимания»: вклад каждого соседа в прогноз акции j
|
|
506
506
|
export function explainAttention(m, f, u, j, top = 3) {
|
|
507
507
|
const N = f.N;
|
|
508
|
+
const k = u * N + j;
|
|
508
509
|
const contrib = new Float64Array(N);
|
|
509
|
-
|
|
510
|
+
let sAtt = 0;
|
|
510
511
|
HEADS.forEach((h, hi) => {
|
|
511
512
|
const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
|
|
512
513
|
const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
|
|
513
|
-
for (let i = 0; i < N; i++)
|
|
514
|
+
for (let i = 0; i < N; i++) {
|
|
515
|
+
const c = m.coef[hi] * A[j * N + i] * X[u * N + i];
|
|
516
|
+
contrib[i] += c;
|
|
517
|
+
sAtt += c;
|
|
518
|
+
}
|
|
514
519
|
});
|
|
515
|
-
let
|
|
516
|
-
for (let q = 0; q < 3; q++)
|
|
520
|
+
let selfAtt = 0, sIn = 0;
|
|
521
|
+
for (let q = 0; q < 3; q++) {
|
|
522
|
+
const xq = f.X[0][q][k];
|
|
523
|
+
selfAtt += m.coef[HEADS.length + q] * xq;
|
|
524
|
+
if (m.inertiaCoef) sIn += (m.inertiaCoef[q] || 0) * xq;
|
|
525
|
+
}
|
|
526
|
+
sAtt += selfAtt;
|
|
527
|
+
const ML = f.macro ? f.macro.list : [];
|
|
528
|
+
for (let mi = 0; mi < ML.length; mi++) {
|
|
529
|
+
let zz = 0;
|
|
530
|
+
const zs = ML[mi].zs;
|
|
531
|
+
for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
|
|
532
|
+
sAtt += ML[mi].b[j] * zz;
|
|
533
|
+
}
|
|
534
|
+
const conflictScale = (m.inertiaCoef && sAtt * sIn < 0) ? 0.5 : 1;
|
|
535
|
+
const wAtt = (m.inertiaCoef ? 0.50 : 1) * conflictScale;
|
|
536
|
+
const wIn = (m.inertiaCoef ? 0.50 : 0) * conflictScale;
|
|
537
|
+
for (let i = 0; i < N; i++) contrib[i] *= wAtt;
|
|
538
|
+
const self = wAtt * selfAtt + wIn * sIn;
|
|
517
539
|
const idx = [...contrib.keys()].filter((i) => i !== j).sort((a, b) => Math.abs(contrib[b]) - Math.abs(contrib[a])).slice(0, top);
|
|
518
540
|
return { neighbors: idx.map((i) => ({ i, contrib: contrib[i] })), self };
|
|
519
541
|
}
|
package/server/model/research.js
CHANGED
|
@@ -9,7 +9,7 @@ import { ensureProfiles } from './profiles.js';
|
|
|
9
9
|
import { spearman } from './linalg.js';
|
|
10
10
|
import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
|
|
11
11
|
import { strategyDefaults } from '../config.js';
|
|
12
|
-
import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
|
|
12
|
+
import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
|
|
13
13
|
import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
|
|
14
14
|
|
|
15
15
|
const DAY = 86400000;
|
|
@@ -40,7 +40,9 @@ export function isMarketDown(ds, t) {
|
|
|
40
40
|
// 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
|
|
41
41
|
// 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
|
|
42
42
|
// 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
|
|
43
|
-
|
|
43
|
+
// 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
|
|
44
|
+
// 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
|
|
45
|
+
const DATA_VERSION = 13;
|
|
44
46
|
|
|
45
47
|
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
46
48
|
function reuseParamsKey(params) {
|
|
@@ -461,8 +463,8 @@ async function run(runId, p, state) {
|
|
|
461
463
|
}
|
|
462
464
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
463
465
|
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
464
|
-
const okLong = (i) => !divRule(i, t)?.blockLong;
|
|
465
|
-
const okShort = (i) => !divRule(i, t)?.blockShort;
|
|
466
|
+
const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
467
|
+
const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
|
|
466
468
|
let order, entries;
|
|
467
469
|
if (dir !== 'long') {
|
|
468
470
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
@@ -474,12 +476,15 @@ async function run(runId, p, state) {
|
|
|
474
476
|
const e = edge(i);
|
|
475
477
|
const barsHeld = tIn - h.entryT;
|
|
476
478
|
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
477
|
-
|
|
479
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
|
|
480
|
+
if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) {
|
|
481
|
+
closePos(i, h, tIn, false, shocked ? 'Защита от экстремального сквиза/обвала' : 'Прогноз ослаб');
|
|
482
|
+
}
|
|
478
483
|
}
|
|
479
484
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
480
485
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
481
|
-
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) =>
|
|
482
|
-
const candS = !timeOk ? [] : [...pr.keys()].filter((i) =>
|
|
486
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
487
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
483
488
|
// Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
|
|
484
489
|
// если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
|
|
485
490
|
// освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
|
|
@@ -516,11 +521,27 @@ async function run(runId, p, state) {
|
|
|
516
521
|
const e = edge(i);
|
|
517
522
|
const barsHeld = tIn - h.entryT;
|
|
518
523
|
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
519
|
-
|
|
524
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
|
|
525
|
+
if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn, false, shocked ? 'Защита от экстремального обвала' : '');
|
|
520
526
|
}
|
|
521
|
-
const free = K - held.size;
|
|
522
527
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
523
|
-
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) =>
|
|
528
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
529
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
530
|
+
if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
|
|
531
|
+
const bestNewEdge = edge(rawCand[0]);
|
|
532
|
+
if (bestNewEdge >= need * 2.2) {
|
|
533
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
534
|
+
for (const [i] of held) {
|
|
535
|
+
const curSignedE = edge(i);
|
|
536
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
537
|
+
weakestE = curSignedE;
|
|
538
|
+
weakestIdx = i;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn, false, 'Ротация в более сильного лидера');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const free = K - held.size;
|
|
524
545
|
const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
|
|
525
546
|
order = [...held.keys(), ...cand];
|
|
526
547
|
} else {
|
|
@@ -912,7 +933,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
|
|
|
912
933
|
return round(((lo + hi) / 2) * 100, 3);
|
|
913
934
|
};
|
|
914
935
|
// Подбор порога входа под реальные издержки: живое исполнение, издержки 0,09% на сторону (комиссия 0,04% + проскальзывание ~0,05%)
|
|
915
|
-
const thresholdSweep = [1, 1.5, 1.65, 2, 2.5, 3].map((m) => {
|
|
936
|
+
const thresholdSweep = [1, 1.5, 1.6, 1.65, 2, 2.5, 3].map((m) => {
|
|
916
937
|
const sim = simulate(chosen, 0.0009, p.modelShortFeePct, 0, { ...LIVE, needMult: m });
|
|
917
938
|
const f = computeReport(sim.trades, sim.curve, p.capital, 0).full;
|
|
918
939
|
return { needMult: m, current: m === ENTRY_EDGE_MULT, entryEdgePct: round(2.5 * 2 * base * m * 100, 3), returnPct: f.returnPct, cagrPct: cagr(f.returnPct, yearsOf(sim.curve)), profitFactor: f.profitFactor, maxDrawdownPct: f.maxDrawdownPct, trades: f.trades, avgTradePct: f.avgTradePct, last12Pct: segment(sim, lastTs - 365 * 86400000).returnPct, last6Pct: segment(sim, lastTs - 182 * 86400000).returnPct };
|
|
@@ -924,7 +945,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
|
|
|
924
945
|
market: { full: mSeg(-Infinity), last12: mSeg(lastTs - 365 * 86400000), last6: mSeg(lastTs - 182 * 86400000) },
|
|
925
946
|
shortableKnown: Boolean(shortable),
|
|
926
947
|
caveats: [
|
|
927
|
-
'Состав рынка —
|
|
948
|
+
'Состав рынка — исторический состав индекса MOEXBMI на каждую дату плюс текущий состав; отсекаются только бумаги с покрытием данных менее 15% периода.',
|
|
928
949
|
'Константы стратегии подбирались на этой же истории, поэтому «последние 6 и 12 месяцев» не являются честно отложенной выборкой. Честная проверка — торговля после фиксации версии.',
|
|
929
950
|
'Список бумаг для шорта — текущий, а не на каждую дату.',
|
|
930
951
|
],
|
|
@@ -974,7 +995,9 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
974
995
|
await pause();
|
|
975
996
|
const p = normalizeParams(runRow.params || {});
|
|
976
997
|
const shifts = [-8, -4, 0, 4, 8];
|
|
977
|
-
const
|
|
998
|
+
const autoAdapt = p.modelAutoAdapt !== false;
|
|
999
|
+
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
1000
|
+
const H = barsFromHours(effHorizon);
|
|
978
1001
|
const tillTs = Date.now();
|
|
979
1002
|
const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
|
|
980
1003
|
const fromTs = todayStartMsk - 1650 * DAY;
|
|
@@ -991,11 +1014,18 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
991
1014
|
const trainLen = Math.round(p.modelTrainDays * bpd), testLen = Math.round(20 * bpd);
|
|
992
1015
|
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
993
1016
|
const realCost = (p.commissionPct + p.slippagePct) / 100;
|
|
994
|
-
const
|
|
995
|
-
const
|
|
1017
|
+
const rawTopK = Number(p.modelTopK ?? 2);
|
|
1018
|
+
const K = rawTopK > 0 ? rawTopK : 4;
|
|
1019
|
+
const dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
|
|
996
1020
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
997
1021
|
const exitRatio = Number(p.modelExitRatio ?? 0.5);
|
|
998
1022
|
const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
|
|
1023
|
+
const baseSymMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
|
|
1024
|
+
const divCal = buildDividendCalendar(ds);
|
|
1025
|
+
const divRule = (i, t) => {
|
|
1026
|
+
const nd = nextDividendAt(divCal, i, t);
|
|
1027
|
+
return nd ? dividendRules(nd.daysToEx, nd.y) : null;
|
|
1028
|
+
};
|
|
999
1029
|
const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
|
|
1000
1030
|
const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
|
|
1001
1031
|
const tsOf = (t) => timeline[Math.min(t, T - 1)];
|
|
@@ -1019,7 +1049,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1019
1049
|
const maxH = autoAdapt ? Math.round(H * 1.4) : H;
|
|
1020
1050
|
const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
|
|
1021
1051
|
let fi = 0;
|
|
1022
|
-
const preds = new Map(), sigmaAt = new Map();
|
|
1052
|
+
const preds = new Map(), sigmaAt = new Map(), multAt = new Map();
|
|
1023
1053
|
while (testStart < T - maxH - 2) {
|
|
1024
1054
|
if (st.cancelled) throw new Error('Отменено');
|
|
1025
1055
|
const testEnd = Math.min(testStart + testLen, T - maxH - 2);
|
|
@@ -1028,12 +1058,14 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1028
1058
|
emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
|
|
1029
1059
|
await tick();
|
|
1030
1060
|
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
1061
|
+
const foldEdgeMults = computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart);
|
|
1031
1062
|
const m = await MODELS[chosen].fit(f);
|
|
1032
1063
|
for (let t = testStart; t < testEnd; t++) {
|
|
1033
1064
|
const u = t - f.lo;
|
|
1034
1065
|
if (!f.yok[u]) continue;
|
|
1035
1066
|
preds.set(t, MODELS[chosen].predict(m, f, u));
|
|
1036
1067
|
sigmaAt.set(t, f.sigma);
|
|
1068
|
+
multAt.set(t, foldEdgeMults);
|
|
1037
1069
|
}
|
|
1038
1070
|
testStart = testEnd;
|
|
1039
1071
|
fi++;
|
|
@@ -1051,7 +1083,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1051
1083
|
const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
|
|
1052
1084
|
const curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
1053
1085
|
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1054
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
1086
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
1055
1087
|
let closedThisBar = new Set();
|
|
1056
1088
|
const closePos = (i, h, t) => {
|
|
1057
1089
|
const exitPrice = px(i, t);
|
|
@@ -1075,9 +1107,14 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1075
1107
|
for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
|
|
1076
1108
|
}
|
|
1077
1109
|
const pr = preds.get(t), sg = sigmaAt.get(t);
|
|
1110
|
+
const symMult = multAt.get(t) || baseSymMult;
|
|
1078
1111
|
const bear = isMarketDown(ds, t);
|
|
1079
1112
|
const mktDown = mktFilter && bear;
|
|
1080
1113
|
const barTs = tsOf(t);
|
|
1114
|
+
for (const [i, h] of [...held]) {
|
|
1115
|
+
const r = divRule(i, t);
|
|
1116
|
+
if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) closePos(i, h, tIn);
|
|
1117
|
+
}
|
|
1081
1118
|
if (autoAdapt) {
|
|
1082
1119
|
for (const [i, h] of [...held]) {
|
|
1083
1120
|
if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) closePos(i, h, tIn);
|
|
@@ -1085,29 +1122,64 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1085
1122
|
}
|
|
1086
1123
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
1087
1124
|
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
1125
|
+
const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
1126
|
+
const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
|
|
1088
1127
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
1089
1128
|
const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
1090
1129
|
let order, entries;
|
|
1091
1130
|
if (dir !== 'long') {
|
|
1092
1131
|
for (const [i, h] of [...held]) {
|
|
1093
1132
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
1094
|
-
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) *
|
|
1095
|
-
|
|
1133
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
1134
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
|
|
1135
|
+
if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1096
1136
|
}
|
|
1097
1137
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1098
1138
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
1099
|
-
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) =>
|
|
1100
|
-
const candS = !timeOk ? [] : [...pr.keys()].filter((i) =>
|
|
1139
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1140
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
1141
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1142
|
+
if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
1143
|
+
const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
|
|
1144
|
+
if (bestNewEdge >= need * 2.2) {
|
|
1145
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
1146
|
+
for (const [i, h] of held) {
|
|
1147
|
+
const curSignedE = h.side === 'long' ? edge(i) : -edge(i);
|
|
1148
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
1149
|
+
weakestE = curSignedE;
|
|
1150
|
+
weakestIdx = i;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1101
1156
|
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
1102
1157
|
entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
|
|
1103
1158
|
} else if (effMinEdge > 0) {
|
|
1104
1159
|
for (const [i, h] of [...held]) {
|
|
1105
1160
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
1106
|
-
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
1107
|
-
|
|
1161
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
1162
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
|
|
1163
|
+
if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1108
1164
|
}
|
|
1109
1165
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1110
|
-
const
|
|
1166
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1167
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1168
|
+
if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
|
|
1169
|
+
const bestNewEdge = edge(rawCand[0]);
|
|
1170
|
+
if (bestNewEdge >= need * 2.2) {
|
|
1171
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
1172
|
+
for (const [i] of held) {
|
|
1173
|
+
const curSignedE = edge(i);
|
|
1174
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
1175
|
+
weakestE = curSignedE;
|
|
1176
|
+
weakestIdx = i;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
const cand = rawCand.slice(0, Math.max(0, K - held.size));
|
|
1111
1183
|
order = [...held.keys(), ...cand];
|
|
1112
1184
|
} else {
|
|
1113
1185
|
const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => !closedThisBar.has(i) && stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
@@ -1125,11 +1197,25 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1125
1197
|
}
|
|
1126
1198
|
const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1127
1199
|
let avail = Math.max(0, equity * 0.99 - usedStart);
|
|
1200
|
+
const activeTotal = Math.max(2, held.size + newEntries.length);
|
|
1201
|
+
const dynSlotWeight = autoAdapt
|
|
1202
|
+
? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
|
|
1203
|
+
: maxSlotWeight;
|
|
1204
|
+
if (autoAdapt && newEntries.length > 1) {
|
|
1205
|
+
newEntries.sort((a, b) => Math.abs(b[2] ?? 0) - Math.abs(a[2] ?? 0));
|
|
1206
|
+
}
|
|
1207
|
+
const edgeWeights = newEntries.map(([i, , edgeAt]) => {
|
|
1208
|
+
const effE = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
|
|
1209
|
+
return Math.max(0.0001, effE);
|
|
1210
|
+
});
|
|
1211
|
+
let remWeightSum = edgeWeights.reduce((a, b) => a + b, 0);
|
|
1128
1212
|
for (let idx = 0; idx < newEntries.length; idx++) {
|
|
1129
|
-
const [i, side
|
|
1130
|
-
const effEdge =
|
|
1131
|
-
const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.
|
|
1132
|
-
const
|
|
1213
|
+
const [i, side] = newEntries[idx];
|
|
1214
|
+
const effEdge = edgeWeights[idx];
|
|
1215
|
+
const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (effEdge / need))) : 1;
|
|
1216
|
+
const propShare = autoAdapt && remWeightSum > 0 ? avail * (effEdge / remWeightSum) : avail / Math.max(1, newEntries.length - idx);
|
|
1217
|
+
remWeightSum = Math.max(0, remWeightSum - effEdge);
|
|
1218
|
+
const baseAlloc = autoAdapt ? Math.min(equity * dynSlotWeight * conv, propShare, avail) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
|
|
1133
1219
|
let alloc = baseAlloc;
|
|
1134
1220
|
if (invVol && avgInv > 0 && sg[i] > 0) {
|
|
1135
1221
|
const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
|
|
@@ -283,6 +283,7 @@ export const TOOLS = {
|
|
|
283
283
|
динамика_проц: x.deltaPct || 0,
|
|
284
284
|
сигнал_покупки: !!x.signal,
|
|
285
285
|
...(buyBlockedByDip ? { покупка_заблокирована: `сейчас бумага падает на текущей свече (${x.lastMovePct}%), сработала защита от ловли падающего ножа` } : {}),
|
|
286
|
+
...(x.buyBlockedByOverheat ? { покупка_заблокирована_перегревом: `бумага уже перегрета (+${x.buyBlockedByOverheat}% сверх рынка за 1–3 дня), сработала защита от покупки на вершине пампа` } : {}),
|
|
286
287
|
...(x.buyBlockedByDiv ? { покупка_заблокирована_дивидендами: x.buyBlockedByDiv } : {}),
|
|
287
288
|
...(x.buyBlockedByNews ? { покупка_заблокирована_новостями: x.buyBlockedByNews } : {}),
|
|
288
289
|
...(x.shortSignal !== undefined ? { сигнал_шорта: !!x.shortSignal } : {}),
|