neuroalpha 0.2.0 → 0.3.13

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.
Files changed (52) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +9 -3
  3. package/bin/cli.js +69 -69
  4. package/config/seed-run.json +1 -0
  5. package/config/strategy.json +4 -10
  6. package/deploy/install.sh +128 -0
  7. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
  8. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
  9. package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +171 -0
  10. package/package.json +15 -5
  11. package/public/admin.html +449 -0
  12. package/public/app.js +1182 -379
  13. package/public/index.html +87 -22
  14. package/public/landing.html +1217 -870
  15. package/public/legal.html +51 -8
  16. package/public/styles.css +71 -16
  17. package/server/api/routes.js +220 -35
  18. package/server/auth/index.js +633 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1369 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +69 -4
  24. package/server/hub.js +1 -0
  25. package/server/index.js +10 -2
  26. package/server/llm/client.js +1 -0
  27. package/server/llm/providers.js +50 -3
  28. package/server/model/dataset.js +362 -352
  29. package/server/model/forecast.js +202 -37
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +476 -486
  32. package/server/model/metrics.js +1 -0
  33. package/server/model/models.js +31 -21
  34. package/server/model/profiles.js +1 -0
  35. package/server/model/research.js +128 -205
  36. package/server/model/vocab.js +1 -0
  37. package/server/news/index.js +1 -0
  38. package/server/notify/index.js +304 -168
  39. package/server/params-meta.js +22 -26
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +2 -1
  42. package/server/strategist/index.js +7 -7
  43. package/server/strategist/tools.js +2 -1
  44. package/server/util.js +1 -0
  45. package/server/model/gpu-worker.html +0 -11
  46. package/server/model/gpu-worker.js +0 -60
  47. package/server/model/tf-node.js +0 -26
  48. package/server/model/transformer-core.js +0 -330
  49. package/server/model/transformer-gpu.js +0 -221
  50. package/server/model/transformer-pool.js +0 -200
  51. package/server/model/transformer-worker.js +0 -28
  52. package/server/model/transformer.js +0 -57
