neuroalpha 0.5.34 → 0.6.0

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.
@@ -0,0 +1,366 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Симулятор стратегии с правилами живого контура (brokers/index.js) по 10-минутным свечам:
3
+ // • решения на закрытии свечи t, исполнение входов и плановых выходов по цене открытия t+1;
4
+ // • входы — на любой свече (быстрый добор живого контура), плановые выходы, ротация — раз в stepBars свечей;
5
+ // • защитный стоп, стоп отмены идеи и фиксация прибыли — внутри каждой свечи по её максимуму/минимуму
6
+ // (при неоднозначности внутри свечи считается, что сначала случилось неблагоприятное движение);
7
+ // • фильтры живого прогноза: окна начала/конца сессий, планки, перегрев, экстремальная свеча, утренние шорты 2-го эшелона,
8
+ // паузы повторного входа, карантин, дивидендный предохранитель, список бумаг для шорта;
9
+ // • размер позиции и пределы на бумагу — как у живого счёта (policy.js); издержки по эшелонам ликвидности;
10
+ // • по выбору: доход фонда денежного рынка LQDT на весь капитал (залог для внутридневной маржинальной торговли),
11
+ // защита вечным фьючерсом на индекс IMOEXF от движения рынка, плечо, пассивные лимитные входы, выход по времени.
12
+ // Не моделируются: минутный фильтр затухания импульса, спред-фильтр стакана, новостной фильтр (нет истории).
13
+ import { BAR_INTERVAL, BAR_MS } from './dataset.js';
14
+ import { LIQ_MIN } from './models.js';
15
+ import {
16
+ ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, REGIME_SHOCK_1D, REGIME_CRASH_30M, QUARANTINE_DECAY,
17
+ isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
18
+ } from './edge.js';
19
+ import { nextDividendAt, dividendRules } from './dividends.js';
20
+ import { STRATEGY_POLICY, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
21
+
22
+ // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
+ export function alignSeries(timeline, rows) {
24
+ const out = new Float64Array(timeline.length);
25
+ const sorted = [...rows].filter((r) => r.close > 0).sort((a, b) => a.ts - b.ts);
26
+ let j = 0, last = 0;
27
+ for (let i = 0; i < timeline.length; i++) {
28
+ while (j < sorted.length && sorted[j].ts <= timeline[i]) { last = sorted[j].close; j++; }
29
+ out[i] = last;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital = 800000, plan = 'owner', fill = 'open', shortable = null, divCal = null, stopSlipPct = 0.10, collectTrades = true, fromTs = -Infinity, tillTs = Infinity, fut = null, lqdt = null, leverage = 1 }) {
35
+ const { tokens, timeline, T } = ds;
36
+ const N = tokens.length;
37
+ const H = wf.H;
38
+ const sqrtH = Math.sqrt(H);
39
+ const bpd = Math.max(1, Math.round(ds.barsPerDay || 94));
40
+ const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(H / 5)) : H;
41
+ const K = policy.K ?? 4;
42
+ const dir = p.modelDirection === 'long' ? 'long' : 'longshort';
43
+ const realCost = (p.commissionPct + p.slippagePct) / 100;
44
+ const need = 2.5 * 2 * realCost * (policy.entryEdgeMult ?? ENTRY_EDGE_MULT);
45
+ const roundTripFeePct = 2 * (p.commissionPct + p.slippagePct);
46
+ const isT1 = (i) => TIER1_LIQUID_SYMBOLS.has(tokens[i].symbol);
47
+ const costOf = (i) => (isT1(i) ? 0.00065 : 0.00125);
48
+ const commission = p.commissionPct / 100;
49
+ const cooldownBars = Math.round((policy.cooldownMin ?? 30) / BAR_INTERVAL);
50
+ const stopCooldownBars = Math.round((policy.stopCooldownMin ?? 60) / BAR_INTERVAL);
51
+ const horizonMin = Math.max(90, (H * BAR_INTERVAL));
52
+ const slotW = policy.slotPct ?? (plan === 'owner' ? 0.45 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
53
+ const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
54
+ const convK = plan === 'owner' ? 0.30 : 0.25;
55
+ const lev = Math.max(1, Number(leverage) || 1);
56
+ const maxGross = policy.maxGross ?? 0.99 * lev;
57
+ const capScale = policy.capScale ?? 1;
58
+ const tierScale = (i) => (isT1(i) ? (policy.edgeScaleT1 ?? 1) : (policy.edgeScaleT2 ?? 1));
59
+ const minEdgeL = (policy.minEdgePct ?? 0.30) / 100;
60
+ const minEdgeS = (policy.minEdgePct ?? 0.31) / 100;
61
+ const hedge = policy.hedge && fut ? policy.hedge : null;
62
+ const hedgeMult = hedge?.pointRub ?? 10;
63
+ const passive = policy.passiveEntry || null;
64
+
65
+ let equity = capital;
66
+ const held = new Map(); // i -> pos
67
+ const trades = [];
68
+ const curve = [];
69
+ const cooldownUntil = new Int32Array(N).fill(-1);
70
+ const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
71
+ const stats = { stops: 0, thesis: 0, locks: 0, signalExits: 0, timeExits: 0, night: 0, rotations: 0, regime: 0, div: 0, blockedCT: 0, entries: 0, missedPassive: 0, hedgePnl: 0, hedgeCost: 0, cashYield: 0, exposureSum: 0, exposureN: 0 };
72
+ let hedgeRub = 0;
73
+
74
+ const divRule = (i, t) => {
75
+ if (!divCal) return null;
76
+ const nd = nextDividendAt(divCal, i, t);
77
+ return nd ? dividendRules(nd.daysToEx, nd.y) : null;
78
+ };
79
+ const gainOf = (pos, px) => (pos.side === 'long' ? px / pos.entryPx - 1 : 1 - px / pos.entryPx);
80
+ const pxOfGain = (pos, g) => (pos.side === 'long' ? pos.entryPx * (1 + g) : pos.entryPx * (1 - g));
81
+ const barTurnover = (i, t) => {
82
+ const vals = [];
83
+ for (let k = t; k >= 0 && vals.length < 48 && k > t - 400; k--) if (tokens[i].value[k] > 0) vals.push(tokens[i].value[k]);
84
+ if (!vals.length) return 0;
85
+ vals.sort((a, b) => a - b);
86
+ const mid = Math.floor(vals.length / 2);
87
+ const med = vals.length % 2 ? vals[mid] : (vals[mid - 1] + vals[mid]) / 2;
88
+ return med * 0.6 + (vals.reduce((s, v) => s + v, 0) / vals.length) * 0.4;
89
+ };
90
+
91
+ const closePos = (i, pos, exitT, exitPx, why, kind) => {
92
+ const c = costOf(i);
93
+ const g = gainOf(pos, exitPx);
94
+ const gross = pos.alloc * g;
95
+ const fee = (pos.alloc + (pos.side === 'long' ? gross : -gross)) * c + (kind === 'stop' || kind === 'thesis' ? pos.alloc * (stopSlipPct / 100) : 0);
96
+ equity += gross - fee;
97
+ if (collectTrades) {
98
+ trades.push({
99
+ symbol: tokens[i].symbol, side: pos.side, entryTs: timeline[pos.entryT], exitTs: timeline[Math.min(exitT, T - 1)],
100
+ entryPrice: pos.entryPx, exitPrice: exitPx, alloc: pos.alloc,
101
+ pnl: gross - fee - pos.entryFee, pnlPct: ((gross - fee - pos.entryFee) / pos.alloc) * 100,
102
+ commission: fee + pos.entryFee, barsHeld: (exitT - pos.entryT) * BAR_INTERVAL,
103
+ expectedPct: pos.expectedPct, stopPct: pos.stopPct, reasonExit: why, exitKind: kind,
104
+ dayMovePct: pos.dayMovePct, counterTrend: pos.counterTrend, tier: isT1(i) ? 1 : 2,
105
+ });
106
+ }
107
+ held.delete(i);
108
+ cooldownUntil[i] = exitT + (kind === 'stop' || kind === 'thesis' ? stopCooldownBars : cooldownBars);
109
+ stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * realCost);
110
+ stockCnt[i] += 1;
111
+ };
112
+ const mtmUnreal = (t) => {
113
+ let s = 0;
114
+ for (const q of held.values()) s += q.alloc * gainOf(q, tokens[q.i].close[t]);
115
+ return s;
116
+ };
117
+ const grossUsed = (t) => {
118
+ let s = 0;
119
+ for (const q of held.values()) s += q.alloc * (1 + gainOf(q, tokens[q.i].close[t]) * (q.side === 'long' ? 1 : -1));
120
+ return s;
121
+ };
122
+
123
+ let firstK = wf.testTs.findIndex((t) => timeline[t] >= fromTs);
124
+ if (firstK < 0) firstK = wf.testTs.length;
125
+ let lastT = -1;
126
+ let prevT = -1;
127
+ for (let k = firstK; k < wf.testTs.length; k++) {
128
+ const t = wf.testTs[k];
129
+ if (timeline[t] > tillTs || t + 1 >= T) break;
130
+ lastT = t;
131
+ const fo = wf.foldOf[k];
132
+ const pr = wf.pred[k], sg = wf.foldSigma[fo], mult = wf.foldMult[fo], beta = wf.foldBeta[fo];
133
+ const decTs = timeline[t] + BAR_MS;
134
+ const isStep = (k - firstK) % stepBars === 0;
135
+ if (isStep) {
136
+ for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
137
+ }
138
+ // доход фонда денежного рынка на весь капитал (LQDT в залоге; внутридневная маржа без платы за перенос)
139
+ if (policy.cashYield && lqdt && prevT >= 0 && lqdt[t] > 0 && lqdt[prevT] > 0 && lqdt[t] !== lqdt[prevT]) {
140
+ const y = equity * (lqdt[t] / lqdt[prevT] - 1);
141
+ equity += y;
142
+ stats.cashYield += y;
143
+ }
144
+ // результат защиты фьючерсом за свечу
145
+ if (hedge && hedgeRub !== 0 && prevT >= 0 && fut[t] > 0 && fut[prevT] > 0) {
146
+ const hp = hedgeRub * (fut[t] / fut[prevT] - 1);
147
+ equity += hp;
148
+ stats.hedgePnl += hp;
149
+ }
150
+ prevT = t;
151
+
152
+ // 1) внутри свечи t: фиксация прибыли, стоп отмены идеи и защитный стоп
153
+ for (const [i, pos] of [...held]) {
154
+ if (pos.entryT > t) continue;
155
+ const tk = tokens[i];
156
+ if (!tk.has[t]) continue;
157
+ const o = tk.open[t], h = tk.high[t], l = tk.low[t], c = tk.close[t];
158
+ const adverse = pos.side === 'long' ? l : h;
159
+ const favorable = pos.side === 'long' ? h : l;
160
+ const heldMinStart = (t - pos.entryT) * BAR_INTERVAL;
161
+ let done = false;
162
+ if (policy.profitLock) {
163
+ const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
164
+ if (lv) {
165
+ const gO = gainOf(pos, o) * 100;
166
+ if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
167
+ else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
168
+ }
169
+ }
170
+ if (done) continue;
171
+ const stopPx = pxOfGain(pos, -pos.stopPct / 100);
172
+ const levels = [[stopPx, 'stop']];
173
+ if (pos.thesisPx > 0) levels.push([pos.thesisPx, 'thesis']);
174
+ levels.sort((a, b) => (pos.side === 'long' ? b[0] - a[0] : a[0] - b[0]));
175
+ for (const [lvPx, kind] of levels) {
176
+ const hit = pos.side === 'long' ? adverse <= lvPx : adverse >= lvPx;
177
+ if (!hit) continue;
178
+ const px = pos.side === 'long' ? Math.min(o, lvPx) : Math.max(o, lvPx);
179
+ closePos(i, pos, t, px, kind === 'stop' ? 'Защитный стоп' : 'Стоп отмены идеи', kind);
180
+ if (kind === 'stop') stats.stops++; else stats.thesis++;
181
+ done = true;
182
+ break;
183
+ }
184
+ if (done) continue;
185
+ pos.peakGain = Math.max(pos.peakGain, gainOf(pos, favorable));
186
+ if (policy.profitLock) {
187
+ const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart + BAR_INTERVAL, roundTripFeePct);
188
+ const gC = gainOf(pos, c) * 100;
189
+ if (lv && gC <= lv.floorPct && gC >= lv.minExitPct) { closePos(i, pos, t, c, 'Фиксация прибыли', 'lock'); stats.locks++; }
190
+ }
191
+ }
192
+
193
+ // 2) решение на закрытии свечи t
194
+ const bear = isMarketDown(ds, t);
195
+ const needL = bear ? need * 1.25 : need;
196
+ const needS = bear ? need * 0.90 : need * 1.25;
197
+ const edge = (i) => pr[i] * sg[i] * sqrtH;
198
+ const t1d = Math.max(0, t - bpd), t3d = Math.max(0, t - 3 * bpd), t30 = Math.max(0, t - 3);
199
+ const mNow = ds.mclose[t];
200
+ const mx = (a) => (ds.mclose[a] > 0 && mNow > 0 ? mNow / ds.mclose[a] - 1 : 0);
201
+ const m1d = mx(t1d), m3d = mx(t3d), m30 = mx(t30);
202
+ const flags = (i) => {
203
+ const tk = tokens[i];
204
+ const px = tk.close[t];
205
+ const ex = (a, m) => (tk.close[a] > 0 && px > 0 ? px / tk.close[a] - 1 : 0) - m;
206
+ const ex1d = ex(t1d, m1d), ex3d = ex(t3d, m3d), ex30 = ex(t30, m30);
207
+ const flat = tk.high[t] > 0 && tk.high[t] === tk.low[t] && tk.value[t] > 1e6;
208
+ const lastMove = (tk.ret[t] - beta[i] * ds.mret[t]) * 100;
209
+ const rawMove = tk.close[t - 1] > 0 ? (px / tk.close[t - 1] - 1) * 100 : 0;
210
+ const crash = ex1d < -REGIME_SHOCK_1D || ex30 < -REGIME_CRASH_30M || (flat && ex1d < -0.05);
211
+ const squeeze = ex1d > REGIME_SHOCK_1D || (flat && ex1d > 0.05);
212
+ return {
213
+ crash, squeeze,
214
+ dipBlock: crash || lastMove < -4.5 || rawMove < -4.5,
215
+ spikeBlock: squeeze || lastMove > 4.5 || rawMove > 4.5,
216
+ overheat: ex1d >= 0.06 || ex3d >= 0.12,
217
+ };
218
+ };
219
+ const liquid = (i) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
220
+ const nightNow = shouldExitBeforeNight(decTs, 0, 3, BAR_INTERVAL);
221
+ const exitsAt = [];
222
+ for (const [i, pos] of held) {
223
+ const nightExit = shouldExitBeforeNight(decTs, timeline[pos.entryT], 3, BAR_INTERVAL);
224
+ const dr = divRule(i, t);
225
+ const divExit = Boolean(dr && (pos.side === 'long' ? dr.exitLong : dr.exitShort));
226
+ if (nightExit || divExit) { exitsAt.push([i, nightExit ? 'Закрытие перед ночью' : 'Дивидендная отсечка', nightExit ? 'night' : 'div']); continue; }
227
+ const barsHeldNext = t + 1 - pos.entryT;
228
+ if (policy.maxHoldBars > 0 && barsHeldNext >= policy.maxHoldBars) { exitsAt.push([i, 'Выход по времени', 'time']); continue; }
229
+ if (!isStep) continue;
230
+ const fl = flags(i);
231
+ if (pos.side === 'long' ? fl.crash : fl.squeeze) { exitsAt.push([i, 'Защита от сквиза/обвала', 'regime']); continue; }
232
+ if (policy.signalExit === false) continue;
233
+ const heldMin = barsHeldNext * BAR_INTERVAL;
234
+ const ramp = heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45));
235
+ const thr = (0.35 + (0.95 - 0.35) * ramp) * need * mult[i] * tierScale(i);
236
+ const soft = 0.35 * need * mult[i] * tierScale(i);
237
+ const e = edge(i);
238
+ const signed = pos.side === 'long' ? e : -e;
239
+ const curGainPct = gainOf(pos, tokens[i].close[t]) * 100;
240
+ const holdDip = heldMin < horizonMin && liquid(i) && signed > soft && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
241
+ if (!(liquid(i) && (signed > thr || holdDip))) exitsAt.push([i, 'Прогноз ослаб', 'signal']);
242
+ }
243
+ for (const [i, why, kind] of exitsAt) {
244
+ const pos = held.get(i);
245
+ if (!pos) continue;
246
+ const px = fill === 'mid' ? (tokens[i].open[t + 1] + tokens[i].close[t + 1]) / 2 : tokens[i].open[t + 1];
247
+ closePos(i, pos, t + 1, px > 0 ? px : tokens[i].close[t], why, kind);
248
+ if (kind === 'night') stats.night++; else if (kind === 'signal') stats.signalExits++; else if (kind === 'time') stats.timeExits++; else if (kind === 'regime') stats.regime++; else stats.div++;
249
+ }
250
+
251
+ // 3) входы
252
+ const timeOk = canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
253
+ if (timeOk) {
254
+ const baseOk = (i) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i]) && (policy.tier2 !== false || isT1(i));
255
+ const candL = [];
256
+ const candS = [];
257
+ for (let i = 0; i < N; i++) {
258
+ const e = edge(i);
259
+ if (e > Math.max(needL * mult[i] * tierScale(i), minEdgeL)) {
260
+ if (!baseOk(i)) continue;
261
+ const fl = flags(i);
262
+ if (fl.dipBlock || fl.overheat || divRule(i, t)?.blockLong) continue;
263
+ candL.push(i);
264
+ } else if (dir !== 'long' && e < -Math.max(needS * mult[i] * tierScale(i), minEdgeS)) {
265
+ if (!baseOk(i)) continue;
266
+ if (shortable && !shortable.has(tokens[i].symbol)) continue;
267
+ if (!canEnterIntraday(decTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)) continue;
268
+ const fl = flags(i);
269
+ if (fl.spikeBlock || divRule(i, t)?.blockShort) continue;
270
+ candS.push(i);
271
+ }
272
+ }
273
+ candL.sort((a, b) => pr[b] - pr[a]);
274
+ candS.sort((a, b) => pr[a] - pr[b]);
275
+ let nL = 0, nS = 0;
276
+ for (const q of held.values()) { if (q.side === 'long') nL++; else nS++; }
277
+ const budget = equity + mtmUnreal(t);
278
+ // ротация остывшей позиции в сильного лидера (на плановом шаге)
279
+ if (isStep && (candL.length || candS.length) && grossUsed(t) > budget * maxGross * 0.65) {
280
+ const best = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? -edge(candS[0]) : 0);
281
+ if (best >= need * 2.2) {
282
+ let wi = -1, we = Infinity;
283
+ for (const [i, q] of held) {
284
+ const s = q.side === 'long' ? edge(i) : -edge(i);
285
+ if (s < need * 0.95 * 0.90 && s < we && best > Math.max(need, s) * 2.4) { we = s; wi = i; }
286
+ }
287
+ if (wi >= 0) {
288
+ const q = held.get(wi);
289
+ closePos(wi, q, t + 1, tokens[wi].open[t + 1], 'Ротация', 'rotation');
290
+ stats.rotations++;
291
+ if (q.side === 'long') nL--; else nS--;
292
+ }
293
+ }
294
+ }
295
+ const picks = [
296
+ ...candL.slice(0, Math.max(0, K - nL)).map((i) => [i, 'long']),
297
+ ...candS.slice(0, Math.max(0, K - nS)).map((i) => [i, 'short']),
298
+ ];
299
+ picks.sort((a, b) => Math.abs(edge(b[0])) - Math.abs(edge(a[0])));
300
+ let avail = Math.max(0, budget * maxGross - grossUsed(t));
301
+ let remW = picks.reduce((s, [i]) => s + Math.max(0.0001, Math.abs(edge(i))), 0);
302
+ for (const [i, side] of picks) {
303
+ const e = edge(i);
304
+ const w = Math.max(0.0001, Math.abs(e));
305
+ const propShare = remW > 0 ? avail * (w / remW) : avail;
306
+ remW -= w;
307
+ const sigmaBarPct = sg[i] * 100;
308
+ const ctx = dayContext(ds, i, t, sigmaBarPct);
309
+ if (counterTrendBlocked(side, ctx, policy)) { stats.blockedCT++; continue; }
310
+ const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: H }, policy);
311
+ const conv = Math.min(convMax, Math.max(0.85, 0.80 + convK * (Math.abs(e) / need)));
312
+ const tm = isT1(i) ? (policy.sizeMultT1 ?? lev) : (policy.sizeMultT2 ?? 1);
313
+ if (!(tm > 0)) continue;
314
+ const target = targetPositionRub({ symbol: tokens[i].symbol, side, budget: budget * capScale * tm, slotRub: budget * slotW * tm, conv: convMax > 1 ? conv : 1, stopPct, barTurnoverRub: isT1(i) ? 0 : barTurnover(i, t) }, policy);
315
+ const alloc = Math.min(target, propShare, avail);
316
+ if (!(alloc > 5000)) continue;
317
+ const tk = tokens[i];
318
+ let entryPx = fill === 'mid' ? (tk.open[t + 1] + tk.close[t + 1]) / 2 : tk.open[t + 1];
319
+ let entryFee = alloc * costOf(i);
320
+ if (passive) {
321
+ // пассивная лимитная заявка на своей стороне спреда: исполняется, только если цена свечи t+1 прошла сквозь неё
322
+ const off = (isT1(i) ? passive.t1Pct : passive.t2Pct) / 100;
323
+ const lim = side === 'long' ? tk.open[t + 1] * (1 - off) : tk.open[t + 1] * (1 + off);
324
+ const filled = tk.has[t + 1] && (side === 'long' ? tk.low[t + 1] < lim : tk.high[t + 1] > lim);
325
+ if (!filled) { stats.missedPassive++; continue; }
326
+ entryPx = lim;
327
+ entryFee = alloc * commission;
328
+ }
329
+ if (!(entryPx > 0)) continue;
330
+ equity -= entryFee;
331
+ avail -= alloc;
332
+ held.set(i, {
333
+ i, side, entryT: t + 1, entryPx, alloc, entryFee, peakGain: 0, stopPct, beta: beta[i],
334
+ thesisPx: thesisStopPx(side, ctx, policy),
335
+ expectedPct: Math.round(e * 10000) / 100, dayMovePct: Math.round(ctx.dayMovePct * 100) / 100,
336
+ counterTrend: side === 'short' ? ctx.dayMovePct > 0 : ctx.dayMovePct < 0,
337
+ });
338
+ stats.entries++;
339
+ }
340
+ }
341
+
342
+ // 4) защита фьючерсом: позиция против чистой беты портфеля (в целых контрактах)
343
+ if (hedge && fut[t] > 0) {
344
+ let netBeta = 0;
345
+ for (const q of held.values()) netBeta += (q.side === 'long' ? 1 : -1) * q.beta * q.alloc * (1 + gainOf(q, tokens[q.i].close[t]) * (q.side === 'long' ? 1 : -1));
346
+ const contractRub = fut[t] * hedgeMult;
347
+ const target = Math.round(-netBeta / contractRub) * contractRub;
348
+ if (target !== hedgeRub) {
349
+ const cost = Math.abs(target - hedgeRub) * (hedge.costPct / 100);
350
+ equity -= cost;
351
+ stats.hedgeCost += cost;
352
+ hedgeRub = target;
353
+ }
354
+ }
355
+ const used = grossUsed(t);
356
+ stats.exposureSum += used;
357
+ stats.exposureN++;
358
+ if (isStep) curve.push({ ts: timeline[t], equity: equity + mtmUnreal(t) });
359
+ }
360
+ if (lastT >= 0) {
361
+ for (const [i, pos] of [...held]) closePos(i, pos, lastT, tokens[i].close[lastT], 'Конец периода', 'end');
362
+ curve.push({ ts: timeline[lastT], equity });
363
+ }
364
+ stats.avgExposurePct = stats.exposureN ? (stats.exposureSum / stats.exposureN / capital) * 100 : 0;
365
+ return { trades, curve, stats, equity };
366
+ }
@@ -0,0 +1,59 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Пошаговое (walk-forward) обучение одной модели на истории: окно обучения → прогноз 20 дней → сдвиг.
3
+ // Те же фолды, что в research.js, но только для выбранной модели и без телеметрии интерфейса —
4
+ // результат (прогнозы по каждой свече) используется симулятором стратегии (simulator.js) и лабораторией правил.
5
+ import { buildFold, MODELS, FEATURE_WARMUP } from './models.js';
6
+ import { computeTokenEdgeMults } from './edge.js';
7
+ import { barsFromHours } from './dataset.js';
8
+
9
+ const DAY = 86400000;
10
+ const MSK = 3 * 3600000;
11
+ const EPOCH_ANCHOR_TS = 1718272800000;
12
+ const tick = () => new Promise((r) => setImmediate(r));
13
+
14
+ export async function computeWalkForward(ds, p, { model = 'attention', tillTs = Date.now(), onProgress = () => {}, signal } = {}) {
15
+ const autoAdapt = p.modelAutoAdapt !== false;
16
+ const H = barsFromHours(autoAdapt ? 2.5 : p.modelHorizon);
17
+ const maxH = autoAdapt ? Math.round(H * 1.4) : H;
18
+ const { tokens, T, timeline } = ds;
19
+ const N = tokens.length;
20
+ const bpd = ds.barsPerDay;
21
+ const trainLen = Math.round(p.modelTrainDays * bpd);
22
+ const testLen = Math.round(20 * bpd);
23
+ const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
24
+ const rawFrom = todayStartMsk - p.modelHistoryDays * DAY;
25
+ const gridBlockMs = 20 * DAY;
26
+ const researchFrom = Math.max(EPOCH_ANCHOR_TS, EPOCH_ANCHOR_TS + Math.round((rawFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs);
27
+ let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom), FEATURE_WARMUP + maxH + 100);
28
+ if (testStart < 0 || testStart >= T - H - 10) throw new Error('Недостаточно истории для пошаговой проверки');
29
+ const folds = Math.ceil((T - maxH - 2 - testStart) / testLen);
30
+ const testTs = [];
31
+ const pred = [];
32
+ const foldOf = [];
33
+ const foldSigma = [];
34
+ const foldBeta = [];
35
+ const foldMult = [];
36
+ let fi = 0;
37
+ while (testStart < T - maxH - 2) {
38
+ if (signal?.aborted) throw new Error('Отменено');
39
+ const testEnd = Math.min(testStart + testLen, T - maxH - 2);
40
+ const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
41
+ onProgress({ fold: fi + 1, folds, from: timeline[testStart], till: timeline[testEnd - 1] });
42
+ await tick();
43
+ const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: 0, profiles: null, multiHorizon: autoAdapt });
44
+ foldSigma.push(Float64Array.from(f.sigma));
45
+ foldBeta.push(Float64Array.from(f.beta));
46
+ foldMult.push(computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart));
47
+ const fitted = await MODELS[model].fit(f);
48
+ for (let t = testStart; t < testEnd; t++) {
49
+ const u = t - f.lo;
50
+ if (!f.yok[u]) continue;
51
+ testTs.push(t);
52
+ pred.push(Float32Array.from(MODELS[model].predict(fitted, f, u)));
53
+ foldOf.push(fi);
54
+ }
55
+ testStart = testEnd;
56
+ fi++;
57
+ }
58
+ return { H, N, testTs, pred, foldOf, foldSigma, foldBeta, foldMult };
59
+ }
@@ -48,6 +48,8 @@ export const TOOLS = {
48
48
  режим_старого_портфеля: a.legacyMode || 'isolate',
49
49
  лимит_на_стратегию_руб: a.maxCapitalRub,
50
50
  дневной_стоп_проц: a.maxDailyDrawdownPct,
51
+ плечо: a.maxLeverage || 1,
52
+ стоп_по_общей_просадке_проц: a.maxDrawdownPct || 0,
51
53
  отступ_лимитки_проц: a.limitOffsetPct,
52
54
  пауза_причина: a.haltedReason || null,
53
55
  время_снимка_портфеля_мск: a.lastSync?.at ? fmtBarMsk(a.lastSync.at) : null,