neuroalpha 0.5.34 → 0.6.1

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.
@@ -4,10 +4,10 @@ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
4
4
  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
- import { isMarketDown } from './research.js';
8
- import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday } from './edge.js';
7
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday, isMarketDown } from './edge.js';
9
8
  export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
10
9
  import { newsForSymbols, blockingNews } from '../news/index.js';
10
+ import { dayContext } from './policy.js';
11
11
  import { liveDividendGuard } from './dividends.js';
12
12
  import { fetchTbankRealtimePrices } from '../brokers/index.js';
13
13
  import { kvGet, kvSet } from '../db.js';
@@ -450,6 +450,12 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
450
450
  if (shortBlockedByBroker) row.shortBlockedByBroker = true;
451
451
  if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
452
452
  }
453
+ // контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
454
+ const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
455
+ row.day = {
456
+ prevClose: dc.prevClose, dayHigh: dc.dayHigh, dayLow: dc.dayLow, barsSinceHigh: dc.barsSinceHigh, barsSinceLow: dc.barsSinceLow,
457
+ marketDayPct: round(dc.marketDayPct, 3), realizedBarPct: round(dc.realizedBarPct, 3), dayMovePct: round(dc.dayMovePct, 2), sigmaBarPct: round(dc.sigmaBarPct, 3),
458
+ };
453
459
  if (explain && ex) {
454
460
  row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
455
461
  row.selfPct = round(selfRaw * 100, 3);
@@ -533,7 +539,26 @@ function withNews(result, p) {
533
539
  r.thresholdRatioPct = Math.round(ratio * 100);
534
540
  r.crossedThreshold = ratio >= 1;
535
541
  }
536
- if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
542
+ // Если в сохранённом кэше список был обрезан прежним лимитом (40), пересобираем полный список из накопленных записей
543
+ const saved = !nearSignalsCache.episodesMap.size ? kvGet('nearSignalsLog') : null;
544
+ if (saved?.entries?.length && (saved.list?.length || 0) < Math.min(120, saved.entries.length)) {
545
+ nearSignalsCache = { key: saved.key, scannedBarTs: saved.scannedBarTs || 0, episodesMap: new Map(saved.entries), list: [] };
546
+ }
547
+ if (!Array.isArray(result.nearSignals) || !result.nearSignals.length || (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length)) {
548
+ if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
549
+ nearSignalsCache.list = [...nearSignalsCache.episodesMap.values()]
550
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
551
+ .slice(0, 120)
552
+ .map((ep) => {
553
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
554
+ const reasonText = ep.status === 'signal'
555
+ ? 'Порог пройден — сигнал сформирован'
556
+ : ep.status === 'blocked'
557
+ ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
558
+ : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
559
+ return { ...ep, gapPct, reasonText };
560
+ });
561
+ }
537
562
  const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
538
563
  result.nearSignals = savedNear;
539
564
  }
