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