neuroalpha 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,303 +1,486 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import { PARAMS, PARAM_MAP } from '../params-meta.js';
3
- import { getSettings, updateSettings, validateChanges } from '../settings.js';
4
- import { getRun, listRuns, addJournal, listJournal, kvGet, kvSet } from '../db.js';
5
- import { analyzeRun, compareRuns, recentRuns, runSummary, compactMetrics } from './analytics.js';
6
- import { hub } from '../hub.js';
7
- import { startModelResearch } from '../model/research.js';
8
- import { forecastNow } from '../model/forecast.js';
9
- import { live } from '../model/live.js';
10
- import { listNews } from '../news/index.js';
11
- import { plural, fmtBarMsk } from '../util.js';
12
-
13
- export const OOS_LIMIT = 3;
14
- const AI_LOCKED = PARAMS.filter((p) => p.ai === false).map((p) => p.key);
15
-
16
- const changesSchema = {
17
- type: 'object',
18
- description: 'Изменения настроек: ключ — точное название настройки из списка в системной инструкции (например modelHorizon, modelMinEdge), значение — новое значение.',
19
- properties: Object.fromEntries(PARAMS.filter((p) => p.ai !== false && !p.hidden && !p.fixed).map((p) => [p.key, {
20
- type: p.type === 'checkbox' ? 'boolean' : p.type === 'select' ? 'string' : 'number',
21
- description: `${p.label}${p.min !== undefined ? ` (${p.min}–${p.max})` : ''}`,
22
- }])),
23
- additionalProperties: false,
24
- };
25
-
26
- function checkChanges(changes) {
27
- const locked = Object.keys(changes || {}).filter((k) => AI_LOCKED.includes(k));
28
- const { values, errors } = validateChanges(changes);
29
- for (const k of locked) { errors.push(`${k}: эту настройку ИИ менять нельзя (она отвечает за честность проверки)`); delete values[k]; }
30
- return { values, errors };
31
- }
32
-
33
- const describeChanges = (values) => Object.entries(values).map(([k, v]) => `${PARAM_MAP[k]?.label || k}: ${v}`).join('; ') || 'без изменений';
34
- const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
35
- const metricLine = (m) => (m && !m.trades ? 'сделок не было' : m ? `${m.trades} ${plural(m.trades, 'сделка', 'сделки', 'сделок')}, итог ${fmtPct(m.returnPct)}, на 1 ₽ убытка ${m.profitFactor ?? '—'} ₽, худшее падение ${m.maxDrawdownPct}%` : 'нет данных');
36
-
37
- // Последнее исследование с текущими настройками (база для сравнения)
38
- function findBaseline(excludeId) {
39
- const cur = getSettings();
40
- const keys = PARAMS.map((p) => p.key).filter((k) => !['capital', 'paperDailyReview', 'paperPollSec', 'autopilotBudget'].includes(k));
41
- return listRuns().find((r) => r.id !== excludeId && r.mode === 'model' && r.status === 'done'
42
- && keys.every((k) => JSON.stringify(r.params?.[k] ?? null) === JSON.stringify(cur[k] ?? null))) || null;
43
- }
44
-
45
- // kind: read — без подтверждения; write — подтверждение в режиме «Советник»; danger — подтверждение всегда;
46
- // confirmAlways — подтверждение во всех режимах (кроме read-режимов, где инструмент недоступен)
47
- export const TOOLS = {
48
- get_settings: {
49
- kind: 'read',
50
- label: 'Смотрит текущие настройки',
51
- description: 'Текущие настройки модели и счёта с пояснениями.',
52
- parameters: { type: 'object', properties: {} },
53
- async run() {
54
- const s = getSettings();
55
- return {
56
- result: Object.fromEntries(PARAMS.map((p) => [p.key, { значение: s[p.key], что_это: p.label, пояснение: p.hint, ...(p.ai === false ? { менять_нельзя: true } : {}) }])),
57
- summary: 'Прочитал текущие настройки',
58
- };
59
- },
60
- },
61
-
62
- list_runs: {
63
- kind: 'read',
64
- label: 'Смотрит список исследований',
65
- description: 'Последние исследования модели и живые проверки с результатами на периоде настройки.',
66
- parameters: { type: 'object', properties: { limit: { type: 'number', description: 'сколько последних, по умолчанию 10' } } },
67
- async run({ limit = 10 }) {
68
- return { result: recentRuns(Math.min(30, limit)), summary: `Просмотрел последние исследования` };
69
- },
70
- },
71
-
72
- analyze_run: {
73
- kind: 'read',
74
- label: 'Разбирает исследование',
75
- description: 'Подробный разбор исследования: сравнение моделей, сделки торгующей модели по акциям, часу входа, сроку удержания, веса голов внимания. Только период настройки.',
76
- parameters: { type: 'object', properties: { run_id: { type: 'number', description: 'номер исследования' } }, required: ['run_id'] },
77
- async run({ run_id }) {
78
- const r = analyzeRun(Number(run_id));
79
- return { result: r, summary: r.ошибка || `Разобрал исследование №${run_id}` };
80
- },
81
- },
82
-
83
- compare_runs: {
84
- kind: 'read',
85
- label: 'Сравнивает исследования',
86
- description: 'Сравнить два исследования: чем отличаются настройки и результаты на периоде настройки.',
87
- parameters: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] },
88
- async run({ a, b }) {
89
- return { result: compareRuns(Number(a), Number(b)), summary: `Сравнил исследования №${a} и №${b}` };
90
- },
91
- },
92
-
93
- run_model_research: {
94
- kind: 'write',
95
- budget: true,
96
- label: 'Исследует модель',
97
- description: 'Пошаговая проверка трёх моделей предсказания «следующего состояния рынка» (~100 акций, часовые свечи): своя инерция, кто за кем ходит, внимание через карту рынка (и трансформер, если modelUseTransformer=true — тогда в разы дольше). Модель учится на окне прошлого, прогнозирует следующий месяц, окно сдвигается. Возвращает сравнение моделей на периоде настройки и базу для сравнения. Можно передать изменения настроек (сами настройки не меняются). Около минуты.',
98
- parameters: { type: 'object', properties: { changes: changesSchema, note: { type: 'string', description: 'что проверяем (гипотеза)' } }, required: ['note'] },
99
- preview(args) {
100
- const { values, errors } = checkChanges(args.changes);
101
- return `${args.note || 'Исследование модели'}\nИзменения: ${describeChanges(values)}${errors.length ? `\nОшибки: ${errors.join('; ')}` : ''}`;
102
- },
103
- async run(args, ctx) {
104
- const { values, errors } = checkChanges(args.changes);
105
- if (errors.length) return { result: { ошибка: errors }, summary: `Ошибка в изменениях: ${errors.join('; ')}`, ok: false };
106
- const { runId, done, reused } = startModelResearch({ ...getSettings(), ...values }, { source: 'ai', note: args.note || '' });
107
- ctx.onRunStart?.(runId);
108
- try { await done; } catch (e) {
109
- return { result: { исследование: runId, ошибка: e.message }, summary: `Исследование №${runId}: ${e.message}`, ok: false };
110
- }
111
- const run = getRun(runId);
112
- const base = findBaseline(runId);
113
- const c = run.metrics?.model?.comparison?.attention?.inSample;
114
- return {
115
- result: {
116
- ...runSummary(run),
117
- изменения: values,
118
- ...(reused ? { повтор: `результат взят из исследования №${runId} с теми же настройками` } : {}),
119
- база_для_сравнения: base ? runSummary(base) : 'нет исследования с текущими настройками без изменений — для честного сравнения запусти run_model_research без changes',
120
- },
121
- summary: reused
122
- ? `Исследование №${runId} (готовый результат): внимание через карту — совпадение ${c?.ic}, итог ${fmtPct(c?.returnPct)} (без комиссий ${fmtPct(c?.grossReturnPct)}), ${c?.trades} сделок (период настройки)`
123
- : `Исследование №${runId}: внимание через карту — совпадение ${c?.ic}, итог ${fmtPct(c?.returnPct)} (без комиссий ${fmtPct(c?.grossReturnPct)}), ${c?.trades} сделок (период настройки)`,
124
- runId,
125
- };
126
- },
127
- },
128
-
129
- model_forecast: {
130
- kind: 'read',
131
- label: 'Смотрит прогноз на ближайшие часы',
132
- description: 'Прогноз модели по последней закрытой свече: какие акции, по её мнению, будут сильнее или слабее рынка в ближайшие часы и под чьим влиянием (соседи по карте).',
133
- parameters: { type: 'object', properties: { top: { type: 'number', description: 'сколько акций показать, по умолчанию 10' } } },
134
- async run({ top = 10 }) {
135
- const r = await forecastNow(getSettings());
136
- const pick = (x) => ({ акция: x.symbol, ожидание_проц: x.expectedPct, сигнал_покупки: !!x.signal, ...(x.shortSignal !== undefined ? { сигнал_продажи: !!x.shortSignal } : {}), ликвидна: x.liquid, последний_час_проц: x.lastMovePct, влияют: x.influences.map((n) => `${n.symbol} (${n.contribPct > 0 ? '+' : ''}${n.contribPct}%, близость ${n.closeness})`), своё_движение_проц: x.selfPct });
137
- return {
138
- result: { свеча: r.bar, горизонт_часов: r.horizon, порог_покупки_проц: r.thresholdPct, сильнее_рынка: r.rows.slice(0, top).map(pick), слабее_рынка: r.rows.slice(-Math.min(5, top)).map(pick) },
139
- summary: `Прогноз на свече ${r.bar}: сигналов покупки ${r.rows.filter((x) => x.signal).length}`,
140
- };
141
- },
142
- },
143
-
144
- market_neighbors: {
145
- kind: 'read',
146
- label: 'Смотрит соседей на карте',
147
- description: 'Ближайшие соседи акции на карте рынка (кто движется похоже) и её группа — по последнему исследованию.',
148
- parameters: { type: 'object', properties: { symbol: { type: 'string' } }, required: ['symbol'] },
149
- async run({ symbol }) {
150
- const run = listRuns().find((r) => r.mode === 'model' && r.status === 'done');
151
- const ex = run && getRun(run.id).extra;
152
- if (!ex) return { result: { ошибка: 'нет исследования — запусти run_model_research' }, summary: 'Нет исследования', ok: false };
153
- const i = ex.symbols.indexOf(String(symbol).toUpperCase());
154
- if (i < 0) return { result: { ошибка: `${symbol} нет в словаре модели` }, summary: `${symbol} нет в словаре`, ok: false };
155
- const snap = ex.map.snapshots.at(-1);
156
- return {
157
- result: { акция: ex.symbols[i], соседи: ex.neighbors[i].map(([k, c]) => ({ акция: ex.symbols[k], близость: c })), группа_на_карте: ex.symbols.filter((_, k) => snap.cl[k] === snap.cl[i]) },
158
- summary: `Соседи ${ex.symbols[i]}: ${ex.neighbors[i].slice(0, 4).map(([k]) => ex.symbols[k]).join(', ')}`,
159
- };
160
- },
161
- },
162
-
163
- get_news: {
164
- kind: 'read',
165
- label: 'Смотрит свежие новости',
166
- description: 'Свежие новости и раскрытия эмитентов: каких акций из словаря касаются, тон −1…1 (плохо/хорошо для акции), важность 0…1 и короткая суть. Без symbol — все важные новости за период. Новости есть только «сейчас»: в исторических проверках они не используются.',
167
- parameters: {
168
- type: 'object',
169
- properties: {
170
- symbol: { type: 'string', description: 'тикер, например SBER; без него — все важные новости' },
171
- hours: { type: 'number', description: 'за сколько часов смотреть, по умолчанию 24' },
172
- },
173
- },
174
- async run({ symbol, hours = 24 }) {
175
- const items = listNews({ symbol, hours, limit: 25 });
176
- if (!items.length) return { result: { новостей: 'нет' }, summary: `Новостей ${symbol ? `по ${symbol}` : ''} за последние ${hours} ч нет` };
177
- return {
178
- result: items.map((n) => ({
179
- время: fmtBarMsk(n.ts), источник: n.src,
180
- ...(n.symbols.length ? { акции: n.symbols.map((s) => `${s.symbol} (тон ${s.tone ?? '—'})`) } : {}),
181
- ...(n.imp !== null ? { важность: n.imp } : {}), суть: n.gist || n.title, ссылка: n.link,
182
- })),
183
- summary: `Новости за ${hours} ч: ${items.length}${symbol ? ` по ${symbol}` : ''}`,
184
- };
185
- },
186
- },
187
-
188
- journal_list: {
189
- kind: 'read',
190
- label: 'Читает журнал гипотез',
191
- description: 'Полный журнал гипотез: что уже проверяли и к каким выводам пришли.',
192
- parameters: { type: 'object', properties: {} },
193
- async run() {
194
- return { result: listJournal(100), summary: 'Прочитал журнал гипотез' };
195
- },
196
- },
197
-
198
- journal_add: {
199
- kind: 'read',
200
- label: 'Записывает вывод в журнал',
201
- description: 'Записать в журнал гипотез результат эксперимента. Делай это после каждого законченного эксперимента.',
202
- parameters: {
203
- type: 'object',
204
- properties: {
205
- hypothesis: { type: 'string', description: 'что предполагали, простыми словами' },
206
- change: { type: 'string', description: 'что меняли' },
207
- run_ids: { type: 'array', items: { type: 'number' }, description: 'номера исследований: базовое и новое' },
208
- result: { type: 'string', description: 'что получилось, с цифрами и числом сделок' },
209
- verdict: { type: 'string', enum: ['confirmed', 'rejected', 'unclear'], description: 'подтвердилась / не подтвердилась / неясно' },
210
- },
211
- required: ['hypothesis', 'result', 'verdict'],
212
- },
213
- async run(args) {
214
- const id = addJournal({ ...args, source: 'ai' });
215
- hub.emit({ type: 'journal:update' });
216
- const v = { confirmed: 'подтвердилась', rejected: 'не подтвердилась', unclear: 'неясно' }[args.verdict] || args.verdict;
217
- return { result: { ok: true, id }, summary: `Записал в журнал: «${args.hypothesis}» — ${v}` };
218
- },
219
- },
220
-
221
- final_check: {
222
- kind: 'read',
223
- label: 'Смотрит контрольный период',
224
- description: `Открыть результат исследования на КОНТРОЛЬНОМ периоде (данные, которые не используются для подбора). Не больше ${OOS_LIMIT} раз за разговор — только для финальной оценки лучшего варианта против базы.`,
225
- parameters: { type: 'object', properties: { run_id: { type: 'number' } }, required: ['run_id'] },
226
- async run({ run_id }, ctx) {
227
- const key = `oos_used_${ctx.convId}`;
228
- const used = kvGet(key, []);
229
- if (!used.includes(Number(run_id)) && used.length >= OOS_LIMIT) {
230
- return { result: { ошибка: `лимит ${OOS_LIMIT} просмотров контрольного периода за разговор исчерпан` }, summary: 'Лимит просмотров контрольного периода исчерпан', ok: false };
231
- }
232
- const run = getRun(Number(run_id));
233
- if (!run) return { result: { ошибка: 'нет такого исследования' }, summary: 'Нет такого исследования', ok: false };
234
- if (!used.includes(Number(run_id))) kvSet(key, [...used, Number(run_id)]);
235
- return {
236
- result: { ...runSummary(run, { revealOOS: true }), осталось_просмотров: OOS_LIMIT - kvGet(key, []).length },
237
- summary: `Контрольный период №${run_id}: ${metricLine(run.metrics?.outOfSample)}`,
238
- };
239
- },
240
- },
241
-
242
- set_settings: {
243
- kind: 'write',
244
- confirmAlways: true,
245
- label: 'Меняет настройки',
246
- description: 'Сохранить изменения в рабочие настройки (их видит пользователь и использует живая проверка). Только когда изменение подтверждено исследованиями.',
247
- parameters: { type: 'object', properties: { changes: changesSchema, reason: { type: 'string', description: 'почему' } }, required: ['changes', 'reason'] },
248
- preview(args) {
249
- const { values, errors } = checkChanges(args.changes);
250
- return `${args.reason || ''}\nИзменения: ${describeChanges(values)}${errors.length ? `\nОшибки: ${errors.join('; ')}` : ''}`;
251
- },
252
- async run(args) {
253
- const { values, errors } = checkChanges(args.changes);
254
- const r = updateSettings(values, { source: 'ai', reason: args.reason });
255
- const all = [...errors, ...r.errors];
256
- return {
257
- result: { изменено: r.changed, ошибки: all.length ? all : undefined },
258
- summary: Object.keys(r.changed).length ? `Настройки изменены: ${describeChanges(Object.fromEntries(Object.entries(r.changed).map(([k, v]) => [k, v.стало])))}` : `Настройки не изменились${all.length ? `: ${all.join('; ')}` : ''}`,
259
- ok: !all.length,
260
- };
261
- },
262
- },
263
-
264
- paper_start: {
265
- kind: 'danger',
266
- label: 'Запускает живую проверку',
267
- description: 'Запустить живую проверку модели (виртуальная торговля на текущих ценах) с текущими настройками.',
268
- parameters: { type: 'object', properties: { reason: { type: 'string' } } },
269
- preview: (args) => args.reason || 'Запустить живую проверку с текущими настройками',
270
- async run() {
271
- if (live.status().running) return { result: { ошибка: 'уже запущена' }, summary: 'Живая проверка уже запущена', ok: false };
272
- const runId = await live.start(getSettings(), { source: 'ai' });
273
- return { result: { ok: true, проверка: runId }, summary: `Живая проверка №${runId} запущена` };
274
- },
275
- },
276
-
277
- paper_stop: {
278
- kind: 'danger',
279
- label: 'Останавливает живую проверку',
280
- description: 'Остановить живую проверку.',
281
- parameters: { type: 'object', properties: { reason: { type: 'string' } } },
282
- preview: (args) => args.reason || 'Остановить живую проверку',
283
- async run() {
284
- const st = live.status();
285
- if (!st.running) return { result: { ошибка: 'не запущена' }, summary: 'Живая проверка не запущена', ok: false };
286
- live.stop();
287
- return { result: { ok: true, итог: compactMetrics(getRun(st.runId)?.metrics?.full) }, summary: `Живая проверка №${st.runId} остановлена` };
288
- },
289
- },
290
- };
291
-
292
- export const MODES = {
293
- discuss: { label: 'Обсуждение', allowed: (t) => t.kind === 'read' },
294
- advisor: { label: 'Советник', allowed: () => true, confirm: (t) => t.kind !== 'read' },
295
- autopilot: { label: 'Автопилот', allowed: () => true, confirm: (t) => t.kind === 'danger' || !!t.confirmAlways },
296
- review: { label: 'Разбор дня', allowed: (t) => t.kind === 'read' },
297
- };
298
-
299
- export function toolSchemas(mode) {
300
- return Object.entries(TOOLS)
301
- .filter(([, t]) => MODES[mode].allowed(t))
302
- .map(([name, t]) => ({ type: 'function', function: { name, description: t.description, parameters: t.parameters } }));
303
- }
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import { PARAMS, PARAM_MAP } from '../params-meta.js';
3
+ import { getSettings, updateSettings, validateChanges } from '../settings.js';
4
+ import { getRun, listRuns } from '../db.js';
5
+ import { analyzeRun, compareRuns, recentRuns } from './analytics.js';
6
+ import { forecastNow } from '../model/forecast.js';
7
+ import { listNews } from '../news/index.js';
8
+ import { BROKER_META, brokersState, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders } from '../brokers/index.js';
9
+ import { fmtBarMsk } from '../util.js';
10
+
11
+ export const OOS_LIMIT = 3;
12
+ const AI_LOCKED = PARAMS.filter((p) => p.ai === false).map((p) => p.key);
13
+
14
+ const changesSchema = {
15
+ type: 'object',
16
+ description: 'Изменения настроек: ключ — точное название настройки из списка в системной инструкции (например modelShortFeePct, newsFilter), значение — новое значение.',
17
+ properties: Object.fromEntries(PARAMS.filter((p) => p.ai !== false && !p.hidden && !p.fixed).map((p) => [p.key, {
18
+ type: p.type === 'checkbox' ? 'boolean' : p.type === 'select' ? 'string' : 'number',
19
+ description: `${p.label}${p.min !== undefined ? ` (${p.min}–${p.max})` : ''}`,
20
+ }])),
21
+ additionalProperties: false,
22
+ };
23
+
24
+ function checkChanges(changes) {
25
+ const locked = Object.keys(changes || {}).filter((k) => AI_LOCKED.includes(k));
26
+ const { values, errors } = validateChanges(changes);
27
+ for (const k of locked) { errors.push(`${k}: эту настройку ИИ менять нельзя`); delete values[k]; }
28
+ return { values, errors };
29
+ }
30
+
31
+ const describeChanges = (values) => Object.entries(values).map(([k, v]) => `${PARAM_MAP[k]?.label || k}: ${v}`).join('; ') || 'без изменений';
32
+
33
+ export const TOOLS = {
34
+ get_brokers_status: {
35
+ kind: 'read',
36
+ label: 'Смотрит брокерские счета и портфель',
37
+ description: 'Текущее состояние подключённых брокерских счетов пользователя: баланс, свободные рубли, открытые позиции, доход, активные заявки в стакане, подготовленный пакет заявок по прогнозу и настройки предохранителей.',
38
+ parameters: { type: 'object', properties: {} },
39
+ async run() {
40
+ const st = brokersState();
41
+ const accs = (st.accounts || []).map((a) => ({
42
+ id: a.id,
43
+ название: a.name,
44
+ брокер: a.broker,
45
+ субсчёт: a.accountId || '—',
46
+ режим_работы: a.mode,
47
+ песочница: Boolean(a.sandbox),
48
+ режим_старого_портфеля: a.legacyMode || 'isolate',
49
+ лимит_на_стратегию_руб: a.maxCapitalRub,
50
+ дневной_стоп_проц: a.maxDailyDrawdownPct,
51
+ отступ_лимитки_проц: a.limitOffsetPct,
52
+ пауза_причина: a.haltedReason || null,
53
+ баланс_руб: a.lastSync?.totalRub ?? null,
54
+ свободные_рубли: a.lastSync?.cashRub ?? null,
55
+ маржинальная_торговля: a.lastSync?.marginEnabled ?? null,
56
+ позиции: (a.lastSync?.positions || []).map((p) => ({
57
+ тикер: p.symbol,
58
+ название: p.name,
59
+ кол_во_шт: p.qty,
60
+ средняя_цена: p.avgPrice,
61
+ текущая_цена: p.price,
62
+ стоимость_руб: p.valueRub,
63
+ доход_руб: p.pnlRub,
64
+ })),
65
+ активные_заявки_в_стакане: a.lastSync?.activeOrders || [],
66
+ подготовленный_пакет_заявок: a.pendingBatch ? {
67
+ свеча: a.pendingBatch.bar,
68
+ статус: a.pendingBatch.status,
69
+ рабочий_бюджет_руб: a.pendingBatch.budgetRub,
70
+ свободно_под_вход_руб: a.pendingBatch.availRub,
71
+ направление: a.pendingBatch.effectiveDirection,
72
+ диагностика: a.pendingBatch.diagNote || null,
73
+ тарифная_заметка: a.pendingBatch.planThrottleNote || null,
74
+ заявки: a.pendingBatch.orders || [],
75
+ } : null,
76
+ }));
77
+ return {
78
+ result: { подключено_счетов: accs.length, счета: accs },
79
+ summary: accs.length ? `Проверил брокерские счета (${accs.length})` : 'Брокерские счета пока не подключены',
80
+ };
81
+ },
82
+ },
83
+
84
+ model_forecast: {
85
+ kind: 'read',
86
+ label: 'Смотрит прогноз на ближайшие часы',
87
+ description: 'Живой прогноз модели по ~100 акциям Мосбиржи: какие акции ожидаются сильнее или слабее рынка в ближайшие 1,5–3,5 часа, пороги входа в лонг и шорт, а также цепочка влияния соседей по карте рынка.',
88
+ parameters: { type: 'object', properties: { top: { type: 'number', description: 'сколько акций показать, по умолчанию 10' } } },
89
+ async run({ top = 10 }) {
90
+ const r = await forecastNow(getSettings());
91
+ const pick = (x) => ({
92
+ акция: x.symbol,
93
+ название: x.name,
94
+ цена_руб: x.price,
95
+ ожидание_проц: x.expectedPct,
96
+ динамика_проц: x.deltaPct || 0,
97
+ сигнал_покупки: !!x.signal,
98
+ ...(x.shortSignal !== undefined ? { сигнал_шорта: !!x.shortSignal } : {}),
99
+ ...(x.shortBlockedByBroker ? { шорт_запрещён_брокером: true } : {}),
100
+ ликвидна: x.liquid,
101
+ влияют_соседи: (x.influences || []).map((n) => `${n.symbol} (${n.contribPct > 0 ? '+' : ''}${n.contribPct}%, близость ${n.closeness})`),
102
+ });
103
+ return {
104
+ result: {
105
+ свеча: r.bar,
106
+ горизонт_часов: r.horizon,
107
+ порог_покупки_проц: r.buyThresholdPct ?? r.thresholdPct,
108
+ порог_шорта_проц: r.shortThresholdPct ?? r.thresholdPct,
109
+ тренд_индекса: r.marketDown ? 'падающий (облегчён шорт, ужесточён лонг)' : 'растущий (стандартный лонг, ужесточён шорт)',
110
+ сильнее_рынка: r.rows.slice(0, top).map(pick),
111
+ слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
112
+ },
113
+ summary: `Прогноз на свече ${r.bar}: сигналов покупки ${r.rows.filter((x) => x.signal).length}, шорта ${r.rows.filter((x) => x.shortSignal).length}`,
114
+ };
115
+ },
116
+ },
117
+
118
+ get_news: {
119
+ kind: 'read',
120
+ label: 'Смотрит свежие новости',
121
+ description: 'Свежие новости и раскрытия эмитентов Мосбиржи: каких акций касаются, тон −1…+1 (негатив/позитив), важность 0…1 и краткая суть.',
122
+ parameters: {
123
+ type: 'object',
124
+ properties: {
125
+ symbol: { type: 'string', description: 'тикер, например SBER; без него — все важные новости по рынку' },
126
+ hours: { type: 'number', description: 'за сколько часов смотреть, по умолчанию 24' },
127
+ },
128
+ },
129
+ async run({ symbol, hours = 24 }) {
130
+ const items = listNews({ symbol, hours, limit: 25 });
131
+ if (!items.length) return { result: { новостей: 'нет' }, summary: `Новостей ${symbol ? `по ${symbol}` : ''} за последние ${hours} ч нет` };
132
+ return {
133
+ result: items.map((n) => ({
134
+ время: fmtBarMsk(n.ts),
135
+ источник: n.src,
136
+ ...(n.symbols.length ? { акции: n.symbols.map((s) => `${s.symbol} (тон ${s.tone ?? '—'})`) } : {}),
137
+ ...(n.imp !== null ? { важность: n.imp } : {}),
138
+ суть: n.gist || n.title,
139
+ ссылка: n.link,
140
+ })),
141
+ summary: `Новости за ${hours} ч: ${items.length}${symbol ? ` по ${symbol}` : ''}`,
142
+ };
143
+ },
144
+ },
145
+
146
+ market_neighbors: {
147
+ kind: 'read',
148
+ label: 'Смотрит соседей акции на карте рынка',
149
+ description: 'Ближайшие соседи акции на спектральной карте рынка (какие бумаги движутся синхронно и передают импульс).',
150
+ parameters: { type: 'object', properties: { symbol: { type: 'string' } }, required: ['symbol'] },
151
+ async run({ symbol }) {
152
+ const symUp = String(symbol || '').toUpperCase().trim();
153
+ const fc = await forecastNow(getSettings()).catch(() => null);
154
+ const row = fc?.rows?.find((r) => r.symbol === symUp);
155
+ if (row?.influences?.length) {
156
+ return {
157
+ result: { акция: symUp, название: row.name, цена_руб: row.price, ожидание_проц: row.expectedPct, ближайшие_влияющие_соседи: row.influences },
158
+ summary: `Соседи ${symUp}: ${row.influences.slice(0, 4).map((x) => x.symbol).join(', ')}`,
159
+ };
160
+ }
161
+ const run = listRuns().find((r) => r.mode === 'model' && r.status === 'done');
162
+ const ex = run && getRun(run.id)?.extra;
163
+ if (!ex) return { result: { ошибка: 'нет данных по соседям' }, summary: 'Нет данных по соседям', ok: false };
164
+ const i = ex.symbols.indexOf(symUp);
165
+ if (i < 0) return { result: { ошибка: `${symUp} нет в словаре модели` }, summary: `${symUp} нет в словаре`, ok: false };
166
+ return {
167
+ result: { акция: ex.symbols[i], соседи: ex.neighbors[i].map(([k, c]) => ({ акция: ex.symbols[k], близость: c })) },
168
+ summary: `Соседи ${ex.symbols[i]}: ${ex.neighbors[i].slice(0, 4).map(([k]) => ex.symbols[k]).join(', ')}`,
169
+ };
170
+ },
171
+ },
172
+
173
+ get_settings: {
174
+ kind: 'read',
175
+ label: 'Смотрит настройки издержек и фильтра новостей',
176
+ description: 'Текущие настройки комиссий брокера, проскальзывания, ставки за шорт и новостного фильтра.',
177
+ parameters: { type: 'object', properties: {} },
178
+ async run() {
179
+ const s = getSettings();
180
+ const visible = PARAMS.filter((p) => !p.hidden);
181
+ return {
182
+ result: Object.fromEntries(visible.map((p) => [p.key, { значение: s[p.key], что_это: p.label, пояснение: p.hint }])),
183
+ summary: 'Прочитал настройки издержек и новостей',
184
+ };
185
+ },
186
+ },
187
+
188
+ set_settings: {
189
+ kind: 'write',
190
+ confirmAlways: true,
191
+ label: 'Меняет настройки',
192
+ description: 'Предложить изменение настроек в разделе «Издержки и новости» (с подтверждением пользователя кнопкой).',
193
+ parameters: { type: 'object', properties: { changes: changesSchema, reason: { type: 'string', description: 'пояснение для пользователя' } }, required: ['changes', 'reason'] },
194
+ preview(args) {
195
+ const { values, errors } = checkChanges(args.changes);
196
+ return `${args.reason || ''}\nИзменения: ${describeChanges(values)}${errors.length ? `\nОшибки: ${errors.join('; ')}` : ''}`;
197
+ },
198
+ async run(args) {
199
+ const { values, errors } = checkChanges(args.changes);
200
+ const r = updateSettings(values, { source: 'ai', reason: args.reason });
201
+ const all = [...errors, ...r.errors];
202
+ return {
203
+ result: { изменено: r.changed, ошибки: all.length ? all : undefined },
204
+ summary: Object.keys(r.changed).length ? `Настройки изменены: ${describeChanges(Object.fromEntries(Object.entries(r.changed).map(([k, v]) => [k, v.стало])))}` : `Настройки не изменились${all.length ? `: ${all.join('; ')}` : ''}`,
205
+ ok: !all.length,
206
+ };
207
+ },
208
+ },
209
+
210
+ broker_onboarding_guide: {
211
+ kind: 'read',
212
+ label: 'Справка по открытию и подключению брокерского счёта',
213
+ description: 'Получить подробную пошаговую инструкцию по открытию брокерского счёта, включению маржинальной торговли, выпуску API-токена и список уже подключённых счетов пользователя.',
214
+ parameters: {
215
+ type: 'object',
216
+ properties: {
217
+ broker: {
218
+ type: 'string',
219
+ enum: ['tbank', 'finam', 'bcs', 'alor', 'cifra', 'iticapital', 'sber', 'vtb', 'alfa'],
220
+ description: 'код выбранного брокера',
221
+ },
222
+ },
223
+ },
224
+ async run({ broker = 'tbank' } = {}) {
225
+ const st = brokersState();
226
+ const meta = BROKER_META.find((b) => b.id === broker) || BROKER_META[0];
227
+ const guides = {
228
+ tbank: {
229
+ openAccountUrl: 'https://www.tbank.ru/invest/',
230
+ tokenUrl: 'https://www.tbank.ru/invest/settings/',
231
+ accountTypeAdvice: 'Открывайте обычный «Брокерский счёт» (не Инвесткопилку и не счёт автоследования; для шортов на тарифах Профи/VIP нужен обычный брокерский счёт с включённой маржинальной торговлей).',
232
+ stepsToGetToken: [
233
+ '1. Откройте веб-кабинет Т-Инвестиций: https://www.tbank.ru/invest/settings/ (раздел «Токены T-Invest API»).',
234
+ '2. Нажмите «Выпустить токен» → выберите «Полный доступ» (для выставления заявок) и «Все счета».',
235
+ '3. Скопируйте токен (начинается с t.…) и пришлите его сюда в чат или вставьте в форму — я сам найду все ваши субсчета через discover_broker_accounts и подключу нужный счёт.',
236
+ '4. Чтобы стратегия зарабатывала и на падении акций (шорт), в мобильном приложении Т-Инвестиций зайдите в настройки выбранного счёта (шестерёнка вверху справа) и включите «Маржинальная торговля».',
237
+ ],
238
+ },
239
+ finam: {
240
+ openAccountUrl: 'https://www.finam.ru/',
241
+ tokenUrl: 'https://edox.finam.ru/',
242
+ accountTypeAdvice: 'Единый брокерский счёт Мосбиржи (КлФ / ЕДП).',
243
+ stepsToGetToken: [
244
+ '1. В кабинете клиента Финам (edox.finam.ru) откройте «Торговля → Токены доступа (Finam Trade API)».',
245
+ '2. Выпустите новый токен (начинается на CA…) и скопируйте ваш торговый код клиента ClientId (например 123456R).',
246
+ '3. Пришлите токен и ClientId или нажмите кнопку подтверждения — я подключу счёт через connect_broker_account.',
247
+ ],
248
+ },
249
+ bcs: {
250
+ openAccountUrl: 'https://bcs.ru/',
251
+ tokenUrl: 'https://my.broker.ru/',
252
+ accountTypeAdvice: 'Обычный брокерский счёт БКС Мир инвестиций с доступом к Мосбирже TQBR.',
253
+ stepsToGetToken: [
254
+ '1. В веб-терминале «БКС Мир инвестиций» откройте Настройки профиля → «Торговый API (BCS Trade API)».',
255
+ '2. Выпустите Refresh-токен с правом «Чтение и торговля» (trade-api-write).',
256
+ '3. Передайте Refresh-токен — я создам и синхронизирую счёт.',
257
+ ],
258
+ },
259
+ alor: {
260
+ openAccountUrl: 'https://www.alorbroker.ru/',
261
+ tokenUrl: 'https://alor.dev/',
262
+ accountTypeAdvice: 'Фондовый счёт Мосбиржи (портфель с литерой D, например D12345).',
263
+ stepsToGetToken: [
264
+ '1. Авторизуйтесь под брокерским аккаунтом на портале разработчика https://alor.dev (или в кабинете my.alor.ru → OpenAPI).',
265
+ '2. Выпустите Refresh Token для боевого контура и укажите номер портфеля D…',
266
+ ],
267
+ },
268
+ cifra: {
269
+ openAccountUrl: 'https://cifra-broker.ru/',
270
+ tokenUrl: 'https://tradernet.ru/',
271
+ accountTypeAdvice: 'Брокерский счёт Tradernet (Мосбиржа).',
272
+ stepsToGetToken: [
273
+ '1. В кабинете tradernet.ru откройте Настройки профиля → «API ключи».',
274
+ '2. Сгенерируйте пару ключей PublicKey и SecretKey и передайте их через двоеточие: PublicKey:SecretKey.',
275
+ ],
276
+ },
277
+ iticapital: {
278
+ openAccountUrl: 'https://iticapital.ru/',
279
+ tokenUrl: 'https://iticapital.ru/',
280
+ accountTypeAdvice: 'Фондовый портфель Мосбиржи вида BP12345.',
281
+ stepsToGetToken: [
282
+ '1. В личном кабинете ITI Capital откройте «Торговые системы → OpenAPI» и выпустите Refresh Token.',
283
+ '2. Укажите Refresh-токен и код портфеля BP…',
284
+ ],
285
+ },
286
+ sber: {
287
+ openAccountUrl: 'https://www.sberbank.ru/ru/person/investments',
288
+ accountTypeAdvice: 'Обычный брокерский счёт СберИнвестиций.',
289
+ stepsToGetToken: [
290
+ 'У СберИнвестиций нет облачного REST-токена для физлиц, поэтому счёт подключается БЕЗ токена в режиме «Подтверждение заявок» (или через адрес локального HTTP-шлюза QUIK).',
291
+ 'Достаточно указать желаемый лимит суммы на стратегию (например 100 000 ₽) и номер счёта — я сразу создам счёт в платформе через connect_broker_account и рассчитаю первые заявки в лотах Мосбиржи!',
292
+ ],
293
+ },
294
+ vtb: {
295
+ openAccountUrl: 'https://broker.vtb.ru/',
296
+ accountTypeAdvice: 'Обычный брокерский счёт ВТБ Мои Инвестиции.',
297
+ stepsToGetToken: [
298
+ 'У ВТБ Мои Инвестиции нет облачного REST-токена для физлиц — счёт подключается без токена в режиме «Подтверждение заявок» (с уведомлениями в Telegram/MAX и учётом портфеля) или через адрес моста QUIK.',
299
+ ],
300
+ },
301
+ alfa: {
302
+ openAccountUrl: 'https://alfabank.ru/make-money/investments/',
303
+ accountTypeAdvice: 'Обычный брокерский счёт Альфа-Инвестиций.',
304
+ stepsToGetToken: [
305
+ 'У Альфа-Инвестиций нет облачного REST-токена для физлиц — счёт подключается без токена в режиме «Подтверждение заявок» или через адрес моста QUIK.',
306
+ ],
307
+ },
308
+ };
309
+ return {
310
+ result: {
311
+ брокер: meta,
312
+ инструкция: guides[broker] || guides.tbank,
313
+ уже_подключённые_счета: st.accounts.map((a) => ({
314
+ id: a.id,
315
+ название: a.name,
316
+ брокер: a.broker,
317
+ субсчёт: a.accountId,
318
+ режим: a.mode,
319
+ режим_старого_портфеля: a.legacyMode || 'isolate',
320
+ лимит_руб: a.maxCapitalRub,
321
+ баланс_руб: a.lastSync?.totalRub ?? null,
322
+ маржиналка: a.lastSync?.marginEnabled ?? null,
323
+ })),
324
+ },
325
+ summary: `Загрузил инструкцию по подключению брокера «${meta.name}»`,
326
+ };
327
+ },
328
+ },
329
+
330
+ discover_broker_accounts: {
331
+ kind: 'read',
332
+ label: 'Ищет субсчета брокера по токену',
333
+ description: 'Найти все открытые брокерские субсчета пользователя в Т-Банке (T-Invest API) по токену, показать их баланс, статус маржинальной торговли и проверить отсутствие автоследования.',
334
+ parameters: {
335
+ type: 'object',
336
+ properties: {
337
+ token: { type: 'string', description: 'Токен T-Invest API (начинается на t.…)' },
338
+ sandbox: { type: 'boolean', description: 'Контур песочницы (по умолчанию false)' },
339
+ },
340
+ required: ['token'],
341
+ },
342
+ async run({ token, sandbox = false }) {
343
+ const r = await discoverBrokerAccounts({ broker: 'tbank', token, sandbox });
344
+ return {
345
+ result: {
346
+ найдено_счетов: r.accounts.length,
347
+ счета: r.accounts.map((a) => ({
348
+ accountId: a.id,
349
+ название: a.name,
350
+ тип: a.type,
351
+ доступ: a.access,
352
+ баланс_руб: a.totalRub,
353
+ свободные_рубли: a.cashRub,
354
+ позиций: a.positionsCount,
355
+ маржинальная_торговля: a.marginEnabled ? 'включена (шорты доступны)' : 'выключена (только в рост)',
356
+ автоследование: a.autoFollowDetected ? 'ОБНАРУЖЕНО (подключать запрещено Офертой)' : 'нет (можно подключать)',
357
+ пригодность: a.eligibleForTrading ? 'подходит для торговли' : 'только просмотр / копилка',
358
+ })),
359
+ },
360
+ summary: `Найдено счетов в Т-Банке: ${r.accounts.length}`,
361
+ };
362
+ },
363
+ },
364
+
365
+ connect_broker_account: {
366
+ kind: 'write',
367
+ confirmAlways: true,
368
+ label: 'Подключает или настраивает брокерский счёт',
369
+ description: 'Добавить или обновить настройки брокерского счёта в платформе «НейроАльфа», сразу выполнить синхронизацию портфеля и рассчитать пакет заявок.',
370
+ parameters: {
371
+ type: 'object',
372
+ properties: {
373
+ id: { type: 'string', description: 'ID существующего счёта в платформе (если обновляем существующий счёт)' },
374
+ broker: {
375
+ type: 'string',
376
+ enum: ['tbank', 'finam', 'bcs', 'alor', 'cifra', 'iticapital', 'sber', 'vtb', 'alfa'],
377
+ description: 'Код брокера',
378
+ },
379
+ name: { type: 'string', description: 'Понятное название счёта (например «Т-Банк основной»)' },
380
+ token: { type: 'string', description: 'API-токен / Refresh-токен (для Сбера/ВТБ/Альфы без QUIK оставьте пустым)' },
381
+ accountId: { type: 'string', description: 'ID субсчёта / ClientId / номер портфеля' },
382
+ mode: { type: 'string', enum: ['readonly', 'confirm', 'auto'], description: 'Режим работы: confirm (подтверждение заявок), auto (автомат) или readonly' },
383
+ legacyMode: { type: 'string', enum: ['isolate', 'smooth', 'takeover'], description: 'Режим для старых бумаг на счёте: isolate (не трогать старые бумаги), smooth (плавный переход) или takeover (полное управление)' },
384
+ maxCapitalRub: { type: 'number', description: 'Лимит суммы на стратегию в рублях (например 100000 или 1000000)' },
385
+ maxDailyDrawdownPct: { type: 'number', description: 'Дневной стоп по просадке в % (по умолчанию 3)' },
386
+ limitOffsetPct: { type: 'number', description: 'Защитный отступ лимитной заявки в % (по умолчанию 0.1)' },
387
+ sandbox: { type: 'boolean', description: 'Тестовый режим — песочница брокера (по умолчанию false)' },
388
+ },
389
+ required: ['broker'],
390
+ },
391
+ preview(args) {
392
+ const meta = BROKER_META.find((b) => b.id === args.broker);
393
+ const modeLbl = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' }[args.mode || 'confirm'] || 'Подтверждение заявок';
394
+ const legLbl = { isolate: 'Изолированный карман', smooth: 'Плавный переход', takeover: 'Полное управление' }[args.legacyMode || 'isolate'] || 'Изолированный карман';
395
+ return `Брокер: ${meta?.name || args.broker}${args.name ? ` («${args.name}»)` : ''}${args.accountId ? ` · счёт ${args.accountId}` : ''} · режим «${modeLbl}» · старый портфель: «${legLbl}» · лимит ${args.maxCapitalRub || 100000} ₽`;
396
+ },
397
+ async run(args) {
398
+ const beforeIds = new Set(brokersState().accounts.map((a) => a.id));
399
+ const st = saveBrokerAccount({
400
+ id: args.id,
401
+ broker: args.broker,
402
+ name: args.name,
403
+ token: args.token,
404
+ accountId: args.accountId,
405
+ mode: args.mode || 'confirm',
406
+ legacyMode: args.legacyMode || 'isolate',
407
+ maxCapitalRub: args.maxCapitalRub || 100000,
408
+ maxDailyDrawdownPct: args.maxDailyDrawdownPct ?? 3,
409
+ limitOffsetPct: args.limitOffsetPct ?? 0.1,
410
+ sandbox: Boolean(args.sandbox),
411
+ });
412
+ const created = args.id ? st.accounts.find((a) => a.id === args.id) : st.accounts.find((a) => !beforeIds.has(a.id)) || st.accounts[st.accounts.length - 1];
413
+ let syncRes = null;
414
+ if (created && (created.hasToken || ['sber', 'vtb', 'alfa'].includes(created.broker))) {
415
+ try {
416
+ const synced = await syncBrokerAccount(created.id);
417
+ await prepareBrokerOrders(created.id).catch(() => {});
418
+ const accNow = synced.accounts.find((a) => a.id === created.id);
419
+ syncRes = {
420
+ синхронизировано: Boolean(accNow?.lastSync?.ok),
421
+ ошибка_синхронизации: accNow?.lastSync?.error || null,
422
+ баланс_руб: accNow?.lastSync?.totalRub ?? null,
423
+ свободные_рубли: accNow?.lastSync?.cashRub ?? null,
424
+ маржинальная_торговля: accNow?.lastSync?.marginEnabled ?? null,
425
+ подготовлено_заявок: accNow?.pendingBatch?.orders?.length ?? 0,
426
+ };
427
+ } catch (e) {
428
+ syncRes = { синхронизировано: false, ошибка_синхронизации: e.message };
429
+ }
430
+ }
431
+ return {
432
+ result: {
433
+ ok: true,
434
+ счёт: created ? { id: created.id, название: created.name, брокер: created.broker, субсчёт: created.accountId, режим: created.mode, лимит_руб: created.maxCapitalRub } : null,
435
+ статус_подключения: syncRes,
436
+ },
437
+ summary: `Подключён брокерский счёт «${created?.name || args.broker}»${syncRes?.баланс_руб !== null && syncRes?.баланс_руб !== undefined ? ` (баланс ${syncRes.баланс_руб} ₽)` : ''}`,
438
+ };
439
+ },
440
+ },
441
+
442
+ list_runs: {
443
+ kind: 'read',
444
+ label: 'Смотрит список проведённых исследований',
445
+ description: 'Посмотреть список завершённых исторических исследований модели.',
446
+ parameters: { type: 'object', properties: { limit: { type: 'number', description: 'сколько последних, по умолчанию 6' } } },
447
+ async run({ limit = 6 }) {
448
+ return { result: recentRuns(Math.min(20, limit)), summary: 'Просмотрел список исследований' };
449
+ },
450
+ },
451
+
452
+ analyze_run: {
453
+ kind: 'read',
454
+ label: 'Разбирает результаты исследования',
455
+ description: 'Подробный разбор завершённого исследования по его номеру: вклад акций, часов входа, сроков удержания и голов внимания.',
456
+ parameters: { type: 'object', properties: { run_id: { type: 'number', description: 'номер исследования' } }, required: ['run_id'] },
457
+ async run({ run_id }) {
458
+ const r = analyzeRun(Number(run_id));
459
+ return { result: r, summary: r.ошибка || `Разобрал исследование №${run_id}` };
460
+ },
461
+ },
462
+
463
+ compare_runs: {
464
+ kind: 'read',
465
+ label: 'Сравнивает два исследования',
466
+ description: 'Сравнить два готовых исследования по их номерам.',
467
+ parameters: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] },
468
+ async run({ a, b }) {
469
+ return { result: compareRuns(Number(a), Number(b)), summary: `Сравнил исследования №${a} и №${b}` };
470
+ },
471
+ },
472
+ };
473
+
474
+ export const MODES = {
475
+ discuss: { label: 'Консультант', allowed: (t) => t.kind === 'read' },
476
+ advisor: { label: 'Консультант', allowed: () => true, confirm: (t) => t.kind !== 'read' },
477
+ autopilot: { label: 'Консультант', allowed: () => true, confirm: (t) => t.kind !== 'read' },
478
+ review: { label: 'Разбор', allowed: (t) => t.kind === 'read' },
479
+ };
480
+
481
+ export function toolSchemas(mode = 'advisor') {
482
+ const m = MODES[mode] || MODES.advisor;
483
+ return Object.entries(TOOLS)
484
+ .filter(([, t]) => m.allowed(t))
485
+ .map(([name, t]) => ({ type: 'function', function: { name, description: t.description, parameters: t.parameters } }));
486
+ }