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