@@ -656,7 +681,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
656
681
  if (updated || !nearSignalsCache.list.length) {
657
682
  const list = [...nearSignalsCache.episodesMap.values()]
658
683
  .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
659
- .slice(0, 40)
684
+ .slice(0, 120)
660
685
  .map((ep) => {
661
686
  const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
662
687
  const reasonText = ep.status === 'signal'
@@ -0,0 +1,193 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Единые правила сопровождения позиций для живой торговли (brokers/index.js) и симулятора стратегии (simulator.js):
3
+ // окна начала/конца сессий, фиксация прибыли, защитный стоп, размер позиции и фильтр входа против события.
4
+ // Любое правило меняется только здесь — бэктест и живой контур не могут разойтись.
5
+ import { symbolEdgeMult, TIER1_LIQUID_SYMBOLS } from './edge.js';
6
+
7
+ const MSK = 3 * 3600000;
8
+ const DAY = 86400000;
9
+
10
+ // Параметры стратегии. Изменение принимается, только если в лаборатории правил (scripts/strategy-lab.mjs) на пошаговой проверке
11
+ // с исполнением как в живом контуре оно лучше прежних правил И на первой, И на второй половине периода.
12
+ // Проверка 06.11.2026 (06.2024–11.2026, 788 сделок): аварийный стоп 7% вместо 4,5/5% — +23,6%/+8,2% по половинам против +22,9%/+5,0%,
13
+ // просадка 3,9% против 5,0%; стопы 7–15% дают одинаковый итог (плато). Модель торгует возврат к среднему, и тесный стоп
14
+ // закрывал позиции на пике отклонения. Стоп по волатильности, стоп отмены идеи, размер по риску — хуже на обеих половинах.
15
+ export const STRATEGY_POLICY = Object.freeze({
16
+ // аварийный стоп: фиксированный процент от средней цены входа (у брокера и во внутреннем контуре)
17
+ stop: Object.freeze({ mode: 'fixed', longPct: 7.0, shortPct: 7.0 }),
18
+ // фиксация прибыли (скользящий Take-Profit и защита заработанного)
19
+ profitLock: true,
20
+ // размер позиции: 'slot' — доля бюджета × сила сигнала
21
+ sizing: Object.freeze({ mode: 'slot' }),
22
+ // Множитель размера позиции по эшелонам. Для 1-го эшелона множитель равен плечу счёта (настройка maxLeverage),
23
+ // 2–3-й эшелон торгуется половинным размером. Проверка 06.11.2026: сделки 1-го эшелона — PF 1,9/3,6 по половинам периода
24
+ // (издержки 0,065% на сторону), 2–3-го эшелона — чистыми 0,1% на сделку (издержки 0,125%). Плечо 3 на 1-й эшелон и ½ на 2-й:
25
+ // CAGR 56%/49% по половинам против 44%/24% (с доходом LQDT), PF 1,87; рост итога с плечом почти линейный на обеих половинах.
26
+ sizeMultT2: 0.5,
27
+ // капитал хранится в фонде денежного рынка LQDT как залог: внутридневные позиции открываются под обеспечение
28
+ // (без платы за перенос — позиции закрываются до ночи), LQDT не продаётся под входы
29
+ cashYield: true,
30
+ // фильтр входа против сильного дневного движения (null — выключен)
31
+ counterTrend: null,
32
+ // стоп отмены идеи: выход, если цена обновила дневной экстремум против позиции (null — выключен)
33
+ thesisStop: null,
34
+ // паузы повторного входа после выхода, минуты
35
+ cooldownMin: 30,
36
+ stopCooldownMin: 60,
37
+ });
38
+
39
+ // ---------- окна начала и конца сессий (широкий спред, гэпы, аукционы) ----------
40
+ export const SESSION_EDGE_WINDOWS = [
41
+ [6 * 60 + 50, 7 * 60 + 25],
42
+ [9 * 60 + 50, 10 * 60 + 10],
43
+ [18 * 60 + 35, 18 * 60 + 55],
44
+ ];
45
+ export function sessionEdgeWarning(ts = Date.now()) {
46
+ const mins = Math.floor(((ts + MSK) % DAY) / 60000);
47
+ for (const [a, b] of SESSION_EDGE_WINDOWS) {
48
+ if (mins >= a && mins <= b) return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // ---------- фиксация прибыли ----------
54
+ // Уровни в процентах выигрыша от средней цены. peakGainPct — лучший выигрыш с момента входа.
55
+ // Возвращает { floorPct, minExitPct, kind } — выход, когда текущий выигрыш опустился до floorPct, но не ниже minExitPct; или null.
56
+ export function profitLockLevels(sym, peakGainPct, heldMin, roundTripFeePct = 0.10) {
57
+ const tierMult = symbolEdgeMult(sym);
58
+ const minNetFloorPct = Math.max(roundTripFeePct + 0.30, Math.round(0.38 * tierMult * 100) / 100);
59
+ const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
60
+ if (peakGainPct >= tpTriggerPct) {
61
+ return { floorPct: Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct), minExitPct: minNetFloorPct, kind: 'tp' };
62
+ }
63
+ if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
64
+ return { floorPct: Math.max(minNetFloorPct, peakGainPct * 0.45), minExitPct: roundTripFeePct + 0.22, kind: 'be' };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ export function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 0) {
70
+ if (!(avgPx > 0 && curPx > 0)) return null;
71
+ const isLong = side === 'long';
72
+ const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
73
+ const effPeakPx = peakPx > 0
74
+ ? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
75
+ : (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
76
+ const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
77
+ const lv = profitLockLevels(sym, peakGainPct, heldMin, roundTripFeePct);
78
+ if (!lv || !(curGainPct <= lv.floorPct && curGainPct >= lv.minExitPct)) return null;
79
+ const r2 = (v) => Math.round(v * 100) / 100;
80
+ return lv.kind === 'tp'
81
+ ? `Фиксация прибыли (Take-Profit: пик +${r2(peakGainPct)}% → +${r2(curGainPct)}%)`
82
+ : `Защита прибыли от отката (пик +${r2(peakGainPct)}% → фиксация +${r2(curGainPct)}%)`;
83
+ }
84
+
85
+ // ---------- защитный стоп ----------
86
+ // sigmaBarPct — обычный остаточный размах 10-минутной свечи бумаги (%), realizedBarPct — фактический размах свечей сегодня (%).
87
+ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, horizonBars = 15 } = {}, policy = STRATEGY_POLICY) {
88
+ const st = policy.stop || {};
89
+ if (st.mode === 'vol' && sigmaBarPct > 0) {
90
+ const vol = Math.max(sigmaBarPct, st.useRealized ? realizedBarPct : 0);
91
+ const raw = st.k * vol * Math.sqrt(horizonBars);
92
+ const maxPct = side === 'long' ? (st.maxLongPct ?? 5.0) : (st.maxShortPct ?? 4.5);
93
+ return Math.max(st.minPct ?? 1.0, Math.min(maxPct, raw));
94
+ }
95
+ return side === 'long' ? (st.longPct ?? 5.0) : (st.shortPct ?? 4.5);
96
+ }
97
+
98
+ // ---------- плечо и множители размера по эшелонам ----------
99
+ export const MAX_LEVERAGE = 7;
100
+ export function tierSizeMult(symbol, leverage = 1, policy = STRATEGY_POLICY) {
101
+ const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
102
+ return TIER1_LIQUID_SYMBOLS.has(symbol) ? lev : (policy.sizeMultT2 ?? 1);
103
+ }
104
+
105
+ // ---------- предельный размер позиции на одну бумагу ----------
106
+ export function symbolCapRub(symbol, side, budget, barTurnoverRub = 0) {
107
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
108
+ const rawCap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
109
+ return (!isT1 && barTurnoverRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnoverRub * 0.12)) : rawCap;
110
+ }
111
+
112
+ // Размер новой позиции в рублях до учёта свободного кэша и пропорциональной доли:
113
+ // 'slot' — рабочий слот × сила сигнала (conv), не больше предела бумаги;
114
+ // 'risk' — при срабатывании стопа счёт теряет не больше riskPct% бюджета.
115
+ export function targetPositionRub({ symbol, side, budget, slotRub, conv = 1, stopPct, barTurnoverRub = 0 }, policy = STRATEGY_POLICY) {
116
+ const cap = symbolCapRub(symbol, side, budget, barTurnoverRub);
117
+ const sz = policy.sizing || {};
118
+ if (sz.mode === 'risk' && stopPct > 0) {
119
+ const byRisk = (budget * (sz.riskPct / 100)) / (stopPct / 100);
120
+ return Math.min(cap, byRisk * (sz.useConv ? conv : 1), sz.maxSlotPct ? budget * sz.maxSlotPct : Infinity);
121
+ }
122
+ return Math.min(cap, slotRub * conv);
123
+ }
124
+
125
+ // ---------- контекст торгового дня бумаги по свечам до момента решения ----------
126
+ // t — последняя учтённая свеча; livePx — живая цена внутри формирующейся свечи (в живом контуре), иначе закрытие свечи t.
127
+ export function dayContext(ds, i, t, sigmaBarPct, livePx = 0) {
128
+ const tk = ds.tokens[i];
129
+ const day = Math.floor((ds.timeline[t] + MSK) / DAY);
130
+ let d0 = t;
131
+ while (d0 > 0 && Math.floor((ds.timeline[d0 - 1] + MSK) / DAY) === day) d0--;
132
+ const px = livePx > 0 ? livePx : tk.close[t];
133
+ let hi = 0, lo = Infinity, hiAt = d0, loAt = d0;
134
+ for (let k = d0; k <= t; k++) {
135
+ if (!tk.has[k]) continue;
136
+ if (tk.high[k] > hi) { hi = tk.high[k]; hiAt = k; }
137
+ if (tk.low[k] > 0 && tk.low[k] < lo) { lo = tk.low[k]; loAt = k; }
138
+ }
139
+ if (px > hi) { hi = px; hiAt = t; }
140
+ if (px > 0 && px < lo) { lo = px; loAt = t; }
141
+ const prevClose = d0 > 0 ? tk.close[d0 - 1] : 0;
142
+ const mPrev = d0 > 0 ? ds.mclose[d0 - 1] : 0;
143
+ let s2 = 0, n2 = 0;
144
+ for (let k = Math.max(d0 + 1, t - 17); k <= t; k++) {
145
+ if (!tk.has[k]) continue;
146
+ const r = tk.ret[k] - ds.mret[k];
147
+ s2 += r * r; n2++;
148
+ }
149
+ return {
150
+ px, prevClose, dayHigh: hi, dayLow: Number.isFinite(lo) ? lo : 0,
151
+ barsSinceHigh: t - hiAt, barsSinceLow: t - loAt, sigmaBarPct,
152
+ marketDayPct: mPrev > 0 && ds.mclose[t] > 0 ? (ds.mclose[t] / mPrev - 1) * 100 : 0,
153
+ realizedBarPct: n2 >= 4 ? Math.sqrt(s2 / n2) * 100 : 0,
154
+ dayMovePct: prevClose > 0 ? (px / prevClose - 1) * 100 - (mPrev > 0 && ds.mclose[t] > 0 ? (ds.mclose[t] / mPrev - 1) * 100 : 0) : 0,
155
+ };
156
+ }
157
+
158
+ // ---------- фильтр входа против события ----------
159
+ // Все величины — по ценам до момента решения: px — текущая цена, dayHigh/dayLow — экстремумы сегодняшних свечей,
160
+ // prevClose — закрытие прошлого торгового дня, sigmaBarPct — обычный размах 10-минутной свечи, barsSinceHigh/barsSinceLow — свечей с момента экстремума.
161
+ export function counterTrendBlocked(side, ctx, policy = STRATEGY_POLICY) {
162
+ const g = policy.counterTrend;
163
+ if (!g || !ctx || !(ctx.px > 0) || !(ctx.prevClose > 0) || !(ctx.sigmaBarPct > 0)) return null;
164
+ const sigDay = ctx.sigmaBarPct * Math.sqrt(g.dayBars || 50);
165
+ const dayMovePct = ((ctx.px / ctx.prevClose) - 1) * 100 - (ctx.marketDayPct || 0);
166
+ const againstPct = side === 'short' ? dayMovePct : -dayMovePct;
167
+ const r1 = (v) => Math.round(v * 10) / 10;
168
+ // бумага уже ушла за день слишком далеко против позиции — движение вызвано событием, возврата к среднему не ждём
169
+ if (g.maxMovePct > 0 && againstPct >= g.maxMovePct) return `бумага уже ${side === 'short' ? 'выросла' : 'упала'} за день на ${r1(Math.abs(dayMovePct))}% к индексу (предел ${g.maxMovePct}%)`;
170
+ if (g.maxMoveSig > 0 && againstPct >= g.maxMoveSig * sigDay) return `движение за день ${r1(dayMovePct)}% к индексу больше ${g.maxMoveSig} дневных размахов бумаги`;
171
+ if (!(g.minDayMoveSig > 0)) return null;
172
+ if (side === 'short') {
173
+ if (!(dayMovePct >= g.minDayMoveSig * sigDay)) return null;
174
+ const offHighPct = ctx.dayHigh > 0 ? ((ctx.dayHigh - ctx.px) / ctx.dayHigh) * 100 : 0;
175
+ if (offHighPct < g.minPullbackSig * ctx.sigmaBarPct * Math.sqrt(g.pullbackBars || 6)) return `цена у дневного максимума после роста +${Math.round(dayMovePct * 10) / 10}% (откат ${Math.round(offHighPct * 100) / 100}%)`;
176
+ if ((ctx.barsSinceHigh ?? 99) < (g.stallBars || 0)) return `дневной максимум обновлялся ${ctx.barsSinceHigh * 10} мин назад после роста +${Math.round(dayMovePct * 10) / 10}%`;
177
+ return null;
178
+ }
179
+ if (!(dayMovePct <= -g.minDayMoveSig * sigDay)) return null;
180
+ const offLowPct = ctx.dayLow > 0 ? ((ctx.px - ctx.dayLow) / ctx.dayLow) * 100 : 0;
181
+ if (offLowPct < g.minPullbackSig * ctx.sigmaBarPct * Math.sqrt(g.pullbackBars || 6)) return `цена у дневного минимума после падения ${Math.round(dayMovePct * 10) / 10}% (отскок ${Math.round(offLowPct * 100) / 100}%)`;
182
+ if ((ctx.barsSinceLow ?? 99) < (g.stallBars || 0)) return `дневной минимум обновлялся ${ctx.barsSinceLow * 10} мин назад после падения ${Math.round(dayMovePct * 10) / 10}%`;
183
+ return null;
184
+ }
185
+
186
+ // Уровень стопа отмены идеи: обновление дневного экстремума против позиции с запасом buffer (в обычных размахах свечи).
187
+ export function thesisStopPx(side, { dayHigh, dayLow, sigmaBarPct }, policy = STRATEGY_POLICY) {
188
+ const g = policy.thesisStop;
189
+ if (!g || !(sigmaBarPct > 0)) return 0;
190
+ const buf = Math.max(g.minBufPct ?? 0.3, g.bufSig * sigmaBarPct) / 100;
191
+ if (side === 'short') return dayHigh > 0 ? dayHigh * (1 + buf) : 0;
192
+ return dayLow > 0 ? dayLow * (1 - buf) : 0;
193
+ }
@@ -9,8 +9,10 @@ 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, isRegimeShocked } from './edge.js';
12
+ import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked, isMarketDown } from './edge.js';
13
13
  import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
