neuroalpha 0.5.16 → 0.5.19
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 +61 -60
- package/public/admin.html +603 -569
- package/public/app.js +101 -23
- package/public/landing.html +301 -74
- package/public/legal.html +3 -2
- package/public/splat3d.html +671 -0
- package/public/styles.css +35 -13
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +41 -14
- package/server/auth/index.js +35 -4
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +81 -50
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +41 -19
- package/server/model/forecast.js +111 -17
- package/server/model/live.js +16 -18
- package/server/model/research.js +41 -15
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
package/server/model/dataset.js
CHANGED
|
@@ -19,10 +19,10 @@ export const BAR_MS = BAR_INTERVAL * 60000;
|
|
|
19
19
|
// горизонт в часах → число свечей (минимум одна свеча)
|
|
20
20
|
export const barsFromHours = (h) => Math.max(1, Math.round(Number(h) * (60 / BAR_INTERVAL)));
|
|
21
21
|
|
|
22
|
-
function cacheKey(interval, fromTs, tillTs, useMacro, useDividends) {
|
|
22
|
+
function cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly = false) {
|
|
23
23
|
const day = Math.floor(fromTs / DAY);
|
|
24
|
-
const
|
|
25
|
-
return JSON.stringify([interval, day,
|
|
24
|
+
const slot = Math.floor(tillTs / BAR_MS);
|
|
25
|
+
return JSON.stringify([interval, day, slot, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0, localOnly ? 1 : 0]);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async function pool(items, limit, fn) {
|
|
@@ -219,14 +219,13 @@ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {
|
|
|
219
219
|
// цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
|
|
220
220
|
// Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
|
|
221
221
|
export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, useMacro = false, useDividends = true, localOnly = false, onProgress = () => {}, signal }) {
|
|
222
|
-
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends);
|
|
222
|
+
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
223
223
|
const hit = datasetCache.get(key);
|
|
224
|
-
if (hit && Date.now() - hit.at <
|
|
225
|
-
// Если в памяти уже есть более широкий датасет (
|
|
226
|
-
// который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его мгновенно без пересборки!
|
|
224
|
+
if (hit && Date.now() - hit.at < 4 * 60000) return hit.ds;
|
|
225
|
+
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
227
226
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
228
227
|
for (const v of datasetCache.values()) {
|
|
229
|
-
if (v.modeKey === modeKey && Date.now() - v.at <
|
|
228
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && Date.now() - v.at < 4 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
|
|
230
229
|
return v.ds;
|
|
231
230
|
}
|
|
232
231
|
}
|
|
@@ -412,7 +411,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
412
411
|
}
|
|
413
412
|
}
|
|
414
413
|
const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(T / days) : 54 };
|
|
415
|
-
datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs });
|
|
414
|
+
datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs, localOnly: Boolean(localOnly) });
|
|
416
415
|
if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
|
|
417
416
|
return ds;
|
|
418
417
|
}
|
package/server/model/edge.js
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
// Множители порога входа и выхода стратегии и классификация 1-го эшелона ликвидности Мосбиржи.
|
|
3
|
-
// Базовый порог = 2,5 × (комиссия + проскальзывание) × 2 стороны; множители поднимают его так,
|
|
4
|
-
// чтобы ожидаемое движение покрывало РЕАЛЬНЫЕ издержки с запасом.
|
|
5
|
-
// По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
|
|
6
|
-
// • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
|
|
7
|
-
// • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
|
|
8
|
-
export const ENTRY_EDGE_MULT = 1.65;
|
|
9
|
-
export const TIER2_EDGE_MULT = 1.20;
|
|
10
|
-
|
|
11
|
-
// Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
export const symbolEdgeMult = (symbol) => (TIER1_LIQUID_SYMBOLS.has(symbol) ? 1 : TIER2_EDGE_MULT);
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Множители порога входа и выхода стратегии и классификация 1-го эшелона ликвидности Мосбиржи.
|
|
3
|
+
// Базовый порог = 2,5 × (комиссия + проскальзывание) × 2 стороны; множители поднимают его так,
|
|
4
|
+
// чтобы ожидаемое движение покрывало РЕАЛЬНЫЕ издержки с запасом.
|
|
5
|
+
// По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
|
|
6
|
+
// • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
|
|
7
|
+
// • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
|
|
8
|
+
export const ENTRY_EDGE_MULT = 1.65;
|
|
9
|
+
export const TIER2_EDGE_MULT = 1.20;
|
|
10
|
+
|
|
11
|
+
// Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
|
|
12
|
+
// высоковолатильные бумаги со спредом 2-го эшелона (SMLT, PIKK, POSI, AFKS, VKCO, CBOM, BSPB, SVCB) идут по порогу TIER2_EDGE_MULT.
|
|
13
|
+
export const TIER1_LIQUID_SYMBOLS = new Set([
|
|
14
|
+
'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
|
|
15
|
+
'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
|
|
16
|
+
'OZON', 'HEAD', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const symbolEdgeMult = (symbol) => (TIER1_LIQUID_SYMBOLS.has(symbol) ? 1 : TIER2_EDGE_MULT);
|
|
20
|
+
|
|
21
|
+
// Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48)
|
|
22
|
+
export const QUARANTINE_DECAY = 0.9985;
|
|
23
|
+
export const isStockQuarantined = (cnt, pnl) => (cnt >= 2 && pnl < -0.012) || pnl < -0.025;
|
|
24
|
+
|
|
25
|
+
// Граница вечерней сессии Мосбиржи (последняя 10-минутная свеча 23:40–23:50 МСК):
|
|
26
|
+
// не открываем новые внутридневные позиции, когда до конца торгов остаётся меньше горизонта H, и закрываем позиции перед ночью
|
|
27
|
+
const MSK = 3 * 3600000;
|
|
28
|
+
const DAY = 86400000;
|
|
29
|
+
export const SESSION_END_MINS_MSK = 23 * 60 + 40;
|
|
30
|
+
|
|
31
|
+
export const canEnterIntraday = (ts, horizonBars, barInterval = 10) => {
|
|
32
|
+
const minsMsk = Math.floor(((ts + MSK) % DAY) / 60000);
|
|
33
|
+
return minsMsk <= SESSION_END_MINS_MSK - horizonBars * barInterval;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const shouldExitBeforeNight = (ts, entryTs = 0, stepBars = 3, barInterval = 10) => {
|
|
37
|
+
const minsMsk = Math.floor(((ts + MSK) % DAY) / 60000);
|
|
38
|
+
if (minsMsk >= SESSION_END_MINS_MSK - stepBars * barInterval) return true;
|
|
39
|
+
if (entryTs > 0 && Math.floor((ts + MSK) / DAY) > Math.floor((entryTs + MSK) / DAY)) return true;
|
|
40
|
+
return false;
|
|
41
|
+
};
|
package/server/model/forecast.js
CHANGED
|
@@ -5,9 +5,10 @@ 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, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from './edge.js';
|
|
9
|
-
export { TIER1_LIQUID_SYMBOLS };
|
|
10
|
-
import { newsForSymbols } from '../news/index.js';
|
|
8
|
+
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from './edge.js';
|
|
9
|
+
export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult };
|
|
10
|
+
import { newsForSymbols, blockingNews } from '../news/index.js';
|
|
11
|
+
import { liveDividendGuard } from './dividends.js';
|
|
11
12
|
import { fetchTbankRealtimePrices } from '../brokers/index.js';
|
|
12
13
|
import { kvGet, kvSet } from '../db.js';
|
|
13
14
|
import { hub } from '../hub.js';
|
|
@@ -27,6 +28,9 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
27
28
|
|
|
28
29
|
const z = new Float64Array(W * N);
|
|
29
30
|
const vr = new Float64Array(W * N);
|
|
31
|
+
const barTs = ds.timeline[idx] || 0;
|
|
32
|
+
const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
33
|
+
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
30
34
|
for (let uk = 0; uk < W; uk++) {
|
|
31
35
|
const t = lo + uk;
|
|
32
36
|
const isLast = uk === u && liveSnap?.prices?.size > 0;
|
|
@@ -40,7 +44,7 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
40
44
|
const livePx = liveSnap.prices.get(tk.symbol);
|
|
41
45
|
if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
|
|
42
46
|
}
|
|
43
|
-
const e = rt - beta[i] * mt;
|
|
47
|
+
const e = (rt - beta[i] * mt) * (isLast ? liveScale : 1);
|
|
44
48
|
z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
|
|
45
49
|
vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
|
|
46
50
|
}
|
|
@@ -178,15 +182,18 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
|
178
182
|
const N = f.N;
|
|
179
183
|
const mPrev = ds.mclose?.[idx - 1] || 0;
|
|
180
184
|
const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
|
|
185
|
+
const barTs = ds.timeline[idx] || 0;
|
|
186
|
+
const elapsedBars = liveSnap.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
187
|
+
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
181
188
|
|
|
182
|
-
// 1) пересчитываем z[u*N + i] по живой цене акции
|
|
189
|
+
// 1) пересчитываем z[u*N + i] по живой цене акции с нормировкой к масштабу одной 10-минутной свечи
|
|
183
190
|
for (let i = 0; i < N; i++) {
|
|
184
191
|
const tk = ds.tokens[i];
|
|
185
192
|
const prevPx = tk.close[idx - 1];
|
|
186
193
|
const livePx = liveSnap.prices.get(tk.symbol);
|
|
187
194
|
if (livePx > 0 && prevPx > 0) {
|
|
188
195
|
const ret = Math.log(livePx / prevPx);
|
|
189
|
-
const e = ret - f.beta[i] * mLive;
|
|
196
|
+
const e = (ret - f.beta[i] * mLive) * liveScale;
|
|
190
197
|
f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
|
|
191
198
|
}
|
|
192
199
|
}
|
|
@@ -316,29 +323,38 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
316
323
|
const tierMult = symbolEdgeMult(tk.symbol);
|
|
317
324
|
const symNeedL = Math.max(needL * tierMult, 0.0030);
|
|
318
325
|
const symNeedS = Math.max(needS * tierMult, 0.0031);
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
const liveBuyOk = !(liveSnap
|
|
323
|
-
const liveShortOk = !(liveSnap
|
|
326
|
+
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
|
|
327
|
+
// обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
|
|
328
|
+
const EXTREME_BAR_MOVE_PCT = 4.5;
|
|
329
|
+
const liveBuyOk = !(liveSnap && lastMovePct < -EXTREME_BAR_MOVE_PCT);
|
|
330
|
+
const liveShortOk = !(liveSnap && lastMovePct > EXTREME_BAR_MOVE_PCT);
|
|
324
331
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
325
332
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
333
|
+
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
326
334
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
327
|
-
const shortBlockedByBroker = Boolean(
|
|
335
|
+
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
336
|
+
const shortBlockedBySqueeze = Boolean(liveSnap && exp < 0 && lastMovePct > EXTREME_BAR_MOVE_PCT);
|
|
337
|
+
const buyBlockedByDip = Boolean(liveSnap && exp > 0 && lastMovePct < -EXTREME_BAR_MOVE_PCT);
|
|
328
338
|
const row = {
|
|
329
339
|
i, symbol: tk.symbol, name: tk.name,
|
|
340
|
+
tier: tierMult === 1 ? 1 : 2,
|
|
330
341
|
price: round(livePx, 4),
|
|
331
342
|
liquid: tk.liq[idx] >= LIQ_MIN,
|
|
332
343
|
expected: exp,
|
|
333
344
|
expectedPct: round(exp * 100, 3),
|
|
345
|
+
buyThresholdPct: round(symNeedL * 100, 3),
|
|
346
|
+
shortThresholdPct: round(symNeedS * 100, 3),
|
|
334
347
|
lastMovePct,
|
|
335
348
|
weight: round(weight, 3),
|
|
336
349
|
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
350
|
+
...(shortAllowed !== null ? { shortAllowed } : {}),
|
|
351
|
+
...(buyBlockedByDip ? { buyBlockedByDip: true } : {}),
|
|
337
352
|
...(inIdx ? {} : { inIndex: false }),
|
|
338
353
|
};
|
|
339
354
|
if (dir !== 'long') {
|
|
340
355
|
row.shortSignal = rawShortHit && !shortBlockedByBroker;
|
|
341
356
|
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
357
|
+
if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
|
|
342
358
|
}
|
|
343
359
|
if (explain) {
|
|
344
360
|
const ex = explainAttention(fitted.attention, f, u, i);
|
|
@@ -350,13 +366,74 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
350
366
|
return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
|
|
351
367
|
}
|
|
352
368
|
|
|
353
|
-
// Новости —
|
|
369
|
+
// Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр) — в прогнозе «сейчас»
|
|
354
370
|
function withNews(result, p) {
|
|
355
371
|
const nm = p.newsEnabled ? newsForSymbols(24) : null;
|
|
372
|
+
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
373
|
+
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
374
|
+
const nowTs = Date.now();
|
|
375
|
+
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.413;
|
|
376
|
+
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.516;
|
|
377
|
+
if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
|
|
378
|
+
if (result.shortThresholdTier2Pct === undefined) result.shortThresholdTier2Pct = round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3);
|
|
356
379
|
for (const r of result.rows) {
|
|
380
|
+
const mult = symbolEdgeMult(r.symbol);
|
|
381
|
+
if (r.tier === undefined) r.tier = mult === 1 ? 1 : 2;
|
|
382
|
+
if (r.buyThresholdPct === undefined) r.buyThresholdPct = round(Math.max(baseBuyThr * mult, 0.30), 3);
|
|
383
|
+
if (r.shortThresholdPct === undefined) r.shortThresholdPct = round(Math.max(baseShortThr * mult, 0.31), 3);
|
|
357
384
|
const n = nm?.get(r.symbol);
|
|
358
385
|
if (n) r.news = n;
|
|
359
386
|
else delete r.news;
|
|
387
|
+
|
|
388
|
+
if (shortableMap && r.symbol in shortableMap) {
|
|
389
|
+
r.shortAllowed = Boolean(shortableMap[r.symbol]);
|
|
390
|
+
}
|
|
391
|
+
if (r.expectedPct < 0 && r.shortAllowed === false) {
|
|
392
|
+
r.shortBlockedByBroker = true;
|
|
393
|
+
r.shortSignal = false;
|
|
394
|
+
} else if (r.expectedPct >= 0) {
|
|
395
|
+
delete r.shortBlockedByBroker;
|
|
396
|
+
}
|
|
397
|
+
if (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5) {
|
|
398
|
+
r.shortBlockedBySqueeze = true;
|
|
399
|
+
r.shortSignal = false;
|
|
400
|
+
} else {
|
|
401
|
+
delete r.shortBlockedBySqueeze;
|
|
402
|
+
}
|
|
403
|
+
if (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5) {
|
|
404
|
+
r.buyBlockedByDip = true;
|
|
405
|
+
r.signal = false;
|
|
406
|
+
} else {
|
|
407
|
+
delete r.buyBlockedByDip;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const dg = p.modelUseDividends !== false ? liveDividendGuard(r.symbol, r.price, nowTs) : null;
|
|
411
|
+
if (r.expectedPct > 0 && dg?.blockLong) {
|
|
412
|
+
r.buyBlockedByDiv = `Отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%)`;
|
|
413
|
+
r.signal = false;
|
|
414
|
+
} else {
|
|
415
|
+
delete r.buyBlockedByDiv;
|
|
416
|
+
}
|
|
417
|
+
if (r.expectedPct < 0 && dg?.blockShort) {
|
|
418
|
+
r.shortBlockedByDiv = `Отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%)`;
|
|
419
|
+
r.shortSignal = false;
|
|
420
|
+
} else {
|
|
421
|
+
delete r.shortBlockedByDiv;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const bNews = nb?.get(r.symbol);
|
|
425
|
+
if (r.expectedPct > 0 && bNews?.bad) {
|
|
426
|
+
r.buyBlockedByNews = bNews.bad.gist;
|
|
427
|
+
r.signal = false;
|
|
428
|
+
} else {
|
|
429
|
+
delete r.buyBlockedByNews;
|
|
430
|
+
}
|
|
431
|
+
if (r.expectedPct < 0 && bNews?.good) {
|
|
432
|
+
r.shortBlockedByNews = bNews.good.gist;
|
|
433
|
+
r.shortSignal = false;
|
|
434
|
+
} else {
|
|
435
|
+
delete r.shortBlockedByNews;
|
|
436
|
+
}
|
|
360
437
|
}
|
|
361
438
|
return result;
|
|
362
439
|
}
|
|
@@ -417,6 +494,8 @@ async function tickLiveForecastState(p, now = Date.now()) {
|
|
|
417
494
|
bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
|
|
418
495
|
buyThresholdPct: round(Math.max((r.needL ?? r.threshold) * 100, 0.30), 3),
|
|
419
496
|
shortThresholdPct: round(Math.max((r.needS ?? r.threshold) * 100, 0.31), 3),
|
|
497
|
+
buyThresholdTier2Pct: round(Math.max((r.needL ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.30), 3),
|
|
498
|
+
shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
|
|
420
499
|
marketDown: r.marketDown,
|
|
421
500
|
rows: sortedRows,
|
|
422
501
|
};
|
|
@@ -444,7 +523,7 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
444
523
|
// ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
|
|
445
524
|
// а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
|
|
446
525
|
if (!force && baseState && baseState.key === key) {
|
|
447
|
-
if (now - baseState.dsLoadedAt >=
|
|
526
|
+
if (now - baseState.dsLoadedAt >= 4 * 60000 && !inflight.has(key)) {
|
|
448
527
|
setImmediate(() => {
|
|
449
528
|
refreshDatasetStateInBackground(p, key).catch(() => {});
|
|
450
529
|
});
|
|
@@ -469,15 +548,26 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
469
548
|
}
|
|
470
549
|
const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
|
|
471
550
|
const liveTs = liveSnap?.ts || now;
|
|
551
|
+
const baseBuyThr = saved.result.buyThresholdPct ?? saved.result.thresholdPct ?? 0.413;
|
|
552
|
+
const baseShortThr = saved.result.shortThresholdPct ?? saved.result.thresholdPct ?? 0.516;
|
|
472
553
|
const rows = (saved.result.rows || []).map((r) => {
|
|
473
554
|
const px = liveSnap?.prices?.get(r.symbol);
|
|
474
|
-
|
|
555
|
+
const mult = symbolEdgeMult(r.symbol);
|
|
556
|
+
return {
|
|
557
|
+
...r,
|
|
558
|
+
...(px > 0 ? { price: round(px, 4) } : {}),
|
|
559
|
+
tier: r.tier ?? (mult === 1 ? 1 : 2),
|
|
560
|
+
buyThresholdPct: r.buyThresholdPct ?? round(Math.max(baseBuyThr * mult, 0.30), 3),
|
|
561
|
+
shortThresholdPct: r.shortThresholdPct ?? round(Math.max(baseShortThr * mult, 0.31), 3),
|
|
562
|
+
};
|
|
475
563
|
});
|
|
476
564
|
const updated = {
|
|
477
565
|
...saved.result,
|
|
478
566
|
at: now,
|
|
479
567
|
liveTs,
|
|
480
568
|
bar: fmtLiveBarLabel(saved.result.barTs || saved.result.at, liveSnap ? liveTs : null),
|
|
569
|
+
buyThresholdTier2Pct: saved.result.buyThresholdTier2Pct ?? round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3),
|
|
570
|
+
shortThresholdTier2Pct: saved.result.shortThresholdTier2Pct ?? round(Math.max(baseShortThr * TIER2_EDGE_MULT, 0.31), 3),
|
|
481
571
|
rows,
|
|
482
572
|
};
|
|
483
573
|
return withNews(updated, p);
|
|
@@ -496,8 +586,10 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
496
586
|
const now = Date.now();
|
|
497
587
|
const task = (async () => {
|
|
498
588
|
hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
|
|
499
|
-
|
|
500
|
-
|
|
589
|
+
// Всегда держим полный горизонт окна обучения (например, 160 дн. при modelTrainDays=90):
|
|
590
|
+
// чтение из локальной SQLite занимает ~0,3 с, гарантируя неизменность списка бумаг N (попадание в trainedModelCache за 1 мс без переобучения),
|
|
591
|
+
// прогретый суточный профиль объёма valRatio и корректный 10-дневный фильтр тренда isMarketDown!
|
|
592
|
+
const windowDays = Math.ceil(p.modelTrainDays * 1.5 + 25);
|
|
501
593
|
// При первом холодном прогреве после перезапуска сервера (!baseState && !force) сначала читаем 100% локально из SQLite (за ~1-2 сек без похода в сеть по 100 акциям),
|
|
502
594
|
// а сетевую догрузку свежих свечей запускаем уже после того, как baseState готов в RAM!
|
|
503
595
|
const coldLocalFirst = !baseState && !force;
|
|
@@ -523,6 +615,8 @@ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } =
|
|
|
523
615
|
horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3),
|
|
524
616
|
buyThresholdPct: round(Math.max((r.needL ?? r.threshold) * 100, 0.30), 3),
|
|
525
617
|
shortThresholdPct: round(Math.max((r.needS ?? r.threshold) * 100, 0.31), 3),
|
|
618
|
+
buyThresholdTier2Pct: round(Math.max((r.needL ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.30), 3),
|
|
619
|
+
shortThresholdTier2Pct: round(Math.max((r.needS ?? r.threshold) * TIER2_EDGE_MULT * 100, 0.31), 3),
|
|
526
620
|
marketDown: r.marketDown,
|
|
527
621
|
rows: sortedRows,
|
|
528
622
|
map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
|
package/server/model/live.js
CHANGED
|
@@ -5,7 +5,8 @@ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
|
|
|
5
5
|
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
|
-
import { predictAt, lastClosedIndex
|
|
8
|
+
import { predictAt, lastClosedIndex } from './forecast.js';
|
|
9
|
+
import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
|
|
9
10
|
import { computeReport, downsample } from './metrics.js';
|
|
10
11
|
import { MODELS } from './models.js';
|
|
11
12
|
import { blockingNews } from '../news/index.js';
|
|
@@ -261,8 +262,7 @@ class LiveEngine {
|
|
|
261
262
|
try {
|
|
262
263
|
const p = s.params;
|
|
263
264
|
const now = Date.now();
|
|
264
|
-
const
|
|
265
|
-
const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
|
|
265
|
+
const windowDays = Math.ceil(p.modelTrainDays * 1.5 + 25);
|
|
266
266
|
const ds = await loadDataset({ fromTs: now - windowDays * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
|
|
267
267
|
s.lastSeenTs = latest;
|
|
268
268
|
changed = true;
|
|
@@ -416,25 +416,30 @@ class LiveEngine {
|
|
|
416
416
|
const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
|
|
417
417
|
const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
|
|
418
418
|
const quarantined = new Set();
|
|
419
|
-
if (autoAdapt && s.trades.length >=
|
|
419
|
+
if (autoAdapt && s.trades.length >= 1) {
|
|
420
420
|
const stMap = new Map();
|
|
421
421
|
for (const t of s.trades) {
|
|
422
422
|
// затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
|
|
423
423
|
const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
|
|
424
|
-
const wDecay = Math.pow(
|
|
424
|
+
const wDecay = Math.pow(QUARANTINE_DECAY, ageSteps);
|
|
425
425
|
const cur = stMap.get(t.symbol) || { pnl: 0, cnt: 0 };
|
|
426
426
|
cur.pnl = cur.pnl * 0.90 + (Number(t.pnlPct || 0) / 100) * wDecay;
|
|
427
427
|
cur.cnt += wDecay;
|
|
428
428
|
stMap.set(t.symbol, cur);
|
|
429
429
|
}
|
|
430
|
-
for (const [sym, st] of stMap) if (st.cnt
|
|
430
|
+
for (const [sym, st] of stMap) if (isStockQuarantined(st.cnt, st.pnl)) quarantined.add(sym);
|
|
431
431
|
}
|
|
432
432
|
for (const [sym, h] of s.held) {
|
|
433
|
+
if (autoAdapt && shouldExitBeforeNight(ts, h.entryTs, stepBars, BAR_INTERVAL)) {
|
|
434
|
+
closePos(sym, 'Закрытие перед ночным клирингом');
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
433
437
|
const x = row.get(sym);
|
|
434
438
|
const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
|
|
435
|
-
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio);
|
|
439
|
+
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * symbolEdgeMult(sym);
|
|
436
440
|
if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
|
|
437
441
|
}
|
|
442
|
+
const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
|
|
438
443
|
const sideHeld = (side) => [...s.held].filter(([sym, h]) => h.side === side && !closing.has(sym));
|
|
439
444
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
440
445
|
const take = (list, side, n) => {
|
|
@@ -445,13 +450,6 @@ class LiveEngine {
|
|
|
445
450
|
if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
|
|
446
451
|
const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
|
|
447
452
|
if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
|
|
448
|
-
if (autoK && out.length >= 1) {
|
|
449
|
-
const bestE = Math.abs(out[0].expected);
|
|
450
|
-
const curE = Math.abs(x.expected);
|
|
451
|
-
const relMin = out.length === 1 ? 0.62 : 0.78;
|
|
452
|
-
const absMin = out.length === 1 ? r.threshold * 1.15 : r.threshold * 1.45;
|
|
453
|
-
if (!(curE >= bestE * relMin && curE >= absMin)) break;
|
|
454
|
-
}
|
|
455
453
|
out.push(x);
|
|
456
454
|
}
|
|
457
455
|
return out;
|
|
@@ -459,13 +457,13 @@ class LiveEngine {
|
|
|
459
457
|
let entries;
|
|
460
458
|
if (dir === 'long') {
|
|
461
459
|
const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
|
|
462
|
-
const candL = mktDown ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol)).sort((a, b) => b.expected - a.expected);
|
|
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);
|
|
463
461
|
if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
464
462
|
entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
|
|
465
463
|
} else {
|
|
466
|
-
const open = (x) => !s.held.has(x.symbol)
|
|
467
|
-
const candL = mktDown ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
468
|
-
const candS = r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
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);
|
|
469
467
|
if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
470
468
|
const nL = K - sideHeld('long').length;
|
|
471
469
|
const nS = K - sideHeld('short').length;
|
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, symbolEdgeMult } from './edge.js';
|
|
12
|
+
import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
|
|
13
13
|
import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
|
|
14
14
|
|
|
15
15
|
const DAY = 86400000;
|
|
@@ -38,7 +38,8 @@ export function isMarketDown(ds, t) {
|
|
|
38
38
|
// Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
|
|
39
39
|
// 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
|
|
40
40
|
// 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
|
|
41
|
-
|
|
41
|
+
// 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
|
|
42
|
+
const DATA_VERSION = 10;
|
|
42
43
|
|
|
43
44
|
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
44
45
|
function reuseParamsKey(params) {
|
|
@@ -389,10 +390,11 @@ async function run(runId, p, state) {
|
|
|
389
390
|
const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
|
|
390
391
|
// Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
|
|
391
392
|
const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
|
|
392
|
-
const stockAllowed = (i) => !autoAdapt || !(stockCnt[i]
|
|
393
|
+
const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
|
|
393
394
|
let curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
394
395
|
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
395
396
|
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
397
|
+
let closedThisBar = new Set();
|
|
396
398
|
|
|
397
399
|
const closePos = (i, h, t, end, why, forcedExitPrice = null) => {
|
|
398
400
|
const exitPrice = forcedExitPrice > 0 ? forcedExitPrice : exitPx(i, t);
|
|
@@ -417,6 +419,7 @@ async function run(runId, p, state) {
|
|
|
417
419
|
stats.feesRub += fee + h.entryCost;
|
|
418
420
|
trades.push(tradeRec(name, i, h, t, exitPrice, gross - fee - borrow - divLoss - h.entryCost, h.entryCost + fee, borrow, end, why));
|
|
419
421
|
held.delete(i);
|
|
422
|
+
closedThisBar.add(i);
|
|
420
423
|
if (autoAdapt) {
|
|
421
424
|
const realGross = h.side === 'short' ? (1 - exitPrice / h.entryPrice) : (exitPrice / h.entryPrice - 1);
|
|
422
425
|
const realNetPct = realGross - 2 * realCost;
|
|
@@ -427,13 +430,15 @@ async function run(runId, p, state) {
|
|
|
427
430
|
for (let k = startIdx; k < testTs.length; k += stepBars) {
|
|
428
431
|
const t = testTs[k];
|
|
429
432
|
const tIn = Math.min(t + lag, T - 1), tOut = Math.min(t + lag + stepBars, T - 1);
|
|
433
|
+
closedThisBar = new Set();
|
|
430
434
|
if (autoAdapt) {
|
|
431
|
-
for (let i = 0; i < N; i++) { stockPnl[i] *=
|
|
435
|
+
for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
|
|
432
436
|
}
|
|
433
437
|
const pr = preds[name].get(t);
|
|
434
438
|
const sg = sigmaAt.get(t);
|
|
435
439
|
const bear = isMarketDown(ds, t);
|
|
436
440
|
const mktDown = mktFilter && bear;
|
|
441
|
+
const barTs = tsOf(t);
|
|
437
442
|
// дивидендный предохранитель: закрываем позиции в последний день с правом на дивиденд и не входим перед отсечкой
|
|
438
443
|
if (divGuard) {
|
|
439
444
|
for (const [i, h] of [...held]) {
|
|
@@ -441,6 +446,16 @@ async function run(runId, p, state) {
|
|
|
441
446
|
if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) { closePos(i, h, tIn, false, 'Дивидендная отсечка'); stats.divExits++; }
|
|
442
447
|
}
|
|
443
448
|
}
|
|
449
|
+
// не переносим внутридневные позиции через ночь и выходные
|
|
450
|
+
if (autoAdapt) {
|
|
451
|
+
for (const [i, h] of [...held]) {
|
|
452
|
+
if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) {
|
|
453
|
+
closePos(i, h, tIn, false, 'Закрытие перед ночным клирингом');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
458
|
+
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
444
459
|
const okLong = (i) => !divRule(i, t)?.blockLong;
|
|
445
460
|
const okShort = (i) => !divRule(i, t)?.blockShort;
|
|
446
461
|
let order, entries;
|
|
@@ -458,8 +473,8 @@ async function run(runId, p, state) {
|
|
|
458
473
|
}
|
|
459
474
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
460
475
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
461
|
-
const candL = mktDown ? [] : [...pr.keys()].filter((i) =>
|
|
462
|
-
const candS = [...pr.keys()].filter((i) =>
|
|
476
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
|
|
477
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
|
|
463
478
|
// Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
|
|
464
479
|
// если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
|
|
465
480
|
// освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
|
|
@@ -500,11 +515,11 @@ async function run(runId, p, state) {
|
|
|
500
515
|
}
|
|
501
516
|
const free = K - held.size;
|
|
502
517
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
503
|
-
const rawCand = mktDown ? [] : [...pr.keys()].filter((i) =>
|
|
518
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
|
|
504
519
|
const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
|
|
505
520
|
order = [...held.keys(), ...cand];
|
|
506
521
|
} else {
|
|
507
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
522
|
+
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);
|
|
508
523
|
order = cand;
|
|
509
524
|
const next = new Set(order);
|
|
510
525
|
for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
|
|
@@ -1028,10 +1043,11 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1028
1043
|
const borrowFee = (h, t) => h.alloc * (p.modelShortFeePct / 100) * ((tsOf(t) - tsOf(h.entryT)) / 3600000 / 24 / 365);
|
|
1029
1044
|
const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
|
|
1030
1045
|
const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
|
|
1031
|
-
const stockAllowed = (i) => !autoAdapt || !(stockCnt[i]
|
|
1046
|
+
const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
|
|
1032
1047
|
const curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
1033
|
-
const stepBars = autoAdapt ? Math.max(
|
|
1048
|
+
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1034
1049
|
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
|
|
1050
|
+
let closedThisBar = new Set();
|
|
1035
1051
|
const closePos = (i, h, t) => {
|
|
1036
1052
|
const exitPrice = px(i, t);
|
|
1037
1053
|
const gross = h.side === 'short' ? h.alloc * (1 - exitPrice / h.entryPrice) : h.alloc * (exitPrice / h.entryPrice - 1);
|
|
@@ -1040,6 +1056,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1040
1056
|
equity += gross - fee - borrow;
|
|
1041
1057
|
trades.push({ pnl: gross - fee - borrow - h.entryCost, entryTs: tsOf(h.entryT), exitTs: tsOf(t) });
|
|
1042
1058
|
held.delete(i);
|
|
1059
|
+
closedThisBar.add(i);
|
|
1043
1060
|
if (autoAdapt) {
|
|
1044
1061
|
const realGross = h.side === 'short' ? (1 - exitPrice / h.entryPrice) : (exitPrice / h.entryPrice - 1);
|
|
1045
1062
|
stockPnl[i] = stockPnl[i] * 0.90 + (realGross - 2 * realCost);
|
|
@@ -1048,12 +1065,21 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1048
1065
|
};
|
|
1049
1066
|
for (let k = 0; k < testTs.length; k += stepBars) {
|
|
1050
1067
|
const t = testTs[k], tIn = t + 1, tOut = Math.min(t + 1 + stepBars, T - 1);
|
|
1068
|
+
closedThisBar = new Set();
|
|
1051
1069
|
if (autoAdapt) {
|
|
1052
|
-
for (let i = 0; i < N; i++) { stockPnl[i] *=
|
|
1070
|
+
for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
|
|
1053
1071
|
}
|
|
1054
1072
|
const pr = preds.get(t), sg = sigmaAt.get(t);
|
|
1055
1073
|
const bear = isMarketDown(ds, t);
|
|
1056
1074
|
const mktDown = mktFilter && bear;
|
|
1075
|
+
const barTs = tsOf(t);
|
|
1076
|
+
if (autoAdapt) {
|
|
1077
|
+
for (const [i, h] of [...held]) {
|
|
1078
|
+
if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) closePos(i, h, tIn);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
1082
|
+
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
1057
1083
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
1058
1084
|
const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
1059
1085
|
let order, entries;
|
|
@@ -1065,8 +1091,8 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1065
1091
|
}
|
|
1066
1092
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1067
1093
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
1068
|
-
const candL = mktDown ? [] : [...pr.keys()].filter((i) =>
|
|
1069
|
-
const candS = [...pr.keys()].filter((i) =>
|
|
1094
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
|
|
1095
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
|
|
1070
1096
|
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
1071
1097
|
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)])];
|
|
1072
1098
|
} else if (effMinEdge > 0) {
|
|
@@ -1076,10 +1102,10 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1076
1102
|
if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1077
1103
|
}
|
|
1078
1104
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1079
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) =>
|
|
1105
|
+
const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => canOpenAt(i) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
|
|
1080
1106
|
order = [...held.keys(), ...cand];
|
|
1081
1107
|
} else {
|
|
1082
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
1108
|
+
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);
|
|
1083
1109
|
order = cand;
|
|
1084
1110
|
const next = new Set(order);
|
|
1085
1111
|
for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);
|