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/brokers/index.js
CHANGED
|
@@ -1,645 +1,1224 @@
|
|
|
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
|
-
{ id: '
|
|
18
|
-
{ id: '
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
return
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
460
|
-
};
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
};
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (!
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
acc
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
if (
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
|
|
5
|
+
import { encryptSecret, decryptSecret, currentUserId, runWithUser } from '../auth/index.js';
|
|
6
|
+
import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
|
|
7
|
+
import { getSettings, normalizeParams } from '../settings.js';
|
|
8
|
+
import { latestBarTs } from '../data/moex.js';
|
|
9
|
+
import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
10
|
+
import { forecastNow } from '../model/forecast.js';
|
|
11
|
+
import { MODELS } from '../model/models.js';
|
|
12
|
+
import { blockingNews } from '../news/index.js';
|
|
13
|
+
import { round, MSK, DAY } from '../util.js';
|
|
14
|
+
import { hub } from '../hub.js';
|
|
15
|
+
|
|
16
|
+
export const BROKER_META = [
|
|
17
|
+
{ id: 'tbank', name: 'Т-Банк (T-Invest API)', accountHint: 'ID счёта (оставьте пустым для выбора первого доступного)', tokenHint: 'Токен T-Invest API (t.…)' },
|
|
18
|
+
{ id: 'finam', name: 'Финам (Trade API)', accountHint: 'Код клиента (ClientId, например 123456R)', tokenHint: 'Токен Finam Trade API (CA…)' },
|
|
19
|
+
{ id: 'alor', name: 'Алор (OpenAPI)', accountHint: 'Портфель Мосбиржи (например D12345)', tokenHint: 'Refresh-токен Alor OpenAPI' },
|
|
20
|
+
{ id: 'bcs', name: 'БКС Мир инвестиций', accountHint: 'Номер соглашения / субсчёта (необязательно)', tokenHint: 'Refresh-токен БКС Trade API' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
24
|
+
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
25
|
+
export async function ensureBoardSpecs() {
|
|
26
|
+
const cached = kvGet('moexSpecs');
|
|
27
|
+
if (cached?.specs && Date.now() - cached.at < DAY) return cached.specs;
|
|
28
|
+
try {
|
|
29
|
+
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
30
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
31
|
+
const j = await r.json();
|
|
32
|
+
const sc = j.securities.columns, mc = j.marketdata.columns;
|
|
33
|
+
const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
|
|
34
|
+
const prices = new Map();
|
|
35
|
+
for (const row of j.marketdata.data) {
|
|
36
|
+
const sym = row[im('SECID')];
|
|
37
|
+
const px = Number(row[im('LAST')] || row[im('MARKETPRICE')] || row[im('LCURRENTPRICE')] || 0);
|
|
38
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
39
|
+
}
|
|
40
|
+
const specs = {};
|
|
41
|
+
for (const row of j.securities.data) {
|
|
42
|
+
const sym = row[is('SECID')];
|
|
43
|
+
if (!sym) continue;
|
|
44
|
+
const lotSize = Math.max(1, Number(row[is('LOTSIZE')]) || 1);
|
|
45
|
+
const minStep = Number(row[is('MINSTEP')]) > 0 ? Number(row[is('MINSTEP')]) : 0.01;
|
|
46
|
+
const decimals = Math.max(0, Number(row[is('DECIMALS')]) || 0);
|
|
47
|
+
const prev = Number(row[is('PREVPRICE')] || row[is('PREVADMITTEDQUOTE')] || 0);
|
|
48
|
+
specs[sym] = { lotSize, minStep, decimals, lastPrice: prices.get(sym) || prev || 0 };
|
|
49
|
+
}
|
|
50
|
+
kvSet('moexSpecs', { at: Date.now(), specs });
|
|
51
|
+
return specs;
|
|
52
|
+
} catch {
|
|
53
|
+
return cached?.specs || {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function roundToStep(price, minStep = 0.01, decimals = 2) {
|
|
58
|
+
const step = minStep > 0 ? minStep : 0.01;
|
|
59
|
+
const v = Math.round(price / step) * step;
|
|
60
|
+
return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function calcLotOrder(symbol, targetSumRub, refPrice, action, offsetPct = 0.1, specs = {}) {
|
|
64
|
+
const sp = specs[symbol] || { lotSize: 1, minStep: 0.01, decimals: 2, lastPrice: 0 };
|
|
65
|
+
const basePx = refPrice > 0 ? refPrice : sp.lastPrice;
|
|
66
|
+
if (!(basePx > 0)) return null;
|
|
67
|
+
const dir = action === 'buy' || action === 'cover' ? 1 : -1;
|
|
68
|
+
const rawLimit = basePx * (1 + dir * (Number(offsetPct) || 0) / 100);
|
|
69
|
+
const limitPrice = roundToStep(rawLimit, sp.minStep, sp.decimals);
|
|
70
|
+
const lotCost = limitPrice * sp.lotSize;
|
|
71
|
+
const lots = Math.floor(targetSumRub / lotCost);
|
|
72
|
+
if (lots < 1) return null;
|
|
73
|
+
const shares = lots * sp.lotSize;
|
|
74
|
+
return {
|
|
75
|
+
symbol,
|
|
76
|
+
action,
|
|
77
|
+
lotSize: sp.lotSize,
|
|
78
|
+
lots,
|
|
79
|
+
shares,
|
|
80
|
+
refPrice: round(basePx, 4),
|
|
81
|
+
limitPrice,
|
|
82
|
+
amountRub: round(shares * limitPrice, 2),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- хранение счетов (с изоляцией по user_id и AES-256-GCM шифрованием токенов) ----------
|
|
87
|
+
function loadRawAccounts() {
|
|
88
|
+
const list = userKvGet('brokerAccounts');
|
|
89
|
+
if (!Array.isArray(list)) return [];
|
|
90
|
+
return list.map((a) => ({ ...a, token: decryptSecret(a.token) }));
|
|
91
|
+
}
|
|
92
|
+
function saveRawAccounts(list) {
|
|
93
|
+
userKvSet('brokerAccounts', list.map((a) => ({ ...a, token: a.token ? encryptSecret(a.token) : '' })));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function publicAccount(a) {
|
|
97
|
+
const { token, ...rest } = a;
|
|
98
|
+
let baseRunLabel = null;
|
|
99
|
+
if (a.baseRunId) {
|
|
100
|
+
const r = getRun(Number(a.baseRunId));
|
|
101
|
+
if (r) baseRunLabel = `№${r.id}${r.params?._title ? ` (${r.params._title})` : ''}`;
|
|
102
|
+
}
|
|
103
|
+
return { ...rest, hasToken: Boolean(token && String(token).trim()), baseRunLabel };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function brokersState() {
|
|
107
|
+
return {
|
|
108
|
+
brokers: BROKER_META,
|
|
109
|
+
accounts: loadRawAccounts().map(publicAccount),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function saveBrokerAccount(input = {}) {
|
|
114
|
+
const list = loadRawAccounts();
|
|
115
|
+
const id = input.id && String(input.id).trim() ? String(input.id).trim() : `brk_${crypto.randomBytes(4).toString('hex')}`;
|
|
116
|
+
const idx = list.findIndex((x) => x.id === id);
|
|
117
|
+
const prev = idx >= 0 ? list[idx] : {};
|
|
118
|
+
const broker = BROKER_META.some((b) => b.id === input.broker) ? input.broker : (prev.broker || 'tbank');
|
|
119
|
+
const name = String(input.name ?? prev.name ?? '').trim() || BROKER_META.find((b) => b.id === broker).name;
|
|
120
|
+
const mode = ['readonly', 'confirm', 'auto'].includes(input.mode) ? input.mode : (prev.mode || 'confirm');
|
|
121
|
+
const legacyMode = ['isolate', 'smooth', 'takeover'].includes(input.legacyMode) ? input.legacyMode : (prev.legacyMode || 'isolate');
|
|
122
|
+
let token = prev.token || '';
|
|
123
|
+
if (input.clearToken) token = '';
|
|
124
|
+
else if (typeof input.token === 'string' && input.token.trim()) token = input.token.trim();
|
|
125
|
+
|
|
126
|
+
const acctChanged = input.accountId !== undefined && String(input.accountId).trim() !== String(prev.accountId || '').trim();
|
|
127
|
+
const next = {
|
|
128
|
+
id,
|
|
129
|
+
name,
|
|
130
|
+
broker,
|
|
131
|
+
accountId: String(input.accountId ?? prev.accountId ?? '').trim(),
|
|
132
|
+
sandbox: input.sandbox !== undefined ? Boolean(input.sandbox) : Boolean(prev.sandbox),
|
|
133
|
+
mode,
|
|
134
|
+
legacyMode,
|
|
135
|
+
baselineQty: acctChanged ? null : (prev.baselineQty || null),
|
|
136
|
+
tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
|
|
137
|
+
equityHistory: acctChanged ? [] : (prev.equityHistory || []),
|
|
138
|
+
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
139
|
+
baseRunId: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
|
|
140
|
+
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
141
|
+
maxCapitalRub: Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000))),
|
|
142
|
+
maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
|
|
143
|
+
limitOffsetPct: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
|
|
144
|
+
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
145
|
+
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
146
|
+
lastScheduledBarTs: prev.lastScheduledBarTs || 0,
|
|
147
|
+
haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
|
|
148
|
+
token,
|
|
149
|
+
lastSync: prev.lastSync || null,
|
|
150
|
+
pendingBatch: prev.pendingBatch || null,
|
|
151
|
+
};
|
|
152
|
+
if (idx >= 0) list[idx] = next;
|
|
153
|
+
else list.push(next);
|
|
154
|
+
saveRawAccounts(list);
|
|
155
|
+
const st = brokersState();
|
|
156
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
157
|
+
return st;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function deleteBrokerAccount(id) {
|
|
161
|
+
const list = loadRawAccounts().filter((x) => x.id !== id);
|
|
162
|
+
saveRawAccounts(list);
|
|
163
|
+
const st = brokersState();
|
|
164
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
165
|
+
return st;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
169
|
+
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.)
|
|
170
|
+
function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const u = new URL(url);
|
|
173
|
+
const payload = body === null || body === undefined ? null : (typeof body === 'string' ? body : body.toString());
|
|
174
|
+
const reqHeaders = { ...headers };
|
|
175
|
+
if (payload !== null && !reqHeaders['Content-Length'] && !reqHeaders['content-length']) {
|
|
176
|
+
reqHeaders['Content-Length'] = Buffer.byteLength(payload);
|
|
177
|
+
}
|
|
178
|
+
const req = https.request({
|
|
179
|
+
hostname: u.hostname,
|
|
180
|
+
port: u.port || 443,
|
|
181
|
+
path: u.pathname + u.search,
|
|
182
|
+
method,
|
|
183
|
+
headers: reqHeaders,
|
|
184
|
+
rejectUnauthorized: false,
|
|
185
|
+
timeout: timeoutMs,
|
|
186
|
+
}, (res) => {
|
|
187
|
+
const chunks = [];
|
|
188
|
+
res.on('data', (c) => chunks.push(c));
|
|
189
|
+
res.on('end', () => {
|
|
190
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
191
|
+
const status = res.statusCode || 0;
|
|
192
|
+
resolve({
|
|
193
|
+
ok: status >= 200 && status < 300,
|
|
194
|
+
status,
|
|
195
|
+
text: async () => text,
|
|
196
|
+
json: async () => (text ? JSON.parse(text) : {}),
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
|
|
201
|
+
req.on('error', reject);
|
|
202
|
+
if (payload !== null) req.write(payload);
|
|
203
|
+
req.end();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const qVal = (q) => (q ? Number(q.units || 0) + Number(q.nano || 0) / 1e9 : 0);
|
|
208
|
+
const fmtRubSimple = (n) => Math.round(Number(n) || 0).toLocaleString('ru-RU');
|
|
209
|
+
const tbankUidCache = new Map();
|
|
210
|
+
|
|
211
|
+
const KNOWN_TBANK_INSTRUMENTS = {
|
|
212
|
+
PLTRUB_TOM: { ticker: 'PLTRUB_TOM', name: 'Платина (₽/г)', kind: 'metal' },
|
|
213
|
+
BBGPLTRUBTOM: { ticker: 'PLTRUB_TOM', name: 'Платина (₽/г)', kind: 'metal' },
|
|
214
|
+
SLVRUB_TOM: { ticker: 'SLVRUB_TOM', name: 'Серебро (₽/г)', kind: 'metal' },
|
|
215
|
+
BBG000VHQTD1: { ticker: 'SLVRUB_TOM', name: 'Серебро (₽/г)', kind: 'metal' },
|
|
216
|
+
GLDRUB_TOM: { ticker: 'GLDRUB_TOM', name: 'Золото (₽/г)', kind: 'metal' },
|
|
217
|
+
PLDRUB_TOM: { ticker: 'PLDRUB_TOM', name: 'Палладий (₽/г)', kind: 'metal' },
|
|
218
|
+
CNYRUB_TOM: { ticker: 'CNYRUB_TOM', name: 'Китайский юань (CNY)', kind: 'currency' },
|
|
219
|
+
USD000UTSTOM: { ticker: 'USD000UTSTOM', name: 'Доллар США (USD)', kind: 'currency' },
|
|
220
|
+
EUR_RUB__TOM: { ticker: 'EUR_RUB__TOM', name: 'Евро (EUR)', kind: 'currency' },
|
|
221
|
+
HKDRUB_TOM: { ticker: 'HKDRUB_TOM', name: 'Гонконгский доллар (HKD)', kind: 'currency' },
|
|
222
|
+
LQDT: { ticker: 'LQDT', name: 'Фонд ликвидности ВИМ', kind: 'etf' },
|
|
223
|
+
TMOS: { ticker: 'TMOS', name: 'Т-Капитал Индекс МосБиржи', kind: 'etf' },
|
|
224
|
+
SBMM: { ticker: 'SBMM', name: 'Первая Сберегательный', kind: 'etf' },
|
|
225
|
+
TGLD: { ticker: 'TGLD', name: 'Т-Капитал Золото', kind: 'etf' },
|
|
226
|
+
TRUR: { ticker: 'TRUR', name: 'Т-Капитал Вечный портфель', kind: 'etf' },
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const isRubCashPos = (pos, ticker) => pos.figi === 'RUB000UTSTOM' || ticker === 'RUB000UTSTOM' || ticker === 'RUB';
|
|
230
|
+
|
|
231
|
+
function classifyTbankKind(instrumentType, ticker = '', figi = '') {
|
|
232
|
+
const known = KNOWN_TBANK_INSTRUMENTS[ticker] || KNOWN_TBANK_INSTRUMENTS[figi];
|
|
233
|
+
if (known?.kind) return known.kind;
|
|
234
|
+
if (/^(PLT|SLV|GLD|PLD)RUB/i.test(ticker)) return 'metal';
|
|
235
|
+
if (instrumentType === 'currency') return 'currency';
|
|
236
|
+
if (instrumentType === 'bond') return 'bond';
|
|
237
|
+
if (instrumentType === 'etf') return 'etf';
|
|
238
|
+
if (instrumentType === 'futures') return 'futures';
|
|
239
|
+
return 'share';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
243
|
+
const cache = kvGet('tbankInstMeta') || {};
|
|
244
|
+
let dirty = false;
|
|
245
|
+
for (const [k, v] of Object.entries(KNOWN_TBANK_INSTRUMENTS)) {
|
|
246
|
+
if (!cache[k]) { cache[k] = v; dirty = true; }
|
|
247
|
+
}
|
|
248
|
+
const vocab = kvGet('vocab')?.list || [];
|
|
249
|
+
for (const v of vocab) {
|
|
250
|
+
if (v.symbol && v.name && !cache[v.symbol]) {
|
|
251
|
+
cache[v.symbol] = { ticker: v.symbol, name: v.name, kind: 'share' };
|
|
252
|
+
dirty = true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0, 25);
|
|
256
|
+
await Promise.all(missing.map(async (id) => {
|
|
257
|
+
try {
|
|
258
|
+
const idType = id.includes('-') ? 'INSTRUMENT_ID_TYPE_UID' : 'INSTRUMENT_ID_TYPE_FIGI';
|
|
259
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/GetInstrumentBy`, {
|
|
260
|
+
method: 'POST', headers, body: JSON.stringify({ idType, id }), timeoutMs: 8000,
|
|
261
|
+
});
|
|
262
|
+
if (r.ok) {
|
|
263
|
+
const j = await r.json();
|
|
264
|
+
const inst = j.instrument;
|
|
265
|
+
if (inst) {
|
|
266
|
+
const entry = {
|
|
267
|
+
ticker: inst.ticker || id,
|
|
268
|
+
name: inst.name || inst.ticker || id,
|
|
269
|
+
kind: classifyTbankKind(inst.instrumentType, inst.ticker, inst.figi),
|
|
270
|
+
};
|
|
271
|
+
cache[id] = entry;
|
|
272
|
+
if (inst.ticker) cache[inst.ticker] = entry;
|
|
273
|
+
if (inst.figi) cache[inst.figi] = entry;
|
|
274
|
+
if (inst.uid) cache[inst.uid] = entry;
|
|
275
|
+
dirty = true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch { /* ignore */ }
|
|
279
|
+
}));
|
|
280
|
+
if (dirty) kvSet('tbankInstMeta', cache);
|
|
281
|
+
return cache;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
285
|
+
if (tbankUidCache.has(symbol)) return tbankUidCache.get(symbol);
|
|
286
|
+
try {
|
|
287
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/ShareBy`, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers,
|
|
290
|
+
body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode: 'TQBR', id: symbol }),
|
|
291
|
+
timeoutMs: 10000,
|
|
292
|
+
});
|
|
293
|
+
if (r.ok) {
|
|
294
|
+
const j = await r.json();
|
|
295
|
+
const uid = j.instrument?.uid || j.instrument?.figi;
|
|
296
|
+
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
297
|
+
}
|
|
298
|
+
} catch { /* fallback */ }
|
|
299
|
+
return `${symbol}_TQBR`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function syncTbank(acc) {
|
|
303
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
304
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
305
|
+
let accountId = acc.accountId;
|
|
306
|
+
let accountName = acc.tbankAccountName || '';
|
|
307
|
+
let accountType = '';
|
|
308
|
+
const rAcc = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetAccounts`, {
|
|
309
|
+
method: 'POST', headers, body: JSON.stringify({}), timeoutMs: 15000,
|
|
310
|
+
}).catch(() => null);
|
|
311
|
+
if (rAcc?.ok) {
|
|
312
|
+
const jAcc = await rAcc.json();
|
|
313
|
+
const openAccs = (jAcc.accounts || []).filter((a) => !a.status || a.status === 'ACCOUNT_STATUS_OPEN');
|
|
314
|
+
if (!accountId) accountId = (openAccs[0] || jAcc.accounts?.[0])?.id;
|
|
315
|
+
const matched = (jAcc.accounts || []).find((a) => a.id === accountId);
|
|
316
|
+
if (matched?.name) accountName = matched.name;
|
|
317
|
+
if (matched?.type) accountType = matched.type;
|
|
318
|
+
}
|
|
319
|
+
if (!accountId) throw new Error('В Т-Банке не найдено открытых счетов (проверьте токен API)');
|
|
320
|
+
|
|
321
|
+
const [rPort, rOrd, rMargin] = await Promise.all([
|
|
322
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
|
|
323
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), timeoutMs: 15000,
|
|
324
|
+
}),
|
|
325
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
326
|
+
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
327
|
+
}).catch(() => null),
|
|
328
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetMarginAttributes`, {
|
|
329
|
+
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
330
|
+
}).catch(() => null),
|
|
331
|
+
]);
|
|
332
|
+
if (!rPort.ok) throw new Error(`T-Invest GetPortfolio: HTTP ${rPort.status}`);
|
|
333
|
+
let marginEnabled = false;
|
|
334
|
+
if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
|
|
335
|
+
marginEnabled = false;
|
|
336
|
+
} else if (rMargin?.ok) {
|
|
337
|
+
marginEnabled = true;
|
|
338
|
+
} else if (rMargin) {
|
|
339
|
+
const mTxt = await rMargin.text().catch(() => '');
|
|
340
|
+
marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
|
|
341
|
+
}
|
|
342
|
+
const p = await rPort.json();
|
|
343
|
+
const totalRub = round(qVal(p.totalAmountPortfolio), 2);
|
|
344
|
+
const rawPositions = p.positions || [];
|
|
345
|
+
const lookupIds = rawPositions.flatMap((pos) => [pos.ticker, pos.figi, pos.instrumentUid]).filter(Boolean);
|
|
346
|
+
const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
|
|
347
|
+
|
|
348
|
+
const positions = [];
|
|
349
|
+
let cashRub = 0;
|
|
350
|
+
let metalsFxRub = 0;
|
|
351
|
+
let totalPnlRub = 0;
|
|
352
|
+
let dailyYieldRub = 0;
|
|
353
|
+
for (const pos of rawPositions) {
|
|
354
|
+
const rawTicker = pos.ticker || pos.figi || '';
|
|
355
|
+
if (pos.ticker && (pos.instrumentUid || pos.figi)) tbankUidCache.set(pos.ticker, pos.instrumentUid || pos.figi);
|
|
356
|
+
const qty = qVal(pos.quantity);
|
|
357
|
+
const px = qVal(pos.currentPrice);
|
|
358
|
+
if (isRubCashPos(pos, rawTicker)) {
|
|
359
|
+
cashRub += qty;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (qty === 0) continue;
|
|
363
|
+
const meta = metaMap[rawTicker] || metaMap[pos.figi] || metaMap[pos.instrumentUid] || {};
|
|
364
|
+
const symbol = meta.ticker || rawTicker;
|
|
365
|
+
const kind = classifyTbankKind(pos.instrumentType, symbol, pos.figi);
|
|
366
|
+
const name = meta.name || symbol;
|
|
367
|
+
const avgPrice = round(qVal(pos.averagePositionPrice || pos.averagePositionPriceFifo), 4);
|
|
368
|
+
const valueRub = round(qty * px, 2);
|
|
369
|
+
const pnlRub = round(qVal(pos.expectedYield), 2);
|
|
370
|
+
const costBasis = avgPrice > 0 ? Math.abs(qty) * avgPrice : 0;
|
|
371
|
+
const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : 0;
|
|
372
|
+
const dayRub = round(qVal(pos.dailyYield), 2);
|
|
373
|
+
const lots = qVal(pos.quantityLots) || qty;
|
|
374
|
+
if (kind === 'metal' || kind === 'currency') metalsFxRub += valueRub;
|
|
375
|
+
totalPnlRub += pnlRub;
|
|
376
|
+
dailyYieldRub += dayRub;
|
|
377
|
+
positions.push({
|
|
378
|
+
symbol,
|
|
379
|
+
name,
|
|
380
|
+
figi: pos.figi || '',
|
|
381
|
+
instrumentType: kind,
|
|
382
|
+
qty,
|
|
383
|
+
lots,
|
|
384
|
+
avgPrice,
|
|
385
|
+
price: round(px, 4),
|
|
386
|
+
valueRub,
|
|
387
|
+
sharePct: totalRub > 0 ? round((Math.abs(valueRub) / totalRub) * 100, 1) : 0,
|
|
388
|
+
pnlRub,
|
|
389
|
+
pnlPct,
|
|
390
|
+
dailyYieldRub: dayRub,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const activeOrders = [];
|
|
395
|
+
if (rOrd?.ok) {
|
|
396
|
+
const jOrd = await rOrd.json();
|
|
397
|
+
for (const o of jOrd.orders || []) {
|
|
398
|
+
const m = metaMap[o.figi] || metaMap[o.instrumentUid] || {};
|
|
399
|
+
activeOrders.push({
|
|
400
|
+
orderId: o.orderId,
|
|
401
|
+
symbol: m.ticker || o.figi || '—',
|
|
402
|
+
name: m.name || '',
|
|
403
|
+
direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
|
|
404
|
+
lotsRequested: Number(o.lotsRequested || 0),
|
|
405
|
+
lotsExecuted: Number(o.lotsExecuted || 0),
|
|
406
|
+
price: round(qVal(o.initialSecurityPrice), 4),
|
|
407
|
+
amountRub: round(qVal(o.initialOrderPrice), 2),
|
|
408
|
+
orderDate: o.orderDate || null,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const breakdown = {
|
|
414
|
+
sharesRub: round(qVal(p.totalAmountShares), 0),
|
|
415
|
+
bondsRub: round(qVal(p.totalAmountBonds), 0),
|
|
416
|
+
etfRub: round(qVal(p.totalAmountEtf), 0),
|
|
417
|
+
metalsFxRub: round(metalsFxRub, 0),
|
|
418
|
+
futuresRub: round(qVal(p.totalAmountFutures), 0),
|
|
419
|
+
cashRub: round(cashRub, 0),
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
accountId,
|
|
424
|
+
accountName,
|
|
425
|
+
accountType,
|
|
426
|
+
marginEnabled,
|
|
427
|
+
totalRub: totalRub || cashRub,
|
|
428
|
+
cashRub: round(cashRub, 2),
|
|
429
|
+
totalPnlRub: round(totalPnlRub, 2),
|
|
430
|
+
dailyYieldRub: round(dailyYieldRub, 2),
|
|
431
|
+
breakdown,
|
|
432
|
+
positions,
|
|
433
|
+
activeOrders,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Авто-поиск всех субсчетов в Т-Инвестициях по токену (с названиями из мобильного приложения и балансами)
|
|
438
|
+
export async function discoverBrokerAccounts(input = {}) {
|
|
439
|
+
const broker = input.broker || 'tbank';
|
|
440
|
+
const sandbox = Boolean(input.sandbox);
|
|
441
|
+
let token = typeof input.token === 'string' ? input.token.trim() : '';
|
|
442
|
+
if (!token && input.id) {
|
|
443
|
+
const found = loadRawAccounts().find((x) => x.id === input.id);
|
|
444
|
+
if (found?.token) token = found.token;
|
|
445
|
+
}
|
|
446
|
+
if (!token) throw new Error('Введите токен T-Invest API (t.…), чтобы загрузить список ваших счетов');
|
|
447
|
+
if (broker !== 'tbank') throw new Error('Автоматический список субсчетов поддерживается для Т-Банка (T-Invest API)');
|
|
448
|
+
const base = sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
449
|
+
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
450
|
+
const rAcc = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetAccounts`, {
|
|
451
|
+
method: 'POST', headers, body: JSON.stringify({}), timeoutMs: 15000,
|
|
452
|
+
});
|
|
453
|
+
if (!rAcc.ok) throw new Error(`T-Invest GetAccounts: HTTP ${rAcc.status} (проверьте правильность токена t.…)`);
|
|
454
|
+
const jAcc = await rAcc.json();
|
|
455
|
+
const rawAccs = (jAcc.accounts || []).filter((a) => !a.status || a.status === 'ACCOUNT_STATUS_OPEN');
|
|
456
|
+
const TYPE_MAP = {
|
|
457
|
+
ACCOUNT_TYPE_TINKOFF: 'Брокерский счёт',
|
|
458
|
+
ACCOUNT_TYPE_TINKOFF_IIS: 'ИИС',
|
|
459
|
+
ACCOUNT_TYPE_INVEST_BOX: 'Инвесткопилка',
|
|
460
|
+
};
|
|
461
|
+
const ACCESS_MAP = {
|
|
462
|
+
ACCOUNT_ACCESS_LEVEL_FULL_ACCESS: 'полный доступ',
|
|
463
|
+
ACCOUNT_ACCESS_LEVEL_READ_ONLY: 'только чтение',
|
|
464
|
+
};
|
|
465
|
+
const out = await Promise.all(rawAccs.map(async (a) => {
|
|
466
|
+
let totalRub = null, cashRub = null, positionsCount = 0, marginEnabled = false;
|
|
467
|
+
try {
|
|
468
|
+
const [rp, rm] = await Promise.all([
|
|
469
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
|
|
470
|
+
method: 'POST', headers, body: JSON.stringify({ accountId: a.id, currency: 'RUB' }), timeoutMs: 10000,
|
|
471
|
+
}),
|
|
472
|
+
a.type === 'ACCOUNT_TYPE_TINKOFF'
|
|
473
|
+
? brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetMarginAttributes`, {
|
|
474
|
+
method: 'POST', headers, body: JSON.stringify({ accountId: a.id }), timeoutMs: 8000,
|
|
475
|
+
}).catch(() => null)
|
|
476
|
+
: Promise.resolve(null),
|
|
477
|
+
]);
|
|
478
|
+
if (rm?.ok) marginEnabled = true;
|
|
479
|
+
if (rp.ok) {
|
|
480
|
+
const pj = await rp.json();
|
|
481
|
+
totalRub = round(qVal(pj.totalAmountPortfolio), 0);
|
|
482
|
+
let c = 0, cnt = 0;
|
|
483
|
+
for (const pos of pj.positions || []) {
|
|
484
|
+
const tk = pos.ticker || pos.figi || '';
|
|
485
|
+
if (isRubCashPos(pos, tk)) c += qVal(pos.quantity);
|
|
486
|
+
else if (qVal(pos.quantity) !== 0) cnt++;
|
|
487
|
+
}
|
|
488
|
+
cashRub = round(c, 0);
|
|
489
|
+
positionsCount = cnt;
|
|
490
|
+
}
|
|
491
|
+
} catch { /* ignore individual portfolio error */ }
|
|
492
|
+
return {
|
|
493
|
+
id: a.id,
|
|
494
|
+
name: a.name || TYPE_MAP[a.type] || `Счёт ${a.id}`,
|
|
495
|
+
typeLabel: TYPE_MAP[a.type] || 'Счёт',
|
|
496
|
+
accessLabel: ACCESS_MAP[a.accessLevel] || '',
|
|
497
|
+
marginEnabled,
|
|
498
|
+
totalRub,
|
|
499
|
+
cashRub,
|
|
500
|
+
positionsCount,
|
|
501
|
+
};
|
|
502
|
+
}));
|
|
503
|
+
return { accounts: out };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function syncFinam(acc) {
|
|
507
|
+
if (!acc.accountId) throw new Error('Укажите код клиента (ClientId) Финам');
|
|
508
|
+
const headers = { 'X-Api-Key': acc.token };
|
|
509
|
+
const url = `https://trade-api.finam.ru/public/api/v1/portfolio?ClientId=${encodeURIComponent(acc.accountId)}&Content.IncludeCurrencies=true&Content.IncludePositions=true`;
|
|
510
|
+
const r = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
|
|
511
|
+
if (!r.ok) throw new Error(`Финам Trade API: HTTP ${r.status}`);
|
|
512
|
+
const j = await r.json();
|
|
513
|
+
const data = j.data || j;
|
|
514
|
+
const totalRub = round(Number(data.equity || 0), 2);
|
|
515
|
+
const cashRub = round(Number((data.currencies || []).find((c) => c.name === 'RUB')?.balance || data.balance || 0), 2);
|
|
516
|
+
const positions = (data.positions || []).map((p) => ({
|
|
517
|
+
symbol: p.securityCode, qty: Number(p.balance || 0), price: round(Number(p.currentPrice || 0), 4),
|
|
518
|
+
valueRub: round(Number(p.balance || 0) * Number(p.currentPrice || 0), 2), pnlRub: round(Number(p.unrealizedProfit || 0), 2),
|
|
519
|
+
}));
|
|
520
|
+
return { accountId: acc.accountId, totalRub, cashRub, positions };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function alorAccessToken(refreshToken, sandbox) {
|
|
524
|
+
const oauth = sandbox ? 'https://oauthdev.alor.ru' : 'https://oauth.alor.ru';
|
|
525
|
+
const r = await fetch(`${oauth}/refresh?token=${encodeURIComponent(refreshToken)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
|
|
526
|
+
if (!r.ok) throw new Error(`Алор OAuth: HTTP ${r.status}`);
|
|
527
|
+
const j = await r.json();
|
|
528
|
+
if (!j.AccessToken) throw new Error('Алор не вернул AccessToken');
|
|
529
|
+
return j.AccessToken;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function syncAlor(acc) {
|
|
533
|
+
if (!acc.accountId) throw new Error('Укажите портфель Алор (например D12345)');
|
|
534
|
+
const jwt = await alorAccessToken(acc.token, acc.sandbox);
|
|
535
|
+
const api = acc.sandbox ? 'https://apidev.alor.ru' : 'https://api.alor.ru';
|
|
536
|
+
const headers = { Authorization: `Bearer ${jwt}` };
|
|
537
|
+
const [rSum, rPos] = await Promise.all([
|
|
538
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/summary`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
539
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/positions`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
540
|
+
]);
|
|
541
|
+
if (!rSum.ok) throw new Error(`Алор summary: HTTP ${rSum.status}`);
|
|
542
|
+
const sum = await rSum.json();
|
|
543
|
+
const posList = rPos.ok ? await rPos.json() : [];
|
|
544
|
+
let cashRub = round(Number(sum.buyingPower || sum.cash || 0), 2);
|
|
545
|
+
const positions = [];
|
|
546
|
+
for (const p of Array.isArray(posList) ? posList : []) {
|
|
547
|
+
if (p.isCurrency || p.symbol === 'RUB') { cashRub = round(Number(p.qtyUnits || p.qty || cashRub), 2); continue; }
|
|
548
|
+
const qty = Number(p.qtyUnits || p.qty || 0);
|
|
549
|
+
const val = Number(p.volume || 0);
|
|
550
|
+
positions.push({ symbol: p.symbol, qty, price: qty ? round(val / qty, 4) : 0, valueRub: round(val, 2), pnlRub: round(Number(p.unrealisedPl || 0), 2) });
|
|
551
|
+
}
|
|
552
|
+
return { accountId: acc.accountId, totalRub: round(Number(sum.portfolioLiquidationValue || cashRub), 2), cashRub, positions };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function bcsAccessToken(refreshToken) {
|
|
556
|
+
const body = new URLSearchParams({ client_id: 'trade-api-write', refresh_token: refreshToken, grant_type: 'refresh_token' });
|
|
557
|
+
const r = await fetch('https://be.broker.ru/trade-api-keycloak/realms/tradeapi/protocol/openid-connect/token', {
|
|
558
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000),
|
|
559
|
+
});
|
|
560
|
+
if (!r.ok) throw new Error(`БКС OAuth: HTTP ${r.status}`);
|
|
561
|
+
const j = await r.json();
|
|
562
|
+
if (!j.access_token) throw new Error('БКС не вернул access_token');
|
|
563
|
+
return j.access_token;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function syncBcs(acc) {
|
|
567
|
+
const jwt = await bcsAccessToken(acc.token);
|
|
568
|
+
const r = await fetch('https://be.broker.ru/trade-api-bff-portfolio/api/v1/portfolio', {
|
|
569
|
+
headers: { Authorization: `Bearer ${jwt}` }, signal: AbortSignal.timeout(15000),
|
|
570
|
+
});
|
|
571
|
+
if (!r.ok) throw new Error(`БКС портфель: HTTP ${r.status}`);
|
|
572
|
+
const j = await r.json();
|
|
573
|
+
const items = j.positions || j.data || [];
|
|
574
|
+
let cashRub = 0, totalRub = 0;
|
|
575
|
+
const positions = [];
|
|
576
|
+
for (const p of Array.isArray(items) ? items : []) {
|
|
577
|
+
if (p.type === 'MONEY' || p.ticker === 'RUB') {
|
|
578
|
+
cashRub += Number(p.CurrentValue || p.balance || 0);
|
|
579
|
+
totalRub += Number(p.CurrentValue || p.balance || 0);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const qty = Number(p.quantity || p.balance || 0);
|
|
583
|
+
const px = Number(p.currentPrice || p.price || 0);
|
|
584
|
+
const val = Number(p.currentValue || qty * px || 0);
|
|
585
|
+
totalRub += val;
|
|
586
|
+
positions.push({ symbol: p.ticker || p.symbol, qty, price: round(px, 4), valueRub: round(val, 2), pnlRub: round(Number(p.unrealizedPL || 0), 2) });
|
|
587
|
+
}
|
|
588
|
+
return { accountId: acc.accountId || 'основной', totalRub: round(totalRub, 2), cashRub: round(cashRub, 2), positions };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export async function syncBrokerAccount(id) {
|
|
592
|
+
const list = loadRawAccounts();
|
|
593
|
+
const acc = list.find((x) => x.id === id);
|
|
594
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
595
|
+
if (!acc.token) throw new Error('У счёта не задан токен API');
|
|
596
|
+
const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
|
|
597
|
+
try {
|
|
598
|
+
let data;
|
|
599
|
+
if (acc.broker === 'tbank') data = await syncTbank(acc);
|
|
600
|
+
else if (acc.broker === 'finam') data = await syncFinam(acc);
|
|
601
|
+
else if (acc.broker === 'alor') data = await syncAlor(acc);
|
|
602
|
+
else if (acc.broker === 'bcs') data = await syncBcs(acc);
|
|
603
|
+
else throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
604
|
+
if (data.accountId && !acc.accountId) acc.accountId = data.accountId;
|
|
605
|
+
if (data.accountName) acc.tbankAccountName = data.accountName;
|
|
606
|
+
if ((!acc.name || acc.name === 'Т-Банк (T-Invest API)') && data.accountName) {
|
|
607
|
+
acc.name = `Т-Банк · ${data.accountName}`;
|
|
608
|
+
}
|
|
609
|
+
// При первой синхронизации (или если старый снимок был пустым, а бумаги на счёте есть) запоминаем стартовый портфель
|
|
610
|
+
if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
|
|
611
|
+
acc.baselineQty = Object.fromEntries((data.positions || []).filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
|
|
612
|
+
}
|
|
613
|
+
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
614
|
+
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
615
|
+
const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
|
|
616
|
+
if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
617
|
+
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
618
|
+
}
|
|
619
|
+
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
620
|
+
const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
|
|
621
|
+
const nowTs = Date.now();
|
|
622
|
+
if (!hist.length || nowTs - hist[hist.length - 1].ts >= 5 * 60000 || Math.abs(hist[hist.length - 1].totalRub - data.totalRub) >= 10) {
|
|
623
|
+
hist.push({ ts: nowTs, totalRub: data.totalRub, cashRub: data.cashRub });
|
|
624
|
+
if (hist.length > 500) hist.splice(0, hist.length - 500);
|
|
625
|
+
}
|
|
626
|
+
acc.equityHistory = hist;
|
|
627
|
+
acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
|
|
628
|
+
} catch (e) {
|
|
629
|
+
acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
|
|
630
|
+
saveRawAccounts(list);
|
|
631
|
+
hub.emit({ type: 'brokers:update', state: brokersState() });
|
|
632
|
+
throw e;
|
|
633
|
+
}
|
|
634
|
+
saveRawAccounts(list);
|
|
635
|
+
const st = brokersState();
|
|
636
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
637
|
+
return st;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Зафиксировать текущие позиции счёта как «неприкосновенный стартовый портфель» (baselineQty)
|
|
641
|
+
export async function freezeBrokerBaseline(id) {
|
|
642
|
+
const list = loadRawAccounts();
|
|
643
|
+
const acc = list.find((x) => x.id === id);
|
|
644
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
645
|
+
const positions = acc.lastSync?.positions || [];
|
|
646
|
+
acc.baselineQty = Object.fromEntries(positions.filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
|
|
647
|
+
saveRawAccounts(list);
|
|
648
|
+
const st = brokersState();
|
|
649
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
650
|
+
return st;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------- предохранитель времени сессии (первые и последние 10 минут торгов МСК) ----------
|
|
654
|
+
function sessionEdgeWarning(now = Date.now()) {
|
|
655
|
+
const d = new Date(now + MSK);
|
|
656
|
+
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
657
|
+
// утреннее открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
|
|
658
|
+
if ((mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
|
|
659
|
+
return 'Первые или последние 10 минут сессии (широкий спред в стакане)';
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
665
|
+
export async function prepareBrokerOrders(id) {
|
|
666
|
+
const list = loadRawAccounts();
|
|
667
|
+
const acc = list.find((x) => x.id === id);
|
|
668
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
669
|
+
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
670
|
+
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
671
|
+
|
|
672
|
+
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
673
|
+
const params = normalizeParams(baseRun?.params || getSettings());
|
|
674
|
+
const [fc, specs] = await Promise.all([
|
|
675
|
+
forecastNow(params),
|
|
676
|
+
ensureBoardSpecs(),
|
|
677
|
+
]);
|
|
678
|
+
const budget = Math.min(acc.maxCapitalRub, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : acc.maxCapitalRub);
|
|
679
|
+
const K = params.modelTopK || 3;
|
|
680
|
+
// Автоматическая подстройка направления торговли по реальным данным счёта:
|
|
681
|
+
// если на брокерском счёте отключена маржинальная торговля (или это ИИС), короткие продажи (шорты) автоматически отключаются
|
|
682
|
+
const marginAllowed = acc.lastSync?.marginEnabled !== false;
|
|
683
|
+
const rawDir = params.modelDirection || 'long';
|
|
684
|
+
const dir = marginAllowed ? rawDir : 'long';
|
|
685
|
+
const autoAdapt = params.modelAutoAdapt !== false;
|
|
686
|
+
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
687
|
+
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
688
|
+
const slots = dir === 'long' ? K : 2 * K;
|
|
689
|
+
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)));
|
|
690
|
+
const slotRub = autoAdapt ? budget * maxSlotWeight : budget / slots;
|
|
691
|
+
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
692
|
+
const convOf = (r) => (autoAdapt && r.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 1);
|
|
693
|
+
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
694
|
+
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
695
|
+
const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
|
|
696
|
+
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
697
|
+
|
|
698
|
+
const buyCands = fc.rows.filter((r) => r.signal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
699
|
+
const rawShortCands = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct).slice(0, K) : [];
|
|
700
|
+
const shortCands = marginAllowed ? rawShortCands : [];
|
|
701
|
+
|
|
702
|
+
const legacyMode = acc.legacyMode || 'isolate';
|
|
703
|
+
const baseline = acc.baselineQty || {};
|
|
704
|
+
// В режиме isolate/smooth отделяем бумаги, открытые стратегией (algoQty), от начального портфеля счёта (baseQty)
|
|
705
|
+
const algoQtyOf = (sym, totalQty) => {
|
|
706
|
+
if (legacyMode === 'takeover') return totalQty;
|
|
707
|
+
const baseQ = Number(baseline[sym] || 0);
|
|
708
|
+
if (totalQty > 0 && baseQ > 0) return Math.max(0, totalQty - baseQ);
|
|
709
|
+
if (totalQty < 0 && baseQ < 0) return Math.min(0, totalQty - baseQ);
|
|
710
|
+
return totalQty;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const orders = [];
|
|
714
|
+
const closing = new Set();
|
|
715
|
+
const smoothSellPool = [];
|
|
716
|
+
// 1) закрытие бумаг стратегии (покупок и шортов), чей прогноз ослаб ниже порога выхода exitThresholdPct
|
|
717
|
+
// Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
|
|
718
|
+
for (const [sym, pos] of heldMap) {
|
|
719
|
+
if (!pos.qty) continue;
|
|
720
|
+
const r = rowMap.get(sym);
|
|
721
|
+
if (!r) continue;
|
|
722
|
+
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
723
|
+
const aQty = algoQtyOf(sym, pos.qty);
|
|
724
|
+
const baseQty = pos.qty - aQty;
|
|
725
|
+
if (aQty > 0) {
|
|
726
|
+
const lots = Math.floor(aQty / sp.lotSize);
|
|
727
|
+
const keep = r.liquid && r.expectedPct > exitThresholdPct;
|
|
728
|
+
if (lots >= 1 && !keep) {
|
|
729
|
+
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
730
|
+
orders.push({
|
|
731
|
+
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
732
|
+
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
733
|
+
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${exitThresholdPct}%`,
|
|
734
|
+
});
|
|
735
|
+
closing.add(sym);
|
|
736
|
+
}
|
|
737
|
+
} else if (aQty < 0) {
|
|
738
|
+
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
739
|
+
const keep = dir !== 'long' && r.liquid && r.expectedPct < -exitThresholdPct;
|
|
740
|
+
if (lots >= 1 && !keep) {
|
|
741
|
+
const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
742
|
+
orders.push({
|
|
743
|
+
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
744
|
+
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
745
|
+
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${exitThresholdPct}%`,
|
|
746
|
+
});
|
|
747
|
+
closing.add(sym);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// В режиме «Плавный переход» (smooth) проверяем начальные акции:
|
|
751
|
+
// если по старой акции вышел сильный сигнал падения (shortSignal), защищаем счёт и закрываем её;
|
|
752
|
+
// иначе сохраняем в список кандидатов на плавное высвобождение капитала под новые покупки
|
|
753
|
+
if (legacyMode === 'smooth' && baseQty > 0 && !closing.has(sym)) {
|
|
754
|
+
const bLots = Math.floor(baseQty / sp.lotSize);
|
|
755
|
+
if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
|
|
756
|
+
if (r.expectedPct < -needPct) {
|
|
757
|
+
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
758
|
+
orders.push({
|
|
759
|
+
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
|
|
760
|
+
refPrice: pos.price, limitPrice, amountRub: round(bLots * sp.lotSize * limitPrice, 2),
|
|
761
|
+
reason: `Плавный переход (защита от падения): ожидание ${r.expectedPct}% по бумаге из начального портфеля`,
|
|
762
|
+
});
|
|
763
|
+
closing.add(sym);
|
|
764
|
+
} else {
|
|
765
|
+
smoothSellPool.push({ sym, pos, r, sp, bLots });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const activeLongs = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) > 0 && !closing.has(p.symbol)).length;
|
|
771
|
+
const activeShorts = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol)).length;
|
|
772
|
+
const usedRub = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) !== 0 && !closing.has(p.symbol)).reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
773
|
+
const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
774
|
+
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
|
|
775
|
+
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
776
|
+
|
|
777
|
+
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи и свободного лимита)
|
|
778
|
+
const newBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol))).slice(0, Math.max(0, K - activeLongs));
|
|
779
|
+
const newShorts = shortCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) < 0 && !closing.has(r.symbol))).slice(0, Math.max(0, K - activeShorts));
|
|
780
|
+
|
|
781
|
+
// В режиме «Плавный переход», если под новые покупки не хватает свободных рублей, плавно высвобождаем из начального портфеля
|
|
782
|
+
// (в первую очередь бумаги в плюсе или с наименьшим прогнозом, не более 2 бумаг за шаг)
|
|
783
|
+
if (legacyMode === 'smooth' && newBuys.length > 0 && smoothSellPool.length > 0) {
|
|
784
|
+
const neededRub = newBuys.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r), 0);
|
|
785
|
+
let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), neededRub) - availRub;
|
|
786
|
+
if (shortfall > 1000) {
|
|
787
|
+
smoothSellPool.sort((a, b) => ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (a.r.expectedPct - b.r.expectedPct));
|
|
788
|
+
for (const item of smoothSellPool.slice(0, 2)) {
|
|
789
|
+
if (shortfall <= 1000) break;
|
|
790
|
+
const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
|
|
791
|
+
const lotVal = limitPrice * item.sp.lotSize;
|
|
792
|
+
const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
|
|
793
|
+
const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
|
|
794
|
+
orders.push({
|
|
795
|
+
symbol: item.sym, action: 'sell', lotSize: item.sp.lotSize, lots: lotsToSell, shares: lotsToSell * item.sp.lotSize,
|
|
796
|
+
refPrice: item.pos.price, limitPrice, amountRub,
|
|
797
|
+
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (ожидание ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) под новый сигнал покупки`,
|
|
798
|
+
});
|
|
799
|
+
availRub += amountRub;
|
|
800
|
+
shortfall -= amountRub;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const totalNew = newBuys.length + newShorts.length;
|
|
805
|
+
let remNew = totalNew;
|
|
806
|
+
|
|
807
|
+
for (const r of newBuys) {
|
|
808
|
+
const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r);
|
|
809
|
+
const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
|
|
810
|
+
remNew--;
|
|
811
|
+
const refPx = specs[r.symbol]?.lastPrice || 0;
|
|
812
|
+
const ord = calcLotOrder(r.symbol, sum, refPx, 'buy', acc.limitOffsetPct, specs);
|
|
813
|
+
if (ord) {
|
|
814
|
+
availRub = Math.max(0, availRub - ord.amountRub);
|
|
815
|
+
orders.push({
|
|
816
|
+
...ord,
|
|
817
|
+
expectedPct: r.expectedPct,
|
|
818
|
+
reason: `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
for (const r of newShorts) {
|
|
823
|
+
const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r);
|
|
824
|
+
const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
|
|
825
|
+
remNew--;
|
|
826
|
+
const refPx = specs[r.symbol]?.lastPrice || 0;
|
|
827
|
+
const ord = calcLotOrder(r.symbol, sum, refPx, 'short', acc.limitOffsetPct, specs);
|
|
828
|
+
if (ord) {
|
|
829
|
+
availRub = Math.max(0, availRub - ord.amountRub);
|
|
830
|
+
orders.push({
|
|
831
|
+
...ord,
|
|
832
|
+
expectedPct: r.expectedPct,
|
|
833
|
+
reason: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let diagNote = '';
|
|
839
|
+
if (!orders.length) {
|
|
840
|
+
if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
841
|
+
const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
|
|
842
|
+
const cashNow = round(acc.lastSync?.cashRub || 0, 0);
|
|
843
|
+
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
844
|
+
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
845
|
+
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
846
|
+
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
847
|
+
} else if (!newBuys.length && !newShorts.length) {
|
|
848
|
+
diagNote = `На свече ${fc.bar} ни одна бумага не превысила порог выгоды +${fc.thresholdPct}% (текущие позиции соответствуют прогнозу).`;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
acc.pendingBatch = {
|
|
853
|
+
id: `batch_${Date.now()}`,
|
|
854
|
+
createdAt: Date.now(),
|
|
855
|
+
bar: fc.bar,
|
|
856
|
+
budgetRub: budget,
|
|
857
|
+
availRub: round(availRub, 0),
|
|
858
|
+
effectiveDirection: dir,
|
|
859
|
+
marginAllowed,
|
|
860
|
+
edgeWarning: edgeWarn,
|
|
861
|
+
diagNote,
|
|
862
|
+
topSignals: [...buyCands.slice(0, 3).map((r) => ({ symbol: r.symbol, side: 'buy', expectedPct: r.expectedPct })), ...shortCands.slice(0, 3).map((r) => ({ symbol: r.symbol, side: 'short', expectedPct: r.expectedPct }))],
|
|
863
|
+
orders,
|
|
864
|
+
status: 'pending',
|
|
865
|
+
results: [],
|
|
866
|
+
};
|
|
867
|
+
saveRawAccounts(list);
|
|
868
|
+
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
869
|
+
if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
|
|
870
|
+
return executeBrokerOrders(id);
|
|
871
|
+
}
|
|
872
|
+
const st = brokersState();
|
|
873
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
874
|
+
return st;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function formatTbankOrderError(status, rawText = '', ord = {}) {
|
|
878
|
+
let msg = '', desc = '';
|
|
879
|
+
try {
|
|
880
|
+
const j = JSON.parse(rawText);
|
|
881
|
+
msg = j.message || '';
|
|
882
|
+
desc = String(j.description || '');
|
|
883
|
+
} catch {
|
|
884
|
+
msg = rawText.slice(0, 140);
|
|
885
|
+
}
|
|
886
|
+
const code = `${msg} ${desc}`;
|
|
887
|
+
if (code.includes('30051') || /margin.*disabled/i.test(code)) {
|
|
888
|
+
return 'На счёте отключена маржинальная торговля (шорты недоступны; включите маржинальную торговлю в настройках счёта Т-Инвестиций)';
|
|
889
|
+
}
|
|
890
|
+
if (code.includes('30034') || code.includes('30042') || /not enough|insufficient/i.test(code)) {
|
|
891
|
+
return `Недостаточно доступного лимита или бумаг у брокера для заявки (${ord.lots || ''} л.)`;
|
|
892
|
+
}
|
|
893
|
+
if (code.includes('30079') || /test|qual/i.test(code)) {
|
|
894
|
+
return 'Бумага требует прохождения тестирования в приложении Т-Инвестиций';
|
|
895
|
+
}
|
|
896
|
+
if (code.includes('30052') || /short.*disabled|short.*not/i.test(code)) {
|
|
897
|
+
return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
|
|
898
|
+
}
|
|
899
|
+
return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function sendOrderToBroker(acc, ord) {
|
|
903
|
+
if (!acc.token) throw new Error('Не задан токен API брокера');
|
|
904
|
+
if (acc.broker === 'tbank') {
|
|
905
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
906
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
907
|
+
const instrumentId = await resolveTbankInstrumentId(base, headers, ord.symbol);
|
|
908
|
+
const units = Math.trunc(ord.limitPrice);
|
|
909
|
+
const nano = Math.round((ord.limitPrice - units) * 1e9);
|
|
910
|
+
const isBuy = ord.action === 'buy' || ord.action === 'cover';
|
|
911
|
+
let lotsToOrder = Number(ord.lots) || 0;
|
|
912
|
+
|
|
913
|
+
// Проверяем реальный доступный лимит лотов у брокера через GetMaxLots перед отправкой заявки
|
|
914
|
+
if (!acc.sandbox && lotsToOrder > 0) {
|
|
915
|
+
const rMax = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetMaxLots`, {
|
|
916
|
+
method: 'POST',
|
|
917
|
+
headers,
|
|
918
|
+
body: JSON.stringify({ accountId: acc.accountId, instrumentId, price: { units: String(units), nano } }),
|
|
919
|
+
timeoutMs: 8000,
|
|
920
|
+
}).catch(() => null);
|
|
921
|
+
if (rMax?.ok) {
|
|
922
|
+
const jMax = await rMax.json().catch(() => null);
|
|
923
|
+
const maxLots = isBuy
|
|
924
|
+
? Number(jMax?.buyLimits?.buyMaxLots ?? jMax?.buyMarginLimits?.buyMaxLots ?? lotsToOrder)
|
|
925
|
+
: Number((ord.action === 'short' ? jMax?.sellMarginLimits?.sellMaxLots : null) ?? jMax?.sellLimits?.sellMaxLots ?? lotsToOrder);
|
|
926
|
+
if (Number.isFinite(maxLots)) {
|
|
927
|
+
if (maxLots < 1) {
|
|
928
|
+
if (ord.action === 'short') {
|
|
929
|
+
throw new Error('Короткая продажа (шорт) отклонена: на счёте отключена маржинальная торговля или исчерпан лимит шорта у брокера');
|
|
930
|
+
}
|
|
931
|
+
throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
|
|
932
|
+
}
|
|
933
|
+
if (maxLots < lotsToOrder) lotsToOrder = Math.floor(maxLots);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const body = {
|
|
939
|
+
accountId: acc.accountId,
|
|
940
|
+
instrumentId,
|
|
941
|
+
quantity: String(lotsToOrder),
|
|
942
|
+
price: { units: String(units), nano },
|
|
943
|
+
direction: isBuy ? 'ORDER_DIRECTION_BUY' : 'ORDER_DIRECTION_SELL',
|
|
944
|
+
orderType: 'ORDER_TYPE_LIMIT',
|
|
945
|
+
orderId: crypto.randomUUID(),
|
|
946
|
+
};
|
|
947
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/PostOrder`, {
|
|
948
|
+
method: 'POST',
|
|
949
|
+
headers,
|
|
950
|
+
body: JSON.stringify(body),
|
|
951
|
+
timeoutMs: 15000,
|
|
952
|
+
});
|
|
953
|
+
if (!r.ok) {
|
|
954
|
+
const errText = await r.text().catch(() => '');
|
|
955
|
+
throw new Error(formatTbankOrderError(r.status, errText, ord));
|
|
956
|
+
}
|
|
957
|
+
const j = await r.json();
|
|
958
|
+
return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder };
|
|
959
|
+
}
|
|
960
|
+
if (acc.broker === 'finam') {
|
|
961
|
+
const body = {
|
|
962
|
+
clientId: acc.accountId,
|
|
963
|
+
securityBoard: 'TQBR',
|
|
964
|
+
securityCode: ord.symbol,
|
|
965
|
+
buySell: ord.action === 'buy' || ord.action === 'cover' ? 'Buy' : 'Sell',
|
|
966
|
+
quantity: ord.lots,
|
|
967
|
+
useCredit: true,
|
|
968
|
+
price: ord.limitPrice,
|
|
969
|
+
property: 'PutInQueue',
|
|
970
|
+
};
|
|
971
|
+
const r = await fetch('https://trade-api.finam.ru/public/api/v1/orders', {
|
|
972
|
+
method: 'POST',
|
|
973
|
+
headers: { 'X-Api-Key': acc.token, 'Content-Type': 'application/json' },
|
|
974
|
+
body: JSON.stringify(body),
|
|
975
|
+
signal: AbortSignal.timeout(15000),
|
|
976
|
+
});
|
|
977
|
+
if (!r.ok) throw new Error(`Финам PostOrder HTTP ${r.status}`);
|
|
978
|
+
const j = await r.json();
|
|
979
|
+
return { ok: true, orderId: j.data?.transactionId || 'ok' };
|
|
980
|
+
}
|
|
981
|
+
if (acc.broker === 'alor') {
|
|
982
|
+
const jwt = await alorAccessToken(acc.token, acc.sandbox);
|
|
983
|
+
const api = acc.sandbox ? 'https://apidev.alor.ru' : 'https://api.alor.ru';
|
|
984
|
+
const body = {
|
|
985
|
+
side: ord.action === 'buy' || ord.action === 'cover' ? 'buy' : 'sell',
|
|
986
|
+
type: 'limit',
|
|
987
|
+
quantity: ord.lots,
|
|
988
|
+
price: ord.limitPrice,
|
|
989
|
+
instrument: { symbol: ord.symbol, exchange: 'MOEX', instrumentGroup: 'TQBR' },
|
|
990
|
+
user: { portfolio: acc.accountId },
|
|
991
|
+
};
|
|
992
|
+
const r = await fetch(`${api}/commandapi/warptrans/TRADE/v2/client/orders/actions/limit`, {
|
|
993
|
+
method: 'POST',
|
|
994
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json', 'X-ALOR-REQID': crypto.randomUUID() },
|
|
995
|
+
body: JSON.stringify(body),
|
|
996
|
+
signal: AbortSignal.timeout(15000),
|
|
997
|
+
});
|
|
998
|
+
if (!r.ok) throw new Error(`Алор PostOrder HTTP ${r.status}`);
|
|
999
|
+
const j = await r.json();
|
|
1000
|
+
return { ok: true, orderId: j.orderNumber || 'ok' };
|
|
1001
|
+
}
|
|
1002
|
+
if (acc.broker === 'bcs') {
|
|
1003
|
+
const jwt = await bcsAccessToken(acc.token);
|
|
1004
|
+
const body = {
|
|
1005
|
+
clientOrderId: crypto.randomUUID(),
|
|
1006
|
+
side: ord.action === 'buy' || ord.action === 'cover' ? '1' : '2',
|
|
1007
|
+
orderType: '2',
|
|
1008
|
+
orderQuantity: ord.lots,
|
|
1009
|
+
price: ord.limitPrice,
|
|
1010
|
+
ticker: ord.symbol,
|
|
1011
|
+
classCode: 'TQBR',
|
|
1012
|
+
};
|
|
1013
|
+
const r = await fetch('https://be.broker.ru/trade-api-bff-operations/api/v1/orders', {
|
|
1014
|
+
method: 'POST',
|
|
1015
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
1016
|
+
body: JSON.stringify(body),
|
|
1017
|
+
signal: AbortSignal.timeout(15000),
|
|
1018
|
+
});
|
|
1019
|
+
if (!r.ok) throw new Error(`БКС PostOrder HTTP ${r.status}`);
|
|
1020
|
+
const j = await r.json();
|
|
1021
|
+
return { ok: true, orderId: j.orderId || body.clientOrderId };
|
|
1022
|
+
}
|
|
1023
|
+
throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export async function executeBrokerOrders(id) {
|
|
1027
|
+
const list = loadRawAccounts();
|
|
1028
|
+
const acc = list.find((x) => x.id === id);
|
|
1029
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
1030
|
+
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
1031
|
+
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
1032
|
+
const batch = acc.pendingBatch;
|
|
1033
|
+
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
1034
|
+
const results = [];
|
|
1035
|
+
for (const ord of batch.orders) {
|
|
1036
|
+
try {
|
|
1037
|
+
const r = await sendOrderToBroker(acc, ord);
|
|
1038
|
+
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
|
|
1044
|
+
batch.results = results;
|
|
1045
|
+
batch.executedAt = Date.now();
|
|
1046
|
+
saveRawAccounts(list);
|
|
1047
|
+
const st = brokersState();
|
|
1048
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
1049
|
+
return st;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export function rejectBrokerOrders(id) {
|
|
1053
|
+
const list = loadRawAccounts();
|
|
1054
|
+
const acc = list.find((x) => x.id === id);
|
|
1055
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
1056
|
+
acc.pendingBatch = null;
|
|
1057
|
+
saveRawAccounts(list);
|
|
1058
|
+
const st = brokersState();
|
|
1059
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
1060
|
+
return st;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения) ----------
|
|
1064
|
+
export async function fetchBrokerHistory(id) {
|
|
1065
|
+
const list = loadRawAccounts();
|
|
1066
|
+
const acc = list.find((x) => x.id === id);
|
|
1067
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
1068
|
+
if (!acc.token) throw new Error('У счёта не задан токен API');
|
|
1069
|
+
if (acc.broker !== 'tbank') {
|
|
1070
|
+
return { operations: [], summary: { tradesCount: 0, buySumRub: 0, sellSumRub: 0, commissionsRub: 0, dividendsRub: 0, inOutRub: 0 } };
|
|
1071
|
+
}
|
|
1072
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
1073
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1074
|
+
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
1075
|
+
if (!accountId) throw new Error('Сначала нажмите «Обновить портфель», чтобы определить ID счёта');
|
|
1076
|
+
const fromIso = new Date(Date.now() - 365 * 5 * DAY).toISOString();
|
|
1077
|
+
const toIso = new Date().toISOString();
|
|
1078
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
|
|
1079
|
+
method: 'POST',
|
|
1080
|
+
headers,
|
|
1081
|
+
body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
|
|
1082
|
+
timeoutMs: 20000,
|
|
1083
|
+
});
|
|
1084
|
+
if (!r.ok) throw new Error(`T-Invest GetOperations: HTTP ${r.status}`);
|
|
1085
|
+
const j = await r.json();
|
|
1086
|
+
const rawOps = j.operations || [];
|
|
1087
|
+
const figis = [...new Set(rawOps.slice(-120).map((o) => o.figi || o.instrumentUid).filter(Boolean))];
|
|
1088
|
+
const metaMap = await resolveTbankMetaMap(base, headers, figis);
|
|
1089
|
+
|
|
1090
|
+
let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
|
|
1091
|
+
for (const op of rawOps) {
|
|
1092
|
+
const pay = qVal(op.payment);
|
|
1093
|
+
const t = String(op.operationType || '');
|
|
1094
|
+
if (t.includes('BUY')) { tradesCount++; buySumRub += Math.abs(pay); }
|
|
1095
|
+
else if (t.includes('SELL')) { tradesCount++; sellSumRub += Math.abs(pay); }
|
|
1096
|
+
else if (t.includes('FEE') || t.includes('COMMISSION') || t.includes('TAX')) { commissionsRub += Math.abs(pay); }
|
|
1097
|
+
else if (t.includes('DIVIDEND') || t.includes('COUPON')) { dividendsRub += pay; }
|
|
1098
|
+
else if (t.includes('INPUT') || t.includes('OUTPUT')) { inOutRub += pay; }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const OP_KIND = (t = '') => {
|
|
1102
|
+
if (t.includes('BUY')) return ['buy', 'Покупка'];
|
|
1103
|
+
if (t.includes('SELL')) return ['sell', 'Продажа'];
|
|
1104
|
+
if (t.includes('DIVIDEND')) return ['div', 'Дивиденды'];
|
|
1105
|
+
if (t.includes('COUPON')) return ['div', 'Купоны'];
|
|
1106
|
+
if (t.includes('INPUT')) return ['in', 'Пополнение'];
|
|
1107
|
+
if (t.includes('OUTPUT')) return ['out', 'Вывод'];
|
|
1108
|
+
if (t.includes('FEE') || t.includes('COMMISSION')) return ['fee', 'Комиссия'];
|
|
1109
|
+
if (t.includes('TAX')) return ['fee', 'Налог'];
|
|
1110
|
+
return ['other', 'Операция'];
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// Последние 80 операций в обратном хронологическом порядке (свежие сверху)
|
|
1114
|
+
const recentRaw = [...rawOps].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))).slice(0, 80);
|
|
1115
|
+
const operations = recentRaw.map((op) => {
|
|
1116
|
+
const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
|
|
1117
|
+
const [kind, kindLabel] = OP_KIND(op.operationType);
|
|
1118
|
+
return {
|
|
1119
|
+
id: op.id || '',
|
|
1120
|
+
date: op.date ? new Date(op.date).getTime() : 0,
|
|
1121
|
+
kind,
|
|
1122
|
+
kindLabel,
|
|
1123
|
+
title: op.type || kindLabel,
|
|
1124
|
+
symbol: m.ticker || op.figi || '',
|
|
1125
|
+
name: m.name || '',
|
|
1126
|
+
qty: Number(op.quantityDone || op.quantity || 0),
|
|
1127
|
+
price: round(qVal(op.price), 4),
|
|
1128
|
+
paymentRub: round(qVal(op.payment), 2),
|
|
1129
|
+
};
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const histData = {
|
|
1133
|
+
at: Date.now(),
|
|
1134
|
+
totalCount: rawOps.length,
|
|
1135
|
+
summary: {
|
|
1136
|
+
tradesCount,
|
|
1137
|
+
buySumRub: round(buySumRub, 0),
|
|
1138
|
+
sellSumRub: round(sellSumRub, 0),
|
|
1139
|
+
commissionsRub: round(commissionsRub, 0),
|
|
1140
|
+
dividendsRub: round(dividendsRub, 0),
|
|
1141
|
+
inOutRub: round(inOutRub, 0),
|
|
1142
|
+
},
|
|
1143
|
+
operations,
|
|
1144
|
+
};
|
|
1145
|
+
acc.lastHistory = histData;
|
|
1146
|
+
saveRawAccounts(list);
|
|
1147
|
+
const st = brokersState();
|
|
1148
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
1149
|
+
return st;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrders });
|
|
1153
|
+
|
|
1154
|
+
// ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
|
|
1155
|
+
let lastSeenBar = null;
|
|
1156
|
+
let schedulerBusy = false;
|
|
1157
|
+
|
|
1158
|
+
async function tickBrokerSchedule() {
|
|
1159
|
+
if (schedulerBusy) return;
|
|
1160
|
+
const nowMsk = new Date(Date.now() + MSK);
|
|
1161
|
+
const dow = nowMsk.getUTCDay();
|
|
1162
|
+
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
1163
|
+
if (dow === 0 || dow === 6 || mins < 9 * 60 + 50 || mins > 23 * 60 + 55) return;
|
|
1164
|
+
schedulerBusy = true;
|
|
1165
|
+
try {
|
|
1166
|
+
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
1167
|
+
if (!latest || latest === lastSeenBar) return;
|
|
1168
|
+
lastSeenBar = latest;
|
|
1169
|
+
const users = db.prepare('SELECT * FROM users').all();
|
|
1170
|
+
for (const u of users) {
|
|
1171
|
+
await runWithUser(u, async () => {
|
|
1172
|
+
const list = loadRawAccounts();
|
|
1173
|
+
if (!list.length) return;
|
|
1174
|
+
for (const acc of list) {
|
|
1175
|
+
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
1176
|
+
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
1177
|
+
const params = normalizeParams(baseRun?.params || getSettings());
|
|
1178
|
+
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1179
|
+
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1180
|
+
const H = barsFromHours(effHorizon);
|
|
1181
|
+
const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
|
|
1182
|
+
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
1183
|
+
acc.lastScheduledBarTs = latest;
|
|
1184
|
+
saveRawAccounts(list);
|
|
1185
|
+
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
1186
|
+
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
} finally {
|
|
1191
|
+
schedulerBusy = false;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Автоматическое восстановление и синхронизация брокерских счетов после перезапуска сервера
|
|
1196
|
+
async function resumeBrokerAccountsOnBoot() {
|
|
1197
|
+
try {
|
|
1198
|
+
const users = db.prepare('SELECT * FROM users').all();
|
|
1199
|
+
for (const u of users) {
|
|
1200
|
+
await runWithUser(u, async () => {
|
|
1201
|
+
const list = loadRawAccounts();
|
|
1202
|
+
for (const acc of list) {
|
|
1203
|
+
if (!acc.token) continue;
|
|
1204
|
+
await syncBrokerAccount(acc.id).catch(() => {});
|
|
1205
|
+
if (!acc.lastHistory && acc.broker === 'tbank') {
|
|
1206
|
+
await fetchBrokerHistory(acc.id).catch(() => {});
|
|
1207
|
+
}
|
|
1208
|
+
// Если в предыдущем пакете была ошибка 400 из-за шорта на безмаржинальном счёте — пересчитываем пакет под реальные настройки счёта
|
|
1209
|
+
const fresh = loadRawAccounts().find((x) => x.id === acc.id);
|
|
1210
|
+
if (fresh?.pendingBatch?.results?.some((r) => !r.ok && /HTTP 400|margin/i.test(String(r.error || '')))) {
|
|
1211
|
+
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
await tickBrokerSchedule().catch(() => {});
|
|
1217
|
+
} catch { /* ignore startup sync errors */ }
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
export function startBrokerScheduler() {
|
|
1221
|
+
setTimeout(resumeBrokerAccountsOnBoot, 2500);
|
|
1222
|
+
const timer = setInterval(tickBrokerSchedule, 60000);
|
|
1223
|
+
timer.unref?.();
|
|
1224
|
+
}
|