neuroalpha 0.6.12 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/bin/cli.js +79 -69
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +300 -152
- package/public/index.html +4 -37
- package/server/api/routes.js +570 -906
- package/server/auth/index.js +576 -761
- package/server/brokers/index.js +1 -4
- package/server/config.js +73 -53
- package/server/index.js +186 -187
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/dataset.js +2 -4
- package/server/model/forecast.js +1 -3
- package/server/news/index.js +2 -3
- package/server/notify/index.js +304 -466
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- package/server/role.js +0 -44
package/server/api/routes.js
CHANGED
|
@@ -1,906 +1,570 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
r.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
r.
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
r.
|
|
175
|
-
r.
|
|
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
|
-
r.
|
|
206
|
-
r.post('/
|
|
207
|
-
r.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
res.json(
|
|
227
|
-
}));
|
|
228
|
-
r.post('/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
r.post('/
|
|
281
|
-
const
|
|
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
|
-
r.post('/
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
r.
|
|
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
|
-
|
|
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
|
-
r.get('/
|
|
536
|
-
r.post('/
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
r.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
r.post('/
|
|
559
|
-
r.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const ranked = [...done].sort((a, b) => (b.metrics?.full?.returnPct ?? -1e9) - (a.metrics?.full?.returnPct ?? -1e9));
|
|
572
|
-
const keepSet = new Set(ranked.slice(0, keep).map((r) => r.id));
|
|
573
|
-
let deleted = 0;
|
|
574
|
-
for (const r of all) {
|
|
575
|
-
if (r.mode !== 'model' || r.status === 'running' || keepSet.has(r.id)) continue;
|
|
576
|
-
deleteRun(r.id);
|
|
577
|
-
deleted++;
|
|
578
|
-
}
|
|
579
|
-
res.json({ deleted, kept: [...keepSet] });
|
|
580
|
-
}));
|
|
581
|
-
|
|
582
|
-
r.post('/model/research', wrap((req, res) => {
|
|
583
|
-
const plan = effectivePlan(req.user);
|
|
584
|
-
if (!plan.allowResearch && req.user?.role !== 'admin') {
|
|
585
|
-
throw new Error('Раздел исследований доступен только в режиме «Хозяин».');
|
|
586
|
-
}
|
|
587
|
-
if (countUserRunsToday() >= plan.maxRunsPerDay) {
|
|
588
|
-
throw new Error(`Достигнут дневной лимит исследований (${plan.maxRunsPerDay} в сутки).`);
|
|
589
|
-
}
|
|
590
|
-
const body = req.body || {};
|
|
591
|
-
const merged = { ...getSettings(), ...body };
|
|
592
|
-
const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
|
|
593
|
-
res.json({ runId, reused: !!reused });
|
|
594
|
-
}));
|
|
595
|
-
r.post('/model/:id/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelModelResearch(Number(req.params.id)) }); }));
|
|
596
|
-
r.get('/model/forecast', wrap(async (req, res) => {
|
|
597
|
-
const fc = await forecastNow(getSettings(), { force: req.query.force === '1' });
|
|
598
|
-
if (fc?.rows?.some((x) => x.signal || x.shortSignal)) {
|
|
599
|
-
triggerBrokerFastEntryCheck();
|
|
600
|
-
}
|
|
601
|
-
res.json(fc);
|
|
602
|
-
}));
|
|
603
|
-
|
|
604
|
-
// ---------- описания компаний (текстовая карта) ----------
|
|
605
|
-
r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
|
|
606
|
-
r.post('/profiles/refresh', wrap((req, res) => res.json(startProfilesRefresh())));
|
|
607
|
-
|
|
608
|
-
// ---------- живая проверка (несколько виртуальных счетов) ----------
|
|
609
|
-
r.get('/live/status', (req, res) => res.json(live.status()));
|
|
610
|
-
r.post('/live/start', wrap(async (req, res) => {
|
|
611
|
-
const plan = effectivePlan(req.user);
|
|
612
|
-
const b = req.body || {};
|
|
613
|
-
const runId = await live.start(getSettings(), { source: 'user', baseRunId: b.baseRunId || null, name: b.name || '', capital: b.capital || null, maxAccounts: plan.maxVirtualAccounts });
|
|
614
|
-
res.json({ runId, status: live.status() });
|
|
615
|
-
}));
|
|
616
|
-
r.post('/live/stop', (req, res) => res.json({ ok: live.stop(req.body?.runId || null), status: live.status() }));
|
|
617
|
-
r.delete('/live/:id', (req, res) => res.json({ ok: live.remove(Number(req.params.id)), status: live.status() }));
|
|
618
|
-
|
|
619
|
-
// ---------- брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
|
|
620
|
-
r.get('/brokers', (req, res) => res.json(brokersState()));
|
|
621
|
-
r.post('/brokers/discover', wrap(async (req, res) => res.json(await discoverBrokerAccounts(req.body || {}))));
|
|
622
|
-
r.post('/brokers', wrap((req, res) => {
|
|
623
|
-
const plan = effectivePlan(req.user);
|
|
624
|
-
const cur = brokersState().accounts.length;
|
|
625
|
-
if (!req.body?.id && cur >= plan.maxBrokerAccounts) {
|
|
626
|
-
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
627
|
-
throw new Error('Для подключения брокерского счёта оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP».');
|
|
628
|
-
}
|
|
629
|
-
throw new Error(`По вашему тарифу «${plan.name}» доступно подключение до ${plan.maxBrokerAccounts} брокерских счетов. Перейдите на тариф «Профи» или «VIP».`);
|
|
630
|
-
}
|
|
631
|
-
res.json(saveBrokerAccount(req.body || {}));
|
|
632
|
-
}));
|
|
633
|
-
r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
|
|
634
|
-
r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
|
|
635
|
-
r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
|
|
636
|
-
r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id, req.body || {}))));
|
|
637
|
-
r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
|
|
638
|
-
r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
|
|
639
|
-
r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
|
|
640
|
-
r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));
|
|
641
|
-
r.post('/brokers/:id/cancel-order', wrap(async (req, res) => res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null))));
|
|
642
|
-
r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => res.json(resetBrokerDailyStop(req.params.id))));
|
|
643
|
-
r.get('/brokers/:id/events', wrap((req, res) => res.json({ events: listBrokerEvents(req.params.id, req.query.limit) })));
|
|
644
|
-
r.get('/brokers/:id/decisions', wrap((req, res) => res.json({ decisions: listTradeDecisions(req.params.id, { fromTs: req.query.from, limit: req.query.limit }) })));
|
|
645
|
-
r.post('/brokers-stop-all', wrap(async (req, res) => res.json(await stopAllBrokerTrading())));
|
|
646
|
-
|
|
647
|
-
// ---------- новости (только для прогноза «сейчас» и живой проверки) ----------
|
|
648
|
-
r.get('/news', (req, res) => res.json({
|
|
649
|
-
items: listNews({ symbol: req.query.symbol || undefined, hours: Number(req.query.hours) || 72, limit: Number(req.query.limit) || 30 }),
|
|
650
|
-
}));
|
|
651
|
-
|
|
652
|
-
// ---------- подробное досье акции (котировки, график, прогноз, соседи по карте, дивиденды, бизнес-профиль, сделки пользователя, новости) ----------
|
|
653
|
-
r.get('/stock/:symbol', wrap(async (req, res) => {
|
|
654
|
-
const sym = String(req.params.symbol || '').toUpperCase().trim();
|
|
655
|
-
if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
|
|
656
|
-
|
|
657
|
-
const nowTs = Date.now();
|
|
658
|
-
const [fc, rawSpecs, divs, tbLiveBars, tbMinBars] = await Promise.all([
|
|
659
|
-
forecastNow(getSettings()).catch(() => null),
|
|
660
|
-
ensureBoardSpecs().catch(() => ({})),
|
|
661
|
-
ensureDividends(sym).catch(() => []),
|
|
662
|
-
fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
|
|
663
|
-
fetchTbankCandles(sym, nowTs - DAY + 5 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null),
|
|
664
|
-
ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
|
|
665
|
-
]);
|
|
666
|
-
if (tbLiveBars?.length) {
|
|
667
|
-
const doneBars = tbLiveBars.filter((b) => b.isComplete);
|
|
668
|
-
if (doneBars.length) saveCandles(sym, 10, doneBars);
|
|
669
|
-
}
|
|
670
|
-
const tbMap = kvGet('tbankShortableMap') || {};
|
|
671
|
-
const tbSpecs = tbMap.specMap || {};
|
|
672
|
-
const shortableMap = tbMap.map || {};
|
|
673
|
-
const sp = { ...(rawSpecs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2, issueSize: 0, lastPrice: 0 }), ...(tbSpecs[sym] || {}) };
|
|
674
|
-
const vocabList = kvGet('vocab')?.list || [];
|
|
675
|
-
const vocabHit = vocabList.find((v) => v.symbol === sym);
|
|
676
|
-
const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
|
|
677
|
-
const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
|
|
678
|
-
const tierMult = symbolEdgeMult(sym);
|
|
679
|
-
|
|
680
|
-
// 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
|
|
681
|
-
const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
|
|
682
|
-
const intraday = rawBars30d.slice(-1500).map((b) => ({
|
|
683
|
-
ts: b.ts,
|
|
684
|
-
o: b.open,
|
|
685
|
-
h: b.high,
|
|
686
|
-
l: b.low,
|
|
687
|
-
c: b.close,
|
|
688
|
-
v: b.value || 0,
|
|
689
|
-
}));
|
|
690
|
-
|
|
691
|
-
const allBars = db.prepare('SELECT ts, open, high, low, close, value FROM candles WHERE symbol = ? AND interval = 10 ORDER BY ts ASC').all(sym);
|
|
692
|
-
const dailyMap = new Map();
|
|
693
|
-
for (const b of allBars) {
|
|
694
|
-
const dayKey = Math.floor((b.ts + MSK) / DAY) * DAY;
|
|
695
|
-
let d = dailyMap.get(dayKey);
|
|
696
|
-
if (!d) {
|
|
697
|
-
d = { ts: dayKey, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0 };
|
|
698
|
-
dailyMap.set(dayKey, d);
|
|
699
|
-
} else {
|
|
700
|
-
if (b.high > d.h) d.h = b.high;
|
|
701
|
-
if (b.low > 0 && (d.l <= 0 || b.low < d.l)) d.l = b.low;
|
|
702
|
-
d.c = b.close;
|
|
703
|
-
d.v += b.value || 0;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
const daily = [...dailyMap.values()];
|
|
707
|
-
|
|
708
|
-
const tbFresh1m = tbMinBars?.length ? tbMinBars[tbMinBars.length - 1] : null;
|
|
709
|
-
const tbFreshBar = tbLiveBars?.length ? tbLiveBars[tbLiveBars.length - 1] : null;
|
|
710
|
-
const curPrice = (tbFresh1m?.close > 0 ? tbFresh1m.close : 0) || (tbFreshBar?.close > 0 ? tbFreshBar.close : 0) || fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
|
|
711
|
-
const intraday1m = (tbMinBars || []).map((b) => ({
|
|
712
|
-
ts: b.ts,
|
|
713
|
-
o: b.open,
|
|
714
|
-
h: b.high,
|
|
715
|
-
l: b.low,
|
|
716
|
-
c: b.close,
|
|
717
|
-
v: b.value || 0,
|
|
718
|
-
}));
|
|
719
|
-
if (curPrice > 0 && intraday1m.length) {
|
|
720
|
-
const last1m = intraday1m[intraday1m.length - 1];
|
|
721
|
-
const liveMinSlotTs = Math.floor(nowTs / 60000) * 60000;
|
|
722
|
-
if (liveMinSlotTs > last1m.ts && liveMinSlotTs - last1m.ts <= 3600000) {
|
|
723
|
-
intraday1m.push({ ts: liveMinSlotTs, o: last1m.c, h: Math.max(last1m.c, curPrice), l: Math.min(last1m.c, curPrice), c: curPrice, v: 0 });
|
|
724
|
-
} else if (last1m.c !== curPrice) {
|
|
725
|
-
last1m.c = curPrice;
|
|
726
|
-
if (curPrice > last1m.h) last1m.h = curPrice;
|
|
727
|
-
if (last1m.l <= 0 || curPrice < last1m.l) last1m.l = curPrice;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
if (tbFreshBar && !tbFreshBar.isComplete && intraday.length) {
|
|
731
|
-
const lastBar = intraday[intraday.length - 1];
|
|
732
|
-
if (tbFreshBar.ts > lastBar.ts) {
|
|
733
|
-
intraday.push({ ts: tbFreshBar.ts, o: tbFreshBar.open, h: tbFreshBar.high, l: tbFreshBar.low, c: curPrice, v: tbFreshBar.value || 0 });
|
|
734
|
-
} else if (tbFreshBar.ts === lastBar.ts) {
|
|
735
|
-
lastBar.c = curPrice;
|
|
736
|
-
if (tbFreshBar.high > lastBar.h) lastBar.h = tbFreshBar.high;
|
|
737
|
-
if (tbFreshBar.low > 0 && (lastBar.l <= 0 || tbFreshBar.low < lastBar.l)) lastBar.l = tbFreshBar.low;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
if (curPrice > 0 && intraday.length) {
|
|
741
|
-
const lastBar = intraday[intraday.length - 1];
|
|
742
|
-
const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
|
|
743
|
-
if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
|
|
744
|
-
intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
|
|
745
|
-
} else if (lastBar.c !== curPrice) {
|
|
746
|
-
lastBar.c = curPrice;
|
|
747
|
-
if (curPrice > lastBar.h) lastBar.h = curPrice;
|
|
748
|
-
if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
|
|
749
|
-
}
|
|
750
|
-
if (daily.length) {
|
|
751
|
-
const lastDay = daily[daily.length - 1];
|
|
752
|
-
lastDay.c = curPrice;
|
|
753
|
-
if (curPrice > lastDay.h) lastDay.h = curPrice;
|
|
754
|
-
if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
|
|
758
|
-
const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
|
|
759
|
-
const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
|
|
760
|
-
const monthChangePct = monthAgoClose > 0 && curPrice > 0 ? round(((curPrice - monthAgoClose) / monthAgoClose) * 100, 2) : 0;
|
|
761
|
-
const yearAgoClose = daily.length >= 250 ? daily[daily.length - 250].c : (daily[0]?.o || curPrice);
|
|
762
|
-
const yearChangePct = yearAgoClose > 0 && curPrice > 0 ? round(((curPrice - yearAgoClose) / yearAgoClose) * 100, 2) : 0;
|
|
763
|
-
const marketCapRub = sp.issueSize > 0 && curPrice > 0 ? Math.round(sp.issueSize * curPrice) : null;
|
|
764
|
-
|
|
765
|
-
// 20 факторов бизнес-профиля
|
|
766
|
-
const profMap = loadProfiles();
|
|
767
|
-
const profVec = profMap.get(sym) || null;
|
|
768
|
-
const traits = profVec ? TRAITS.map((t, idx) => ({ id: t.id, label: t.label, ask: t.ask, value: round(profVec[idx] ?? 0, 2) })) : [];
|
|
769
|
-
|
|
770
|
-
// Позиции и сделки пользователя по этой акции на подключённых брокерских счетах
|
|
771
|
-
const bState = brokersState();
|
|
772
|
-
const userPositions = [];
|
|
773
|
-
const userOperations = [];
|
|
774
|
-
for (const acc of bState.accounts || []) {
|
|
775
|
-
const pos = (acc.lastSync?.positions || []).find((p) => p.symbol === sym && p.qty !== 0);
|
|
776
|
-
if (pos) {
|
|
777
|
-
const livePx = curPrice > 0 ? curPrice : pos.price;
|
|
778
|
-
const avgPx = Number(pos.avgPrice) || 0;
|
|
779
|
-
const valRub = round(pos.qty * livePx, 2);
|
|
780
|
-
const pnlRub = avgPx > 0 ? round(pos.qty * (livePx - avgPx), 2) : pos.pnlRub;
|
|
781
|
-
const costBasis = avgPx > 0 ? Math.abs(pos.qty) * avgPx : 0;
|
|
782
|
-
const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : pos.pnlPct;
|
|
783
|
-
userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos, price: livePx, valueRub: valRub, pnlRub, pnlPct });
|
|
784
|
-
}
|
|
785
|
-
for (const op of acc.lastHistory?.operations || []) {
|
|
786
|
-
if (op.symbol === sym && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'div' || op.kind === 'fee')) {
|
|
787
|
-
userOperations.push({ accountName: acc.name, ...op });
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
userOperations.sort((a, b) => (b.date || 0) - (a.date || 0));
|
|
792
|
-
|
|
793
|
-
// Считаем историческую результативность по бумаге в последнем эталонном эксперименте
|
|
794
|
-
let backtestStat = null;
|
|
795
|
-
const lastModelRun = listRuns().find((run) => run.mode === 'model' && run.status === 'done');
|
|
796
|
-
if (lastModelRun) {
|
|
797
|
-
const simTrades = db.prepare('SELECT side, entry_ts, exit_ts, entry_price, exit_price, pnl, pnl_pct FROM trades WHERE run_id = ? AND symbol = ? ORDER BY entry_ts DESC').all(lastModelRun.id, sym);
|
|
798
|
-
if (simTrades.length) {
|
|
799
|
-
const wins = simTrades.filter((t) => t.pnl > 0).length;
|
|
800
|
-
const avgPct = simTrades.reduce((s, t) => s + (t.pnl_pct || 0), 0) / simTrades.length;
|
|
801
|
-
backtestStat = {
|
|
802
|
-
runId: lastModelRun.id,
|
|
803
|
-
tradesCount: simTrades.length,
|
|
804
|
-
winRate: round((wins / simTrades.length) * 100, 1),
|
|
805
|
-
avgPnlPct: round(avgPct, 2),
|
|
806
|
-
recentTrades: simTrades.slice(0, 10),
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const news = listNews({ symbol: sym, hours: 24 * 30, limit: 20 });
|
|
812
|
-
const sortedDivs = [...(divs || [])].sort((a, b) => String(b.registry).localeCompare(String(a.registry)));
|
|
813
|
-
|
|
814
|
-
res.json({
|
|
815
|
-
symbol: sym,
|
|
816
|
-
name: fcRow?.name || vocabHit?.name || sym,
|
|
817
|
-
price: round(curPrice, curPrice < 1 ? 5 : curPrice < 10 ? 3 : 2),
|
|
818
|
-
dayChangePct,
|
|
819
|
-
monthChangePct,
|
|
820
|
-
yearChangePct,
|
|
821
|
-
tier: isTier1 ? 1 : 2,
|
|
822
|
-
tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
|
|
823
|
-
edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
|
|
824
|
-
sigmaPct: fcRow?.sigmaPct ?? null,
|
|
825
|
-
buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.40) * tierMult, 0.30), 3),
|
|
826
|
-
shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.50) * tierMult, 0.31), 3),
|
|
827
|
-
shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
|
|
828
|
-
specs: {
|
|
829
|
-
lotSize: sp.lotSize,
|
|
830
|
-
lotCostRub: round((sp.lotSize || 1) * curPrice, 2),
|
|
831
|
-
minStep: sp.minStep,
|
|
832
|
-
decimals: sp.decimals,
|
|
833
|
-
issueSize: sp.issueSize || null,
|
|
834
|
-
marketCapRub,
|
|
835
|
-
indexWeightPct: vocabHit?.weight ? round(vocabHit.weight, 2) : null,
|
|
836
|
-
},
|
|
837
|
-
forecast: fcRow ? {
|
|
838
|
-
bar: fc?.bar,
|
|
839
|
-
horizonHours: fc?.horizon || 2.5,
|
|
840
|
-
expectedPct: fcRow.expectedPct,
|
|
841
|
-
lastMovePct: fcRow.lastMovePct ?? 0,
|
|
842
|
-
deltaPct: fcRow.deltaPct || 0,
|
|
843
|
-
signal: Boolean(fcRow.signal),
|
|
844
|
-
shortSignal: Boolean(fcRow.shortSignal),
|
|
845
|
-
shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
|
|
846
|
-
shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
|
|
847
|
-
buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
|
|
848
|
-
buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
|
|
849
|
-
shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
|
|
850
|
-
buyBlockedByNews: fcRow.buyBlockedByNews || null,
|
|
851
|
-
shortBlockedByNews: fcRow.shortBlockedByNews || null,
|
|
852
|
-
selfPct: fcRow.selfPct ?? 0,
|
|
853
|
-
influences: (fcRow.influences || []).map((inf) => {
|
|
854
|
-
const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
|
|
855
|
-
return { ...inf, name: infRow?.name || inf.symbol, price: infRow?.price || null, expectedPct: infRow?.expectedPct ?? null };
|
|
856
|
-
}),
|
|
857
|
-
} : null,
|
|
858
|
-
traits,
|
|
859
|
-
dividends: sortedDivs,
|
|
860
|
-
news,
|
|
861
|
-
userPositions,
|
|
862
|
-
userOperations: userOperations.slice(0, 60),
|
|
863
|
-
backtestStat,
|
|
864
|
-
intraday1m,
|
|
865
|
-
intraday,
|
|
866
|
-
daily,
|
|
867
|
-
});
|
|
868
|
-
}));
|
|
869
|
-
|
|
870
|
-
// ---------- журнал гипотез ----------
|
|
871
|
-
r.get('/journal', (req, res) => res.json(listJournal(500)));
|
|
872
|
-
r.post('/journal', wrap((req, res) => {
|
|
873
|
-
const id = addJournal({ ...req.body, source: 'user' });
|
|
874
|
-
hub.emit({ type: 'journal:update' });
|
|
875
|
-
res.json({ id });
|
|
876
|
-
}));
|
|
877
|
-
r.delete('/journal/:id', (req, res) => { deleteJournal(Number(req.params.id)); hub.emit({ type: 'journal:update' }); res.json({ ok: true }); });
|
|
878
|
-
|
|
879
|
-
// ---------- чат со Стратегом ----------
|
|
880
|
-
r.get('/chat', (req, res) => res.json(strategist.history()));
|
|
881
|
-
r.post('/chat/send', wrap((req, res) => {
|
|
882
|
-
const plan = effectivePlan(req.user);
|
|
883
|
-
if (countUserChatToday() >= plan.maxChatPerDay) {
|
|
884
|
-
throw new Error(`Достигнут дневной лимит сообщений ИИ-Стратегу по тарифу «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
|
|
885
|
-
}
|
|
886
|
-
const { text, mode, newSession, title } = req.body || {};
|
|
887
|
-
res.json({ message: strategist.sendUserMessage(text, mode, { newSession: Boolean(newSession), title: title || null }) });
|
|
888
|
-
}));
|
|
889
|
-
r.post('/chat/action', wrap((req, res) => {
|
|
890
|
-
const { msgId, callId, decision, comment } = req.body || {};
|
|
891
|
-
strategist.resolveAction(Number(msgId), String(callId), decision, comment);
|
|
892
|
-
res.json({ ok: true });
|
|
893
|
-
}));
|
|
894
|
-
r.post('/chat/stop', (req, res) => { strategist.stopAgent(); res.json({ ok: true }); });
|
|
895
|
-
r.post('/chat/mode', wrap((req, res) => { strategist.setMode(req.body?.mode, req.body?.budget); res.json(strategist.chatState()); }));
|
|
896
|
-
r.post('/chat/new', (req, res) => { strategist.resetConversation(); res.json(strategist.history()); });
|
|
897
|
-
r.get('/chat/archive', (req, res) => res.json({ sessions: strategist.listArchivedSessions(120) }));
|
|
898
|
-
r.post('/chat/archive/:id/open', wrap((req, res) => res.json(strategist.openArchivedSession(Number(req.params.id)))));
|
|
899
|
-
r.get('/chat/archive/:id/md', wrap((req, res) => {
|
|
900
|
-
const info = strategist.exportConversationMarkdown(Number(req.params.id));
|
|
901
|
-
if (!info) throw Object.assign(new Error('Сессия не найдена или пуста'), { status: 404 });
|
|
902
|
-
res.json(info);
|
|
903
|
-
}));
|
|
904
|
-
|
|
905
|
-
return r;
|
|
906
|
-
}
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { strategyDefaults } from '../config.js';
|
|
4
|
+
import { activeModelInfo } from '../llm/client.js';
|
|
5
|
+
import {
|
|
6
|
+
listProviders, getSelected, addProvider, updateProvider, deleteProvider,
|
|
7
|
+
fetchRemoteModels, testProvider, selectModel,
|
|
8
|
+
} from '../llm/providers.js';
|
|
9
|
+
import {
|
|
10
|
+
getLocalEngineFullState, startLocalModelInstall, cancelLocalModelInstall,
|
|
11
|
+
startLocalEngine, stopLocalEngine, deleteLocalModel,
|
|
12
|
+
} from '../llm/local-engine.js';
|
|
13
|
+
import {
|
|
14
|
+
listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle,
|
|
15
|
+
countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal,
|
|
16
|
+
listSettingsHistory, kvGet, db, loadCandles, saveCandles,
|
|
17
|
+
} from '../db.js';
|
|
18
|
+
import {
|
|
19
|
+
PLANS, effectivePlan, publicUser, createSession, deleteSession, parseAllCookieValues,
|
|
20
|
+
isLoopbackRequest, getTerminalLicenseState, activateTerminalByToken,
|
|
21
|
+
loginRemoteTerminal, setTerminalLocalPassword,
|
|
22
|
+
} from '../auth/index.js';
|
|
23
|
+
import { publicNotifyConfig, saveNotifyConfig, notifyUser } from '../notify/index.js';
|
|
24
|
+
import { PARAMS } from '../params-meta.js';
|
|
25
|
+
import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
|
|
26
|
+
import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
|
|
27
|
+
import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
|
|
28
|
+
import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
|
|
29
|
+
import { ensureDividends, ensureCandles } from '../data/moex.js';
|
|
30
|
+
import { live } from '../model/live.js';
|
|
31
|
+
import {
|
|
32
|
+
brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts,
|
|
33
|
+
freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders,
|
|
34
|
+
rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs,
|
|
35
|
+
resetBrokerDailyStop, listBrokerEvents, listTradeDecisions, stopAllBrokerTrading, fetchTbankCandles,
|
|
36
|
+
} from '../brokers/index.js';
|
|
37
|
+
import { listNews } from '../news/index.js';
|
|
38
|
+
import * as strategist from '../strategist/index.js';
|
|
39
|
+
import { round, MSK, DAY } from '../util.js';
|
|
40
|
+
import { hub } from '../hub.js';
|
|
41
|
+
import { isCrossOrigin } from '../security.js';
|
|
42
|
+
|
|
43
|
+
function clientIp(req) {
|
|
44
|
+
const xff = String(req?.headers?.['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
45
|
+
if (process.env.TRUST_PROXY === '1' && xff.length) return xff[xff.length - 1];
|
|
46
|
+
return String(req?.socket?.remoteAddress || '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isHttpsRequest(req) {
|
|
50
|
+
return Boolean(req?.secure || String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createRouter() {
|
|
54
|
+
const r = express.Router();
|
|
55
|
+
const wrap = (fn) => (req, res) => Promise.resolve()
|
|
56
|
+
.then(() => fn(req, res))
|
|
57
|
+
.catch((e) => res.status(e.status || 400).json({ error: e.message }));
|
|
58
|
+
|
|
59
|
+
const PUBLIC_API = [/^\/local\/(status|activate|login)$/];
|
|
60
|
+
|
|
61
|
+
r.use((req, res, next) => {
|
|
62
|
+
const isRead = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
|
|
63
|
+
if (!isRead && isCrossOrigin(req)) {
|
|
64
|
+
return res.status(403).json({ error: 'Запрос с чужого сайта отклонён' });
|
|
65
|
+
}
|
|
66
|
+
if (req.user || PUBLIC_API.some((rx) => rx.test(req.path))) return next();
|
|
67
|
+
return res.status(401).json({ error: 'Требуется активация или вход в терминал' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const setCookie = (res, sid, maxAgeSec = 30 * 86400) => {
|
|
71
|
+
const secure = isHttpsRequest(res.req) ? '; Secure' : '';
|
|
72
|
+
res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---------- Состояние лицензии, активация по токену ЛК и вход на удалённый VPS ----------
|
|
76
|
+
r.get('/local/status', (req, res) => {
|
|
77
|
+
const st = getTerminalLicenseState();
|
|
78
|
+
res.json({
|
|
79
|
+
...st,
|
|
80
|
+
loggedIn: Boolean(req.user),
|
|
81
|
+
isLoopback: isLoopbackRequest(req),
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
r.post('/local/activate', wrap(async (req, res) => {
|
|
86
|
+
const st = getTerminalLicenseState();
|
|
87
|
+
// Повторная привязка из неавторизованной внешней сети блокируется, если терминал уже активирован (кроме режима разработчика)
|
|
88
|
+
if (st.activated && !st.devBypass && !req.user && !isLoopbackRequest(req)) {
|
|
89
|
+
throw Object.assign(new Error('Терминал уже активирован. Войдите по своему токену личного кабинета или локальному паролю.'), { status: 409 });
|
|
90
|
+
}
|
|
91
|
+
const out = await activateTerminalByToken({
|
|
92
|
+
token: req.body?.token,
|
|
93
|
+
portalUrl: req.body?.portalUrl,
|
|
94
|
+
slotId: req.body?.slotId,
|
|
95
|
+
});
|
|
96
|
+
if (!out.chooseSlot) {
|
|
97
|
+
const sid = createSession(1, 30);
|
|
98
|
+
setCookie(res, sid);
|
|
99
|
+
llmChanged();
|
|
100
|
+
}
|
|
101
|
+
res.json(out);
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
r.post('/local/login', wrap(async (req, res) => {
|
|
105
|
+
const sid = await loginRemoteTerminal(req.body?.secret, { ip: clientIp(req) });
|
|
106
|
+
setCookie(res, sid);
|
|
107
|
+
res.json({ ok: true });
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
r.post('/local/refresh-license', wrap(async (req, res) => {
|
|
111
|
+
const cur = getTerminalLicenseState();
|
|
112
|
+
const out = await activateTerminalByToken({
|
|
113
|
+
token: req.body?.token || '',
|
|
114
|
+
portalUrl: req.body?.portalUrl || cur.portalUrl,
|
|
115
|
+
slotId: req.body?.slotId || cur.licId || '',
|
|
116
|
+
});
|
|
117
|
+
llmChanged();
|
|
118
|
+
res.json(out);
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
r.post('/local/password', wrap((req, res) => {
|
|
122
|
+
res.json(setTerminalLocalPassword(req.body?.password || ''));
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// ---------- Конфигурация терминала и профиль лицензии ----------
|
|
126
|
+
r.get('/config', (req, res) => res.json({
|
|
127
|
+
defaults: strategyDefaults,
|
|
128
|
+
params: PARAMS,
|
|
129
|
+
llm: activeModelInfo(),
|
|
130
|
+
user: publicUser(req.user),
|
|
131
|
+
plans: PLANS,
|
|
132
|
+
notify: publicNotifyConfig(),
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
r.get('/auth/me', (req, res) => res.json({
|
|
136
|
+
user: publicUser(req.user),
|
|
137
|
+
license: getTerminalLicenseState(),
|
|
138
|
+
plans: PLANS,
|
|
139
|
+
notify: publicNotifyConfig(),
|
|
140
|
+
isLoopback: isLoopbackRequest(req),
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
r.post('/auth/logout', (req, res) => {
|
|
144
|
+
for (const tok of parseAllCookieValues(req.headers?.cookie, 'sid')) {
|
|
145
|
+
if (tok && tok !== 'logged_out') deleteSession(tok);
|
|
146
|
+
}
|
|
147
|
+
setCookie(res, 'logged_out', 365 * 86400);
|
|
148
|
+
res.json({ ok: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------- Уведомления (шлюз портала + опционально прямой личный бот) ----------
|
|
152
|
+
r.post('/notify/config', wrap((req, res) => {
|
|
153
|
+
res.json({ ok: true, notify: saveNotifyConfig(req.body || {}) });
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
r.post('/notify/test', wrap(async (req, res) => {
|
|
157
|
+
const sent = await notifyUser(1, '◈ НейроАльфа Терминал: тестовое уведомление успешно доставлено.');
|
|
158
|
+
if (!sent.tg && !sent.max) {
|
|
159
|
+
throw new Error('Уведомление не доставлено: привяжите Telegram/MAX по коду в личном кабинете на портале либо укажите токен личного бота ниже.');
|
|
160
|
+
}
|
|
161
|
+
res.json({ ok: true, ...sent });
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
// ---------- Модели ИИ для Стратега (облачный прокси, внешние API и локальный движок llama.cpp) ----------
|
|
165
|
+
const llmState = () => ({ providers: listProviders(), selected: getSelected(), active: activeModelInfo() });
|
|
166
|
+
const llmChanged = () => {
|
|
167
|
+
hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
|
|
168
|
+
hub.emit({ type: 'chat:state', state: strategist.chatState() });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
r.get('/llm/providers', wrap((req, res) => res.json(llmState())));
|
|
172
|
+
r.post('/llm/providers', wrap((req, res) => { const p = addProvider(req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
|
|
173
|
+
r.put('/llm/providers/:id', wrap((req, res) => { const p = updateProvider(req.params.id, req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
|
|
174
|
+
r.delete('/llm/providers/:id', wrap((req, res) => { deleteProvider(req.params.id); llmChanged(); res.json(llmState()); }));
|
|
175
|
+
r.get('/llm/providers/:id/remote-models', wrap(async (req, res) => res.json({ models: await fetchRemoteModels(req.params.id) })));
|
|
176
|
+
r.post('/llm/test', wrap(async (req, res) => res.json(await testProvider(req.body?.providerId, req.body?.model))));
|
|
177
|
+
r.post('/llm/select', wrap((req, res) => { selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
|
|
178
|
+
|
|
179
|
+
// Локальная модель ИИ (диагностика железа, скачивание и запуск llama-server + Qwen3 GGUF)
|
|
180
|
+
r.get('/llm/local/state', wrap(async (req, res) => {
|
|
181
|
+
res.json(await getLocalEngineFullState({ refresh: req.query.refresh === '1' }));
|
|
182
|
+
}));
|
|
183
|
+
r.post('/llm/local/install', wrap((req, res) => {
|
|
184
|
+
res.json(startLocalModelInstall(req.body || {}));
|
|
185
|
+
}));
|
|
186
|
+
r.post('/llm/local/cancel', wrap((req, res) => {
|
|
187
|
+
res.json(cancelLocalModelInstall());
|
|
188
|
+
}));
|
|
189
|
+
r.post('/llm/local/start', wrap(async (req, res) => {
|
|
190
|
+
const st = await startLocalEngine(req.body || {});
|
|
191
|
+
llmChanged();
|
|
192
|
+
res.json(st);
|
|
193
|
+
}));
|
|
194
|
+
r.post('/llm/local/stop', wrap((req, res) => {
|
|
195
|
+
stopLocalEngine();
|
|
196
|
+
llmChanged();
|
|
197
|
+
res.json({ ok: true });
|
|
198
|
+
}));
|
|
199
|
+
r.delete('/llm/local/model/:id', wrap((req, res) => {
|
|
200
|
+
res.json(deleteLocalModel(req.params.id));
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
// ---------- Настройки стратегии ----------
|
|
204
|
+
r.get('/settings', (req, res) => res.json(getSettings()));
|
|
205
|
+
r.put('/settings', wrap((req, res) => res.json(updateSettings(req.body || {}, { source: 'user' }))));
|
|
206
|
+
r.post('/settings/reset', (req, res) => res.json(resetSettings('user')));
|
|
207
|
+
r.get('/settings/history', (req, res) => res.json(listSettingsHistory(Math.min(200, Number(req.query.limit) || 50))));
|
|
208
|
+
r.post('/settings/history/:id/revert', wrap((req, res) => res.json(revertSettingsChange(req.params.id))));
|
|
209
|
+
|
|
210
|
+
// ---------- Исследования и история прогонов ----------
|
|
211
|
+
const ownRunOr404 = (req) => {
|
|
212
|
+
const run = getRun(Number(req.params.id));
|
|
213
|
+
if (!run) throw Object.assign(new Error('Не найдено'), { status: 404 });
|
|
214
|
+
return run;
|
|
215
|
+
};
|
|
216
|
+
r.get('/runs', (req, res) => res.json(listRuns()));
|
|
217
|
+
r.get('/runs/:id', (req, res) => {
|
|
218
|
+
const run = getRun(Number(req.params.id));
|
|
219
|
+
if (!run) return res.status(404).json({ error: 'Не найдено' });
|
|
220
|
+
res.json({ ...run, trades: getTrades(run.id) });
|
|
221
|
+
});
|
|
222
|
+
r.put('/runs/:id/title', wrap((req, res) => {
|
|
223
|
+
ownRunOr404(req);
|
|
224
|
+
const updated = updateRunTitle(Number(req.params.id), req.body?.title || '');
|
|
225
|
+
if (!updated) return res.status(404).json({ error: 'Не найдено' });
|
|
226
|
+
res.json(updated);
|
|
227
|
+
}));
|
|
228
|
+
r.post('/runs/:id/stability', wrap((req, res) => { ownRunOr404(req); res.json(startStabilityCheck(Number(req.params.id))); }));
|
|
229
|
+
r.post('/runs/:id/stability/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelStabilityCheck(Number(req.params.id)) }); }));
|
|
230
|
+
r.delete('/runs/:id', wrap((req, res) => {
|
|
231
|
+
const run = ownRunOr404(req);
|
|
232
|
+
if (run?.status === 'running') throw new Error('Эксперимент ещё идёт — сначала отмените его');
|
|
233
|
+
deleteRun(Number(req.params.id));
|
|
234
|
+
res.json({ ok: true });
|
|
235
|
+
}));
|
|
236
|
+
r.post('/runs/cleanup', wrap((req, res) => {
|
|
237
|
+
const raw = Number(req.body?.keep);
|
|
238
|
+
const keep = Number.isFinite(raw) ? Math.max(0, Math.min(50, Math.floor(raw))) : 0;
|
|
239
|
+
const all = allRuns();
|
|
240
|
+
const done = all.filter((x) => x.mode === 'model' && x.status === 'done');
|
|
241
|
+
const ranked = [...done].sort((a, b) => (b.metrics?.full?.returnPct ?? -1e9) - (a.metrics?.full?.returnPct ?? -1e9));
|
|
242
|
+
const keepSet = new Set(ranked.slice(0, keep).map((x) => x.id));
|
|
243
|
+
let deleted = 0;
|
|
244
|
+
for (const item of all) {
|
|
245
|
+
if (item.mode !== 'model' || item.status === 'running' || keepSet.has(item.id)) continue;
|
|
246
|
+
deleteRun(item.id);
|
|
247
|
+
deleted++;
|
|
248
|
+
}
|
|
249
|
+
res.json({ deleted, kept: [...keepSet] });
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
r.post('/model/research', wrap((req, res) => {
|
|
253
|
+
const plan = effectivePlan();
|
|
254
|
+
if (!plan.allowResearch) {
|
|
255
|
+
throw new Error('Раздел исследований доступен только по лицензии «Хозяин».');
|
|
256
|
+
}
|
|
257
|
+
if (countUserRunsToday() >= plan.maxRunsPerDay) {
|
|
258
|
+
throw new Error(`Достигнут дневной лимит исследований (${plan.maxRunsPerDay} в сутки).`);
|
|
259
|
+
}
|
|
260
|
+
const body = req.body || {};
|
|
261
|
+
const merged = { ...getSettings(), ...body };
|
|
262
|
+
const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
|
|
263
|
+
res.json({ runId, reused: !!reused });
|
|
264
|
+
}));
|
|
265
|
+
r.post('/model/:id/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelModelResearch(Number(req.params.id)) }); }));
|
|
266
|
+
r.get('/model/forecast', wrap(async (req, res) => {
|
|
267
|
+
const fc = await forecastNow(getSettings(), { force: req.query.force === '1' });
|
|
268
|
+
if (fc?.rows?.some((x) => x.signal || x.shortSignal)) {
|
|
269
|
+
triggerBrokerFastEntryCheck();
|
|
270
|
+
}
|
|
271
|
+
res.json(fc);
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
// ---------- Описания компаний ----------
|
|
275
|
+
r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
|
|
276
|
+
r.post('/profiles/refresh', wrap((req, res) => res.json(startProfilesRefresh())));
|
|
277
|
+
|
|
278
|
+
// ---------- Виртуальные счета (песочница живой проверки) ----------
|
|
279
|
+
r.get('/live/status', (req, res) => res.json(live.status()));
|
|
280
|
+
r.post('/live/start', wrap(async (req, res) => {
|
|
281
|
+
const plan = effectivePlan();
|
|
282
|
+
const b = req.body || {};
|
|
283
|
+
const runId = await live.start(getSettings(), {
|
|
284
|
+
source: 'user',
|
|
285
|
+
baseRunId: b.baseRunId || null,
|
|
286
|
+
name: b.name || '',
|
|
287
|
+
capital: b.capital || null,
|
|
288
|
+
maxAccounts: plan.maxVirtualAccounts,
|
|
289
|
+
});
|
|
290
|
+
res.json({ runId, status: live.status() });
|
|
291
|
+
}));
|
|
292
|
+
r.post('/live/stop', (req, res) => res.json({ ok: live.stop(req.body?.runId || null), status: live.status() }));
|
|
293
|
+
r.delete('/live/:id', (req, res) => res.json({ ok: live.remove(Number(req.params.id)), status: live.status() }));
|
|
294
|
+
|
|
295
|
+
// ---------- Брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
|
|
296
|
+
r.get('/brokers', (req, res) => res.json(brokersState()));
|
|
297
|
+
r.post('/brokers/discover', wrap(async (req, res) => res.json(await discoverBrokerAccounts(req.body || {}))));
|
|
298
|
+
r.post('/brokers', wrap((req, res) => {
|
|
299
|
+
const plan = effectivePlan();
|
|
300
|
+
const cur = brokersState().accounts.length;
|
|
301
|
+
if (!req.body?.id && cur >= plan.maxBrokerAccounts) {
|
|
302
|
+
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
303
|
+
throw new Error('Для подключения брокерского счёта активируйте оплаченную лицензию из личного кабинета.');
|
|
304
|
+
}
|
|
305
|
+
throw new Error(`По вашей лицензии «${plan.name}» доступно подключение до ${plan.maxBrokerAccounts} брокерских счетов.`);
|
|
306
|
+
}
|
|
307
|
+
res.json(saveBrokerAccount(req.body || {}));
|
|
308
|
+
}));
|
|
309
|
+
r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
|
|
310
|
+
r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
|
|
311
|
+
r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
|
|
312
|
+
r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id, req.body || {}))));
|
|
313
|
+
r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
|
|
314
|
+
r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
|
|
315
|
+
r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
|
|
316
|
+
r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));
|
|
317
|
+
r.post('/brokers/:id/cancel-order', wrap(async (req, res) => res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null))));
|
|
318
|
+
r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => res.json(resetBrokerDailyStop(req.params.id))));
|
|
319
|
+
r.get('/brokers/:id/events', wrap((req, res) => res.json({ events: listBrokerEvents(req.params.id, req.query.limit) })));
|
|
320
|
+
r.get('/brokers/:id/decisions', wrap((req, res) => res.json({ decisions: listTradeDecisions(req.params.id, { fromTs: req.query.from, limit: req.query.limit }) })));
|
|
321
|
+
r.post('/brokers-stop-all', wrap(async (req, res) => res.json(await stopAllBrokerTrading())));
|
|
322
|
+
|
|
323
|
+
// ---------- Новости ----------
|
|
324
|
+
r.get('/news', (req, res) => res.json({
|
|
325
|
+
items: listNews({ symbol: req.query.symbol || undefined, hours: Number(req.query.hours) || 72, limit: Number(req.query.limit) || 30 }),
|
|
326
|
+
}));
|
|
327
|
+
|
|
328
|
+
// ---------- Досье акции ----------
|
|
329
|
+
r.get('/stock/:symbol', wrap(async (req, res) => {
|
|
330
|
+
const sym = String(req.params.symbol || '').toUpperCase().trim();
|
|
331
|
+
if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
|
|
332
|
+
|
|
333
|
+
const nowTs = Date.now();
|
|
334
|
+
const [fc, rawSpecs, divs, tbLiveBars, tbMinBars] = await Promise.all([
|
|
335
|
+
forecastNow(getSettings()).catch(() => null),
|
|
336
|
+
ensureBoardSpecs().catch(() => ({})),
|
|
337
|
+
ensureDividends(sym).catch(() => []),
|
|
338
|
+
fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
|
|
339
|
+
fetchTbankCandles(sym, nowTs - DAY + 5 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null),
|
|
340
|
+
ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
|
|
341
|
+
]);
|
|
342
|
+
if (tbLiveBars?.length) {
|
|
343
|
+
const doneBars = tbLiveBars.filter((b) => b.isComplete);
|
|
344
|
+
if (doneBars.length) saveCandles(sym, 10, doneBars);
|
|
345
|
+
}
|
|
346
|
+
const tbMap = kvGet('tbankShortableMap') || {};
|
|
347
|
+
const tbSpecs = tbMap.specMap || {};
|
|
348
|
+
const shortableMap = tbMap.map || {};
|
|
349
|
+
const sp = { ...(rawSpecs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2, issueSize: 0, lastPrice: 0 }), ...(tbSpecs[sym] || {}) };
|
|
350
|
+
const vocabList = kvGet('vocab')?.list || [];
|
|
351
|
+
const vocabHit = vocabList.find((v) => v.symbol === sym);
|
|
352
|
+
const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
|
|
353
|
+
const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
|
|
354
|
+
const tierMult = symbolEdgeMult(sym);
|
|
355
|
+
|
|
356
|
+
const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
|
|
357
|
+
const intraday = rawBars30d.slice(-1500).map((b) => ({
|
|
358
|
+
ts: b.ts, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0,
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
const allBars = db.prepare('SELECT ts, open, high, low, close, value FROM candles WHERE symbol = ? AND interval = 10 ORDER BY ts ASC').all(sym);
|
|
362
|
+
const dailyMap = new Map();
|
|
363
|
+
for (const b of allBars) {
|
|
364
|
+
const dayKey = Math.floor((b.ts + MSK) / DAY) * DAY;
|
|
365
|
+
let d = dailyMap.get(dayKey);
|
|
366
|
+
if (!d) {
|
|
367
|
+
d = { ts: dayKey, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0 };
|
|
368
|
+
dailyMap.set(dayKey, d);
|
|
369
|
+
} else {
|
|
370
|
+
if (b.high > d.h) d.h = b.high;
|
|
371
|
+
if (b.low > 0 && (d.l <= 0 || b.low < d.l)) d.l = b.low;
|
|
372
|
+
d.c = b.close;
|
|
373
|
+
d.v += b.value || 0;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const daily = [...dailyMap.values()];
|
|
377
|
+
|
|
378
|
+
const tbFresh1m = tbMinBars?.length ? tbMinBars[tbMinBars.length - 1] : null;
|
|
379
|
+
const tbFreshBar = tbLiveBars?.length ? tbLiveBars[tbLiveBars.length - 1] : null;
|
|
380
|
+
const curPrice = (tbFresh1m?.close > 0 ? tbFresh1m.close : 0)
|
|
381
|
+
|| (tbFreshBar?.close > 0 ? tbFreshBar.close : 0)
|
|
382
|
+
|| fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
|
|
383
|
+
const intraday1m = (tbMinBars || []).map((b) => ({
|
|
384
|
+
ts: b.ts, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0,
|
|
385
|
+
}));
|
|
386
|
+
if (curPrice > 0 && intraday1m.length) {
|
|
387
|
+
const last1m = intraday1m[intraday1m.length - 1];
|
|
388
|
+
const liveMinSlotTs = Math.floor(nowTs / 60000) * 60000;
|
|
389
|
+
if (liveMinSlotTs > last1m.ts && liveMinSlotTs - last1m.ts <= 3600000) {
|
|
390
|
+
intraday1m.push({ ts: liveMinSlotTs, o: last1m.c, h: Math.max(last1m.c, curPrice), l: Math.min(last1m.c, curPrice), c: curPrice, v: 0 });
|
|
391
|
+
} else if (last1m.c !== curPrice) {
|
|
392
|
+
last1m.c = curPrice;
|
|
393
|
+
if (curPrice > last1m.h) last1m.h = curPrice;
|
|
394
|
+
if (last1m.l <= 0 || curPrice < last1m.l) last1m.l = curPrice;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (tbFreshBar && !tbFreshBar.isComplete && intraday.length) {
|
|
398
|
+
const lastBar = intraday[intraday.length - 1];
|
|
399
|
+
if (tbFreshBar.ts > lastBar.ts) {
|
|
400
|
+
intraday.push({ ts: tbFreshBar.ts, o: tbFreshBar.open, h: tbFreshBar.high, l: tbFreshBar.low, c: curPrice, v: tbFreshBar.value || 0 });
|
|
401
|
+
} else if (tbFreshBar.ts === lastBar.ts) {
|
|
402
|
+
lastBar.c = curPrice;
|
|
403
|
+
if (tbFreshBar.high > lastBar.h) lastBar.h = tbFreshBar.high;
|
|
404
|
+
if (tbFreshBar.low > 0 && (lastBar.l <= 0 || tbFreshBar.low < lastBar.l)) lastBar.l = tbFreshBar.low;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (curPrice > 0 && intraday.length) {
|
|
408
|
+
const lastBar = intraday[intraday.length - 1];
|
|
409
|
+
const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
|
|
410
|
+
if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
|
|
411
|
+
intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
|
|
412
|
+
} else if (lastBar.c !== curPrice) {
|
|
413
|
+
lastBar.c = curPrice;
|
|
414
|
+
if (curPrice > lastBar.h) lastBar.h = curPrice;
|
|
415
|
+
if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
|
|
416
|
+
}
|
|
417
|
+
if (daily.length) {
|
|
418
|
+
const lastDay = daily[daily.length - 1];
|
|
419
|
+
lastDay.c = curPrice;
|
|
420
|
+
if (curPrice > lastDay.h) lastDay.h = curPrice;
|
|
421
|
+
if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
|
|
425
|
+
const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
|
|
426
|
+
const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
|
|
427
|
+
const monthChangePct = monthAgoClose > 0 && curPrice > 0 ? round(((curPrice - monthAgoClose) / monthAgoClose) * 100, 2) : 0;
|
|
428
|
+
const yearAgoClose = daily.length >= 250 ? daily[daily.length - 250].c : (daily[0]?.o || curPrice);
|
|
429
|
+
const yearChangePct = yearAgoClose > 0 && curPrice > 0 ? round(((curPrice - yearAgoClose) / yearAgoClose) * 100, 2) : 0;
|
|
430
|
+
const marketCapRub = sp.issueSize > 0 && curPrice > 0 ? Math.round(sp.issueSize * curPrice) : null;
|
|
431
|
+
|
|
432
|
+
const profMap = loadProfiles();
|
|
433
|
+
const profVec = profMap.get(sym) || null;
|
|
434
|
+
const traits = profVec ? TRAITS.map((t, idx) => ({ id: t.id, label: t.label, ask: t.ask, value: round(profVec[idx] ?? 0, 2) })) : [];
|
|
435
|
+
|
|
436
|
+
const bState = brokersState();
|
|
437
|
+
const userPositions = [];
|
|
438
|
+
const userOperations = [];
|
|
439
|
+
for (const acc of bState.accounts || []) {
|
|
440
|
+
const pos = (acc.lastSync?.positions || []).find((p) => p.symbol === sym && p.qty !== 0);
|
|
441
|
+
if (pos) {
|
|
442
|
+
const livePx = curPrice > 0 ? curPrice : pos.price;
|
|
443
|
+
const avgPx = Number(pos.avgPrice) || 0;
|
|
444
|
+
const valRub = round(pos.qty * livePx, 2);
|
|
445
|
+
const pnlRub = avgPx > 0 ? round(pos.qty * (livePx - avgPx), 2) : pos.pnlRub;
|
|
446
|
+
const costBasis = avgPx > 0 ? Math.abs(pos.qty) * avgPx : 0;
|
|
447
|
+
const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : pos.pnlPct;
|
|
448
|
+
userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos, price: livePx, valueRub: valRub, pnlRub, pnlPct });
|
|
449
|
+
}
|
|
450
|
+
for (const op of acc.lastHistory?.operations || []) {
|
|
451
|
+
if (op.symbol === sym && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'div' || op.kind === 'fee')) {
|
|
452
|
+
userOperations.push({ accountName: acc.name, ...op });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
userOperations.sort((a, b) => (b.date || 0) - (a.date || 0));
|
|
457
|
+
|
|
458
|
+
let backtestStat = null;
|
|
459
|
+
const lastModelRun = listRuns().find((run) => run.mode === 'model' && run.status === 'done');
|
|
460
|
+
if (lastModelRun) {
|
|
461
|
+
const simTrades = db.prepare('SELECT side, entry_ts, exit_ts, entry_price, exit_price, pnl, pnl_pct FROM trades WHERE run_id = ? AND symbol = ? ORDER BY entry_ts DESC').all(lastModelRun.id, sym);
|
|
462
|
+
if (simTrades.length) {
|
|
463
|
+
const wins = simTrades.filter((t) => t.pnl > 0).length;
|
|
464
|
+
const avgPct = simTrades.reduce((s, t) => s + (t.pnl_pct || 0), 0) / simTrades.length;
|
|
465
|
+
backtestStat = {
|
|
466
|
+
runId: lastModelRun.id,
|
|
467
|
+
tradesCount: simTrades.length,
|
|
468
|
+
winRate: round((wins / simTrades.length) * 100, 1),
|
|
469
|
+
avgPnlPct: round(avgPct, 2),
|
|
470
|
+
recentTrades: simTrades.slice(0, 10),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const news = listNews({ symbol: sym, hours: 24 * 30, limit: 20 });
|
|
476
|
+
const sortedDivs = [...(divs || [])].sort((a, b) => String(b.registry).localeCompare(String(a.registry)));
|
|
477
|
+
|
|
478
|
+
res.json({
|
|
479
|
+
symbol: sym,
|
|
480
|
+
name: fcRow?.name || vocabHit?.name || sym,
|
|
481
|
+
price: round(curPrice, curPrice < 1 ? 5 : curPrice < 10 ? 3 : 2),
|
|
482
|
+
dayChangePct,
|
|
483
|
+
monthChangePct,
|
|
484
|
+
yearChangePct,
|
|
485
|
+
tier: isTier1 ? 1 : 2,
|
|
486
|
+
tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
|
|
487
|
+
edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
|
|
488
|
+
sigmaPct: fcRow?.sigmaPct ?? null,
|
|
489
|
+
buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.40) * tierMult, 0.30), 3),
|
|
490
|
+
shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.50) * tierMult, 0.31), 3),
|
|
491
|
+
shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
|
|
492
|
+
specs: {
|
|
493
|
+
lotSize: sp.lotSize,
|
|
494
|
+
lotCostRub: round((sp.lotSize || 1) * curPrice, 2),
|
|
495
|
+
minStep: sp.minStep,
|
|
496
|
+
decimals: sp.decimals,
|
|
497
|
+
issueSize: sp.issueSize || null,
|
|
498
|
+
marketCapRub,
|
|
499
|
+
indexWeightPct: vocabHit?.weight ? round(vocabHit.weight, 2) : null,
|
|
500
|
+
},
|
|
501
|
+
forecast: fcRow ? {
|
|
502
|
+
bar: fc?.bar,
|
|
503
|
+
horizonHours: fc?.horizon || 2.5,
|
|
504
|
+
expectedPct: fcRow.expectedPct,
|
|
505
|
+
lastMovePct: fcRow.lastMovePct ?? 0,
|
|
506
|
+
deltaPct: fcRow.deltaPct || 0,
|
|
507
|
+
signal: Boolean(fcRow.signal),
|
|
508
|
+
shortSignal: Boolean(fcRow.shortSignal),
|
|
509
|
+
shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
|
|
510
|
+
shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
|
|
511
|
+
buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
|
|
512
|
+
buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
|
|
513
|
+
shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
|
|
514
|
+
buyBlockedByNews: fcRow.buyBlockedByNews || null,
|
|
515
|
+
shortBlockedByNews: fcRow.shortBlockedByNews || null,
|
|
516
|
+
selfPct: fcRow.selfPct ?? 0,
|
|
517
|
+
influences: (fcRow.influences || []).map((inf) => {
|
|
518
|
+
const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
|
|
519
|
+
return { ...inf, name: infRow?.name || inf.symbol, price: infRow?.price || null, expectedPct: infRow?.expectedPct ?? null };
|
|
520
|
+
}),
|
|
521
|
+
} : null,
|
|
522
|
+
traits,
|
|
523
|
+
dividends: sortedDivs,
|
|
524
|
+
news,
|
|
525
|
+
userPositions,
|
|
526
|
+
userOperations: userOperations.slice(0, 60),
|
|
527
|
+
backtestStat,
|
|
528
|
+
intraday1m,
|
|
529
|
+
intraday,
|
|
530
|
+
daily,
|
|
531
|
+
});
|
|
532
|
+
}));
|
|
533
|
+
|
|
534
|
+
// ---------- Журнал гипотез ----------
|
|
535
|
+
r.get('/journal', (req, res) => res.json(listJournal(500)));
|
|
536
|
+
r.post('/journal', wrap((req, res) => {
|
|
537
|
+
const id = addJournal({ ...req.body, source: 'user' });
|
|
538
|
+
hub.emit({ type: 'journal:update' });
|
|
539
|
+
res.json({ id });
|
|
540
|
+
}));
|
|
541
|
+
r.delete('/journal/:id', (req, res) => { deleteJournal(Number(req.params.id)); hub.emit({ type: 'journal:update' }); res.json({ ok: true }); });
|
|
542
|
+
|
|
543
|
+
// ---------- Чат со Стратегом ----------
|
|
544
|
+
r.get('/chat', (req, res) => res.json(strategist.history()));
|
|
545
|
+
r.post('/chat/send', wrap((req, res) => {
|
|
546
|
+
const plan = effectivePlan();
|
|
547
|
+
if (countUserChatToday() >= plan.maxChatPerDay) {
|
|
548
|
+
throw new Error(`Достигнут дневной лимит сообщений ИИ-Стратегу по лицензии «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
|
|
549
|
+
}
|
|
550
|
+
const { text, mode, newSession, title } = req.body || {};
|
|
551
|
+
res.json({ message: strategist.sendUserMessage(text, mode, { newSession: Boolean(newSession), title: title || null }) });
|
|
552
|
+
}));
|
|
553
|
+
r.post('/chat/action', wrap((req, res) => {
|
|
554
|
+
const { msgId, callId, decision, comment } = req.body || {};
|
|
555
|
+
strategist.resolveAction(Number(msgId), String(callId), decision, comment);
|
|
556
|
+
res.json({ ok: true });
|
|
557
|
+
}));
|
|
558
|
+
r.post('/chat/stop', (req, res) => { strategist.stopAgent(); res.json({ ok: true }); });
|
|
559
|
+
r.post('/chat/mode', wrap((req, res) => { strategist.setMode(req.body?.mode, req.body?.budget); res.json(strategist.chatState()); }));
|
|
560
|
+
r.post('/chat/new', (req, res) => { strategist.resetConversation(); res.json(strategist.history()); });
|
|
561
|
+
r.get('/chat/archive', (req, res) => res.json({ sessions: strategist.listArchivedSessions(120) }));
|
|
562
|
+
r.post('/chat/archive/:id/open', wrap((req, res) => res.json(strategist.openArchivedSession(Number(req.params.id)))));
|
|
563
|
+
r.get('/chat/archive/:id/md', wrap((req, res) => {
|
|
564
|
+
const info = strategist.exportConversationMarkdown(Number(req.params.id));
|
|
565
|
+
if (!info) throw Object.assign(new Error('Сессия не найдена или пуста'), { status: 404 });
|
|
566
|
+
res.json(info);
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
return r;
|
|
570
|
+
}
|