neuroalpha 0.3.16 → 0.3.17
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 +0 -1
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -2
- package/package.json +1 -1
- package/public/app.js +35 -116
- package/public/index.html +10 -27
- package/server/brokers/index.js +14 -10
- package/server/params-meta.js +3 -3
- package/server/strategist/index.js +423 -488
- package/server/strategist/tools.js +486 -532
|
@@ -1,488 +1,423 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { PARAMS } from '../params-meta.js';
|
|
3
|
-
import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
4
|
-
import { hub } from '../hub.js';
|
|
5
|
-
import { getSettings } from '../settings.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from '
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const noTools = !activeModelInfo().tools;
|
|
66
|
-
const modeText = noTools
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
if (m.role === '
|
|
156
|
-
|
|
157
|
-
if (!
|
|
158
|
-
out.push({ role: '
|
|
159
|
-
openIds
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
msg = updateMessage(msg.id, { content:
|
|
288
|
-
emitMsg(msg, 'chat:update');
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
function addEvent(content, display, data = {}) {
|
|
426
|
-
const m = addMessage(conv().id, { role: 'user', kind: 'event', content: `[Событие] ${content}`, data: { display, ...data } });
|
|
427
|
-
emitMsg(m);
|
|
428
|
-
return m;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const metricLine = (m) => (m && !m.trades ? 'сделок не было' : m ? `${m.trades} ${plural(m.trades, 'сделка', 'сделки', 'сделок')}, итог ${fmtPct(m.returnPct)}, на 1 ₽ убытка ${m.profitFactor ?? '—'} ₽, худшее падение ${m.maxDrawdownPct}%` : 'нет данных');
|
|
432
|
-
|
|
433
|
-
hub.on((e) => {
|
|
434
|
-
try {
|
|
435
|
-
if (e.type === 'research:done' && e.source === 'user') {
|
|
436
|
-
const c = e.metrics.model?.comparison || {};
|
|
437
|
-
const f = c.attention?.full, base = c.inertia?.full;
|
|
438
|
-
// помогает ли карта — по торговому результату, а не по средней точности по всем акциям
|
|
439
|
-
let vs;
|
|
440
|
-
if (!f) vs = '';
|
|
441
|
-
else if (!base) vs = '';
|
|
442
|
-
else if (f.returnPct - base.returnPct > 5 && (f.profitFactor ?? 0) >= (base.profitFactor ?? 0)) vs = `карта даёт преимущество: ${fmtPct(f.returnPct)} против ${fmtPct(base.returnPct)} у простейшей модели`;
|
|
443
|
-
else if (Math.abs(f.returnPct - base.returnPct) <= 5) vs = `примерно как у простейшей модели (${fmtPct(f.returnPct)} против ${fmtPct(base.returnPct)})`;
|
|
444
|
-
else vs = `хуже простейшей модели (${fmtPct(f.returnPct)} против ${fmtPct(base.returnPct)})`;
|
|
445
|
-
addEvent(
|
|
446
|
-
`Пользователь провёл исследование модели №${e.runId}. «Внимание через карту»: итог ${fmtPct(f?.returnPct)} (без комиссий ${fmtPct(f?.grossReturnPct)}), на 1 ₽ убытка ${f?.profitFactor ?? '—'}, ${f?.trades ?? 0} сделок, рынок ${fmtPct(f?.marketPct)}. Простая модель «своя инерция»: итог ${fmtPct(base?.returnPct)}, ${base?.trades ?? 0} сделок.${c.transformer?.full ? ` «Трансформер»: итог ${fmtPct(c.transformer.full.returnPct)}, ${c.transformer.full.trades} сделок.` : ''} Вывод: ${vs}.`,
|
|
447
|
-
`Исследование №${e.runId}: итог ${fmtPct(f?.returnPct)} (рынок ${fmtPct(f?.marketPct)}) — ${vs}`,
|
|
448
|
-
{ eventType: 'model', runId: e.runId, ok: (f?.returnPct ?? 0) > 0 },
|
|
449
|
-
);
|
|
450
|
-
} else if (e.type === 'research:error' && e.source === 'user' && e.error !== 'Отменено') {
|
|
451
|
-
addEvent(`Исследование №${e.runId} пользователя завершилось ошибкой: ${e.error}`, `Исследование №${e.runId}: ошибка — ${e.error}`, { eventType: 'error', runId: e.runId });
|
|
452
|
-
} else if (e.type === 'paper:started') {
|
|
453
|
-
addEvent(`Запущена живая проверка №${e.runId}.`, `Живая проверка №${e.runId} запущена`, { eventType: 'paper', runId: e.runId });
|
|
454
|
-
} else if (e.type === 'paper:resumed') {
|
|
455
|
-
addEvent(`Живая проверка №${e.runId} продолжена после перезапуска сервера.`, `Живая проверка №${e.runId} продолжена после перезапуска`, { eventType: 'paper', runId: e.runId });
|
|
456
|
-
} else if (e.type === 'paper:stopped') {
|
|
457
|
-
addEvent(`Живая проверка №${e.runId} остановлена. Итог: ${metricLine(e.metrics?.full)}.`, `Живая проверка №${e.runId} остановлена: ${metricLine(e.metrics?.full)}`, { eventType: 'paper', runId: e.runId });
|
|
458
|
-
} else if (e.type === 'paper:trade') {
|
|
459
|
-
const t = e.trade;
|
|
460
|
-
addEvent(
|
|
461
|
-
`Живая проверка: закрыта сделка ${t.symbol}, результат ${Math.round(t.pnl)} ₽ (${t.pnlPct}%), причина: ${t.reasonExit}.`,
|
|
462
|
-
`Закрыта сделка ${t.symbol}: ${t.pnl >= 0 ? '+' : ''}${Math.round(t.pnl).toLocaleString('ru-RU')} ₽ — ${t.reasonExit}`,
|
|
463
|
-
{ eventType: 'trade', runId: e.runId, ok: t.pnl >= 0 },
|
|
464
|
-
);
|
|
465
|
-
} else if (e.type === 'paper:day_end') {
|
|
466
|
-
onDayEnd(e);
|
|
467
|
-
} else if (e.type === 'settings:update' && e.source === 'user' && Object.keys(e.changed || {}).length) {
|
|
468
|
-
// мелкие правки формы не засоряют ленту: фиксируем только для модели одной строкой
|
|
469
|
-
addEvent(`Пользователь изменил настройки: ${Object.entries(e.changed).map(([k, v]) => `${k}: ${v.было} → ${v.стало}`).join('; ')}.`, null, { eventType: 'settings', hidden: true });
|
|
470
|
-
}
|
|
471
|
-
} catch (err) {
|
|
472
|
-
console.error('strategist event', err);
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
function onDayEnd(e) {
|
|
477
|
-
const pnl = Math.round(e.endEquity - e.startEquity);
|
|
478
|
-
const trades = e.trades.map((t) => `${t.symbol}: ${Math.round(t.pnl)} ₽ (${t.reasonExit})`).join('; ') || 'сделок не было';
|
|
479
|
-
const open = e.snapshot.positions.map((p) => `${p.symbol} ${Math.round(p.pnl)} ₽`).join('; ') || 'нет';
|
|
480
|
-
addEvent(
|
|
481
|
-
`Итоги торгового дня ${e.day} в живой проверке №${e.runId}: изменение счёта ${pnl} ₽; закрытые сделки: ${trades}; открытые: ${open}. ${e.params.paperDailyReview && isLlmEnabled() && !state.run ? 'Разбери день кратко: что сработало, что нет, совпадает ли поведение с историей; при необходимости предложи (не применяя) изменения и запиши вывод в журнал.' : ''}`,
|
|
482
|
-
`Итоги дня ${e.day.split('-').reverse().join('.')}: ${pnl >= 0 ? '+' : ''}${pnl.toLocaleString('ru-RU')} ₽, закрыто сделок: ${e.trades.length}`,
|
|
483
|
-
{ eventType: 'day', runId: e.runId, ok: pnl >= 0 },
|
|
484
|
-
);
|
|
485
|
-
if (e.params.paperDailyReview && isLlmEnabled() && !state.run && !state.waiting) runAgent('review').catch(() => {});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
export { fmtTs, buildContext, systemPrompt };
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
import { PARAMS } from '../params-meta.js';
|
|
3
|
+
import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
4
|
+
import { hub } from '../hub.js';
|
|
5
|
+
import { getSettings } from '../settings.js';
|
|
6
|
+
import { brokersState } from '../brokers/index.js';
|
|
7
|
+
import {
|
|
8
|
+
currentConversation, newConversation, addMessage, updateMessage, getMessage, getMessages,
|
|
9
|
+
kvGet, kvSet,
|
|
10
|
+
} from '../db.js';
|
|
11
|
+
import { TOOLS, MODES, toolSchemas, OOS_LIMIT } from './tools.js';
|
|
12
|
+
|
|
13
|
+
const MAX_STEPS = 20;
|
|
14
|
+
const MSK = 3 * 3600000;
|
|
15
|
+
const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
|
|
16
|
+
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
|
|
17
|
+
|
|
18
|
+
const state = {
|
|
19
|
+
conv: null,
|
|
20
|
+
run: null, // { ctrl, mode }
|
|
21
|
+
waiting: null, // { msgId, callId }
|
|
22
|
+
budgetLeft: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getMode = () => 'advisor';
|
|
26
|
+
export const getBudget = () => 8;
|
|
27
|
+
|
|
28
|
+
function conv() {
|
|
29
|
+
if (!state.conv) state.conv = currentConversation();
|
|
30
|
+
return state.conv;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function chatState() {
|
|
34
|
+
const llm = activeModelInfo();
|
|
35
|
+
return {
|
|
36
|
+
convId: conv().id,
|
|
37
|
+
busy: !!state.run,
|
|
38
|
+
waiting: state.waiting,
|
|
39
|
+
mode: 'advisor',
|
|
40
|
+
budget: 8,
|
|
41
|
+
budgetLeft: state.budgetLeft,
|
|
42
|
+
llm: llm.enabled,
|
|
43
|
+
tools: llm.tools,
|
|
44
|
+
oosUsed: 0,
|
|
45
|
+
oosLimit: OOS_LIMIT,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const emitState = () => hub.emit({ type: 'chat:state', state: chatState() });
|
|
50
|
+
const emitMsg = (m, type = 'chat:message') => hub.emit({ type, message: m });
|
|
51
|
+
|
|
52
|
+
// ---------- системная инструкция ----------
|
|
53
|
+
function systemPrompt() {
|
|
54
|
+
const s = getSettings();
|
|
55
|
+
const newsFilterState = s.newsFilter ? 'включён (блокирует покупки на сильном негативе и шорты на сильном позитиве за 6 ч)' : 'выключен';
|
|
56
|
+
|
|
57
|
+
let brkSummary = '(брокерские счета пока не подключены)';
|
|
58
|
+
try {
|
|
59
|
+
const bAccs = brokersState().accounts || [];
|
|
60
|
+
if (bAccs.length) {
|
|
61
|
+
brkSummary = bAccs.map((a) => `- «${a.name}» (${a.broker}, субсчёт ${a.accountId || '—'}, режим ${a.mode}${a.sandbox ? ', ПЕСОЧНИЦА' : ''}, старый портфель: ${a.legacyMode || 'isolate'}, лимит: ${a.maxCapitalRub} ₽, баланс: ${a.lastSync?.totalRub ?? '—'} ₽, свободно: ${a.lastSync?.cashRub ?? '—'} ₽, бумаг: ${a.lastSync?.positions?.length ?? 0}${a.haltedReason ? `, ПАУЗА: ${a.haltedReason}` : ''})`).join('\n');
|
|
62
|
+
}
|
|
63
|
+
} catch { /* ignore */ }
|
|
64
|
+
|
|
65
|
+
const noTools = !activeModelInfo().tools;
|
|
66
|
+
const modeText = noTools
|
|
67
|
+
? 'Выбранная модель ИИ не поддерживает вызов инструментов — ты отвечаешь и консультируешь по данным из контекста ниже.'
|
|
68
|
+
: 'Ты можешь свободно вызывать читающие инструменты (`get_brokers_status`, `model_forecast`, `get_news`, `market_neighbors`, `get_settings`, `broker_onboarding_guide`, `discover_broker_accounts`, `analyze_run`), а действия `connect_broker_account` и `set_settings` предлагаешь с подтверждением пользователя кнопкой.';
|
|
69
|
+
|
|
70
|
+
return `Ты — «Стратег», персональный ИИ-консультант и помощник пользователя в облачной платформе алгоритмической торговли «НейроАльфа» (Московская биржа, MOEX).
|
|
71
|
+
Твоя задача — простым, понятным русским языком объяснять пользователю прогнозы и сигналы модели, разбирать состояние его брокерских счетов и портфеля, объяснять новости рынка и помогать с подключением и настройкой счетов.
|
|
72
|
+
ВАЖНО: Ты НЕ ставишь эксперименты и НЕ подбираешь параметры математического ядра — ядро платформы уже полностью откалибровано и работает в эталонном автоматическом режиме (>400% годовых на истории).
|
|
73
|
+
|
|
74
|
+
## Как устроено автоматическое ядро платформы «НейроАльфа» (Рынок как язык)
|
|
75
|
+
- **Словарь:** ~100 самых ликвидных акций индекса широкого рынка Мосбиржи (MOEXBMI) + индекс IMOEX. Свечи 10-минутные, дивидендные отсечки скорректированы со сверкой по реальному гэпу цены, объёмы нормированы по 24-часовому суточному профилю (valRatio по часам МСК).
|
|
76
|
+
- **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
|
|
77
|
+
- **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
|
|
78
|
+
- **Пороги входа и удержания:**
|
|
79
|
+
- Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания). При стандартных издержках 0,05% за сделку это +0,25%.
|
|
80
|
+
- Авто-балансировка по тренду индекса: когда индекс Мосбиржи выше 20-дневной средней (растущий рынок), покупки открываются от +0,25%, а контртрендовые шорты ужесточаются в 1,25× (до −0,313%). На падающем рынке — наоборот (шорты облегчаются, покупки ужесточаются).
|
|
81
|
+
- Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от базового, ~0,09%), чтобы сделку не выбило случайным шумом, а затем включается плотный порог фиксации прибыли (0,95×, ~0,238%).
|
|
82
|
+
- **Dynamic Top-K (modelTopK = 0 Авто) и Conviction Sizing:** фиксированного числа акций нет — робот держит от 0 до 4 лонгов и до 4 шортов в зависимости от силы сигналов и концентрирует слот в лучших лидерах.
|
|
83
|
+
|
|
84
|
+
## Брокерские счета и режимы работы
|
|
85
|
+
Платформа поддерживает 9 российских брокеров: Т-Банк (T-Invest API, включая секцию TQBR и SPBRU для SPBE), Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ и Альфа-Инвестиции.
|
|
86
|
+
- **Режимы работы счёта (mode):**
|
|
87
|
+
1. readonly («Только чтение») — безопасный мониторинг портфеля и расчёт заявок без отправки в стакан.
|
|
88
|
+
2. confirm («Подтверждение заявок», рекомендуется) — робот считает лимитные заявки в целых лотах и присылает их в веб-терминал и в Telegram / MAX на подтверждение одной кнопкой.
|
|
89
|
+
3. auto («Автомат») — робот сам отправляет лимитные заявки по закрытию свечи (сначала закрывающие sell/cover, через 1,2 сек — открывающие buy/short).
|
|
90
|
+
- Также в настройках счёта есть тумблер **«Тестовый режим (песочница брокера)»** (sandbox) для безрисковой проверки на виртуальных деньгах брокера.
|
|
91
|
+
- **Режимы обращения со старыми бумагами на счёте (legacyMode):**
|
|
92
|
+
1. isolate («Изолированный карман», по умолчанию) — не трогать старые акции, которые были на счёте до подключения.
|
|
93
|
+
2. smooth («Плавный переход») — постепенно продавать слабые старые акции с отрицательным прогнозом ИИ, высвобождая кэш как под новые покупки, так и под новые шорты.
|
|
94
|
+
3. takeover («Полное управление») — сразу продать все старые нецелевые акции из словаря стратегии (облигации, фонды и бумаги вне словаря не трогаются никогда), освободив весь кэш под стратегию.
|
|
95
|
+
|
|
96
|
+
## Тарифные планы SaaS
|
|
97
|
+
- **«Инвестор» (3 900 ₽/мес):** задавленная доходность до 60% годовых, только покупки (без шорта), 1 брокерский счёт со стартовым лимитом до 1 000 000 ₽ (+ 100% реинвестирование всей прибыли).
|
|
98
|
+
- **«Профи» (9 900 ₽/мес):** задавленная доходность до 120% годовых, покупки и шорты, до 5 брокерских счетов со стартовым лимитом до 1 000 000 ₽ на каждом (+ 100% реинвестирование всей прибыли).
|
|
99
|
+
- **«VIP» (по индивидуальному согласованию):** задавленная доходность до 240% годовых, покупки и шорты, количество счетов и начальная сумма по согласованию.
|
|
100
|
+
- **Режим «Хозяин» (owner):** без ограничений по доходности (>400% годовых), счетам и капиталу + доступ к разделу «Эксперименты».
|
|
101
|
+
|
|
102
|
+
## Подключённые брокерские счета пользователя
|
|
103
|
+
${brkSummary}
|
|
104
|
+
|
|
105
|
+
## Фильтр сделок по новостям (в разделе «Новости рынка»)
|
|
106
|
+
- newsFilter = ${JSON.stringify(Boolean(s.newsFilter))} (${newsFilterState})
|
|
107
|
+
|
|
108
|
+
## Доступные тебе инструменты
|
|
109
|
+
${modeText}
|
|
110
|
+
- Чтобы разобрать портфель пользователя, свободные рубли или заявки — вызови \`get_brokers_status\`.
|
|
111
|
+
- Чтобы объяснить текущую ситуацию на рынке — вызови \`model_forecast\` и \`get_news\`.
|
|
112
|
+
- Чтобы помочь подключить счёт — используй \`broker_onboarding_guide\`, \`discover_broker_accounts\` (если пользователь дал токен Т-Банка \`t.…\`) и \`connect_broker_account\`.
|
|
113
|
+
|
|
114
|
+
## Как отвечать
|
|
115
|
+
- Размышляй и пиши по-русски, доброжелательно, чётко и простым языком без лишнего жаргона.
|
|
116
|
+
- Используй списки и выделение жирным для удобного чтения.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------- контекст для модели ----------
|
|
120
|
+
function buildContext(convId) {
|
|
121
|
+
const all = getMessages(convId, 120);
|
|
122
|
+
let start = 0;
|
|
123
|
+
if (all.length > 80) {
|
|
124
|
+
start = all.length - 80;
|
|
125
|
+
while (start < all.length && !['user', 'event'].includes(all[start].kind)) start++;
|
|
126
|
+
}
|
|
127
|
+
const msgs = all.slice(start);
|
|
128
|
+
const out = [];
|
|
129
|
+
const deferred = [];
|
|
130
|
+
let openIds = new Set();
|
|
131
|
+
const toolResults = new Set(msgs.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
|
|
132
|
+
const lastToolIdx = msgs.map((m, i) => (m.role === 'tool' ? i : -1)).filter((i) => i >= 0).slice(-6);
|
|
133
|
+
|
|
134
|
+
msgs.forEach((m, idx) => {
|
|
135
|
+
if (m.kind === 'event' || m.kind === 'user') {
|
|
136
|
+
const msg = { role: 'user', content: m.content || '' };
|
|
137
|
+
if (openIds.size) deferred.push(msg);
|
|
138
|
+
else out.push(msg);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (m.role === 'assistant') {
|
|
142
|
+
const calls = (m.tool_calls || []).map((c) => ({ id: c.id, type: 'function', function: { name: c.function.name, arguments: c.function.arguments || '{}' } }));
|
|
143
|
+
if (!m.content && !calls.length) return;
|
|
144
|
+
out.push({ role: 'assistant', content: m.content || '', ...(calls.length ? { tool_calls: calls } : {}) });
|
|
145
|
+
openIds = new Set(calls.map((c) => c.id));
|
|
146
|
+
for (const c of calls) {
|
|
147
|
+
if (!toolResults.has(c.id)) {
|
|
148
|
+
out.push({ role: 'tool', tool_call_id: c.id, content: JSON.stringify({ ошибка: 'не выполнено (прервано)' }) });
|
|
149
|
+
openIds.delete(c.id);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!openIds.size) { out.push(...deferred); deferred.length = 0; }
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (m.role === 'tool') {
|
|
156
|
+
let content = m.content || '';
|
|
157
|
+
if (!lastToolIdx.includes(idx) && content.length > 1500) content = `${content.slice(0, 1500)}… (сокращено)`;
|
|
158
|
+
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content });
|
|
159
|
+
openIds.delete(m.tool_call_id);
|
|
160
|
+
if (!openIds.size) { out.push(...deferred); deferred.length = 0; }
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
out.push(...deferred);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Модель иногда пишет вызов текстом в формате Qwen: <tool_call>{"name":…,"arguments":…}</tool_call>
|
|
168
|
+
function parseTextToolCalls(content) {
|
|
169
|
+
const calls = [];
|
|
170
|
+
const cleaned = content.replace(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g, (_, body) => {
|
|
171
|
+
try {
|
|
172
|
+
const j = JSON.parse(body);
|
|
173
|
+
if (j.name) calls.push({ id: `call_${Date.now()}_${calls.length}`, type: 'function', function: { name: j.name, arguments: JSON.stringify(j.arguments || {}) } });
|
|
174
|
+
} catch { /* пропускаем */ }
|
|
175
|
+
return '';
|
|
176
|
+
});
|
|
177
|
+
return { calls, cleaned: cleaned.trim() };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseArgs(call) {
|
|
181
|
+
try { return JSON.parse(call.function.arguments || '{}') || {}; } catch { return null; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------- выполнение инструментов ----------
|
|
185
|
+
function setToolStatus(msgId, callId, patch) {
|
|
186
|
+
const m = getMessage(msgId);
|
|
187
|
+
const data = m.data || {};
|
|
188
|
+
data.tools = { ...(data.tools || {}), [callId]: { ...(data.tools?.[callId] || {}), ...patch } };
|
|
189
|
+
const u = updateMessage(msgId, { data });
|
|
190
|
+
emitMsg(u, 'chat:update');
|
|
191
|
+
return u;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function execTool(msg, call, mode) {
|
|
195
|
+
const name = call.function.name;
|
|
196
|
+
const tool = TOOLS[name];
|
|
197
|
+
const args = parseArgs(call);
|
|
198
|
+
const mObj = MODES[mode] || MODES.advisor;
|
|
199
|
+
let res;
|
|
200
|
+
if (!tool) res = { result: { ошибка: `нет инструмента ${name}` }, summary: `Неизвестный инструмент ${name}`, ok: false };
|
|
201
|
+
else if (!mObj.allowed(tool)) res = { result: { ошибка: 'это действие сейчас недоступно' }, summary: 'Недоступно', ok: false };
|
|
202
|
+
else if (args === null) res = { result: { ошибка: 'не удалось разобрать аргументы (нужен JSON)' }, summary: 'Ошибка в аргументах', ok: false };
|
|
203
|
+
else {
|
|
204
|
+
setToolStatus(msg.id, call.id, { status: 'running' });
|
|
205
|
+
emitState();
|
|
206
|
+
try {
|
|
207
|
+
res = await tool.run(args, {
|
|
208
|
+
convId: conv().id,
|
|
209
|
+
onRunStart: (runId) => setToolStatus(msg.id, call.id, { runId }),
|
|
210
|
+
});
|
|
211
|
+
} catch (e) {
|
|
212
|
+
res = { result: { ошибка: e.message }, summary: `Ошибка: ${e.message}`, ok: false };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const ok = res.ok !== false;
|
|
216
|
+
const toolMsg = addMessage(conv().id, {
|
|
217
|
+
role: 'tool', kind: 'tool', tool_call_id: call.id,
|
|
218
|
+
content: JSON.stringify(res.result).slice(0, 12000),
|
|
219
|
+
data: { name, summary: res.summary, ok, runId: res.runId },
|
|
220
|
+
});
|
|
221
|
+
emitMsg(toolMsg);
|
|
222
|
+
setToolStatus(msg.id, call.id, { status: ok ? 'done' : 'error', summary: res.summary, runId: res.runId });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Обрабатывает вызовы инструментов сообщения по порядку. Возвращает 'waiting', если нужно подтверждение
|
|
226
|
+
async function processCalls(msg, mode) {
|
|
227
|
+
const done = new Set(getMessages(conv().id, 400).filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
|
|
228
|
+
const mObj = MODES[mode] || MODES.advisor;
|
|
229
|
+
for (const call of msg.tool_calls || []) {
|
|
230
|
+
if (done.has(call.id)) continue;
|
|
231
|
+
if (state.run?.ctrl.signal.aborted) return 'aborted';
|
|
232
|
+
const tool = TOOLS[call.function.name];
|
|
233
|
+
const st = getMessage(msg.id).data?.tools?.[call.id]?.status;
|
|
234
|
+
if (tool && mObj.confirm?.(tool) && st !== 'approved') {
|
|
235
|
+
const args = parseArgs(call) || {};
|
|
236
|
+
setToolStatus(msg.id, call.id, { status: 'pending', preview: tool.preview ? tool.preview(args) : '' });
|
|
237
|
+
state.waiting = { msgId: msg.id, callId: call.id };
|
|
238
|
+
return 'waiting';
|
|
239
|
+
}
|
|
240
|
+
await execTool(msg, call, mode);
|
|
241
|
+
}
|
|
242
|
+
return 'done';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function loop(mode = 'advisor') {
|
|
246
|
+
const ctrl = state.run.ctrl;
|
|
247
|
+
if (!activeModelInfo().tools) mode = 'discuss';
|
|
248
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
249
|
+
if (ctrl.signal.aborted) return;
|
|
250
|
+
const messages = [{ role: 'system', content: systemPrompt(mode) }, ...buildContext(conv().id)];
|
|
251
|
+
let msg = addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: '', reasoning: '', data: { streaming: true } });
|
|
252
|
+
emitMsg(msg);
|
|
253
|
+
let r;
|
|
254
|
+
for (let attempt = 1; ; attempt++) {
|
|
255
|
+
try {
|
|
256
|
+
r = await chatStream(messages, {
|
|
257
|
+
tools: activeModelInfo().tools ? toolSchemas(mode) : [],
|
|
258
|
+
maxTokens: 12000,
|
|
259
|
+
temperature: 0.3,
|
|
260
|
+
signal: ctrl.signal,
|
|
261
|
+
onDelta: (d) => hub.emit({ type: 'chat:delta', id: msg.id, ...d }),
|
|
262
|
+
});
|
|
263
|
+
break;
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (!ctrl.signal.aborted && attempt < 3 && !/HTTP 4\d\d/.test(e.message) && !e.fatal) {
|
|
266
|
+
msg = updateMessage(msg.id, { content: '', reasoning: '', data: { streaming: true, retry: attempt } });
|
|
267
|
+
emitMsg(msg, 'chat:update');
|
|
268
|
+
await new Promise((res) => setTimeout(res, 1500 * attempt));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
msg = updateMessage(msg.id, { content: ctrl.signal.aborted ? '' : `⚠️ ИИ не ответил: ${e.message}`, data: { error: true } });
|
|
272
|
+
emitMsg(msg, 'chat:update');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let content = r.content.trim();
|
|
277
|
+
let calls = r.toolCalls;
|
|
278
|
+
if (!calls.length && content.includes('<tool_call>')) {
|
|
279
|
+
const p = parseTextToolCalls(content);
|
|
280
|
+
calls = p.calls;
|
|
281
|
+
content = p.cleaned;
|
|
282
|
+
}
|
|
283
|
+
msg = updateMessage(msg.id, { content, reasoning: r.reasoning, tool_calls: calls.length ? calls : null, data: { tools: {} } });
|
|
284
|
+
emitMsg(msg, 'chat:update');
|
|
285
|
+
if (!calls.length) {
|
|
286
|
+
if (!content && r.finishReason === 'length') {
|
|
287
|
+
msg = updateMessage(msg.id, { content: '⚠️ ИИ не уложился в лимит длины ответа. Попросите ответить короче.' });
|
|
288
|
+
emitMsg(msg, 'chat:update');
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const res = await processCalls(msg, mode);
|
|
293
|
+
if (res !== 'done') return;
|
|
294
|
+
}
|
|
295
|
+
const m = addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: '⚠️ Слишком много шагов подряд — остановился. Напишите, продолжать ли.' });
|
|
296
|
+
emitMsg(m);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function runAgent(mode = 'advisor') {
|
|
300
|
+
if (state.run) throw new Error('ИИ уже работает');
|
|
301
|
+
if (!isLlmEnabled()) throw new Error('ИИ не подключён: выберите модель в разделе «Модели ИИ»');
|
|
302
|
+
state.run = { ctrl: new AbortController(), mode };
|
|
303
|
+
emitState();
|
|
304
|
+
try {
|
|
305
|
+
await loop(mode);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.error(e);
|
|
308
|
+
emitMsg(addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
|
|
309
|
+
} finally {
|
|
310
|
+
state.run = null;
|
|
311
|
+
emitState();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------- публичные действия ----------
|
|
316
|
+
export function sendUserMessage(text) {
|
|
317
|
+
text = String(text || '').trim();
|
|
318
|
+
if (!text) throw new Error('Пустое сообщение');
|
|
319
|
+
if (state.run) throw new Error('ИИ ещё работает — дождитесь или нажмите «Стоп»');
|
|
320
|
+
if (state.waiting) rejectPending('Пользователь не стал подтверждать и написал сообщение (см. ниже).');
|
|
321
|
+
const m = addMessage(conv().id, { role: 'user', kind: 'user', content: text });
|
|
322
|
+
emitMsg(m);
|
|
323
|
+
runAgent('advisor').catch(() => {});
|
|
324
|
+
return m;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function rejectPending(comment) {
|
|
328
|
+
const w = state.waiting;
|
|
329
|
+
if (!w) return;
|
|
330
|
+
state.waiting = null;
|
|
331
|
+
const msg = getMessage(w.msgId);
|
|
332
|
+
for (const call of msg.tool_calls || []) {
|
|
333
|
+
const st = msg.data?.tools?.[call.id]?.status;
|
|
334
|
+
if (st === 'done' || st === 'error') continue;
|
|
335
|
+
const tm = addMessage(conv().id, { role: 'tool', tool_call_id: call.id, kind: 'tool', content: JSON.stringify({ отклонено: true, комментарий: comment }), data: { name: call.function.name, summary: 'Отклонено', ok: false } });
|
|
336
|
+
emitMsg(tm);
|
|
337
|
+
setToolStatus(msg.id, call.id, { status: 'rejected' });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function resolveAction(msgId, callId, decision, comment = '') {
|
|
342
|
+
const w = state.waiting;
|
|
343
|
+
if (!w || w.msgId !== msgId || w.callId !== callId) throw new Error('Это действие уже не ожидает подтверждения');
|
|
344
|
+
if (state.run) throw new Error('ИИ ещё работает');
|
|
345
|
+
const mode = 'advisor';
|
|
346
|
+
if (decision === 'approve') {
|
|
347
|
+
state.waiting = null;
|
|
348
|
+
setToolStatus(msgId, callId, { status: 'approved' });
|
|
349
|
+
} else {
|
|
350
|
+
const msg = getMessage(msgId);
|
|
351
|
+
state.waiting = null;
|
|
352
|
+
const call = msg.tool_calls.find((c) => c.id === callId);
|
|
353
|
+
const tm = addMessage(conv().id, { role: 'tool', kind: 'tool', tool_call_id: callId, content: JSON.stringify({ отклонено: true, комментарий: comment || 'пользователь отклонил без комментария' }), data: { name: call?.function.name, summary: `Отклонено${comment ? `: ${comment}` : ''}`, ok: false } });
|
|
354
|
+
emitMsg(tm);
|
|
355
|
+
setToolStatus(msgId, callId, { status: 'rejected' });
|
|
356
|
+
}
|
|
357
|
+
if (!isLlmEnabled()) throw new Error('ИИ не подключён');
|
|
358
|
+
state.run = { ctrl: new AbortController(), mode };
|
|
359
|
+
emitState();
|
|
360
|
+
(async () => {
|
|
361
|
+
try {
|
|
362
|
+
const res = await processCalls(getMessage(msgId), mode);
|
|
363
|
+
if (res === 'done') await loop(mode);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
emitMsg(addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
|
|
366
|
+
} finally {
|
|
367
|
+
state.run = null;
|
|
368
|
+
emitState();
|
|
369
|
+
}
|
|
370
|
+
})();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function stopAgent() {
|
|
374
|
+
if (state.run) {
|
|
375
|
+
state.run.ctrl.abort();
|
|
376
|
+
}
|
|
377
|
+
if (state.waiting) rejectPending('Пользователь остановил ИИ');
|
|
378
|
+
emitState();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function setMode() {
|
|
382
|
+
kvSet('strategistMode', 'advisor');
|
|
383
|
+
emitState();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function resetConversation() {
|
|
387
|
+
stopAgent();
|
|
388
|
+
state.conv = newConversation();
|
|
389
|
+
state.waiting = null;
|
|
390
|
+
hub.emit({ type: 'chat:reset', state: chatState() });
|
|
391
|
+
return state.conv;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function history() {
|
|
395
|
+
return { state: chatState(), messages: getMessages(conv().id, 300) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------- события системы → лента ----------
|
|
399
|
+
function addEvent(content, display, data = {}) {
|
|
400
|
+
const m = addMessage(conv().id, { role: 'user', kind: 'event', content: `[Событие] ${content}`, data: { display, ...data } });
|
|
401
|
+
emitMsg(m);
|
|
402
|
+
return m;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
hub.on((e) => {
|
|
406
|
+
try {
|
|
407
|
+
if (e.type === 'research:done' && e.source === 'user') {
|
|
408
|
+
const c = e.metrics.model?.comparison || {};
|
|
409
|
+
const f = c.attention?.full;
|
|
410
|
+
addEvent(
|
|
411
|
+
`Пользователь провёл исследование модели №${e.runId}: итог ${fmtPct(f?.returnPct)}, на 1 ₽ убытка ${f?.profitFactor ?? '—'}, ${f?.trades ?? 0} сделок, рынок ${fmtPct(f?.marketPct)}.`,
|
|
412
|
+
`Исследование №${e.runId}: итог ${fmtPct(f?.returnPct)} (рынок ${fmtPct(f?.marketPct)})`,
|
|
413
|
+
{ eventType: 'model', runId: e.runId, ok: (f?.returnPct ?? 0) > 0 },
|
|
414
|
+
);
|
|
415
|
+
} else if (e.type === 'settings:update' && e.source === 'user' && Object.keys(e.changed || {}).length) {
|
|
416
|
+
addEvent(`Пользователь изменил настройки: ${Object.entries(e.changed).map(([k, v]) => `${k}: ${v.было} → ${v.стало}`).join('; ')}.`, null, { eventType: 'settings', hidden: true });
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.error('strategist event', err);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
export { fmtTs, buildContext, systemPrompt };
|