@@ -1,486 +1,476 @@
1
- import { createRun, finishRun, saveTrade, updateRunMetrics, getRun, getTrades, deleteRun, kvGet, kvSet } from '../db.js';
2
- import { normalizeParams } from '../settings.js';
3
- import { round, DAY, MSK, fmtBarMsk } from '../util.js';
4
- import { hub } from '../hub.js';
5
- import { latestBarTs } from '../data/moex.js';
6
- import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
7
- import { predictAt, lastClosedIndex } from './forecast.js';
8
- import { computeReport, downsample } from './metrics.js';
9
- import { MODELS } from './models.js';
10
- import { blockingNews } from '../news/index.js';
11
- import { currentUserId } from '../auth/index.js';
12
- import { notifyUser } from '../notify/index.js';
13
-
14
- const dayKey = (ts) => new Date(ts + MSK).toISOString().slice(0, 10);
15
-
16
- function buildBenchmark(baseRunId) {
17
- if (!baseRunId) return null;
18
- const br = getRun(Number(baseRunId));
19
- if (!br?.metrics?.full) return null;
20
- const f = br.metrics.full;
21
- const days = Math.max(1, Math.round(((br.metrics.period?.till || 0) - (br.metrics.period?.from || 0)) / DAY));
22
- return {
23
- runId: br.id,
24
- returnPct: f.returnPct,
25
- marketPct: f.marketPct,
26
- maxDrawdownPct: f.maxDrawdownPct,
27
- trades: f.trades,
28
- periodDays: days,
29
- dailyPct: round(f.returnPct / days, 4),
30
- };
31
- }
32
-
33
- // Движок виртуальных счетов: поддерживает несколько независимых счетов одновременно.
34
- // Каждый счёт замораживает свои настройки при запуске (эксперименты в песочнице его не меняют)
35
- // и при запуске «из прогона» сверяет фактический результат с историческим коридором этого прогона.
36
- class LiveEngine {
37
- constructor() {
38
- this.accounts = new Map(); // runId -> state
39
- this.timer = null;
40
- }
41
-
42
- #direction(s) {
43
- return s.params.modelDirection || 'long';
44
- }
45
-
46
- #borrow(s, h, ts) {
47
- return h.alloc * ((s.params.modelShortFeePct ?? 0) / 100) * ((ts - h.entryTs) / DAY / 365);
48
- }
49
-
50
- #openPnl(s, h, price, ts) {
51
- return h.side === 'short' ? h.alloc * (1 - price / h.entryPrice) - this.#borrow(s, h, ts) : h.alloc * (price / h.entryPrice - 1);
52
- }
53
-
54
- snapshotOf(s, ts = Date.now()) {
55
- let mtm = 0;
56
- const positions = [...s.held.entries()].map(([symbol, h]) => {
57
- const pnl = this.#openPnl(s, h, h.lastPrice, ts);
58
- mtm += pnl;
59
- return {
60
- symbol, side: h.side || 'long', qty: round(h.alloc / h.entryPrice, 2),
61
- entryTs: h.entryTs, entryPrice: round(h.entryPrice, 4), lastPrice: round(h.lastPrice, 4),
62
- pnl: round(pnl - h.entryCost, 2), expectedPct: h.expectedPct,
63
- };
64
- });
65
- return { equity: round(s.equity + mtm, 2), cash: round(s.equity, 2), positions, pending: s.pending.map((o) => ({ action: o.action, symbol: o.symbol })) };
66
- }
67
-
68
- snapshot(ts = Date.now()) {
69
- const p = this.#primary();
70
- return p ? this.snapshotOf(p, ts) : { equity: 0, cash: 0, positions: [], pending: [] };
71
- }
72
-
73
- #primary() {
74
- const list = [...this.accounts.values()].sort((a, b) => b.runId - a.runId);
75
- return list.find((a) => a.running) || list[0] || null;
76
- }
77
-
78
- #accountStatus(s) {
79
- const snap = this.snapshotOf(s);
80
- const cap = s.params.capital || 1000000;
81
- const pnl = round(snap.equity - cap, 2);
82
- const returnPct = round((pnl / cap) * 100, 2);
83
- const endTs = s.running ? Date.now() : (s.stoppedAt || Date.now());
84
- const elapsedDays = Math.max(0.05, round((endTs - s.startedAt) / DAY, 2));
85
- let corridor = null;
86
- if (s.benchmark) {
87
- const expPct = round(s.benchmark.dailyPct * elapsedDays, 2);
88
- const bandPct = round(Math.max(1.5, (s.benchmark.maxDrawdownPct || 10) * Math.sqrt(elapsedDays / Math.max(30, s.benchmark.periodDays))), 1);
89
- const status = returnPct < expPct - bandPct ? 'below' : returnPct > expPct + bandPct ? 'above' : 'inside';
90
- corridor = { runId: s.benchmark.runId, expectedPct: expPct, bandPct, minPct: round(expPct - bandPct, 1), maxPct: round(expPct + bandPct, 1), histReturnPct: s.benchmark.returnPct, histMaxDdPct: s.benchmark.maxDrawdownPct, status };
91
- }
92
- return {
93
- running: s.running, runId: s.runId, name: s.name || `Виртуальный счёт №${s.runId}`,
94
- baseRunId: s.baseRunId || null, benchmark: s.benchmark || null, corridor,
95
- params: s.params, startedAt: s.startedAt, stoppedAt: s.stoppedAt || null, lastBarTs: s.lastBarTs,
96
- error: s.error, busy: s.busy, snapshot: snap, returnPct, pnl,
97
- recent: s.recent.slice(0, 20), metrics: s.metrics,
98
- };
99
- }
100
-
101
- status(uid = currentUserId()) {
102
- const list = [...this.accounts.values()].filter((s) => (s.userId || 1) === (uid || 1)).sort((a, b) => b.runId - a.runId).map((s) => this.#accountStatus(s));
103
- const primary = list.find((a) => a.running) || list[0] || null;
104
- if (!primary) return { running: false, accounts: [] };
105
- return {
106
- ...primary,
107
- running: list.some((a) => a.running),
108
- accounts: list,
109
- };
110
- }
111
-
112
- #ensureTimer() {
113
- const running = [...this.accounts.values()].filter((a) => a.running);
114
- if (!running.length) {
115
- if (this.timer) { clearInterval(this.timer); this.timer = null; }
116
- return;
117
- }
118
- if (!this.timer) {
119
- const pollSec = Math.max(15, Math.min(...running.map((a) => Number(a.params.paperPollSec) || 60)));
120
- this.timer = setInterval(() => this.tick(), pollSec * 1000);
121
- }
122
- }
123
-
124
- async start(input, { source = 'user', name = '', baseRunId = null, capital = null, maxAccounts = 5 } = {}) {
125
- const uid = currentUserId();
126
- const runningCount = [...this.accounts.values()].filter((a) => a.running && (a.userId || 1) === uid).length;
127
- if (runningCount >= maxAccounts) throw new Error(`По вашему тарифу доступно одновременно не более ${maxAccounts} виртуальных счетов`);
128
- const baseRun = baseRunId ? getRun(Number(baseRunId)) : null;
129
- const rawParams = baseRun?.params ? { ...baseRun.params, ...input } : input;
130
- const params = normalizeParams(rawParams);
131
- if (capital && Number(capital) >= 10000) params.capital = Math.round(Number(capital));
132
- const benchmark = buildBenchmark(baseRunId);
133
- const accName = String(name || '').trim() || (baseRunId ? `Из эксперимента №${baseRunId} (${MODELS[params.modelType]?.label || params.modelType}, ${params.modelTopK} акц.)` : `Счёт · ${MODELS[params.modelType]?.label || params.modelType}, ${params.modelTopK} акц.`);
134
- const runId = createRun('paper', { ...params, _source: source, _baseRunId: baseRunId || null, _name: accName }, uid);
135
- const s = {
136
- userId: uid, running: true, runId, name: accName, baseRunId: baseRunId ? Number(baseRunId) : null, benchmark,
137
- params, source, startedAt: Date.now(), stoppedAt: null, lastBarTs: null, lastSeenTs: null, error: null, busy: false,
138
- equity: params.capital, held: new Map(), pending: [], trades: [], curve: [], recent: [], metrics: null,
139
- barsSinceRebalance: Infinity, day: null, dayStartEquity: params.capital, dayTrades: [],
140
- };
141
- this.accounts.set(runId, s);
142
- this.#persist();
143
- hub.emit({ type: 'paper:started', runId, source, name: accName, baseRunId: s.baseRunId });
144
- hub.emit({ type: 'paper:status', status: this.status() });
145
- this.#ensureTimer();
146
- this.tick();
147
- return runId;
148
- }
149
-
150
- #serialize(s) {
151
- return {
152
- userId: s.userId || 1, runId: s.runId, name: s.name, baseRunId: s.baseRunId, benchmark: s.benchmark,
153
- running: s.running, params: s.params, source: s.source, startedAt: s.startedAt, stoppedAt: s.stoppedAt,
154
- equity: s.equity, held: [...s.held.entries()], pending: s.pending, curve: s.curve.slice(-3000),
155
- lastBarTs: s.lastBarTs, lastSeenTs: s.lastSeenTs, decisionTs: s.decisionTs ?? null,
156
- barsSinceRebalance: Number.isFinite(s.barsSinceRebalance) ? s.barsSinceRebalance : null,
157
- day: s.day, dayStartEquity: s.dayStartEquity, dayTrades: s.dayTrades, recent: s.recent.slice(0, 20), savedAt: Date.now(),
158
- };
159
- }
160
-
161
- #persist() {
162
- const arr = [...this.accounts.values()].map((s) => this.#serialize(s));
163
- kvSet('liveAccounts', arr);
164
- const prim = this.#primary();
165
- kvSet('liveState', prim && prim.running ? this.#serialize(prim) : null);
166
- }
167
-
168
- resume() {
169
- let list = kvGet('liveAccounts');
170
- if (!Array.isArray(list) || !list.length) {
171
- const legacy = kvGet('liveState');
172
- list = legacy?.runId ? [legacy] : [];
173
- }
174
- let resumedAny = false;
175
- for (const st of list) {
176
- if (!st?.runId || this.accounts.has(st.runId)) continue;
177
- const run = getRun(st.runId);
178
- if (!run || run.mode !== 'paper' || !st.params?.modelHorizon) continue;
179
- const isRunning = Boolean(st.running && run.status === 'running');
180
- const trades = getTrades(st.runId).map((t) => ({
181
- symbol: t.symbol, side: t.side, pnl: t.pnl, pnlPct: t.pnl_pct, entryTs: t.entry_ts, exitTs: t.exit_ts, commission: t.commission,
182
- barsHeld: Math.round((t.exit_ts - t.entry_ts) / 600000) * BAR_INTERVAL,
183
- }));
184
- const curve = st.curve || [];
185
- const s = {
186
- userId: st.userId || run.user_id || 1,
187
- running: isRunning, runId: st.runId, name: String(st.name || run.params?._name || `Виртуальный счёт №${st.runId}`).replace(/^Из прогона/i, 'Из эксперимента'),
188
- baseRunId: st.baseRunId ?? run.params?._baseRunId ?? null,
189
- benchmark: st.benchmark ?? buildBenchmark(st.baseRunId ?? run.params?._baseRunId),
190
- params: st.params, source: st.source || 'user', startedAt: st.startedAt, stoppedAt: st.stoppedAt || null,
191
- lastBarTs: st.lastBarTs ?? null, lastSeenTs: null, decisionTs: st.decisionTs ?? undefined, error: null, busy: false,
192
- equity: st.equity, held: new Map((st.held || []).map(([sym, h]) => [sym, { side: 'long', ...h }])),
193
- pending: st.pending || [], trades, curve, recent: st.recent || [],
194
- metrics: computeReport(trades, curve.length ? curve : [{ ts: Date.now(), equity: st.params.capital }], st.params.capital, 0),
195
- barsSinceRebalance: st.barsSinceRebalance ?? Infinity, day: st.day ?? null,
196
- dayStartEquity: st.dayStartEquity ?? st.params.capital, dayTrades: st.dayTrades || [],
197
- };
198
- this.accounts.set(s.runId, s);
199
- if (isRunning) {
200
- resumedAny = true;
201
- hub.emit({ type: 'paper:resumed', runId: s.runId });
202
- }
203
- }
204
- if (this.accounts.size) {
205
- this.#persist();
206
- hub.emit({ type: 'paper:status', status: this.status() });
207
- }
208
- if (resumedAny) {
209
- this.#ensureTimer();
210
- this.tick();
211
- }
212
- return resumedAny;
213
- }
214
-
215
- async suspend() {
216
- if (this.timer) { clearInterval(this.timer); this.timer = null; }
217
- for (const s of this.accounts.values()) s.suspended = true;
218
- for (let i = 0; i < 15 && [...this.accounts.values()].some((s) => s.busy); i++) await new Promise((r) => setTimeout(r, 100));
219
- this.#persist();
220
- return true;
221
- }
222
-
223
- async tick() {
224
- const running = [...this.accounts.values()].filter((s) => s.running && !s.busy && !s.suspended);
225
- if (!running.length) return;
226
- let latest = null;
227
- try { latest = await latestBarTs('IMOEX', BAR_INTERVAL); } catch { /* проверим в самом счёте */ }
228
- for (const s of running) {
229
- await this.#tickAccount(s, latest);
230
- }
231
- }
232
-
233
- async #tickAccount(s, latest) {
234
- if (!s.running || s.busy || s.suspended) return;
235
- if (latest !== null && latest === s.lastSeenTs) return;
236
- s.busy = true;
237
- let changed = false;
238
- try {
239
- const p = s.params;
240
- const now = Date.now();
241
- const ds = await loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
242
- s.lastSeenTs = latest;
243
- changed = true;
244
- const bySym = new Map(ds.tokens.map((t, i) => [t.symbol, i]));
245
- const last = lastClosedIndex(ds, now);
246
-
247
- if (s.lastBarTs !== null && last >= 0) {
248
- for (let j = 0; j < last; j++) {
249
- const t = ds.timeline[j];
250
- if (t <= s.lastBarTs) continue;
251
- let mtm = 0;
252
- for (const [sym, h] of s.held) {
253
- const i = bySym.get(sym);
254
- const px = i !== undefined ? ds.tokens[i].close[j] : 0;
255
- if (px > 0) mtm += this.#openPnl(s, h, px, t);
256
- }
257
- s.curve.push({ ts: t, equity: round(s.equity + mtm, 2) });
258
- }
259
- }
260
-
261
- if (s.deleted) return;
262
- if (s.pending.length) {
263
- const k = ds.timeline.findIndex((t) => t > s.decisionTs);
264
- if (k >= 0) this.#execute(s, ds, k, bySym);
265
- }
266
- if (last >= 0 && ds.timeline[last] !== s.lastBarTs && (s.lastBarTs === null || ds.timeline[last] > s.lastBarTs)) {
267
- await this.#onBar(s, ds, last, bySym);
268
- }
269
- s.error = null;
270
- } catch (e) {
271
- s.error = e.message;
272
- if (s.running && !s.deleted) hub.emit({ type: 'log', level: 'error', text: `${s.name}: ${e.message}` });
273
- } finally {
274
- s.busy = false;
275
- if (changed && !s.suspended && !s.deleted) this.#persist();
276
- hub.emit({ type: 'paper:status', status: this.status() });
277
- }
278
- }
279
-
280
- #execute(s, ds, k, bySym) {
281
- const cost = (s.params.commissionPct + s.params.slippagePct) / 100;
282
- const ts = ds.timeline[k];
283
- const price = (sym) => { const i = bySym.get(sym); const tk = ds.tokens[i]; return tk && (tk.open[k] > 0 ? tk.open[k] : tk.close[k]); };
284
- for (const o of s.pending.filter((x) => x.action === 'sell' || x.action === 'cover')) {
285
- const h = s.held.get(o.symbol);
286
- const px = price(o.symbol);
287
- if (!h || !(px > 0)) continue;
288
- const short = h.side === 'short';
289
- const gross = short ? h.alloc * (1 - px / h.entryPrice) : h.alloc * (px / h.entryPrice - 1);
290
- const fee = (short ? h.alloc - gross : h.alloc + gross) * cost;
291
- const borrow = short ? this.#borrow(s, h, ts) : 0;
292
- s.equity += gross - fee - borrow;
293
- const pnl = gross - fee - borrow - h.entryCost;
294
- const t = {
295
- symbol: o.symbol, side: h.side, qty: round(h.alloc / h.entryPrice, 2), entryTs: h.entryTs, entryPrice: round(h.entryPrice, 4),
296
- exitTs: ts, exitPrice: round(px, 4), stop: null, take: null, pnl: round(pnl, 2), pnlPct: round((pnl / h.alloc) * 100, 3),
297
- commission: round(fee + h.entryCost, 2), barsHeld: Math.round((ts - h.entryTs) / 600000) * BAR_INTERVAL, reasonEntry: h.reason, reasonExit: o.reason,
298
- meta: { model: s.params.modelType, exitKind: 'signal', source: 'model', ...(short ? { borrow: round(borrow, 2) } : {}) },
299
- };
300
- s.held.delete(o.symbol);
301
- s.trades.push(t);
302
- s.dayTrades.push(t);
303
- saveTrade(s.runId, t);
304
- hub.emit({ type: 'paper:trade', runId: s.runId, trade: t });
305
- }
306
- const K = s.params.modelTopK;
307
- const dir = this.#direction(s);
308
- const slots = dir === 'long' ? K : 2 * K;
309
- const autoAdapt = s.params.modelAutoAdapt !== false;
310
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : (dir === 'neutral' ? Math.min(0.22, 1 / (2 * K)) : Math.min(0.30, 1.2 / (2 * K)));
311
- const invVol = s.params.modelAllocMode === 'invvol';
312
- const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
313
- const usedStart = [...s.held.values()].reduce((sum, h) => sum + h.alloc, 0);
314
- let avail = Math.max(0, s.equity * 0.99 - usedStart);
315
- for (let idx = 0; idx < buyOrders.length; idx++) {
316
- const o = buyOrders[idx];
317
- const side = o.action === 'short' ? 'short' : 'long';
318
- const px = price(o.symbol);
319
- if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) continue;
320
- const baseAlloc = autoAdapt ? Math.min(s.equity * maxSlotWeight, avail / Math.max(1, buyOrders.length - idx)) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
321
- let alloc = baseAlloc;
322
- if (invVol && o.weight > 0) {
323
- alloc = Math.min(baseAlloc * o.weight, avail / Math.max(1, buyOrders.length - idx));
324
- }
325
- if (!(alloc > 1000)) continue;
326
- avail = Math.max(0, avail - alloc);
327
- const fee = alloc * cost;
328
- s.equity -= fee;
329
- s.held.set(o.symbol, { side, entryTs: ts, entryPrice: px, lastPrice: px, alloc, entryCost: fee, reason: o.reason, expectedPct: o.expectedPct });
330
- hub.emit({ type: 'paper:fill', runId: s.runId, position: { symbol: o.symbol, side, qty: round(alloc / px, 2), price: px } });
331
- notifyUser(s.userId || 1, `НейроАльфа · Виртуальный счёт «${s.name}»\n${side === 'short' ? 'Открыт шорт' : 'Куплено'}: ${o.symbol} по ${round(px, 3)} ₽ на ${round(alloc, 0)} ₽`).catch(() => {});
332
- }
333
- s.pending = [];
334
- }
335
-
336
- async #onBar(s, ds, idx, bySym) {
337
- const p = s.params;
338
- const ts = ds.timeline[idx];
339
- const d = dayKey(ts);
340
- if (s.day && d !== s.day) {
341
- hub.emit({ type: 'paper:day_end', runId: s.runId, day: s.day, startEquity: s.dayStartEquity, endEquity: this.snapshotOf(s).equity, trades: s.dayTrades, snapshot: this.snapshotOf(s), params: p });
342
- s.dayTrades = [];
343
- s.dayStartEquity = this.snapshotOf(s).equity;
344
- }
345
- s.day = d;
346
- for (const [sym, h] of s.held) { const i = bySym.get(sym); if (i !== undefined && ds.tokens[i].close[idx] > 0) h.lastPrice = ds.tokens[i].close[idx]; }
347
- s.curve.push({ ts, equity: this.snapshotOf(s).equity });
348
- s.lastBarTs = ts;
349
-
350
- const step = { ts, bar: fmtBarMsk(ts), decisions: [], top: [] };
351
- s.barsSinceRebalance++;
352
- const H = barsFromHours(p.modelHorizon);
353
- const autoAdapt = p.modelAutoAdapt !== false;
354
- const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
355
- if (s.barsSinceRebalance >= stepBars) {
356
- await new Promise((r) => setImmediate(r));
357
- const r = await predictAt(ds, p, idx);
358
- const row = new Map(r.rows.map((x) => [x.symbol, x]));
359
- const dir = this.#direction(s);
360
- const K = p.modelTopK;
361
- const closing = new Set();
362
- const closePos = (sym, why) => {
363
- const x = row.get(sym);
364
- const short = s.held.get(sym).side === 'short';
365
- const action = short ? 'cover' : 'sell';
366
- s.pending.push({ action, symbol: sym, reason: why || `Прогноз ослаб: ожидание ${x ? x.expectedPct : '—'}% за ${round(p.modelHorizon, 2)} ч` });
367
- step.decisions.push({ action, symbol: sym, text: `${short ? 'закрыть продажу' : 'продать'} — ${why ? why.toLowerCase() : `прогноз ослаб (${x ? x.expectedPct : '—'}%)`}` });
368
- closing.add(sym);
369
- };
370
- const exitRatio = autoAdapt ? 0.75 : Number(p.modelExitRatio ?? 0.5);
371
- const minHoldBars = p.modelMinHoldHours > 0 ? barsFromHours(p.modelMinHoldHours) : 0;
372
- const mktDown = p.modelMarketFilter === 'ma' && dir !== 'neutral' && r.marketDown;
373
- const quarantined = new Set();
374
- if (autoAdapt && s.trades.length >= 3) {
375
- const stMap = new Map();
376
- for (const t of s.trades) {
377
- // затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
378
- const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
379
- const wDecay = Math.pow(0.99, ageSteps);
380
- const cur = stMap.get(t.symbol) || { pnl: 0, cnt: 0 };
381
- cur.pnl = cur.pnl * 0.90 + (Number(t.pnlPct || 0) / 100) * wDecay;
382
- cur.cnt += wDecay;
383
- stMap.set(t.symbol, cur);
384
- }
385
- for (const [sym, st] of stMap) if (st.cnt >= 3 && st.pnl < -0.012) quarantined.add(sym);
386
- }
387
- for (const [sym, h] of s.held) {
388
- const x = row.get(sym);
389
- const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
390
- const lock = minHoldBars > 0 && heldBars < minHoldBars && x && x.liquid && (h.side === 'short' ? x.expected <= 0 : x.expected >= 0);
391
- const req = r.threshold * (autoAdapt && heldBars < H ? 0.35 : exitRatio);
392
- if (!lock && !(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
393
- }
394
- const sideHeld = (side) => [...s.held].filter(([sym, h]) => h.side === side && !closing.has(sym));
395
- const nb = p.newsFilter ? blockingNews(6) : null;
396
- const take = (list, side, n) => {
397
- const lim = Math.max(0, n);
398
- const out = [];
399
- for (const x of list) {
400
- if (out.length >= lim) break;
401
- if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
402
- const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
403
- if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
404
- out.push(x);
405
- }
406
- return out;
407
- };
408
- let entries;
409
- if (dir === 'long') {
410
- const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
411
- const candL = mktDown ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol)).sort((a, b) => b.expected - a.expected);
412
- if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
413
- entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
414
- } else {
415
- const open = (x) => !s.held.has(x.symbol) || closing.has(x.symbol);
416
- const candL = mktDown ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
417
- const candS = r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
418
- if (mktDown && dir === 'longshort') step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
419
- let nL, nS;
420
- if (dir === 'longshort') {
421
- nL = K - sideHeld('long').length;
422
- nS = K - sideHeld('short').length;
423
- } else {
424
- const pairs = Math.min(K, sideHeld('long').length + candL.length, countSideShort(sideHeld('short').length, candS.length));
425
- if (!autoAdapt) {
426
- for (const side of ['long', 'short']) {
427
- const extra = sideHeld(side).sort((a, b) => (side === 'long' ? 1 : -1) * ((row.get(a[0])?.expected ?? 0) - (row.get(b[0])?.expected ?? 0)));
428
- for (const [sym] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(sym, 'Нет пары с другой стороны');
429
- }
430
- }
431
- nL = Math.max(0, pairs - sideHeld('long').length);
432
- nS = Math.max(0, pairs - sideHeld('short').length);
433
- }
434
- entries = [...take(candL, 'buy', nL).map((x) => [x, 'buy']), ...take(candS, 'short', nS).map((x) => [x, 'short'])];
435
- }
436
- for (const [x, action] of entries) {
437
- const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
438
- const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
439
- const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(p.modelHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
440
- s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
441
- step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
442
- }
443
- step.top = [...r.rows].sort((a, b) => b.expected - a.expected).slice(0, 5).map((x) => ({ symbol: x.symbol, expectedPct: x.expectedPct, signal: x.signal }));
444
- step.thresholdPct = round(r.threshold * 100, 3);
445
- if (s.pending.length) s.decisionTs = ts;
446
- s.barsSinceRebalance = 0;
447
- }
448
- s.recent.unshift(step);
449
- s.recent.length = Math.min(s.recent.length, 50);
450
- s.metrics = computeReport(s.trades, s.curve, p.capital, 0);
451
- updateRunMetrics(s.runId, s.metrics, downsample(s.curve));
452
- hub.emit({ type: 'paper:step', runId: s.runId, step, snapshot: this.snapshotOf(s) });
453
- }
454
-
455
- stop(runId = null) {
456
- const target = runId ? this.accounts.get(Number(runId)) : [...this.accounts.values()].find((a) => a.running);
457
- if (!target || !target.running) return false;
458
- target.running = false;
459
- target.stoppedAt = Date.now();
460
- target.metrics = computeReport(target.trades, target.curve.length ? target.curve : [{ ts: Date.now(), equity: target.params.capital }], target.params.capital, 0);
461
- finishRun(target.runId, { status: 'stopped', metrics: target.metrics, equity: downsample(target.curve) });
462
- this.#ensureTimer();
463
- this.#persist();
464
- hub.emit({ type: 'paper:stopped', runId: target.runId, metrics: target.metrics, open: this.snapshotOf(target).positions.length });
465
- hub.emit({ type: 'paper:status', status: this.status() });
466
- return true;
467
- }
468
-
469
- remove(runId) {
470
- const id = Number(runId);
471
- const s = this.accounts.get(id);
472
- if (!s) return false;
473
- s.deleted = true;
474
- if (s.running) this.stop(id);
475
- this.accounts.delete(id);
476
- deleteRun(id);
477
- this.#persist();
478
- hub.emit({ type: 'paper:status', status: this.status() });
479
- return true;
480
- }
481
- }
482
-
483
- const countSideShort = (a, b) => a + b;
484
- const symName = (x) => x.symbol;
485
-
486
- export const live = new LiveEngine();
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import { createRun, finishRun, saveTrade, updateRunMetrics, getRun, getTrades, deleteRun, kvGet, kvSet } from '../db.js';
3
+ import { normalizeParams } from '../settings.js';
4
+ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
5
+ import { hub } from '../hub.js';
6
+ import { latestBarTs } from '../data/moex.js';
7
+ import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
8
+ import { predictAt, lastClosedIndex } from './forecast.js';
9
+ import { computeReport, downsample } from './metrics.js';
10
+ import { MODELS } from './models.js';
11
+ import { blockingNews } from '../news/index.js';
12
+ import { currentUserId } from '../auth/index.js';
13
+ import { notifyUser } from '../notify/index.js';
14
+
15
+ const dayKey = (ts) => new Date(ts + MSK).toISOString().slice(0, 10);
16
+
17
+ function buildBenchmark(baseRunId) {
18
+ if (!baseRunId) return null;
19
+ const br = getRun(Number(baseRunId));
20
+ if (!br?.metrics?.full) return null;
21
+ const f = br.metrics.full;
22
+ const days = Math.max(1, Math.round(((br.metrics.period?.till || 0) - (br.metrics.period?.from || 0)) / DAY));
23
+ return {
24
+ runId: br.id,
25
+ returnPct: f.returnPct,
26
+ marketPct: f.marketPct,
27
+ maxDrawdownPct: f.maxDrawdownPct,
28
+ trades: f.trades,
29
+ periodDays: days,
30
+ dailyPct: round(f.returnPct / days, 4),
31
+ };
32
+ }
33
+
34
+ // Движок виртуальных счетов: поддерживает несколько независимых счетов одновременно.
35
+ // Каждый счёт замораживает свои настройки при запуске (эксперименты в песочнице его не меняют)
36
+ // и при запуске «из прогона» сверяет фактический результат с историческим коридором этого прогона.
37
+ class LiveEngine {
38
+ constructor() {
39
+ this.accounts = new Map(); // runId -> state
40
+ this.timer = null;
41
+ }
42
+
43
+ #direction(s) {
44
+ return s.params.modelDirection || 'long';
45
+ }
46
+
47
+ #borrow(s, h, ts) {
48
+ return h.alloc * ((s.params.modelShortFeePct ?? 0) / 100) * ((ts - h.entryTs) / DAY / 365);
49
+ }
50
+
51
+ #openPnl(s, h, price, ts) {
52
+ return h.side === 'short' ? h.alloc * (1 - price / h.entryPrice) - this.#borrow(s, h, ts) : h.alloc * (price / h.entryPrice - 1);
53
+ }
54
+
55
+ snapshotOf(s, ts = Date.now()) {
56
+ let mtm = 0;
57
+ const positions = [...s.held.entries()].map(([symbol, h]) => {
58
+ const pnl = this.#openPnl(s, h, h.lastPrice, ts);
59
+ mtm += pnl;
60
+ return {
61
+ symbol, side: h.side || 'long', qty: round(h.alloc / h.entryPrice, 2),
62
+ entryTs: h.entryTs, entryPrice: round(h.entryPrice, 4), lastPrice: round(h.lastPrice, 4),
63
+ pnl: round(pnl - h.entryCost, 2), expectedPct: h.expectedPct,
64
+ };
65
+ });
66
+ return { equity: round(s.equity + mtm, 2), cash: round(s.equity, 2), positions, pending: s.pending.map((o) => ({ action: o.action, symbol: o.symbol })) };
67
+ }
68
+
69
+ snapshot(ts = Date.now()) {
70
+ const p = this.#primary();
71
+ return p ? this.snapshotOf(p, ts) : { equity: 0, cash: 0, positions: [], pending: [] };
72
+ }
73
+
74
+ #primary() {
75
+ const list = [...this.accounts.values()].sort((a, b) => b.runId - a.runId);
76
+ return list.find((a) => a.running) || list[0] || null;
77
+ }
78
+
79
+ #accountStatus(s) {
80
+ const snap = this.snapshotOf(s);
81
+ const cap = s.params.capital || 1000000;
82
+ const pnl = round(snap.equity - cap, 2);
83
+ const returnPct = round((pnl / cap) * 100, 2);
84
+ const endTs = s.running ? Date.now() : (s.stoppedAt || Date.now());
85
+ const elapsedDays = Math.max(0.05, round((endTs - s.startedAt) / DAY, 2));
86
+ let corridor = null;
87
+ if (s.benchmark) {
88
+ const expPct = round(s.benchmark.dailyPct * elapsedDays, 2);
89
+ const bandPct = round(Math.max(1.5, (s.benchmark.maxDrawdownPct || 10) * Math.sqrt(elapsedDays / Math.max(30, s.benchmark.periodDays))), 1);
90
+ const status = returnPct < expPct - bandPct ? 'below' : returnPct > expPct + bandPct ? 'above' : 'inside';
91
+ corridor = { runId: s.benchmark.runId, expectedPct: expPct, bandPct, minPct: round(expPct - bandPct, 1), maxPct: round(expPct + bandPct, 1), histReturnPct: s.benchmark.returnPct, histMaxDdPct: s.benchmark.maxDrawdownPct, status };
92
+ }
93
+ return {
94
+ running: s.running, runId: s.runId, name: s.name || `Виртуальный счёт №${s.runId}`,
95
+ baseRunId: s.baseRunId || null, benchmark: s.benchmark || null, corridor,
96
+ params: s.params, startedAt: s.startedAt, stoppedAt: s.stoppedAt || null, lastBarTs: s.lastBarTs,
97
+ error: s.error, busy: s.busy, snapshot: snap, returnPct, pnl,
98
+ recent: s.recent.slice(0, 20), metrics: s.metrics,
99
+ };
100
+ }
101
+
102
+ status(uid = currentUserId()) {
103
+ const list = [...this.accounts.values()].filter((s) => (s.userId || 1) === (uid || 1)).sort((a, b) => b.runId - a.runId).map((s) => this.#accountStatus(s));
104
+ const primary = list.find((a) => a.running) || list[0] || null;
105
+ if (!primary) return { running: false, accounts: [] };
106
+ return {
107
+ ...primary,
108
+ running: list.some((a) => a.running),
109
+ accounts: list,
110
+ };
111
+ }
112
+
113
+ #ensureTimer() {
114
+ const running = [...this.accounts.values()].filter((a) => a.running);
115
+ if (!running.length) {
116
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
117
+ return;
118
+ }
119
+ if (!this.timer) {
120
+ const pollSec = Math.max(15, Math.min(...running.map((a) => Number(a.params.paperPollSec) || 60)));
121
+ this.timer = setInterval(() => this.tick(), pollSec * 1000);
122
+ }
123
+ }
124
+
125
+ async start(input, { source = 'user', name = '', baseRunId = null, capital = null, maxAccounts = 5 } = {}) {
126
+ const uid = currentUserId();
127
+ const runningCount = [...this.accounts.values()].filter((a) => a.running && (a.userId || 1) === uid).length;
128
+ if (runningCount >= maxAccounts) throw new Error(`По вашему тарифу доступно одновременно не более ${maxAccounts} виртуальных счетов`);
129
+ const baseRun = baseRunId ? getRun(Number(baseRunId)) : null;
130
+ const rawParams = baseRun?.params ? { ...baseRun.params, ...input } : input;
131
+ const params = normalizeParams(rawParams);
132
+ if (capital && Number(capital) >= 10000) params.capital = Math.round(Number(capital));
133
+ const benchmark = buildBenchmark(baseRunId);
134
+ const accName = String(name || '').trim() || (baseRunId ? `Из эксперимента №${baseRunId} (${MODELS[params.modelType]?.label || params.modelType}, ${params.modelTopK} акц.)` : `Счёт · ${MODELS[params.modelType]?.label || params.modelType}, ${params.modelTopK} акц.`);
135
+ const runId = createRun('paper', { ...params, _source: source, _baseRunId: baseRunId || null, _name: accName }, uid);
136
+ const s = {
137
+ userId: uid, running: true, runId, name: accName, baseRunId: baseRunId ? Number(baseRunId) : null, benchmark,
138
+ params, source, startedAt: Date.now(), stoppedAt: null, lastBarTs: null, lastSeenTs: null, error: null, busy: false,
139
+ equity: params.capital, held: new Map(), pending: [], trades: [], curve: [], recent: [], metrics: null,
140
+ barsSinceRebalance: Infinity, day: null, dayStartEquity: params.capital, dayTrades: [],
141
+ };
142
+ this.accounts.set(runId, s);
143
+ this.#persist();
144
+ hub.emit({ type: 'paper:started', runId, source, name: accName, baseRunId: s.baseRunId });
145
+ hub.emit({ type: 'paper:status', status: this.status() });
146
+ this.#ensureTimer();
147
+ this.tick();
148
+ return runId;
149
+ }
150
+
151
+ #serialize(s) {
152
+ return {
153
+ userId: s.userId || 1, runId: s.runId, name: s.name, baseRunId: s.baseRunId, benchmark: s.benchmark,
154
+ running: s.running, params: s.params, source: s.source, startedAt: s.startedAt, stoppedAt: s.stoppedAt,
155
+ equity: s.equity, held: [...s.held.entries()], pending: s.pending, curve: s.curve.slice(-3000),
156
+ lastBarTs: s.lastBarTs, lastSeenTs: s.lastSeenTs, decisionTs: s.decisionTs ?? null,
157
+ barsSinceRebalance: Number.isFinite(s.barsSinceRebalance) ? s.barsSinceRebalance : null,
158
+ day: s.day, dayStartEquity: s.dayStartEquity, dayTrades: s.dayTrades, recent: s.recent.slice(0, 20), savedAt: Date.now(),
159
+ };
160
+ }
161
+
162
+ #persist() {
163
+ const arr = [...this.accounts.values()].map((s) => this.#serialize(s));
164
+ kvSet('liveAccounts', arr);
165
+ const prim = this.#primary();
166
+ kvSet('liveState', prim && prim.running ? this.#serialize(prim) : null);
167
+ }
168
+
169
+ resume() {
170
+ let list = kvGet('liveAccounts');
171
+ if (!Array.isArray(list) || !list.length) {
172
+ const legacy = kvGet('liveState');
173
+ list = legacy?.runId ? [legacy] : [];
174
+ }
175
+ let resumedAny = false;
176
+ for (const st of list) {
177
+ if (!st?.runId || this.accounts.has(st.runId)) continue;
178
+ const run = getRun(st.runId);
179
+ if (!run || run.mode !== 'paper' || !st.params?.modelHorizon) continue;
180
+ const isRunning = Boolean(st.running && run.status === 'running');
181
+ const trades = getTrades(st.runId).map((t) => ({
182
+ symbol: t.symbol, side: t.side, pnl: t.pnl, pnlPct: t.pnl_pct, entryTs: t.entry_ts, exitTs: t.exit_ts, commission: t.commission,
183
+ barsHeld: Math.round((t.exit_ts - t.entry_ts) / 600000) * BAR_INTERVAL,
184
+ }));
185
+ const curve = st.curve || [];
186
+ const s = {
187
+ userId: st.userId || run.user_id || 1,
188
+ running: isRunning, runId: st.runId, name: String(st.name || run.params?._name || `Виртуальный счёт №${st.runId}`).replace(/^Из прогона/i, 'Из эксперимента'),
189
+ baseRunId: st.baseRunId ?? run.params?._baseRunId ?? null,
190
+ benchmark: st.benchmark ?? buildBenchmark(st.baseRunId ?? run.params?._baseRunId),
191
+ params: st.params, source: st.source || 'user', startedAt: st.startedAt, stoppedAt: st.stoppedAt || null,
192
+ lastBarTs: st.lastBarTs ?? null, lastSeenTs: null, decisionTs: st.decisionTs ?? undefined, error: null, busy: false,
193
+ equity: st.equity, held: new Map((st.held || []).map(([sym, h]) => [sym, { side: 'long', ...h }])),
194
+ pending: st.pending || [], trades, curve, recent: st.recent || [],
195
+ metrics: computeReport(trades, curve.length ? curve : [{ ts: Date.now(), equity: st.params.capital }], st.params.capital, 0),
196
+ barsSinceRebalance: st.barsSinceRebalance ?? Infinity, day: st.day ?? null,
197
+ dayStartEquity: st.dayStartEquity ?? st.params.capital, dayTrades: st.dayTrades || [],
198
+ };
199
+ this.accounts.set(s.runId, s);
200
+ if (isRunning) {
201
+ resumedAny = true;
202
+ hub.emit({ type: 'paper:resumed', runId: s.runId });
203
+ }
204
+ }
205
+ if (this.accounts.size) {
206
+ this.#persist();
207
+ hub.emit({ type: 'paper:status', status: this.status() });
208
+ }
209
+ if (resumedAny) {
210
+ this.#ensureTimer();
211
+ this.tick();
212
+ }
213
+ return resumedAny;
214
+ }
215
+
216
+ async suspend() {
217
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
218
+ for (const s of this.accounts.values()) s.suspended = true;
219
+ for (let i = 0; i < 15 && [...this.accounts.values()].some((s) => s.busy); i++) await new Promise((r) => setTimeout(r, 100));
220
+ this.#persist();
221
+ return true;
222
+ }
223
+
224
+ async tick() {
225
+ const running = [...this.accounts.values()].filter((s) => s.running && !s.busy && !s.suspended);
226
+ if (!running.length) return;
227
+ let latest = null;
228
+ try { latest = await latestBarTs('IMOEX', BAR_INTERVAL); } catch { /* проверим в самом счёте */ }
229
+ for (const s of running) {
230
+ await this.#tickAccount(s, latest);
231
+ }
232
+ }
233
+
234
+ async #tickAccount(s, latest) {
235
+ if (!s.running || s.busy || s.suspended) return;
236
+ if (latest !== null && latest === s.lastSeenTs) return;
237
+ s.busy = true;
238
+ let changed = false;
239
+ try {
240
+ const p = s.params;
241
+ const now = Date.now();
242
+ const ds = await loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends !== false });
243
+ s.lastSeenTs = latest;
244
+ changed = true;
245
+ const bySym = new Map(ds.tokens.map((t, i) => [t.symbol, i]));
246
+ const last = lastClosedIndex(ds, now);
247
+
248
+ if (s.lastBarTs !== null && last >= 0) {
249
+ for (let j = 0; j < last; j++) {
250
+ const t = ds.timeline[j];
251
+ if (t <= s.lastBarTs) continue;
252
+ let mtm = 0;
253
+ for (const [sym, h] of s.held) {
254
+ const i = bySym.get(sym);
255
+ const px = i !== undefined ? ds.tokens[i].close[j] : 0;
256
+ if (px > 0) mtm += this.#openPnl(s, h, px, t);
257
+ }
258
+ s.curve.push({ ts: t, equity: round(s.equity + mtm, 2) });
259
+ }
260
+ }
261
+
262
+ if (s.deleted) return;
263
+ if (s.pending.length) {
264
+ const k = ds.timeline.findIndex((t) => t > s.decisionTs);
265
+ if (k >= 0) this.#execute(s, ds, k, bySym);
266
+ }
267
+ if (last >= 0 && ds.timeline[last] !== s.lastBarTs && (s.lastBarTs === null || ds.timeline[last] > s.lastBarTs)) {
268
+ await this.#onBar(s, ds, last, bySym);
269
+ }
270
+ s.error = null;
271
+ } catch (e) {
272
+ s.error = e.message;
273
+ if (s.running && !s.deleted) hub.emit({ type: 'log', level: 'error', text: `${s.name}: ${e.message}` });
274
+ } finally {
275
+ s.busy = false;
276
+ if (changed && !s.suspended && !s.deleted) this.#persist();
277
+ hub.emit({ type: 'paper:status', status: this.status() });
278
+ }
279
+ }
280
+
281
+ #execute(s, ds, k, bySym) {
282
+ const cost = (s.params.commissionPct + s.params.slippagePct) / 100;
283
+ const ts = ds.timeline[k];
284
+ const price = (sym) => { const i = bySym.get(sym); const tk = ds.tokens[i]; return tk && (tk.open[k] > 0 ? tk.open[k] : tk.close[k]); };
285
+ for (const o of s.pending.filter((x) => x.action === 'sell' || x.action === 'cover')) {
286
+ const h = s.held.get(o.symbol);
287
+ const px = price(o.symbol);
288
+ if (!h || !(px > 0)) continue;
289
+ const short = h.side === 'short';
290
+ const gross = short ? h.alloc * (1 - px / h.entryPrice) : h.alloc * (px / h.entryPrice - 1);
291
+ const fee = (short ? h.alloc - gross : h.alloc + gross) * cost;
292
+ const borrow = short ? this.#borrow(s, h, ts) : 0;
293
+ s.equity += gross - fee - borrow;
294
+ const pnl = gross - fee - borrow - h.entryCost;
295
+ const t = {
296
+ symbol: o.symbol, side: h.side, qty: round(h.alloc / h.entryPrice, 2), entryTs: h.entryTs, entryPrice: round(h.entryPrice, 4),
297
+ exitTs: ts, exitPrice: round(px, 4), stop: null, take: null, pnl: round(pnl, 2), pnlPct: round((pnl / h.alloc) * 100, 3),
298
+ commission: round(fee + h.entryCost, 2), barsHeld: Math.round((ts - h.entryTs) / 600000) * BAR_INTERVAL, reasonEntry: h.reason, reasonExit: o.reason,
299
+ meta: { model: s.params.modelType, exitKind: 'signal', source: 'model', ...(short ? { borrow: round(borrow, 2) } : {}) },
300
+ };
301
+ s.held.delete(o.symbol);
302
+ s.trades.push(t);
303
+ s.dayTrades.push(t);
304
+ saveTrade(s.runId, t);
305
+ hub.emit({ type: 'paper:trade', runId: s.runId, trade: t });
306
+ }
307
+ const K = s.params.modelTopK;
308
+ const dir = this.#direction(s);
309
+ const slots = dir === 'long' ? K : 2 * K;
310
+ const autoAdapt = s.params.modelAutoAdapt !== false;
311
+ const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
312
+ const invVol = s.params.modelAllocMode === 'invvol';
313
+ const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
314
+ const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
315
+ const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
316
+ const usedStart = [...s.held.values()].reduce((sum, h) => sum + h.alloc, 0);
317
+ let avail = Math.max(0, s.equity * 0.99 - usedStart);
318
+ for (let idx = 0; idx < buyOrders.length; idx++) {
319
+ const o = buyOrders[idx];
320
+ const side = o.action === 'short' ? 'short' : 'long';
321
+ const px = price(o.symbol);
322
+ if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) continue;
323
+ const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(o.expectedPct) / needPct))) : 1;
324
+ const baseAlloc = autoAdapt ? Math.min(s.equity * maxSlotWeight * conv, avail / Math.max(1, buyOrders.length - idx)) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
325
+ let alloc = baseAlloc;
326
+ if (invVol && o.weight > 0) {
327
+ alloc = Math.min(baseAlloc * o.weight, avail / Math.max(1, buyOrders.length - idx));
328
+ }
329
+ if (!(alloc > 1000)) continue;
330
+ avail = Math.max(0, avail - alloc);
331
+ const fee = alloc * cost;
332
+ s.equity -= fee;
333
+ s.held.set(o.symbol, { side, entryTs: ts, entryPrice: px, lastPrice: px, alloc, entryCost: fee, reason: o.reason, expectedPct: o.expectedPct });
334
+ hub.emit({ type: 'paper:fill', runId: s.runId, position: { symbol: o.symbol, side, qty: round(alloc / px, 2), price: px } });
335
+ notifyUser(s.userId || 1, `НейроАльфа · Виртуальный счёт «${s.name}»\n${side === 'short' ? 'Открыт шорт' : 'Куплено'}: ${o.symbol} по ${round(px, 3)} ₽ на ${round(alloc, 0)} ₽`).catch(() => {});
336
+ }
337
+ s.pending = [];
338
+ }
339
+
340
+ async #onBar(s, ds, idx, bySym) {
341
+ const p = s.params;
342
+ const ts = ds.timeline[idx];
343
+ const d = dayKey(ts);
344
+ if (s.day && d !== s.day) {
345
+ hub.emit({ type: 'paper:day_end', runId: s.runId, day: s.day, startEquity: s.dayStartEquity, endEquity: this.snapshotOf(s).equity, trades: s.dayTrades, snapshot: this.snapshotOf(s), params: p });
346
+ s.dayTrades = [];
347
+ s.dayStartEquity = this.snapshotOf(s).equity;
348
+ }
349
+ s.day = d;
350
+ for (const [sym, h] of s.held) { const i = bySym.get(sym); if (i !== undefined && ds.tokens[i].close[idx] > 0) h.lastPrice = ds.tokens[i].close[idx]; }
351
+ s.curve.push({ ts, equity: this.snapshotOf(s).equity });
352
+ s.lastBarTs = ts;
353
+
354
+ const step = { ts, bar: fmtBarMsk(ts), decisions: [], top: [] };
355
+ s.barsSinceRebalance++;
356
+ const autoAdapt = p.modelAutoAdapt !== false;
357
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
358
+ const H = barsFromHours(effHorizon);
359
+ const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
360
+ if (s.barsSinceRebalance >= stepBars) {
361
+ await new Promise((r) => setImmediate(r));
362
+ const r = await predictAt(ds, p, idx);
363
+ const row = new Map(r.rows.map((x) => [x.symbol, x]));
364
+ const dir = this.#direction(s);
365
+ const K = p.modelTopK;
366
+ const closing = new Set();
367
+ const closePos = (sym, why) => {
368
+ const x = row.get(sym);
369
+ const short = s.held.get(sym).side === 'short';
370
+ const action = short ? 'cover' : 'sell';
371
+ s.pending.push({ action, symbol: sym, reason: why || `Прогноз ослаб: ожидание ${x ? x.expectedPct : '—'}% за ${round(effHorizon, 2)} ч` });
372
+ step.decisions.push({ action, symbol: sym, text: `${short ? 'закрыть продажу' : 'продать'} — ${why ? why.toLowerCase() : `прогноз ослаб (${x ? x.expectedPct : '—'}%)`}` });
373
+ closing.add(sym);
374
+ };
375
+ const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
376
+ const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
377
+ const quarantined = new Set();
378
+ if (autoAdapt && s.trades.length >= 3) {
379
+ const stMap = new Map();
380
+ for (const t of s.trades) {
381
+ // затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
382
+ const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
383
+ const wDecay = Math.pow(0.99, ageSteps);
384
+ const cur = stMap.get(t.symbol) || { pnl: 0, cnt: 0 };
385
+ cur.pnl = cur.pnl * 0.90 + (Number(t.pnlPct || 0) / 100) * wDecay;
386
+ cur.cnt += wDecay;
387
+ stMap.set(t.symbol, cur);
388
+ }
389
+ for (const [sym, st] of stMap) if (st.cnt >= 3 && st.pnl < -0.012) quarantined.add(sym);
390
+ }
391
+ for (const [sym, h] of s.held) {
392
+ const x = row.get(sym);
393
+ const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
394
+ const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio);
395
+ if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
396
+ }
397
+ const sideHeld = (side) => [...s.held].filter(([sym, h]) => h.side === side && !closing.has(sym));
398
+ const nb = p.newsFilter ? blockingNews(6) : null;
399
+ const take = (list, side, n) => {
400
+ const lim = Math.max(0, n);
401
+ const out = [];
402
+ for (const x of list) {
403
+ if (out.length >= lim) break;
404
+ if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
405
+ const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
406
+ if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
407
+ out.push(x);
408
+ }
409
+ return out;
410
+ };
411
+ let entries;
412
+ if (dir === 'long') {
413
+ const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
414
+ const candL = mktDown ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol)).sort((a, b) => b.expected - a.expected);
415
+ if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
416
+ entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
417
+ } else {
418
+ const open = (x) => !s.held.has(x.symbol) || closing.has(x.symbol);
419
+ const candL = mktDown ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
420
+ const candS = r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
421
+ if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
422
+ const nL = K - sideHeld('long').length;
423
+ const nS = K - sideHeld('short').length;
424
+ entries = [...take(candL, 'buy', nL).map((x) => [x, 'buy']), ...take(candS, 'short', nS).map((x) => [x, 'short'])];
425
+ }
426
+ for (const [x, action] of entries) {
427
+ const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
428
+ const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
429
+ const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(p.modelHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
430
+ s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
431
+ step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
432
+ }
433
+ step.top = [...r.rows].sort((a, b) => b.expected - a.expected).slice(0, 5).map((x) => ({ symbol: x.symbol, expectedPct: x.expectedPct, signal: x.signal }));
434
+ step.thresholdPct = round(r.threshold * 100, 3);
435
+ if (s.pending.length) s.decisionTs = ts;
436
+ s.barsSinceRebalance = 0;
437
+ }
438
+ s.recent.unshift(step);
439
+ s.recent.length = Math.min(s.recent.length, 50);
440
+ s.metrics = computeReport(s.trades, s.curve, p.capital, 0);
441
+ updateRunMetrics(s.runId, s.metrics, downsample(s.curve));
442
+ hub.emit({ type: 'paper:step', runId: s.runId, step, snapshot: this.snapshotOf(s) });
443
+ }
444
+
445
+ stop(runId = null) {
446
+ const target = runId ? this.accounts.get(Number(runId)) : [...this.accounts.values()].find((a) => a.running);
447
+ if (!target || !target.running) return false;
448
+ target.running = false;
449
+ target.stoppedAt = Date.now();
450
+ target.metrics = computeReport(target.trades, target.curve.length ? target.curve : [{ ts: Date.now(), equity: target.params.capital }], target.params.capital, 0);
451
+ finishRun(target.runId, { status: 'stopped', metrics: target.metrics, equity: downsample(target.curve) });
452
+ this.#ensureTimer();
453
+ this.#persist();
454
+ hub.emit({ type: 'paper:stopped', runId: target.runId, metrics: target.metrics, open: this.snapshotOf(target).positions.length });
455
+ hub.emit({ type: 'paper:status', status: this.status() });
456
+ return true;
457
+ }
458
+
459
+ remove(runId) {
460
+ const id = Number(runId);
461
+ const s = this.accounts.get(id);
462
+ if (!s) return false;
463
+ s.deleted = true;
464
+ if (s.running) this.stop(id);
465
+ this.accounts.delete(id);
466
+ deleteRun(id);
467
+ this.#persist();
468
+ hub.emit({ type: 'paper:status', status: this.status() });
469
+ return true;
470
+ }
471
+ }
472
+
473
+ const countSideShort = (a, b) => a + b;
474
+ const symName = (x) => x.symbol;
475
+
476
+ export const live = new LiveEngine();