neuroalpha 0.5.16 → 0.5.19
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/package.json +61 -60
- package/public/admin.html +603 -569
- package/public/app.js +101 -23
- package/public/landing.html +301 -74
- package/public/legal.html +3 -2
- package/public/splat3d.html +671 -0
- package/public/styles.css +35 -13
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +41 -14
- package/server/auth/index.js +35 -4
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +81 -50
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +41 -19
- package/server/model/forecast.js +111 -17
- package/server/model/live.js +16 -18
- package/server/model/research.js +41 -15
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
|
@@ -1,757 +1,758 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { DATA_DIR } from '../config.js';
|
|
5
|
-
import { PARAMS } from '../params-meta.js';
|
|
6
|
-
import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
7
|
-
import { hub } from '../hub.js';
|
|
8
|
-
import { getSettings } from '../settings.js';
|
|
9
|
-
import { brokersState } from '../brokers/index.js';
|
|
10
|
-
import { registerChatHandlers } from '../notify/index.js';
|
|
11
|
-
import {
|
|
12
|
-
currentConversation, newConversation, getConversation, setConversationTitle, listConversations,
|
|
13
|
-
addMessage, updateMessage, getMessage, getMessages,
|
|
14
|
-
kvGet, kvSet, currentUid,
|
|
15
|
-
} from '../db.js';
|
|
16
|
-
import { TOOLS, MODES, toolSchemas, OOS_LIMIT } from './tools.js';
|
|
17
|
-
|
|
18
|
-
const ARCHIVE_ROOT = path.join(DATA_DIR, 'chat-archive');
|
|
19
|
-
fs.mkdirSync(ARCHIVE_ROOT, { recursive: true });
|
|
20
|
-
|
|
21
|
-
const MAX_STEPS = 20;
|
|
22
|
-
const MSK = 3 * 3600000;
|
|
23
|
-
const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
|
|
24
|
-
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
|
|
25
|
-
|
|
26
|
-
// Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
|
|
27
|
-
function deriveSessionTitle(rawText = '') {
|
|
28
|
-
const s = String(rawText || '').replace(/\s+/g, ' ').trim();
|
|
29
|
-
if (!s) return 'Диалог со Стратегом';
|
|
30
|
-
const runMatch = s.match(/исследование\s*№\s*(\d+)/i);
|
|
31
|
-
if (runMatch) return `Разбор исследования №${runMatch[1]}`;
|
|
32
|
-
if (/model_forecast|прогноз на ближайшие часы/i.test(s) && !/акцию/i.test(s)) return 'Прогноз и новости рынка';
|
|
33
|
-
const stockMatch = s.match(/Разбери подробно акцию\s+([A-Z0-9]+)\s*(?:\(([^)]+)\))?/i);
|
|
34
|
-
if (stockMatch) return `Досье акции ${stockMatch[1]}${stockMatch[2] ? ` (${stockMatch[2]})` : ''}`;
|
|
35
|
-
const brkMatch = s.match(/открыть и подключить брокерский счёт у брокера «([^»]+)»/i);
|
|
36
|
-
if (brkMatch) return `Подключение счёта ${brkMatch[1]}`;
|
|
37
|
-
const clean = s.replace(/[`*_~<>[\]()]/g, '').trim();
|
|
38
|
-
return clean.length > 56 ? `${clean.slice(0, 54).trim()}…` : clean;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const safeFileSlug = (title = '') => (
|
|
42
|
-
String(title || 'Диалог')
|
|
43
|
-
.replace(/[\\/:*?"<>|]/g, ' ')
|
|
44
|
-
.replace(/\s+/g, '_')
|
|
45
|
-
.replace(/^_+|_+$/g, '')
|
|
46
|
-
.slice(0, 48) || 'Диалог'
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
function userArchiveDir(uid = currentUid()) {
|
|
50
|
-
const d = path.join(ARCHIVE_ROOT, `u${Number(uid) || 1}`);
|
|
51
|
-
fs.mkdirSync(d, { recursive: true });
|
|
52
|
-
return d;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Формирует и сохраняет сессию на диск в виде .md файла с понятным именем и временем МСК
|
|
56
|
-
export function exportConversationMarkdown(convId, uid = currentUid()) {
|
|
57
|
-
const c = getConversation(convId, uid);
|
|
58
|
-
if (!c) return null;
|
|
59
|
-
const msgs = getMessages(c.id, 600);
|
|
60
|
-
const firstUser = msgs.find((m) => m.kind === 'user');
|
|
61
|
-
if (!firstUser && !msgs.some((m) => m.kind === 'assistant' && m.content)) return null;
|
|
62
|
-
const title = c.title || deriveSessionTitle(firstUser?.content || '');
|
|
63
|
-
if (!c.title && title) setConversationTitle(c.id, title, uid);
|
|
64
|
-
const tsMsk = new Date((c.created_at || Date.now()) + MSK).toISOString();
|
|
65
|
-
const datePart = tsMsk.slice(0, 10);
|
|
66
|
-
const timePart = tsMsk.slice(11, 16).replace(':', '-');
|
|
67
|
-
const fileName = `${datePart}_${timePart}_${safeFileSlug(title)}_#${c.id}.md`;
|
|
68
|
-
const dir = userArchiveDir(uid);
|
|
69
|
-
// Удаляем старый файл этой же сессии, если заголовок уточнялся
|
|
70
|
-
try {
|
|
71
|
-
for (const f of fs.readdirSync(dir)) {
|
|
72
|
-
if (f.endsWith(`_#${c.id}.md`) && f !== fileName) fs.unlinkSync(path.join(dir, f));
|
|
73
|
-
}
|
|
74
|
-
} catch { /* ignore */ }
|
|
75
|
-
|
|
76
|
-
const lines = [
|
|
77
|
-
`# ${title}`,
|
|
78
|
-
'',
|
|
79
|
-
`- **Сессия:** №${c.id}`,
|
|
80
|
-
`- **Время (МСК):** ${fmtTs(c.created_at || Date.now())}`,
|
|
81
|
-
'',
|
|
82
|
-
'---',
|
|
83
|
-
'',
|
|
84
|
-
];
|
|
85
|
-
for (const m of msgs) {
|
|
86
|
-
if (m.data?.hidden || m.kind === 'tool') continue;
|
|
87
|
-
const tStr = m.ts ? new Date(m.ts + MSK).toISOString().slice(11, 19) : '';
|
|
88
|
-
if (m.kind === 'user') {
|
|
89
|
-
lines.push(`### 👤 Вы (${tStr})`, '', m.content || '', '');
|
|
90
|
-
} else if (m.kind === 'event') {
|
|
91
|
-
lines.push(`> 📌 **Событие (${tStr}):** ${m.data?.display || m.content || ''}`, '');
|
|
92
|
-
} else if (m.role === 'assistant') {
|
|
93
|
-
if (!m.content && !m.tool_calls?.length) continue;
|
|
94
|
-
lines.push(`### 🤖 Стратег (${tStr})`, '');
|
|
95
|
-
if (m.tool_calls?.length) {
|
|
96
|
-
for (const tc of m.tool_calls) {
|
|
97
|
-
const st = m.data?.tools?.[tc.id] || {};
|
|
98
|
-
lines.push(`- *Инструмент \`${tc.function?.name}\`: ${st.summary || st.status || 'выполнен'}*`);
|
|
99
|
-
}
|
|
100
|
-
lines.push('');
|
|
101
|
-
}
|
|
102
|
-
if (m.content) lines.push(m.content, '');
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
const mdText = lines.join('\n');
|
|
106
|
-
const fullPath = path.join(dir, fileName);
|
|
107
|
-
try { fs.writeFileSync(fullPath, mdText, 'utf8'); } catch { /* ignore */ }
|
|
108
|
-
return { id: c.id, title, createdAt: c.created_at, fileName, markdown: mdText };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Состояние чата отдельно для каждого пользователя (uid берётся из контекста запроса)
|
|
112
|
-
const states = new Map();
|
|
113
|
-
function userState() {
|
|
114
|
-
const uid = currentUid();
|
|
115
|
-
let s = states.get(uid);
|
|
116
|
-
if (!s) {
|
|
117
|
-
s = {
|
|
118
|
-
conv: null,
|
|
119
|
-
run: null, // { ctrl, mode }
|
|
120
|
-
waiting: null, // { msgId, callId }
|
|
121
|
-
budgetLeft: 0,
|
|
122
|
-
};
|
|
123
|
-
states.set(uid, s);
|
|
124
|
-
}
|
|
125
|
-
return s;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export const getMode = () => 'advisor';
|
|
129
|
-
export const getBudget = () => 8;
|
|
130
|
-
|
|
131
|
-
function conv() {
|
|
132
|
-
if (!userState().conv) userState().conv = currentConversation();
|
|
133
|
-
return userState().conv;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function chatState() {
|
|
137
|
-
const llm = activeModelInfo();
|
|
138
|
-
return {
|
|
139
|
-
convId: conv().id,
|
|
140
|
-
busy: !!userState().run,
|
|
141
|
-
waiting: userState().waiting,
|
|
142
|
-
mode: 'advisor',
|
|
143
|
-
budget: 8,
|
|
144
|
-
budgetLeft: userState().budgetLeft,
|
|
145
|
-
llm: llm.enabled,
|
|
146
|
-
tools: llm.tools,
|
|
147
|
-
oosUsed: 0,
|
|
148
|
-
oosLimit: OOS_LIMIT,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const emitState = () => hub.emit({ type: 'chat:state', state: chatState() });
|
|
153
|
-
const emitMsg = (m, type = 'chat:message') => hub.emit({ type, message: m });
|
|
154
|
-
|
|
155
|
-
// ---------- системная инструкция ----------
|
|
156
|
-
function systemPrompt() {
|
|
157
|
-
const s = getSettings();
|
|
158
|
-
const newsFilterState = s.newsFilter ? 'включён (блокирует покупки на сильном негативе и шорты на сильном позитиве за 6 ч)' : 'выключен';
|
|
159
|
-
|
|
160
|
-
let brkSummary = '(брокерские счета пока не подключены)';
|
|
161
|
-
try {
|
|
162
|
-
const bAccs = brokersState().accounts || [];
|
|
163
|
-
if (bAccs.length) {
|
|
164
|
-
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');
|
|
165
|
-
}
|
|
166
|
-
} catch { /* ignore */ }
|
|
167
|
-
|
|
168
|
-
const noTools = !activeModelInfo().tools;
|
|
169
|
-
const nowTs = Date.now();
|
|
170
|
-
const nowMsk = new Date(nowTs + MSK);
|
|
171
|
-
const DAYS_RU = ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота'];
|
|
172
|
-
const todayIso = nowMsk.toISOString().slice(0, 10);
|
|
173
|
-
const yesterdayIso = new Date(nowTs + MSK - 86400000).toISOString().slice(0, 10);
|
|
174
|
-
const fmtRuDate = (iso) => iso.split('-').reverse().join('.');
|
|
175
|
-
const nowClock = nowMsk.toISOString().slice(11, 16);
|
|
176
|
-
const dow = nowMsk.getUTCDay();
|
|
177
|
-
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
178
|
-
const moexStatus = (dow === 0 || dow === 6)
|
|
179
|
-
? 'выходной день (основные торги закрыты)'
|
|
180
|
-
: (mins >= 9 * 60 + 50 && mins <= 18 * 60 + 50)
|
|
181
|
-
? 'идёт основная торговая сессия Мосбиржи (09:50–18:50 МСК)'
|
|
182
|
-
: (mins > 18 * 60 + 50 && mins <= 23 * 60 + 50)
|
|
183
|
-
? 'идёт вечерняя торговая сессия Мосбиржи (19:00–23:50 МСК)'
|
|
184
|
-
: 'межсессионный перерыв / торги ещё не открылись';
|
|
185
|
-
|
|
186
|
-
const modeText = noTools
|
|
187
|
-
? 'Выбранная модель ИИ не поддерживает вызов инструментов — ты отвечаешь и консультируешь по данным из контекста ниже.'
|
|
188
|
-
: 'Ты можешь свободно вызывать читающие инструменты (`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` предлагаешь с подтверждением пользователя кнопкой.';
|
|
189
|
-
|
|
190
|
-
return `Ты — «Стратег», персональный ИИ-консультант и помощник пользователя в облачной платформе алгоритмической торговли «НейроАльфа» (Московская биржа, MOEX).
|
|
191
|
-
Твоя задача — простым, понятным русским языком объяснять пользователю прогнозы и сигналы модели, разбирать состояние его брокерских счетов, портфеля и историю сделок, объяснять новости рынка и помогать с подключением и настройкой счетов.
|
|
192
|
-
ВАЖНО: Ты НЕ ставишь эксперименты и НЕ подбираешь параметры математического ядра — оно работает в зафиксированном автоматическом режиме.
|
|
193
|
-
ЗАПРЕЩЕНО обещать или прогнозировать доходность («X% годовых», «гарантированно», «без риска»), называть цифры бэктеста ожидаемым доходом и давать индивидуальные инвестиционные рекомендации. Если спрашивают про доходность — объясни честно: на истории (бэктест с реальными издержками ~0,1% на сторону, задержкой исполнения и ограничением по ликвидности) стратегия давала порядка десятков процентов годовых при просадках до ~30%, но прошлые результаты не гарантируют будущих, возможны убытки, а при росте издержек выше ~0,17% на сторону стратегия убыточна.
|
|
194
|
-
|
|
195
|
-
## Текущее время и календарь Мосбиржи (МСК, UTC+3)
|
|
196
|
-
- **Сейчас:** ${DAYS_RU[dow]}, **${fmtRuDate(todayIso)}** (${todayIso}), **${nowClock} МСК** — ${moexStatus}.
|
|
197
|
-
- **Сегодняшняя дата:** ${fmtRuDate(todayIso)} (${todayIso}).
|
|
198
|
-
- **Вчерашняя дата:** ${fmtRuDate(yesterdayIso)} (${yesterdayIso}).
|
|
199
|
-
- **ВАЖНОЕ ПРАВИЛО ПО ВРЕМЕНИ И ТОРГАМ:**
|
|
200
|
-
- Инструмент \`get_brokers_status\` показывает **только текущий снимок портфеля прямо сейчас** (какие бумаги лежат на счёте в эту минуту и какие заявки висят в стакане сейчас). Никогда не выдавай текущий портфель из \`get_brokers_status\` за «итоги вчерашних торгов»!
|
|
201
|
-
- Если пользователь спрашивает **«как прошли вчерашние торги»**, **«какие сделки были вчера / сегодня / на неделе»** или просит отчёт по прошлым сделкам — **ОБЯЗАТЕЛЬНО вызывай инструмент \`get_broker_operations\`** (например \`{"period": "yesterday"}\` для вчерашнего дня или \`{"period": "today"}\` для сегодняшнего дня)!
|
|
202
|
-
|
|
203
|
-
## Как устроено автоматическое ядро платформы «НейроАльфа» (Рынок как язык)
|
|
204
|
-
- **Словарь:** ~100 самых ликвидных акций индекса широкого рынка Мосбиржи (MOEXBMI) + индекс IMOEX. Свечи 10-минутные, дивидендные отсечки скорректированы со сверкой по реальному гэпу цены, объёмы нормированы по 24-часовому суточному профилю (valRatio по часам МСК).
|
|
205
|
-
- **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
|
|
206
|
-
- **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
|
|
207
|
-
- **Пороги входа и
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
-
|
|
211
|
-
-
|
|
212
|
-
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
-
|
|
230
|
-
-
|
|
231
|
-
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
- Чтобы
|
|
243
|
-
- Чтобы
|
|
244
|
-
- Чтобы
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
let
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
data
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
else if (
|
|
365
|
-
else {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
let
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (userState().
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
(
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
userState().run
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
let
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
const
|
|
617
|
-
const
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
userState().
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
userState().
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
{
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DATA_DIR } from '../config.js';
|
|
5
|
+
import { PARAMS } from '../params-meta.js';
|
|
6
|
+
import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
7
|
+
import { hub } from '../hub.js';
|
|
8
|
+
import { getSettings } from '../settings.js';
|
|
9
|
+
import { brokersState } from '../brokers/index.js';
|
|
10
|
+
import { registerChatHandlers } from '../notify/index.js';
|
|
11
|
+
import {
|
|
12
|
+
currentConversation, newConversation, getConversation, setConversationTitle, listConversations,
|
|
13
|
+
addMessage, updateMessage, getMessage, getMessages,
|
|
14
|
+
kvGet, kvSet, currentUid,
|
|
15
|
+
} from '../db.js';
|
|
16
|
+
import { TOOLS, MODES, toolSchemas, OOS_LIMIT } from './tools.js';
|
|
17
|
+
|
|
18
|
+
const ARCHIVE_ROOT = path.join(DATA_DIR, 'chat-archive');
|
|
19
|
+
fs.mkdirSync(ARCHIVE_ROOT, { recursive: true });
|
|
20
|
+
|
|
21
|
+
const MAX_STEPS = 20;
|
|
22
|
+
const MSK = 3 * 3600000;
|
|
23
|
+
const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
|
|
24
|
+
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
|
|
25
|
+
|
|
26
|
+
// Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
|
|
27
|
+
function deriveSessionTitle(rawText = '') {
|
|
28
|
+
const s = String(rawText || '').replace(/\s+/g, ' ').trim();
|
|
29
|
+
if (!s) return 'Диалог со Стратегом';
|
|
30
|
+
const runMatch = s.match(/исследование\s*№\s*(\d+)/i);
|
|
31
|
+
if (runMatch) return `Разбор исследования №${runMatch[1]}`;
|
|
32
|
+
if (/model_forecast|прогноз на ближайшие часы/i.test(s) && !/акцию/i.test(s)) return 'Прогноз и новости рынка';
|
|
33
|
+
const stockMatch = s.match(/Разбери подробно акцию\s+([A-Z0-9]+)\s*(?:\(([^)]+)\))?/i);
|
|
34
|
+
if (stockMatch) return `Досье акции ${stockMatch[1]}${stockMatch[2] ? ` (${stockMatch[2]})` : ''}`;
|
|
35
|
+
const brkMatch = s.match(/открыть и подключить брокерский счёт у брокера «([^»]+)»/i);
|
|
36
|
+
if (brkMatch) return `Подключение счёта ${brkMatch[1]}`;
|
|
37
|
+
const clean = s.replace(/[`*_~<>[\]()]/g, '').trim();
|
|
38
|
+
return clean.length > 56 ? `${clean.slice(0, 54).trim()}…` : clean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const safeFileSlug = (title = '') => (
|
|
42
|
+
String(title || 'Диалог')
|
|
43
|
+
.replace(/[\\/:*?"<>|]/g, ' ')
|
|
44
|
+
.replace(/\s+/g, '_')
|
|
45
|
+
.replace(/^_+|_+$/g, '')
|
|
46
|
+
.slice(0, 48) || 'Диалог'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function userArchiveDir(uid = currentUid()) {
|
|
50
|
+
const d = path.join(ARCHIVE_ROOT, `u${Number(uid) || 1}`);
|
|
51
|
+
fs.mkdirSync(d, { recursive: true });
|
|
52
|
+
return d;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Формирует и сохраняет сессию на диск в виде .md файла с понятным именем и временем МСК
|
|
56
|
+
export function exportConversationMarkdown(convId, uid = currentUid()) {
|
|
57
|
+
const c = getConversation(convId, uid);
|
|
58
|
+
if (!c) return null;
|
|
59
|
+
const msgs = getMessages(c.id, 600);
|
|
60
|
+
const firstUser = msgs.find((m) => m.kind === 'user');
|
|
61
|
+
if (!firstUser && !msgs.some((m) => m.kind === 'assistant' && m.content)) return null;
|
|
62
|
+
const title = c.title || deriveSessionTitle(firstUser?.content || '');
|
|
63
|
+
if (!c.title && title) setConversationTitle(c.id, title, uid);
|
|
64
|
+
const tsMsk = new Date((c.created_at || Date.now()) + MSK).toISOString();
|
|
65
|
+
const datePart = tsMsk.slice(0, 10);
|
|
66
|
+
const timePart = tsMsk.slice(11, 16).replace(':', '-');
|
|
67
|
+
const fileName = `${datePart}_${timePart}_${safeFileSlug(title)}_#${c.id}.md`;
|
|
68
|
+
const dir = userArchiveDir(uid);
|
|
69
|
+
// Удаляем старый файл этой же сессии, если заголовок уточнялся
|
|
70
|
+
try {
|
|
71
|
+
for (const f of fs.readdirSync(dir)) {
|
|
72
|
+
if (f.endsWith(`_#${c.id}.md`) && f !== fileName) fs.unlinkSync(path.join(dir, f));
|
|
73
|
+
}
|
|
74
|
+
} catch { /* ignore */ }
|
|
75
|
+
|
|
76
|
+
const lines = [
|
|
77
|
+
`# ${title}`,
|
|
78
|
+
'',
|
|
79
|
+
`- **Сессия:** №${c.id}`,
|
|
80
|
+
`- **Время (МСК):** ${fmtTs(c.created_at || Date.now())}`,
|
|
81
|
+
'',
|
|
82
|
+
'---',
|
|
83
|
+
'',
|
|
84
|
+
];
|
|
85
|
+
for (const m of msgs) {
|
|
86
|
+
if (m.data?.hidden || m.kind === 'tool') continue;
|
|
87
|
+
const tStr = m.ts ? new Date(m.ts + MSK).toISOString().slice(11, 19) : '';
|
|
88
|
+
if (m.kind === 'user') {
|
|
89
|
+
lines.push(`### 👤 Вы (${tStr})`, '', m.content || '', '');
|
|
90
|
+
} else if (m.kind === 'event') {
|
|
91
|
+
lines.push(`> 📌 **Событие (${tStr}):** ${m.data?.display || m.content || ''}`, '');
|
|
92
|
+
} else if (m.role === 'assistant') {
|
|
93
|
+
if (!m.content && !m.tool_calls?.length) continue;
|
|
94
|
+
lines.push(`### 🤖 Стратег (${tStr})`, '');
|
|
95
|
+
if (m.tool_calls?.length) {
|
|
96
|
+
for (const tc of m.tool_calls) {
|
|
97
|
+
const st = m.data?.tools?.[tc.id] || {};
|
|
98
|
+
lines.push(`- *Инструмент \`${tc.function?.name}\`: ${st.summary || st.status || 'выполнен'}*`);
|
|
99
|
+
}
|
|
100
|
+
lines.push('');
|
|
101
|
+
}
|
|
102
|
+
if (m.content) lines.push(m.content, '');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const mdText = lines.join('\n');
|
|
106
|
+
const fullPath = path.join(dir, fileName);
|
|
107
|
+
try { fs.writeFileSync(fullPath, mdText, 'utf8'); } catch { /* ignore */ }
|
|
108
|
+
return { id: c.id, title, createdAt: c.created_at, fileName, markdown: mdText };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Состояние чата отдельно для каждого пользователя (uid берётся из контекста запроса)
|
|
112
|
+
const states = new Map();
|
|
113
|
+
function userState() {
|
|
114
|
+
const uid = currentUid();
|
|
115
|
+
let s = states.get(uid);
|
|
116
|
+
if (!s) {
|
|
117
|
+
s = {
|
|
118
|
+
conv: null,
|
|
119
|
+
run: null, // { ctrl, mode }
|
|
120
|
+
waiting: null, // { msgId, callId }
|
|
121
|
+
budgetLeft: 0,
|
|
122
|
+
};
|
|
123
|
+
states.set(uid, s);
|
|
124
|
+
}
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const getMode = () => 'advisor';
|
|
129
|
+
export const getBudget = () => 8;
|
|
130
|
+
|
|
131
|
+
function conv() {
|
|
132
|
+
if (!userState().conv) userState().conv = currentConversation();
|
|
133
|
+
return userState().conv;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function chatState() {
|
|
137
|
+
const llm = activeModelInfo();
|
|
138
|
+
return {
|
|
139
|
+
convId: conv().id,
|
|
140
|
+
busy: !!userState().run,
|
|
141
|
+
waiting: userState().waiting,
|
|
142
|
+
mode: 'advisor',
|
|
143
|
+
budget: 8,
|
|
144
|
+
budgetLeft: userState().budgetLeft,
|
|
145
|
+
llm: llm.enabled,
|
|
146
|
+
tools: llm.tools,
|
|
147
|
+
oosUsed: 0,
|
|
148
|
+
oosLimit: OOS_LIMIT,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const emitState = () => hub.emit({ type: 'chat:state', state: chatState() });
|
|
153
|
+
const emitMsg = (m, type = 'chat:message') => hub.emit({ type, message: m });
|
|
154
|
+
|
|
155
|
+
// ---------- системная инструкция ----------
|
|
156
|
+
function systemPrompt() {
|
|
157
|
+
const s = getSettings();
|
|
158
|
+
const newsFilterState = s.newsFilter ? 'включён (блокирует покупки на сильном негативе и шорты на сильном позитиве за 6 ч)' : 'выключен';
|
|
159
|
+
|
|
160
|
+
let brkSummary = '(брокерские счета пока не подключены)';
|
|
161
|
+
try {
|
|
162
|
+
const bAccs = brokersState().accounts || [];
|
|
163
|
+
if (bAccs.length) {
|
|
164
|
+
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');
|
|
165
|
+
}
|
|
166
|
+
} catch { /* ignore */ }
|
|
167
|
+
|
|
168
|
+
const noTools = !activeModelInfo().tools;
|
|
169
|
+
const nowTs = Date.now();
|
|
170
|
+
const nowMsk = new Date(nowTs + MSK);
|
|
171
|
+
const DAYS_RU = ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота'];
|
|
172
|
+
const todayIso = nowMsk.toISOString().slice(0, 10);
|
|
173
|
+
const yesterdayIso = new Date(nowTs + MSK - 86400000).toISOString().slice(0, 10);
|
|
174
|
+
const fmtRuDate = (iso) => iso.split('-').reverse().join('.');
|
|
175
|
+
const nowClock = nowMsk.toISOString().slice(11, 16);
|
|
176
|
+
const dow = nowMsk.getUTCDay();
|
|
177
|
+
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
178
|
+
const moexStatus = (dow === 0 || dow === 6)
|
|
179
|
+
? 'выходной день (основные торги закрыты)'
|
|
180
|
+
: (mins >= 9 * 60 + 50 && mins <= 18 * 60 + 50)
|
|
181
|
+
? 'идёт основная торговая сессия Мосбиржи (09:50–18:50 МСК)'
|
|
182
|
+
: (mins > 18 * 60 + 50 && mins <= 23 * 60 + 50)
|
|
183
|
+
? 'идёт вечерняя торговая сессия Мосбиржи (19:00–23:50 МСК)'
|
|
184
|
+
: 'межсессионный перерыв / торги ещё не открылись';
|
|
185
|
+
|
|
186
|
+
const modeText = noTools
|
|
187
|
+
? 'Выбранная модель ИИ не поддерживает вызов инструментов — ты отвечаешь и консультируешь по данным из контекста ниже.'
|
|
188
|
+
: 'Ты можешь свободно вызывать читающие инструменты (`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` предлагаешь с подтверждением пользователя кнопкой.';
|
|
189
|
+
|
|
190
|
+
return `Ты — «Стратег», персональный ИИ-консультант и помощник пользователя в облачной платформе алгоритмической торговли «НейроАльфа» (Московская биржа, MOEX).
|
|
191
|
+
Твоя задача — простым, понятным русским языком объяснять пользователю прогнозы и сигналы модели, разбирать состояние его брокерских счетов, портфеля и историю сделок, объяснять новости рынка и помогать с подключением и настройкой счетов.
|
|
192
|
+
ВАЖНО: Ты НЕ ставишь эксперименты и НЕ подбираешь параметры математического ядра — оно работает в зафиксированном автоматическом режиме.
|
|
193
|
+
ЗАПРЕЩЕНО обещать или прогнозировать доходность («X% годовых», «гарантированно», «без риска»), называть цифры бэктеста ожидаемым доходом и давать индивидуальные инвестиционные рекомендации. Если спрашивают про доходность — объясни честно: на истории (бэктест с реальными издержками ~0,1% на сторону, задержкой исполнения и ограничением по ликвидности) стратегия давала порядка десятков процентов годовых при просадках до ~30%, но прошлые результаты не гарантируют будущих, возможны убытки, а при росте издержек выше ~0,17% на сторону стратегия убыточна.
|
|
194
|
+
|
|
195
|
+
## Текущее время и календарь Мосбиржи (МСК, UTC+3)
|
|
196
|
+
- **Сейчас:** ${DAYS_RU[dow]}, **${fmtRuDate(todayIso)}** (${todayIso}), **${nowClock} МСК** — ${moexStatus}.
|
|
197
|
+
- **Сегодняшняя дата:** ${fmtRuDate(todayIso)} (${todayIso}).
|
|
198
|
+
- **Вчерашняя дата:** ${fmtRuDate(yesterdayIso)} (${yesterdayIso}).
|
|
199
|
+
- **ВАЖНОЕ ПРАВИЛО ПО ВРЕМЕНИ И ТОРГАМ:**
|
|
200
|
+
- Инструмент \`get_brokers_status\` показывает **только текущий снимок портфеля прямо сейчас** (какие бумаги лежат на счёте в эту минуту и какие заявки висят в стакане сейчас). Никогда не выдавай текущий портфель из \`get_brokers_status\` за «итоги вчерашних торгов»!
|
|
201
|
+
- Если пользователь спрашивает **«как прошли вчерашние торги»**, **«какие сделки были вчера / сегодня / на неделе»** или просит отчёт по прошлым сделкам — **ОБЯЗАТЕЛЬНО вызывай инструмент \`get_broker_operations\`** (например \`{"period": "yesterday"}\` для вчерашнего дня или \`{"period": "today"}\` для сегодняшнего дня)!
|
|
202
|
+
|
|
203
|
+
## Как устроено автоматическое ядро платформы «НейроАльфа» (Рынок как язык)
|
|
204
|
+
- **Словарь:** ~100 самых ликвидных акций индекса широкого рынка Мосбиржи (MOEXBMI) + индекс IMOEX. Свечи 10-минутные, дивидендные отсечки скорректированы со сверкой по реальному гэпу цены, объёмы нормированы по 24-часовому суточному профилю (valRatio по часам МСК).
|
|
205
|
+
- **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
|
|
206
|
+
- **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
|
|
207
|
+
- **Пороги входа и удержания (индивидуальные по эшелонам ликвидности):**
|
|
208
|
+
- Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,65 (запас над реальными издержками по стресс-тесту и фактическим сделкам). Для высоколиквидных голубых фишек 1-го эшелона (SBER, LKOH, GAZP, T, YDEX, GMKN, FLOT и др.) при издержках 0,05% за сделку это +0,413% (в таблице отображается как +0,41%).
|
|
209
|
+
- Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до +0,495% (+0,50%).
|
|
210
|
+
- Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от +0,41% (1-й эшелон) и +0,50% (2–3-й эшелон), а контртрендовые шорты ужесточаются в 1,25× (до −0,52% для 1-го эшелона и −0,62% для 2–3-го эшелона). На падающем рынке — наоборот. Всегда называй пользователю именно индивидуальный порог конкретной бумаги (\`порог_покупки_проц\` / \`порог_шорта_проц\`).
|
|
211
|
+
- Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от порога входа), затем — плотный порог фиксации прибыли.
|
|
212
|
+
- Реальные издержки на счёте Т-Банка (по фактическим сделкам): комиссия ~0,04% + отклонение цены исполнения от цены сигнала ~0,03–0,08% на сторону.
|
|
213
|
+
- **Dynamic Top-K (modelTopK = 0 Авто) и Conviction Sizing:** фиксированного числа акций нет — робот держит от 0 до 4 лонгов и до 4 шортов в зависимости от силы сигналов и концентрирует слот в лучших лидерах.
|
|
214
|
+
|
|
215
|
+
## Брокерские счета и режимы работы
|
|
216
|
+
Полная поддержка — Т-Банк (T-Invest API, включая секцию TQBR и SPBRU для SPBE). Финам, БКС, Алор, Цифра брокер и ITI Capital — бета, только «Подтверждение заявок». СберИнвестиции, ВТБ и Альфа-Инвестиции сейчас не подключаются (нет открытого API для заявок).
|
|
217
|
+
- **Режимы работы счёта (mode):**
|
|
218
|
+
1. readonly («Только чтение») — безопасный мониторинг портфеля и расчёт заявок без отправки в стакан.
|
|
219
|
+
2. confirm («Подтверждение заявок», рекомендуется) — робот считает лимитные заявки в целых лотах и присылает их в веб-терминал и в Telegram / MAX на подтверждение одной кнопкой.
|
|
220
|
+
3. auto («Автомат») — робот сам отправляет лимитные заявки по закрытию свечи (сначала закрывающие sell/cover, через 1,2 сек — открывающие buy/short).
|
|
221
|
+
- Также в настройках счёта есть тумблер **«Тестовый режим (песочница брокера)»** (sandbox) для безрисковой проверки на виртуальных деньгах брокера.
|
|
222
|
+
- **Режимы обращения со старыми бумагами на счёте (legacyMode):**
|
|
223
|
+
1. isolate («Изолированный карман», по умолчанию) — не трогать старые акции, которые были на счёте до подключения.
|
|
224
|
+
2. smooth («Плавный переход») — постепенно продавать слабые старые акции с отрицательным прогнозом ИИ, высвобождая кэш как под новые покупки, так и под новые шорты.
|
|
225
|
+
3. takeover («Полное управление») — сразу продать все старые нецелевые акции из словаря стратегии (облигации, фонды и бумаги вне словаря не трогаются никогда), освободив весь кэш под стратегию.
|
|
226
|
+
|
|
227
|
+
## Тарифные планы SaaS
|
|
228
|
+
Тарифы различаются возможностями, а не доходностью (доходность тарифом не определяется и не гарантируется):
|
|
229
|
+
- **«Инвестор» (3 900 ₽/мес):** только покупки (без шорта), 1 брокерский счёт со стартовым лимитом до 1 000 000 ₽ (+ реинвестирование прибыли).
|
|
230
|
+
- **«Профи» (9 900 ₽/мес):** покупки и шорты, до 5 брокерских счетов со стартовым лимитом до 1 000 000 ₽ на каждом.
|
|
231
|
+
- **«VIP» (по договору):** покупки и шорты, количество счетов и сумма по договору, облачная машина или установка на свой компьютер.
|
|
232
|
+
- **Режим «Хозяин» (owner):** без ограничений по счетам и капиталу + доступ к разделу «Эксперименты».
|
|
233
|
+
|
|
234
|
+
## Подключённые брокерские счета пользователя
|
|
235
|
+
${brkSummary}
|
|
236
|
+
|
|
237
|
+
## Фильтр сделок по новостям (в разделе «Новости рынка»)
|
|
238
|
+
- newsFilter = ${JSON.stringify(Boolean(s.newsFilter))} (${newsFilterState})
|
|
239
|
+
|
|
240
|
+
## Доступные тебе инструменты
|
|
241
|
+
${modeText}
|
|
242
|
+
- Чтобы разобрать текущий портфель пользователя прямо сейчас, свободные рубли или активные заявки в стакане — вызови \`get_brokers_status\`.
|
|
243
|
+
- Чтобы посмотреть, как прошли торги вчера, сегодня или на неделе (история исполненных сделок, покупок, продаж, комиссий и оборота по времени МСК) — вызови \`get_broker_operations\` (например \`{"period": "yesterday"}\` или \`{"period": "today"}\`).
|
|
244
|
+
- Чтобы объяснить текущую ситуацию на рынке — вызови \`model_forecast\` и \`get_news\`.
|
|
245
|
+
- Чтобы помочь подключить счёт — используй \`broker_onboarding_guide\`, \`connect_broker_account\` и \`discover_broker_accounts\` (по id уже сохранённого счёта). НИКОГДА не проси прислать токен брокера в чат: токен пользователь вставляет только в форму счёта («+ Добавить счёт» → поле «Токен»). Если пользователь всё же прислал токен в чат — не повторяй его в ответе, посоветуй перевыпустить токен у брокера и вставить новый в форму. Полностью поддерживается Т-Банк; Финам, БКС, Алор, Цифра и ITI — бета, только с подтверждением заявок; СберИнвестиции, ВТБ и Альфа сейчас не подключаются.
|
|
246
|
+
|
|
247
|
+
## Как отвечать
|
|
248
|
+
- Размышляй и пиши по-русски, доброжелательно, чётко и простым языком без лишнего жаргона.
|
|
249
|
+
- Используй списки и выделение жирным для удобного чтения.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- контекст для модели (с автоматическим сжатием длинной истории диалога) ----------
|
|
253
|
+
const CONTEXT_CHAR_BUDGET = 28000;
|
|
254
|
+
function buildContext(convId) {
|
|
255
|
+
const all = getMessages(convId, 200);
|
|
256
|
+
// 1) Вычисляем точку разреза так, чтобы хвост диалога укладывался в бюджет символов (или не более 36 последних сообщений),
|
|
257
|
+
// а все более ранние реплики сворачивались в компактную сводку контекста без потери ключевых вопросов и выводов!
|
|
258
|
+
let start = Math.max(0, all.length - 36);
|
|
259
|
+
let tailChars = 0;
|
|
260
|
+
for (let i = all.length - 1; i >= start; i--) {
|
|
261
|
+
const cLen = (all[i].content || '').length;
|
|
262
|
+
if (tailChars + cLen > CONTEXT_CHAR_BUDGET && all.length - i >= 8) {
|
|
263
|
+
start = i + 1;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
tailChars += Math.min(cLen, 3500);
|
|
267
|
+
}
|
|
268
|
+
while (start < all.length && !['user', 'event'].includes(all[start].kind)) start++;
|
|
269
|
+
|
|
270
|
+
const out = [];
|
|
271
|
+
if (start > 0) {
|
|
272
|
+
const head = all.slice(0, start);
|
|
273
|
+
const notes = [];
|
|
274
|
+
for (const m of head) {
|
|
275
|
+
if (m.kind === 'user' && m.content) {
|
|
276
|
+
notes.push(`• Вопрос: ${m.content.replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
277
|
+
} else if (m.role === 'assistant' && (m.content || m.tool_calls?.length)) {
|
|
278
|
+
const tools = (m.tool_calls || []).map((c) => c.function?.name).filter(Boolean).join(', ');
|
|
279
|
+
const txt = (m.content || '').replace(/\s+/g, ' ').slice(0, 260);
|
|
280
|
+
notes.push(`• Ответ${tools ? ` [${tools}]` : ''}: ${txt}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (notes.length) {
|
|
284
|
+
out.push({
|
|
285
|
+
role: 'user',
|
|
286
|
+
content: `[Сжатая сводка начала текущей сессии (${head.length} сообщ.)]\n${notes.slice(-18).join('\n')}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const msgs = all.slice(start);
|
|
292
|
+
const deferred = [];
|
|
293
|
+
let openIds = new Set();
|
|
294
|
+
const toolResults = new Set(msgs.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
|
|
295
|
+
const lastToolIdx = msgs.map((m, i) => (m.role === 'tool' ? i : -1)).filter((i) => i >= 0).slice(-6);
|
|
296
|
+
|
|
297
|
+
msgs.forEach((m, idx) => {
|
|
298
|
+
if (m.kind === 'event' || m.kind === 'user') {
|
|
299
|
+
const msg = { role: 'user', content: m.content || '' };
|
|
300
|
+
if (openIds.size) deferred.push(msg);
|
|
301
|
+
else out.push(msg);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (m.role === 'assistant') {
|
|
305
|
+
const calls = (m.tool_calls || []).map((c) => ({ id: c.id, type: 'function', function: { name: c.function.name, arguments: c.function.arguments || '{}' } }));
|
|
306
|
+
if (!m.content && !calls.length) return;
|
|
307
|
+
out.push({ role: 'assistant', content: m.content || '', ...(calls.length ? { tool_calls: calls } : {}) });
|
|
308
|
+
openIds = new Set(calls.map((c) => c.id));
|
|
309
|
+
for (const c of calls) {
|
|
310
|
+
if (!toolResults.has(c.id)) {
|
|
311
|
+
out.push({ role: 'tool', tool_call_id: c.id, content: JSON.stringify({ ошибка: 'не выполнено (прервано)' }) });
|
|
312
|
+
openIds.delete(c.id);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!openIds.size) { out.push(...deferred); deferred.length = 0; }
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (m.role === 'tool') {
|
|
319
|
+
let content = m.content || '';
|
|
320
|
+
if (!lastToolIdx.includes(idx) && content.length > 1500) content = `${content.slice(0, 1500)}… (сокращено)`;
|
|
321
|
+
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content });
|
|
322
|
+
openIds.delete(m.tool_call_id);
|
|
323
|
+
if (!openIds.size) { out.push(...deferred); deferred.length = 0; }
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
out.push(...deferred);
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Модель иногда пишет вызов текстом в формате Qwen: <tool_call>{"name":…,"arguments":…}</tool_call>
|
|
331
|
+
function parseTextToolCalls(content) {
|
|
332
|
+
const calls = [];
|
|
333
|
+
const cleaned = content.replace(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g, (_, body) => {
|
|
334
|
+
try {
|
|
335
|
+
const j = JSON.parse(body);
|
|
336
|
+
if (j.name) calls.push({ id: `call_${Date.now()}_${calls.length}`, type: 'function', function: { name: j.name, arguments: JSON.stringify(j.arguments || {}) } });
|
|
337
|
+
} catch { /* пропускаем */ }
|
|
338
|
+
return '';
|
|
339
|
+
});
|
|
340
|
+
return { calls, cleaned: cleaned.trim() };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseArgs(call) {
|
|
344
|
+
try { return JSON.parse(call.function.arguments || '{}') || {}; } catch { return null; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------- выполнение инструментов ----------
|
|
348
|
+
function setToolStatus(msgId, callId, patch) {
|
|
349
|
+
const m = getMessage(msgId);
|
|
350
|
+
const data = m.data || {};
|
|
351
|
+
data.tools = { ...(data.tools || {}), [callId]: { ...(data.tools?.[callId] || {}), ...patch } };
|
|
352
|
+
const u = updateMessage(msgId, { data });
|
|
353
|
+
emitMsg(u, 'chat:update');
|
|
354
|
+
return u;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function execTool(msg, call, mode) {
|
|
358
|
+
const name = call.function.name;
|
|
359
|
+
const tool = TOOLS[name];
|
|
360
|
+
const args = parseArgs(call);
|
|
361
|
+
const mObj = MODES[mode] || MODES.advisor;
|
|
362
|
+
let res;
|
|
363
|
+
if (!tool) res = { result: { ошибка: `нет инструмента ${name}` }, summary: `Неизвестный инструмент ${name}`, ok: false };
|
|
364
|
+
else if (!mObj.allowed(tool)) res = { result: { ошибка: 'это действие сейчас недоступно' }, summary: 'Недоступно', ok: false };
|
|
365
|
+
else if (args === null) res = { result: { ошибка: 'не удалось разобрать аргументы (нужен JSON)' }, summary: 'Ошибка в аргументах', ok: false };
|
|
366
|
+
else {
|
|
367
|
+
setToolStatus(msg.id, call.id, { status: 'running' });
|
|
368
|
+
emitState();
|
|
369
|
+
try {
|
|
370
|
+
res = await tool.run(args, {
|
|
371
|
+
convId: conv().id,
|
|
372
|
+
onRunStart: (runId) => setToolStatus(msg.id, call.id, { runId }),
|
|
373
|
+
});
|
|
374
|
+
} catch (e) {
|
|
375
|
+
res = { result: { ошибка: e.message }, summary: `Ошибка: ${e.message}`, ok: false };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const ok = res.ok !== false;
|
|
379
|
+
const toolMsg = addMessage(conv().id, {
|
|
380
|
+
role: 'tool', kind: 'tool', tool_call_id: call.id,
|
|
381
|
+
content: JSON.stringify(res.result).slice(0, 12000),
|
|
382
|
+
data: { name, summary: res.summary, ok, runId: res.runId },
|
|
383
|
+
});
|
|
384
|
+
emitMsg(toolMsg);
|
|
385
|
+
setToolStatus(msg.id, call.id, { status: ok ? 'done' : 'error', summary: res.summary, runId: res.runId });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Обрабатывает вызовы инструментов сообщения по порядку. Возвращает 'waiting', если нужно подтверждение
|
|
389
|
+
async function processCalls(msg, mode) {
|
|
390
|
+
const done = new Set(getMessages(conv().id, 400).filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
|
|
391
|
+
const mObj = MODES[mode] || MODES.advisor;
|
|
392
|
+
for (const call of msg.tool_calls || []) {
|
|
393
|
+
if (done.has(call.id)) continue;
|
|
394
|
+
if (userState().run?.ctrl.signal.aborted) return 'aborted';
|
|
395
|
+
const tool = TOOLS[call.function.name];
|
|
396
|
+
const st = getMessage(msg.id).data?.tools?.[call.id]?.status;
|
|
397
|
+
if (tool && mObj.confirm?.(tool) && st !== 'approved') {
|
|
398
|
+
const args = parseArgs(call) || {};
|
|
399
|
+
setToolStatus(msg.id, call.id, { status: 'pending', preview: tool.preview ? tool.preview(args) : '' });
|
|
400
|
+
userState().waiting = { msgId: msg.id, callId: call.id };
|
|
401
|
+
return 'waiting';
|
|
402
|
+
}
|
|
403
|
+
await execTool(msg, call, mode);
|
|
404
|
+
}
|
|
405
|
+
return 'done';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function loop(mode = 'advisor') {
|
|
409
|
+
const ctrl = userState().run.ctrl;
|
|
410
|
+
if (!activeModelInfo().tools) mode = 'discuss';
|
|
411
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
412
|
+
if (ctrl.signal.aborted) return;
|
|
413
|
+
const messages = [{ role: 'system', content: systemPrompt(mode) }, ...buildContext(conv().id)];
|
|
414
|
+
let msg = addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: '', reasoning: '', data: { streaming: true } });
|
|
415
|
+
emitMsg(msg);
|
|
416
|
+
let r;
|
|
417
|
+
for (let attempt = 1; ; attempt++) {
|
|
418
|
+
try {
|
|
419
|
+
r = await chatStream(messages, {
|
|
420
|
+
tools: activeModelInfo().tools ? toolSchemas(mode) : [],
|
|
421
|
+
maxTokens: 12000,
|
|
422
|
+
temperature: 0.3,
|
|
423
|
+
signal: ctrl.signal,
|
|
424
|
+
onDelta: (d) => hub.emit({ type: 'chat:delta', id: msg.id, ...d }),
|
|
425
|
+
});
|
|
426
|
+
break;
|
|
427
|
+
} catch (e) {
|
|
428
|
+
if (!ctrl.signal.aborted && attempt < 3 && !/HTTP 4\d\d/.test(e.message) && !e.fatal) {
|
|
429
|
+
msg = updateMessage(msg.id, { content: '', reasoning: '', data: { streaming: true, retry: attempt } });
|
|
430
|
+
emitMsg(msg, 'chat:update');
|
|
431
|
+
await new Promise((res) => setTimeout(res, 1500 * attempt));
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (ctrl.signal.aborted) {
|
|
435
|
+
msg = updateMessage(msg.id, { content: '', data: { hidden: true } });
|
|
436
|
+
} else {
|
|
437
|
+
msg = updateMessage(msg.id, { content: `⚠️ ИИ не ответил: ${e.message}`, data: { error: true } });
|
|
438
|
+
}
|
|
439
|
+
emitMsg(msg, 'chat:update');
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
let content = r.content.trim();
|
|
444
|
+
let calls = r.toolCalls;
|
|
445
|
+
if (!calls.length && content.includes('<tool_call>')) {
|
|
446
|
+
const p = parseTextToolCalls(content);
|
|
447
|
+
calls = p.calls;
|
|
448
|
+
content = p.cleaned;
|
|
449
|
+
}
|
|
450
|
+
msg = updateMessage(msg.id, { content, reasoning: r.reasoning, tool_calls: calls.length ? calls : null, data: { tools: {} } });
|
|
451
|
+
emitMsg(msg, 'chat:update');
|
|
452
|
+
if (!calls.length) {
|
|
453
|
+
if (!content && r.finishReason === 'length') {
|
|
454
|
+
msg = updateMessage(msg.id, { content: '⚠️ ИИ не уложился в лимит длины ответа. Попросите ответить короче.' });
|
|
455
|
+
emitMsg(msg, 'chat:update');
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const res = await processCalls(msg, mode);
|
|
460
|
+
if (res !== 'done') return;
|
|
461
|
+
}
|
|
462
|
+
const m = addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: '⚠️ Слишком много шагов подряд — остановился. Напишите, продолжать ли.' });
|
|
463
|
+
emitMsg(m);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function runAgent(mode = 'advisor') {
|
|
467
|
+
if (userState().run) throw new Error('ИИ уже работает');
|
|
468
|
+
if (!isLlmEnabled()) throw new Error('ИИ не подключён: выберите модель в разделе «Модели ИИ»');
|
|
469
|
+
const myConvId = conv().id;
|
|
470
|
+
const myRun = { ctrl: new AbortController(), mode, convId: myConvId };
|
|
471
|
+
userState().run = myRun;
|
|
472
|
+
emitState();
|
|
473
|
+
try {
|
|
474
|
+
await loop(mode);
|
|
475
|
+
} catch (e) {
|
|
476
|
+
console.error(e);
|
|
477
|
+
if (conv().id === myConvId) {
|
|
478
|
+
emitMsg(addMessage(myConvId, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
|
|
479
|
+
}
|
|
480
|
+
} finally {
|
|
481
|
+
if (userState().run === myRun) userState().run = null;
|
|
482
|
+
try { exportConversationMarkdown(myConvId); } catch { /* ignore */ }
|
|
483
|
+
emitState();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------- публичные действия ----------
|
|
488
|
+
export function sendUserMessage(text, mode = 'advisor', { newSession = false, title = null } = {}) {
|
|
489
|
+
text = String(text || '').trim();
|
|
490
|
+
if (!text) throw new Error('Пустое сообщение');
|
|
491
|
+
if (newSession) {
|
|
492
|
+
resetConversation(title || deriveSessionTitle(text));
|
|
493
|
+
}
|
|
494
|
+
if (userState().run) throw new Error('ИИ ещё работает — дождитесь или нажмите «Стоп»');
|
|
495
|
+
if (userState().waiting) rejectPending('Пользователь не стал подтверждать и написал сообщение (см. ниже).');
|
|
496
|
+
const cur = conv();
|
|
497
|
+
if (!cur.title) {
|
|
498
|
+
const autoTitle = title || deriveSessionTitle(text);
|
|
499
|
+
userState().conv = setConversationTitle(cur.id, autoTitle) || { ...cur, title: autoTitle };
|
|
500
|
+
}
|
|
501
|
+
const m = addMessage(conv().id, { role: 'user', kind: 'user', content: text });
|
|
502
|
+
emitMsg(m);
|
|
503
|
+
try { exportConversationMarkdown(conv().id); } catch { /* ignore */ }
|
|
504
|
+
runAgent(mode || 'advisor').catch(() => {});
|
|
505
|
+
return m;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function rejectPending(comment) {
|
|
509
|
+
const w = userState().waiting;
|
|
510
|
+
if (!w) return;
|
|
511
|
+
userState().waiting = null;
|
|
512
|
+
const msg = getMessage(w.msgId);
|
|
513
|
+
for (const call of msg.tool_calls || []) {
|
|
514
|
+
const st = msg.data?.tools?.[call.id]?.status;
|
|
515
|
+
if (st === 'done' || st === 'error') continue;
|
|
516
|
+
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 } });
|
|
517
|
+
emitMsg(tm);
|
|
518
|
+
setToolStatus(msg.id, call.id, { status: 'rejected' });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function resolveAction(msgId, callId, decision, comment = '') {
|
|
523
|
+
const w = userState().waiting;
|
|
524
|
+
if (!w || w.msgId !== msgId || w.callId !== callId) throw new Error('Это действие уже не ожидает подтверждения');
|
|
525
|
+
if (userState().run) throw new Error('ИИ ещё работает');
|
|
526
|
+
const mode = 'advisor';
|
|
527
|
+
if (decision === 'approve') {
|
|
528
|
+
userState().waiting = null;
|
|
529
|
+
setToolStatus(msgId, callId, { status: 'approved' });
|
|
530
|
+
} else {
|
|
531
|
+
const msg = getMessage(msgId);
|
|
532
|
+
userState().waiting = null;
|
|
533
|
+
const call = msg.tool_calls.find((c) => c.id === callId);
|
|
534
|
+
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 } });
|
|
535
|
+
emitMsg(tm);
|
|
536
|
+
setToolStatus(msgId, callId, { status: 'rejected' });
|
|
537
|
+
}
|
|
538
|
+
if (!isLlmEnabled()) throw new Error('ИИ не подключён');
|
|
539
|
+
const myConvId = conv().id;
|
|
540
|
+
const myRun = { ctrl: new AbortController(), mode, convId: myConvId };
|
|
541
|
+
userState().run = myRun;
|
|
542
|
+
emitState();
|
|
543
|
+
(async () => {
|
|
544
|
+
try {
|
|
545
|
+
const res = await processCalls(getMessage(msgId), mode);
|
|
546
|
+
if (res === 'done') await loop(mode);
|
|
547
|
+
} catch (e) {
|
|
548
|
+
if (conv().id === myConvId) {
|
|
549
|
+
emitMsg(addMessage(myConvId, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
|
|
550
|
+
}
|
|
551
|
+
} finally {
|
|
552
|
+
if (userState().run === myRun) userState().run = null;
|
|
553
|
+
try { exportConversationMarkdown(myConvId); } catch { /* ignore */ }
|
|
554
|
+
emitState();
|
|
555
|
+
}
|
|
556
|
+
})();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function stopAgent() {
|
|
560
|
+
if (userState().run) {
|
|
561
|
+
userState().run.ctrl.abort();
|
|
562
|
+
userState().run = null;
|
|
563
|
+
}
|
|
564
|
+
if (userState().waiting) rejectPending('Пользователь остановил ИИ');
|
|
565
|
+
emitState();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Быстрые сокращения и команды из мессенджеров (Telegram / MAX)
|
|
569
|
+
function expandMessengerPrompt(raw = '') {
|
|
570
|
+
const s = String(raw || '').trim();
|
|
571
|
+
let newSession = false;
|
|
572
|
+
let body = s;
|
|
573
|
+
if (/^\/new(?:\s+|$)/i.test(body)) {
|
|
574
|
+
newSession = true;
|
|
575
|
+
body = body.replace(/^\/new\s*/i, '').trim() || 'Посмотри текущий прогноз и состояние моих брокерских счетов: что сейчас важно?';
|
|
576
|
+
} else if (/^(\/forecast|прогноз|радар)$/i.test(body)) {
|
|
577
|
+
newSession = true;
|
|
578
|
+
body = 'Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас сильнее и слабее рынка и есть ли сигналы?';
|
|
579
|
+
} else if (/^(\/portfolio|\/status|портфель|счет|счёт|счета|баланс)$/i.test(body)) {
|
|
580
|
+
newSession = true;
|
|
581
|
+
body = 'Проверь состояние моих брокерских счетов прямо сейчас (get_brokers_status) и расскажи по позициям, свободным рублям, марже и активным заявкам.';
|
|
582
|
+
} else if (/^(\/today|сегодня|сделки)$/i.test(body)) {
|
|
583
|
+
newSession = true;
|
|
584
|
+
body = 'Посмотри операции и сделки на моём брокерском счёте за сегодня (get_broker_operations с period="today") и разбери финансовый результат.';
|
|
585
|
+
} else if (/^(\/news|новости)$/i.test(body)) {
|
|
586
|
+
newSession = true;
|
|
587
|
+
body = 'Посмотри свежие новости по рынку (get_news) и скажи, на какие бумаги они влияют.';
|
|
588
|
+
}
|
|
589
|
+
return { text: body, newSession };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function collectAssistantReply(convId, afterMsgId) {
|
|
593
|
+
const msgs = getMessages(convId, 200).filter((m) => m.id > afterMsgId);
|
|
594
|
+
const parts = [];
|
|
595
|
+
for (const m of msgs) {
|
|
596
|
+
if (m.role === 'assistant' && m.content && !m.data?.hidden) {
|
|
597
|
+
parts.push(m.content.trim());
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const w = userState().waiting;
|
|
601
|
+
if (w) {
|
|
602
|
+
const wMsg = getMessage(w.msgId);
|
|
603
|
+
const prev = wMsg?.data?.tools?.[w.callId]?.preview;
|
|
604
|
+
if (prev) parts.push(`\n⏳ Подтвердите действие кнопкой ниже:\n${prev}`);
|
|
605
|
+
}
|
|
606
|
+
const replyText = parts.join('\n\n').trim() || 'Готово.';
|
|
607
|
+
return { text: replyText, waiting: w || null };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Запрос к Стратегу из Telegram / MAX с ожиданием полного ответа (и поддержкой всех инструментов)
|
|
611
|
+
export async function askFromMessenger(rawText, { channel = 'tg', forceNewSession = false } = {}) {
|
|
612
|
+
const { text, newSession: cmdNew } = expandMessengerPrompt(rawText);
|
|
613
|
+
if (!text) throw new Error('Пустое сообщение');
|
|
614
|
+
// Если в текущем диалоге последнее сообщение было больше 45 минут назад — начинаем свежую сессию в архиве
|
|
615
|
+
const curMsgs = getMessages(conv().id, 20);
|
|
616
|
+
const lastMsgTs = curMsgs.length ? (curMsgs[curMsgs.length - 1].ts || 0) : 0;
|
|
617
|
+
const staleConv = curMsgs.some((m) => m.kind === 'user') && (Date.now() - lastMsgTs > 45 * 60000);
|
|
618
|
+
const newSession = Boolean(forceNewSession || cmdNew || staleConv);
|
|
619
|
+
const tag = channel === 'max' ? '[MAX]' : '[Telegram]';
|
|
620
|
+
if (userState().run) {
|
|
621
|
+
stopAgent();
|
|
622
|
+
}
|
|
623
|
+
if (newSession) {
|
|
624
|
+
resetConversation(`${tag} ${deriveSessionTitle(text)}`);
|
|
625
|
+
}
|
|
626
|
+
if (userState().waiting) {
|
|
627
|
+
rejectPending('Пользователь написал новое сообщение из мессенджера.');
|
|
628
|
+
}
|
|
629
|
+
const cur = conv();
|
|
630
|
+
if (!cur.title) {
|
|
631
|
+
const autoTitle = `${tag} ${deriveSessionTitle(text)}`;
|
|
632
|
+
userState().conv = setConversationTitle(cur.id, autoTitle) || { ...cur, title: autoTitle };
|
|
633
|
+
}
|
|
634
|
+
const m = addMessage(conv().id, { role: 'user', kind: 'user', content: text, data: { source: channel } });
|
|
635
|
+
emitMsg(m);
|
|
636
|
+
try { exportConversationMarkdown(conv().id); } catch { /* ignore */ }
|
|
637
|
+
await runAgent('advisor');
|
|
638
|
+
return collectAssistantReply(conv().id, m.id);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function resolveActionAndWait(msgId, callId, decision, comment = '') {
|
|
642
|
+
const w = userState().waiting;
|
|
643
|
+
if (!w || w.msgId !== msgId || w.callId !== callId) throw new Error('Это действие уже не ожидает подтверждения');
|
|
644
|
+
if (userState().run) stopAgent();
|
|
645
|
+
const mode = 'advisor';
|
|
646
|
+
const beforeMsgs = getMessages(conv().id, 200);
|
|
647
|
+
const lastId = beforeMsgs.length ? beforeMsgs[beforeMsgs.length - 1].id : msgId;
|
|
648
|
+
if (decision === 'approve') {
|
|
649
|
+
userState().waiting = null;
|
|
650
|
+
setToolStatus(msgId, callId, { status: 'approved' });
|
|
651
|
+
} else {
|
|
652
|
+
const msg = getMessage(msgId);
|
|
653
|
+
userState().waiting = null;
|
|
654
|
+
const call = msg.tool_calls.find((c) => c.id === callId);
|
|
655
|
+
const tm = addMessage(conv().id, { role: 'tool', kind: 'tool', tool_call_id: callId, content: JSON.stringify({ отклонено: true, комментарий: comment || 'пользователь отклонил в мессенджере' }), data: { name: call?.function.name, summary: 'Отклонено', ok: false } });
|
|
656
|
+
emitMsg(tm);
|
|
657
|
+
setToolStatus(msgId, callId, { status: 'rejected' });
|
|
658
|
+
}
|
|
659
|
+
if (!isLlmEnabled()) throw new Error('ИИ не подключён');
|
|
660
|
+
const myConvId = conv().id;
|
|
661
|
+
const myRun = { ctrl: new AbortController(), mode, convId: myConvId };
|
|
662
|
+
userState().run = myRun;
|
|
663
|
+
emitState();
|
|
664
|
+
try {
|
|
665
|
+
const res = await processCalls(getMessage(msgId), mode);
|
|
666
|
+
if (res === 'done') await loop(mode);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
if (conv().id === myConvId) {
|
|
669
|
+
emitMsg(addMessage(myConvId, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
|
|
670
|
+
}
|
|
671
|
+
} finally {
|
|
672
|
+
if (userState().run === myRun) userState().run = null;
|
|
673
|
+
try { exportConversationMarkdown(myConvId); } catch { /* ignore */ }
|
|
674
|
+
emitState();
|
|
675
|
+
}
|
|
676
|
+
return collectAssistantReply(myConvId, lastId);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function setMode() {
|
|
680
|
+
kvSet('strategistMode', 'advisor');
|
|
681
|
+
emitState();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function resetConversation(title = null) {
|
|
685
|
+
stopAgent();
|
|
686
|
+
if (userState().conv?.id) {
|
|
687
|
+
try { exportConversationMarkdown(userState().conv.id); } catch { /* ignore */ }
|
|
688
|
+
}
|
|
689
|
+
userState().conv = newConversation(title);
|
|
690
|
+
userState().waiting = null;
|
|
691
|
+
hub.emit({ type: 'chat:reset', state: chatState() });
|
|
692
|
+
return userState().conv;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function listArchivedSessions(limit = 100) {
|
|
696
|
+
const rows = listConversations(limit);
|
|
697
|
+
return rows.map((r) => {
|
|
698
|
+
const title = r.title || deriveSessionTitle(r.first_user || '');
|
|
699
|
+
const tsMsk = new Date((r.created_at || Date.now()) + MSK).toISOString();
|
|
700
|
+
const fileName = `${tsMsk.slice(0, 10)}_${tsMsk.slice(11, 16).replace(':', '-')}_${safeFileSlug(title)}_#${r.id}.md`;
|
|
701
|
+
return {
|
|
702
|
+
id: r.id,
|
|
703
|
+
title,
|
|
704
|
+
createdAt: r.created_at,
|
|
705
|
+
updatedAt: r.updated_at || r.created_at,
|
|
706
|
+
timeLabel: fmtTs(r.created_at),
|
|
707
|
+
msgCount: r.msg_count,
|
|
708
|
+
fileName,
|
|
709
|
+
active: r.id === conv().id,
|
|
710
|
+
};
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function openArchivedSession(id) {
|
|
715
|
+
stopAgent();
|
|
716
|
+
const c = getConversation(Number(id));
|
|
717
|
+
if (!c) throw new Error('Сессия не найдена');
|
|
718
|
+
userState().conv = c;
|
|
719
|
+
userState().waiting = null;
|
|
720
|
+
const info = exportConversationMarkdown(c.id);
|
|
721
|
+
const st = chatState();
|
|
722
|
+
const messages = getMessages(c.id, 400);
|
|
723
|
+
hub.emit({ type: 'chat:reset', state: st, messages });
|
|
724
|
+
return { state: st, messages, archive: info };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function history() {
|
|
728
|
+
return { state: chatState(), messages: getMessages(conv().id, 300) };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------- события системы → лента ----------
|
|
732
|
+
function addEvent(content, display, data = {}) {
|
|
733
|
+
const m = addMessage(conv().id, { role: 'user', kind: 'event', content: `[Событие] ${content}`, data: { display, ...data } });
|
|
734
|
+
emitMsg(m);
|
|
735
|
+
return m;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
hub.on((e) => {
|
|
739
|
+
try {
|
|
740
|
+
if (e.type === 'research:done' && e.source === 'user') {
|
|
741
|
+
const c = e.metrics.model?.comparison || {};
|
|
742
|
+
const f = c.attention?.full;
|
|
743
|
+
addEvent(
|
|
744
|
+
`Пользователь провёл исследование модели №${e.runId}: итог ${fmtPct(f?.returnPct)}, на 1 ₽ убытка ${f?.profitFactor ?? '—'}, ${f?.trades ?? 0} сделок, рынок ${fmtPct(f?.marketPct)}.`,
|
|
745
|
+
`Исследование №${e.runId}: итог ${fmtPct(f?.returnPct)} (рынок ${fmtPct(f?.marketPct)})`,
|
|
746
|
+
{ eventType: 'model', runId: e.runId, ok: (f?.returnPct ?? 0) > 0 },
|
|
747
|
+
);
|
|
748
|
+
} else if (e.type === 'settings:update' && e.source === 'user' && Object.keys(e.changed || {}).length) {
|
|
749
|
+
addEvent(`Пользователь изменил настройки: ${Object.entries(e.changed).map(([k, v]) => `${k}: ${v.было} → ${v.стало}`).join('; ')}.`, null, { eventType: 'settings', hidden: true });
|
|
750
|
+
}
|
|
751
|
+
} catch (err) {
|
|
752
|
+
console.error('strategist event', err);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
registerChatHandlers({ ask: askFromMessenger, resolve: resolveActionAndWait });
|
|
757
|
+
|
|
758
|
+
export { fmtTs, buildContext, systemPrompt };
|