neuroalpha 0.3.16 → 0.5.1

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