neuroalpha 0.2.0 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -3
- package/bin/cli.js +69 -69
- package/config/seed-run.json +1 -0
- package/config/strategy.json +4 -10
- package/deploy/install.sh +128 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
- 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
- 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
- package/package.json +15 -5
- package/public/admin.html +426 -0
- package/public/app.js +741 -251
- package/public/index.html +45 -20
- package/public/landing.html +1004 -870
- package/public/legal.html +22 -0
- package/public/styles.css +49 -15
- package/server/api/routes.js +176 -23
- package/server/auth/index.js +544 -340
- package/server/billing/yookassa.js +1 -0
- package/server/brokers/index.js +1224 -645
- package/server/config.js +6 -4
- package/server/data/moex.js +1 -0
- package/server/db.js +53 -2
- package/server/hub.js +1 -0
- package/server/index.js +10 -2
- package/server/llm/client.js +1 -0
- package/server/llm/providers.js +50 -3
- package/server/model/dataset.js +362 -352
- package/server/model/forecast.js +15 -21
- package/server/model/linalg.js +1 -0
- package/server/model/live.js +489 -486
- package/server/model/metrics.js +1 -0
- package/server/model/models.js +31 -21
- package/server/model/profiles.js +1 -0
- package/server/model/research.js +119 -172
- package/server/model/vocab.js +1 -0
- package/server/news/index.js +1 -0
- package/server/notify/index.js +304 -168
- package/server/params-meta.js +20 -25
- package/server/settings.js +1 -0
- package/server/strategist/analytics.js +1 -0
- package/server/strategist/index.js +6 -6
- package/server/strategist/tools.js +2 -1
- package/server/util.js +1 -0
- package/server/model/gpu-worker.html +0 -11
- package/server/model/gpu-worker.js +0 -60
- package/server/model/tf-node.js +0 -26
- package/server/model/transformer-core.js +0 -330
- package/server/model/transformer-gpu.js +0 -221
- package/server/model/transformer-pool.js +0 -200
- package/server/model/transformer-worker.js +0 -28
- package/server/model/transformer.js +0 -57
package/server/model/live.js
CHANGED
|
@@ -1,486 +1,489 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
this
|
|
143
|
-
|
|
144
|
-
hub.emit({ type: 'paper:
|
|
145
|
-
this
|
|
146
|
-
this
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
this
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
for (
|
|
219
|
-
this
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
s.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (s.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
s.
|
|
302
|
-
s.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
s.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
target
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
this
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
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)) : (dir === 'neutral' ? Math.min(0.25, 1 / (2 * 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' && dir !== 'neutral' && 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 && dir === 'longshort') step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
422
|
+
let nL, nS;
|
|
423
|
+
if (dir === 'longshort') {
|
|
424
|
+
nL = K - sideHeld('long').length;
|
|
425
|
+
nS = K - sideHeld('short').length;
|
|
426
|
+
} else {
|
|
427
|
+
const pairs = Math.min(K, sideHeld('long').length + candL.length, countSideShort(sideHeld('short').length, candS.length));
|
|
428
|
+
if (!autoAdapt) {
|
|
429
|
+
for (const side of ['long', 'short']) {
|
|
430
|
+
const extra = sideHeld(side).sort((a, b) => (side === 'long' ? 1 : -1) * ((row.get(a[0])?.expected ?? 0) - (row.get(b[0])?.expected ?? 0)));
|
|
431
|
+
for (const [sym] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(sym, 'Нет пары с другой стороны');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
nL = Math.max(0, pairs - sideHeld('long').length);
|
|
435
|
+
nS = Math.max(0, pairs - sideHeld('short').length);
|
|
436
|
+
}
|
|
437
|
+
entries = [...take(candL, 'buy', nL).map((x) => [x, 'buy']), ...take(candS, 'short', nS).map((x) => [x, 'short'])];
|
|
438
|
+
}
|
|
439
|
+
for (const [x, action] of entries) {
|
|
440
|
+
const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
|
|
441
|
+
const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
|
|
442
|
+
const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(p.modelHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
|
|
443
|
+
s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
|
|
444
|
+
step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
|
|
445
|
+
}
|
|
446
|
+
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 }));
|
|
447
|
+
step.thresholdPct = round(r.threshold * 100, 3);
|
|
448
|
+
if (s.pending.length) s.decisionTs = ts;
|
|
449
|
+
s.barsSinceRebalance = 0;
|
|
450
|
+
}
|
|
451
|
+
s.recent.unshift(step);
|
|
452
|
+
s.recent.length = Math.min(s.recent.length, 50);
|
|
453
|
+
s.metrics = computeReport(s.trades, s.curve, p.capital, 0);
|
|
454
|
+
updateRunMetrics(s.runId, s.metrics, downsample(s.curve));
|
|
455
|
+
hub.emit({ type: 'paper:step', runId: s.runId, step, snapshot: this.snapshotOf(s) });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
stop(runId = null) {
|
|
459
|
+
const target = runId ? this.accounts.get(Number(runId)) : [...this.accounts.values()].find((a) => a.running);
|
|
460
|
+
if (!target || !target.running) return false;
|
|
461
|
+
target.running = false;
|
|
462
|
+
target.stoppedAt = Date.now();
|
|
463
|
+
target.metrics = computeReport(target.trades, target.curve.length ? target.curve : [{ ts: Date.now(), equity: target.params.capital }], target.params.capital, 0);
|
|
464
|
+
finishRun(target.runId, { status: 'stopped', metrics: target.metrics, equity: downsample(target.curve) });
|
|
465
|
+
this.#ensureTimer();
|
|
466
|
+
this.#persist();
|
|
467
|
+
hub.emit({ type: 'paper:stopped', runId: target.runId, metrics: target.metrics, open: this.snapshotOf(target).positions.length });
|
|
468
|
+
hub.emit({ type: 'paper:status', status: this.status() });
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
remove(runId) {
|
|
473
|
+
const id = Number(runId);
|
|
474
|
+
const s = this.accounts.get(id);
|
|
475
|
+
if (!s) return false;
|
|
476
|
+
s.deleted = true;
|
|
477
|
+
if (s.running) this.stop(id);
|
|
478
|
+
this.accounts.delete(id);
|
|
479
|
+
deleteRun(id);
|
|
480
|
+
this.#persist();
|
|
481
|
+
hub.emit({ type: 'paper:status', status: this.status() });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const countSideShort = (a, b) => a + b;
|
|
487
|
+
const symName = (x) => x.symbol;
|
|
488
|
+
|
|
489
|
+
export const live = new LiveEngine();
|