14
+ import { simulateStrategy, alignSeries } from './simulator.js';
15
+ import { ensureLqdtHistory } from '../data/lqdt.js';
14
16
 
15
17
  const DAY = 86400000;
16
18
  const MSK = 3 * 3600000;
@@ -23,17 +25,7 @@ const tick = () => new Promise((r) => setImmediate(r));
23
25
  // Ключевые параметры для сравнения исследований (включая capital — он влияет на рубли)
24
26
  const REUSE_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelUseDividends', 'modelTextWeight', 'commissionPct', 'slippagePct', 'oosPct', 'capital'];
25
27
 
26
- // Фильтр падающего рынка: индекс Мосбиржи в среднесрочном нисходящем тренде (ниже уровня 10 торговых дней назад)
27
- // или в сильном локальном обвале (ниже уровня дня и 3 дней назад на >1%)
28
- export function isMarketDown(ds, t) {
29
- const bpd = Math.max(1, Math.round(ds.barsPerDay || 54));
30
- const d10 = bpd * 10, d3 = bpd * 3;
31
- if (t < d3) return false;
32
- const cur = ds.mclose[t], p3 = ds.mclose[t - d3], pd = ds.mclose[t - bpd];
33
- if (!(cur > 0 && p3 > 0 && pd > 0)) return false;
34
- if (t >= d10 && ds.mclose[t - d10] > 0 && cur < ds.mclose[t - d10] * 0.99) return true;
35
- return cur < pd * 0.99 && cur < p3 * 0.99;
36
- }
28
+ export { isMarketDown };
37
29
 
