neuroalpha 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -3
- package/bin/cli.js +69 -0
- package/config/strategy.json +33 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
- package/package.json +43 -6
- package/public/app.js +2410 -0
- package/public/index.html +337 -0
- package/public/landing.html +870 -0
- package/public/legal.html +90 -0
- package/public/styles.css +343 -0
- package/server/api/routes.js +333 -0
- package/server/auth/index.js +340 -0
- package/server/billing/yookassa.js +132 -0
- package/server/brokers/index.js +645 -0
- package/server/config.js +44 -0
- package/server/data/moex.js +165 -0
- package/server/db.js +420 -0
- package/server/hub.js +14 -0
- package/server/index.js +92 -0
- package/server/llm/client.js +187 -0
- package/server/llm/providers.js +355 -0
- package/server/model/dataset.js +352 -0
- package/server/model/forecast.js +121 -0
- package/server/model/gpu-worker.html +11 -0
- package/server/model/gpu-worker.js +60 -0
- package/server/model/linalg.js +142 -0
- package/server/model/live.js +486 -0
- package/server/model/metrics.js +134 -0
- package/server/model/models.js +459 -0
- package/server/model/profiles.js +166 -0
- package/server/model/research.js +880 -0
- package/server/model/tf-node.js +26 -0
- package/server/model/transformer-core.js +330 -0
- package/server/model/transformer-gpu.js +221 -0
- package/server/model/transformer-pool.js +200 -0
- package/server/model/transformer-worker.js +28 -0
- package/server/model/transformer.js +57 -0
- package/server/model/vocab.js +28 -0
- package/server/news/index.js +309 -0
- package/server/notify/index.js +168 -0
- package/server/params-meta.js +63 -0
- package/server/settings.js +79 -0
- package/server/strategist/analytics.js +150 -0
- package/server/strategist/index.js +484 -0
- package/server/strategist/tools.js +302 -0
- package/server/util.js +9 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { PARAMS } from '../params-meta.js';
|
|
2
|
+
import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
3
|
+
import { hub } from '../hub.js';
|
|
4
|
+
import { getSettings } from '../settings.js';
|
|
5
|
+
import { live } from '../model/live.js';
|
|
6
|
+
import { cancelAllModelResearch } from '../model/research.js';
|
|
7
|
+
import {
|
|
8
|
+
currentConversation, newConversation, addMessage, updateMessage, getMessage, getMessages,
|
|
9
|
+
listJournal, kvGet, kvSet,
|
|
10
|
+
} from '../db.js';
|
|
11
|
+
import { TOOLS, MODES, toolSchemas, OOS_LIMIT } from './tools.js';
|
|
12
|
+
import { recentRuns } from './analytics.js';
|
|
13
|
+
import { plural } from '../util.js';
|
|
14
|
+
|
|
15
|
+
const MAX_STEPS = 25;
|
|
16
|
+
const MSK = 3 * 3600000;
|
|
17
|
+
const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
|
|
18
|
+
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
|
|
19
|
+
|
|
20
|
+
const state = {
|
|
21
|
+
conv: null,
|
|
22
|
+
run: null, // { ctrl, mode }
|
|
23
|
+
waiting: null, // { msgId, callId }
|
|
24
|
+
budgetLeft: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getMode = () => kvGet('strategistMode', 'advisor');
|
|
28
|
+
export const getBudget = () => Math.max(1, Math.min(30, Number(getSettings().autopilotBudget ?? kvGet('autopilotBudget', 8)) || 8));
|
|
29
|
+
|
|
30
|
+
function conv() {
|
|
31
|
+
if (!state.conv) state.conv = currentConversation();
|
|
32
|
+
return state.conv;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function chatState() {
|
|
36
|
+
const llm = activeModelInfo();
|
|
37
|
+
return {
|
|
38
|
+
convId: conv().id,
|
|
39
|
+
busy: !!state.run,
|
|
40
|
+
waiting: state.waiting,
|
|
41
|
+
mode: getMode(),
|
|
42
|
+
budget: getBudget(),
|
|
43
|
+
budgetLeft: state.budgetLeft,
|
|
44
|
+
llm: llm.enabled,
|
|
45
|
+
tools: llm.tools,
|
|
46
|
+
oosUsed: kvGet(`oos_used_${conv().id}`, []).length,
|
|
47
|
+
oosLimit: OOS_LIMIT,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const emitState = () => hub.emit({ type: 'chat:state', state: chatState() });
|
|
52
|
+
const emitMsg = (m, type = 'chat:message') => hub.emit({ type, message: m });
|
|
53
|
+
|
|
54
|
+
// ---------- системная инструкция ----------
|
|
55
|
+
function systemPrompt(mode) {
|
|
56
|
+
const s = getSettings();
|
|
57
|
+
const params = PARAMS.map((p) => `- ${p.key} = ${JSON.stringify(s[p.key])} — ${p.label}${p.min !== undefined ? ` [${p.min}…${p.max}]` : ''}${p.ai === false ? ' (НЕ МЕНЯТЬ)' : ''}`).join('\n');
|
|
58
|
+
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') || '(пусто — это первое исследование)';
|
|
59
|
+
const runs = recentRuns(6).map((r) => `- №${r.id} (${r.тип}, ${r.кто_запустил}${r.заметка ? `, «${r.заметка}»` : ''}): отличия ${JSON.stringify(r.отличия_от_умолчаний)}; ${JSON.stringify(r.период_настройки || r.результат || r.ошибка || r.статус)}`).join('\n') || '(исследований ещё не было)';
|
|
60
|
+
const ps = live.status();
|
|
61
|
+
const used = kvGet(`oos_used_${conv().id}`, []).length;
|
|
62
|
+
|
|
63
|
+
const noTools = !activeModelInfo().tools;
|
|
64
|
+
const modeText = noTools ? 'Режим «Только собеседник»: выбранная модель не умеет вызывать инструменты. Ты не можешь сам запускать проверки, смотреть результаты и менять настройки — только разговариваешь, объясняешь и рассуждаешь по данным ниже (настройки, журнал, последние исследования). Если для ответа нужен запуск или чьи-то данные — скажи об этом и предложи пользователю переключить модель на ту, что умеет инструменты. Не выдумывай результаты, которых не видишь.' : {
|
|
65
|
+
discuss: 'Режим «Обсуждение»: ты только анализируешь и разговариваешь. Запускать проверки и менять настройки нельзя — можешь предложить это пользователю словами.',
|
|
66
|
+
advisor: 'Режим «Советник»: запуск проверок и изменение настроек пользователь подтверждает кнопкой. Предлагай по одному-два эксперимента за раз и объясняй зачем.',
|
|
67
|
+
autopilot: `Режим «Автопилот»: ты сам проводишь серию экспериментов без подтверждения. Бюджет — ${getBudget()} проверок на одно задание, осталось ${state.budgetLeft}. Работай циклом: гипотеза → проверка → сравнение → запись в журнал. В конце обязательно дай итоговый отчёт: лучший вариант, сравнение с исходным на контрольном периоде (final_check), рекомендация. Сохранение настроек (set_settings) — исключение: оно всегда ждёт подтверждения пользователя. Не выполняй его среди шагов — предложи изменение в конце отчёта, и только если улучшение надёжно подтверждено.`,
|
|
68
|
+
review: 'Режим «Разбор дня»: кратко (5–8 строк) разбери итоги торгового дня живой проверки. Менять ничего нельзя — только вывод и рекомендации. Один день — это несколько сделок, по ним нельзя ничего «подтвердить»: в журнал пиши только если заметил что-то новое, и только с выводом unclear.',
|
|
69
|
+
}[mode];
|
|
70
|
+
|
|
71
|
+
return `Ты — «Стратег», ИИ-исследователь торговой системы на Московской бирже. Ты работаешь вместе с пользователем: обсуждаешь идею, анализируешь результаты, ставишь эксперименты и учишься на них.
|
|
72
|
+
|
|
73
|
+
## Главная идея: рынок как язык
|
|
74
|
+
Каждая акция — как «токен» со своим эмбеддингом (фиксированной позицией на карте рынка, построенной по тому, как акции движутся вместе).
|
|
75
|
+
Недавнее состояние рынка (движения и обороты всех ~100 акций индекса широкого рынка за последние часы) — это «контекст».
|
|
76
|
+
Задача — как языковая модель предсказывает следующий токен, предсказать следующее состояние: какие акции сдвинутся в ближайшие часы и куда.
|
|
77
|
+
Перетоки капитала видны как «волны» по карте: если двинулся сосед по карте, а акция ещё нет — она может догнать (или, наоборот, вернуться).
|
|
78
|
+
|
|
79
|
+
Исследование идёт лесенкой (инструмент run_model_research, сравнивает все модели сразу):
|
|
80
|
+
- «своя инерция» — простейшая планка: акция предсказывается по своему недавнему движению;
|
|
81
|
+
- «кто за кем ходит» — линейная модель: каждая акция по недавним движениям всех остальных;
|
|
82
|
+
- «внимание через карту» — влияние соседей по карте рынка (12 «голов внимания»: движение цены или денежный поток × 3 масштаба времени × широкое/узкое внимание);
|
|
83
|
+
- «трансформер» (ступень 3, только при modelUseTransformer=true или modelType='transformer') — небольшая нейросеть: 2 слоя внимания между акциями, обучаемый эмбеддинг каждой акции, учится заново в каждом окне с ранней остановкой. Исследование с ним в разы дольше; данных мало, легко переобучается — верь ему, только если он обыгрывает «внимание через карту» и на контрольном периоде.
|
|
84
|
+
Его настройки: transformerEpochs (эпох, 2–40, по умолчанию 8) и transformerStride (шаг свечей, 1–24, по умолчанию 6) меняют результат; больше эпох / меньше шаг — дольше. transformerParallel (окна одновременно на ядрах) и transformerGpu (видеокарта, экспериментально) — только про скорость, их без просьбы пользователя не трогай.
|
|
85
|
+
Метрики модели: «совпадение прогноза» (ранговая корреляция прогноза с фактом по акциям, 0.03+ уже ценно), доля недель с плюсом,
|
|
86
|
+
доход портфеля из лучших прогнозов с комиссиями и БЕЗ комиссий (разница показывает, сколько съедают издержки), сравнение с рынком.
|
|
87
|
+
Сделки исполняются по цене открытия следующей свечи (честно). Порог выгоды modelMinEdge: покупаем, только если ожидаемое движение больше стоимости сделки × порог.
|
|
88
|
+
У стратегии включено самообучение правил торговли (modelAutoAdapt=true по умолчанию): она сама калибрует порог выхода (около 0,80 от порога входа, чтобы не пересиживать угасающие сделки в зоне 3–8 ч) по прошлым шагам с учётом комиссии и автоматически ставит на карантин акции, которые на прошлых сделках систематически приносили убыток («акции-убийцы»). Дополнительно есть ручные настройки: порог входа (modelMinEdge), порог выхода (modelExitRatio — действует при выключенном modelAutoAdapt), минимальное удержание (modelMinHoldHours, ч), число акций (modelTopK), способ распределения долей (modelAllocMode: equal / invvol), фильтр падающего рынка (modelMarketFilter: off / ma) и горизонт (modelHorizon).
|
|
89
|
+
Направление modelDirection: long — только покупки (по умолчанию); longshort — ещё и продажи без покупки (шорт) акций, ожидаемо слабее рынка (ожидание ниже −порога); neutral — парами: равные суммы покупок и продаж, нет пары — не торгуем; общий рост или падение рынка почти не влияют, результат сравнивай с нулём, а не с рынком. С продажами на позицию приходится капитал/2К. За продажу брокер берёт плату за заём акций 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 };
|