38
30
  // Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
39
31
  // 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
@@ -44,7 +36,9 @@ export function isMarketDown(ds, t) {
44
36
  // 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
45
37
  // 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
46
38
  // 15: защита от биржевых планок (high === low) и блокировка утренних шортов 2-го эшелона в первые 20 минут сессии (07:00–07:20 МСК)
47
- const DATA_VERSION = 15;
39
+ // 16: главная оценка — симулятор с правилами живого контура (simulator.js), аварийный стоп 7%
40
+ // 17: капитал в LQDT как залог, половинный размер 2-го эшелона, строки с плечом 3 и 5
41
+ const DATA_VERSION = 17;
48
42
 
49
43
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
50
44
  function reuseParamsKey(params) {
@@ -808,6 +802,48 @@ async function run(runId, p, state) {
808
802
  emit({ stage: 'calc', pct: 96, text: 'Финальный пересчёт · стресс-тест исполнения (издержки, задержка входа, ликвидность, лоты, шорты)…' });
809
803
  await tick();
810
804
  const stress = runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H });
805
+ // Главная честная оценка: те же прогнозы через симулятор с правилами живого контура (server/model/simulator.js)
806
+ emit({ stage: 'calc', pct: 97, text: 'Финальный пересчёт · симуляция с правилами живого контура (стопы, фиксация прибыли, фильтры, размер позиции)…' });
807
+ await tick();
808
+ try {
809
+ const wfLive = {
810
+ H, testTs,
811
+ pred: testTs.map((t) => preds[chosen].get(t)),
812
+ foldOf: testTs.map((_, k) => k),
813
+ foldSigma: testTs.map((t) => sigmaAt.get(t)),
814
+ foldMult: testTs.map((t) => multAt.get(t) || baseSymMult),
815
+ foldBeta: testTs.map((t) => betaAt.get(t)),
816
+ };
817
+ const lqdtRows = await ensureLqdtHistory().catch(() => []);
818
+ const lqdt = lqdtRows.length ? alignSeries(timeline, lqdtRows) : null;
819
+ const liveRows = [];
820
+ for (const lev of [1, 3, 5]) {
821
+ const lp = simulateStrategy({ ds, wf: wfLive, p, capital: p.capital, shortable: shortableSet(), divCal, lqdt, leverage: lev });
822
+ const yrs = Math.max(1 / 365, ((lp.curve.at(-1)?.ts || 0) - (lp.curve[0]?.ts || 0)) / (365.25 * 86400000));
823
+ const lastLpTs = lp.curve.at(-1)?.ts || 0;
824
+ const segOf = (fromTs) => {
825
+ const c = lp.curve.filter((x) => x.ts >= fromTs);
826
+ if (c.length < 2) return { returnPct: null };
827
+ const m = computeReport(lp.trades.filter((t) => t.entryTs >= fromTs), c, c[0].equity, 0).full;
828
+ return { returnPct: m.returnPct, profitFactor: m.profitFactor, maxDrawdownPct: m.maxDrawdownPct, trades: m.trades };
829
+ };
830
+ const full = computeReport(lp.trades, lp.curve, p.capital, 0).full;
831
+ const cagrPct = full.returnPct > -100 && yrs >= 0.25 ? round((Math.pow(1 + full.returnPct / 100, 1 / yrs) - 1) * 100, 1) : null;
832
+ liveRows.push({
833
+ key: lev === 1 ? 'liveRules' : `liveRulesL${lev}`,
834
+ label: lev === 1 ? 'Правила живого контура, без плеча (главная оценка)' : `Правила живого контура, плечо ${lev}`,
835
+ note: `исполнение и сопровождение как у реального счёта: входы на любой свече, аварийный стоп и фиксация прибыли внутри свечи, окна сессий, планки, перегрев, паузы, размер позиции и пределы на бумагу, издержки по эшелонам${lqdt ? ', капитал в LQDT как залог' : ''}${lev > 1 ? `; позиции 1-го эшелона ×${lev}` : ''}`,
836
+ full: { returnPct: full.returnPct, cagrPct, profitFactor: full.profitFactor, maxDrawdownPct: full.maxDrawdownPct, trades: full.trades, winRate: full.winRate, avgTradePct: full.avgTradePct, longPnl: round(lp.trades.filter((t) => t.side === 'long').reduce((a, t) => a + t.pnl, 0), 0), shortPnl: round(lp.trades.filter((t) => t.side === 'short').reduce((a, t) => a + t.pnl, 0), 0), afterTaxCagrPct: cagrPct === null ? null : round(cagrPct > 0 ? cagrPct * 0.87 : cagrPct, 1) },
837
+ costs: null,
838
+ last12: segOf(lastLpTs - 365 * 86400000),
839
+ last6: segOf(lastLpTs - 182 * 86400000),
840
+ exits: lp.stats,
841
+ });
842
+ }
843
+ stress.scenarios.unshift(...liveRows);
844
+ } catch (e) {
845
+ console.error('Симуляция правил живого контура:', e);
846
+ }
811
847
 
812
848
  emit({ stage: 'calc', pct: 98, text: `Финальный пересчёт · запись ${results[chosen].sim.trades.length} сделок и кривых капитала в базу…` });
813
849
  await tick();
@@ -837,7 +873,7 @@ async function run(runId, p, state) {
837
873
  neighbors,
838
874
  });
839
875
  delete report.stressPending;
840
- const realistic = stress.scenarios.find((s) => s.key === 'realistic');
876
+ const realistic = stress.scenarios.find((s) => s.key === 'liveRules') || stress.scenarios.find((s) => s.key === 'realistic');
841
877
  report.stress = realistic ? { returnPct: realistic.full.returnPct, cagrPct: realistic.full.cagrPct, profitFactor: realistic.full.profitFactor, maxDrawdownPct: realistic.full.maxDrawdownPct, last12Pct: realistic.last12.returnPct } : null;
842
878
  finishRun(runId, { metrics: report, equity: downsample(results[chosen].sim.curve) });
843
879
  return report;