neuroalpha 0.6.13 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +471 -166
  8. package/public/index.html +9 -39
  9. package/public/styles.css +42 -3
  10. package/server/api/routes.js +595 -906
  11. package/server/auth/index.js +583 -761
  12. package/server/brokers/index.js +14 -3
  13. package/server/config.js +73 -53
  14. package/server/index.js +186 -212
  15. package/server/llm/local-engine.js +843 -0
  16. package/server/llm/providers.js +45 -34
  17. package/server/model/live.js +2 -1
  18. package/server/notify/index.js +304 -466
  19. package/server/strategist/index.js +4 -1
  20. package/deploy/docs/01-central.md +0 -96
  21. package/deploy/docs/02-cloud-instances.md +0 -130
  22. package/deploy/docs/03-desktop-build.md +0 -92
  23. package/deploy/docs/04-client-guide.md +0 -101
  24. package/deploy/docs/05-operations.md +0 -109
  25. package/deploy/docs/06-env-reference.md +0 -91
  26. package/deploy/docs/07-stress-test.md +0 -253
  27. package/deploy/docs/pdf/01-central.pdf +0 -0
  28. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  29. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  30. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  31. package/deploy/docs/pdf/05-operations.pdf +0 -0
  32. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  33. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  34. package/deploy/install.sh +0 -128
  35. package/deploy/k8s/instance.yaml +0 -147
  36. package/public/admin.html +0 -604
  37. package/public/landing.html +0 -1207
  38. package/public/legal.html +0 -134
  39. package/scripts/set-admin-password.mjs +0 -30
  40. package/server/billing/yookassa.js +0 -142
  41. package/server/instance/agent.js +0 -259
  42. package/server/instance/central.js +0 -514
  43. package/server/instance/identity.js +0 -79
  44. package/server/instance/provision.js +0 -166
  45. package/server/instance/signing.js +0 -71
  46. package/server/role.js +0 -44
@@ -1,906 +1,595 @@
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 express from 'express';
5
- import { ROOT, strategyDefaults } from '../config.js';
6
- import { activeModelInfo } from '../llm/client.js';
7
- import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
8
- import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles, saveCandles } from '../db.js';
9
- import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
- import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
11
- import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
12
- import { PARAMS } from '../params-meta.js';
13
- import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
14
- import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
15
- import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
16
- import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
17
- import { ensureDividends, ensureCandles } from '../data/moex.js';
18
- import { live } from '../model/live.js';
19
- import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, listTradeDecisions, stopAllBrokerTrading, fetchTbankCandles } from '../brokers/index.js';
20
- import { listNews } from '../news/index.js';
21
- import * as strategist from '../strategist/index.js';
22
- import { round, MSK, DAY } from '../util.js';
23
- import { hub } from '../hub.js';
24
- import { isCrossOrigin } from '../security.js';
25
- import { IS_CENTRAL, IS_INSTANCE, CENTRAL_URL, clientIp, isHttpsRequest } from '../role.js';
26
- import { consumeSsoToken } from '../instance/agent.js';
27
- import { IS_DESKTOP } from '../role.js';
28
- import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ensureUserCloudInstance, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
29
- import { activateDesktop, instanceInfo } from '../instance/agent.js';
30
- import { isActivated } from '../instance/identity.js';
31
-
32
- export function createRouter() {
33
- const r = express.Router();
34
- const wrap = (fn) => (req, res) => Promise.resolve().then(() => fn(req, res)).catch((e) => res.status(e.status || 400).json({ error: e.message }));
35
-
36
- // ---------- общая защита API: вход обязателен везде, кроме публичных маршрутов ----------
37
- const PUBLIC_API = [/^\/config$/, /^\/auth\//, /^\/license\/verify$/, /^\/billing\/yookassa\/webhook$/, /^\/notify\/max\/webhook$/, /^\/public\//, /^\/llm\/proxy\//, /^\/instance\//, /^\/instances\/open$/, /^\/local\//];
38
- // внешние серверные вызовы без браузерного Origin-контроля
39
- const MACHINE_API = [/^\/license\/verify$/, /^\/billing\/yookassa\/webhook$/, /^\/notify\/max\/webhook$/, /^\/llm\/proxy\//, /^\/instance\//];
40
- // На машине клиента регистрация, вход по паролю, оплата, админка, боты и ИИ-прокси живут только в центре
41
- const CENTRAL_ONLY_API = [/^\/auth\/(register|login|oauth|telegram|notify-code|notify-test|redeem-promo|vip-request|api-key|accept-offer)/, /^\/billing\//, /^\/admin\//, /^\/license\//, /^\/llm\/proxy\//, /^\/notify\//, /^\/public\/showcase$/, /^\/instance\//, /^\/instances\//];
42
- r.use((req, res, next) => {
43
- const p = req.path;
44
- if (IS_INSTANCE && CENTRAL_ONLY_API.some((rx) => rx.test(p))) {
45
- return res.status(404).json({ error: `Доступно в личном кабинете: ${CENTRAL_URL}` });
46
- }
47
- if (!IS_CENTRAL && (/^\/instance\//.test(p) || /^\/instances\//.test(p) || /^\/admin\/instances/.test(p))) {
48
- return res.status(404).json({ error: 'Доступно только на центральном сервере' });
49
- }
50
- if (!IS_INSTANCE && /^\/local\//.test(p)) {
51
- return res.status(404).json({ error: 'Доступно только на машине клиента' });
52
- }
53
- const isRead = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
54
- if (!isRead && !MACHINE_API.some((rx) => rx.test(p)) && isCrossOrigin(req)) {
55
- return res.status(403).json({ error: 'Запрос с чужого сайта отклонён' });
56
- }
57
- if (req.user || PUBLIC_API.some((rx) => rx.test(p))) return next();
58
- return res.status(401).json({ error: 'Требуется вход в аккаунт' });
59
- });
60
-
61
- r.get('/config', (req, res) => res.json({
62
- defaults: strategyDefaults, params: PARAMS,
63
- llm: activeModelInfo(),
64
- user: publicUser(req.user),
65
- cloudMode: process.env.CLOUD_MODE === '1',
66
- plans: PLANS,
67
- notify: publicNotifyConfig(),
68
- billing: { enabled: publicBillingConfig().enabled },
69
- }));
70
-
71
- // ---------- авторизация, профиль и тарифы ----------
72
- const setCookie = (res, sid, maxAgeSec = 30 * 86400) => {
73
- const secure = isHttpsRequest(res.req) ? '; Secure' : '';
74
- res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
75
- };
76
- const isRuSegmentRequest = (req) => {
77
- if (process.env.ALLOW_FOREIGN_AUTH === '1') return false;
78
- const country = String(req.headers?.['cf-ipcountry'] || req.headers?.['x-country-code'] || req.headers?.['x-geo-country'] || '').trim().toUpperCase();
79
- if (country) return country === 'RU';
80
- const lang = String(req.headers?.['accept-language'] || '').toLowerCase();
81
- if (lang && !lang.includes('ru')) return false;
82
- return true;
83
- };
84
- r.get('/auth/me', (req, res) => res.json({
85
- user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
86
- downloads: IS_CENTRAL ? {
87
- windows: process.env.DESKTOP_DOWNLOAD_WINDOWS || '',
88
- linux: process.env.DESKTOP_DOWNLOAD_LINUX || '',
89
- docker: process.env.DESKTOP_DOCKER_IMAGE || '',
90
- } : null,
91
- role: IS_INSTANCE ? 'instance' : IS_CENTRAL ? 'central' : 'dev',
92
- centralUrl: IS_INSTANCE ? CENTRAL_URL : '',
93
- cloudMode: process.env.CLOUD_MODE === '1',
94
- isRuSegment: isRuSegmentRequest(req),
95
- plans: PLANS,
96
- notify: publicNotifyConfig(),
97
- oauth: publicOAuthConfig(),
98
- billing: { enabled: publicBillingConfig().enabled },
99
- }));
100
- r.get('/auth/oauth/:provider/start', (req, res) => {
101
- try {
102
- const origin = `${req.protocol}://${req.get('host')}`;
103
- const url = startOAuthRedirect(req.params.provider, { origin, plan: req.query.plan || '' });
104
- res.redirect(url);
105
- } catch (e) {
106
- res.redirect(`/landing?oauth_error=${encodeURIComponent(e.message)}`);
107
- }
108
- });
109
- r.get('/auth/oauth/:provider/callback', async (req, res) => {
110
- try {
111
- const { user, plan } = await completeOAuthCallback(req.params.provider, req.query || {});
112
- const token = createSession(user.id);
113
- setCookie(res, token);
114
- res.redirect(`/landing?oauth_ok=1${plan ? `&buy_plan=${encodeURIComponent(plan)}` : ''}`);
115
- } catch (e) {
116
- res.redirect(`/landing?oauth_error=${encodeURIComponent(e.message)}`);
117
- }
118
- });
119
- r.get('/auth/telegram/callback', (req, res) => {
120
- try {
121
- const { tgToken } = getNotifyConfig();
122
- const user = verifyTelegramLoginWidget(req.query || {}, tgToken);
123
- const token = createSession(user.id);
124
- setCookie(res, token);
125
- res.redirect('/landing?oauth_ok=1');
126
- } catch (e) {
127
- res.redirect(`/landing?oauth_error=${encodeURIComponent(e.message)}`);
128
- }
129
- });
130
- // Быстрый вход в 1 клик через бота Telegram или MAX
131
- r.post('/auth/telegram/start', wrap((req, res) => {
132
- const ch = req.body?.channel === 'max' ? 'max' : 'tg';
133
- const nt = publicNotifyConfig();
134
- if (ch === 'max') {
135
- if (!nt.hasMaxToken || !nt.maxBotName) throw new Error('Бот MAX ещё не подключён администратором');
136
- const code = createTelegramLoginChallenge();
137
- return res.json({ code, botUrl: `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` });
138
- }
139
- if (!nt.hasTgToken || !nt.tgBotName) throw new Error('Telegram-бот ещё не подключён администратором');
140
- const code = createTelegramLoginChallenge();
141
- res.json({ code, botUrl: `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` });
142
- }));
143
- r.get('/auth/telegram/poll', (req, res) => {
144
- const st = checkTelegramLoginChallenge(req.query.code);
145
- if (st.status === 'done' && st.userId) {
146
- const u = getUserById(st.userId);
147
- const token = createSession(u.id);
148
- setCookie(res, token);
149
- return res.json({ status: 'done', user: publicUser(u), token });
150
- }
151
- res.json(st);
152
- });
153
- r.post('/auth/register', wrap((req, res) => {
154
- const u = registerUser(req.body || {});
155
- const token = createSession(u.id);
156
- setCookie(res, token);
157
- res.json({ user: publicUser(u), token });
158
- }));
159
- r.post('/auth/login', wrap(async (req, res) => {
160
- const u = await loginUser(req.body || {}, { ip: clientIp(req) });
161
- const token = createSession(u.id);
162
- setCookie(res, token);
163
- res.json({ user: publicUser(u), token });
164
- }));
165
- r.post('/auth/logout', (req, res) => {
166
- for (const tok of parseAllCookieValues(req.headers?.cookie, 'sid')) {
167
- if (tok && tok !== 'logged_out') deleteSession(tok);
168
- }
169
- setCookie(res, 'logged_out', 365 * 86400);
170
- res.json({ ok: true, ...(IS_INSTANCE ? { redirect: CENTRAL_URL } : {}) });
171
- });
172
-
173
- // ---------- установка на компьютер: состояние и активация кодом (до активации входа ещё нет) ----------
174
- r.get('/local/status', (req, res) => res.json({ ...instanceInfo(), loggedIn: Boolean(req.user) }));
175
- r.post('/local/activate', wrap(async (req, res) => {
176
- if (!IS_DESKTOP) throw new Error('Активация кодом доступна только для установки на компьютер');
177
- if (isActivated() && !req.user) throw Object.assign(new Error('Программа уже активирована'), { status: 409 });
178
- res.json(await activateDesktop(req.body?.code));
179
- }));
180
-
181
- // ---------- машина клиента: вход по одноразовой ссылке из кабинета центра ----------
182
- r.get('/auth/sso', (req, res) => {
183
- if (!IS_INSTANCE) return res.status(404).json({ error: 'Вход через центр доступен только на машине клиента' });
184
- try {
185
- consumeSsoToken(String(req.query.t || ''));
186
- const token = createSession(1, 7);
187
- setCookie(res, token, 7 * 86400);
188
- res.redirect('/');
189
- } catch (e) {
190
- res.redirect(`${CENTRAL_URL}/landing?oauth_error=${encodeURIComponent(e.message)}`);
191
- }
192
- });
193
-
194
- // ---------- центр: связь с машинами клиентов ----------
195
- const machineAuth = (req) => {
196
- const h = String(req.headers?.authorization || '');
197
- const inst = instanceByKey(h.startsWith('Bearer ') ? h.slice(7).trim() : '');
198
- if (!inst) throw Object.assign(new Error('Неизвестная машина или неверный ключ'), { status: 401 });
199
- return inst;
200
- };
201
- r.post('/instance/heartbeat', wrap((req, res) => res.json(instanceHeartbeat(machineAuth(req), req.body || {}))));
202
- // Активация установки на компьютер одноразовым кодом из кабинета (ключа у установки ещё нет)
203
- r.post('/instance/activate', wrap((req, res) => res.json(activateDesktopInstall(req.body || {}, { ip: clientIp(req) }))));
204
- // Кабинет клиента: код активации для установки на компьютер и отвязка компьютера
205
- r.post('/instances/desktop-code', wrap((req, res) => res.json(createDesktopActivationCode(req.user))));
206
- r.post('/instances/:id/unbind', wrap((req, res) => res.json({ instance: unbindInstall(req.params.id, { userId: req.user.id }) })));
207
- r.post('/instance/notify', wrap(async (req, res) => {
208
- const inst = machineAuth(req);
209
- if (inst.revoked) throw Object.assign(new Error('Аренда машины завершена'), { status: 403 });
210
- res.json(await relayInstanceNotify(inst, req.body?.text, req.body?.buttons));
211
- }));
212
- // Кнопка «Открыть терминал» в кабинете: одноразовая подписанная ссылка входа на машину клиента
213
- r.get('/instances/open', (req, res) => {
214
- if (!req.user) return res.redirect('/landing?login=1');
215
- const url = ssoRedirectUrl(req.user, req.query.i || '');
216
- if (!url) return res.redirect(`/landing?oauth_error=${encodeURIComponent('Для вашего аккаунта ещё не создана машина — обратитесь в поддержку')}`);
217
- res.redirect(url);
218
- });
219
- r.get('/public/license-key', (req, res) => {
220
- if (!IS_CENTRAL) return res.status(404).json({ error: 'Только на центральном сервере' });
221
- res.type('text/plain').send(centralPublicKeyPem());
222
- });
223
- r.post('/auth/notify-code', wrap((req, res) => {
224
- if (!req.user) throw new Error('Войдите в аккаунт');
225
- const code = ensureNotifyCode(req.user.id);
226
- res.json({ code, notify: publicNotifyConfig() });
227
- }));
228
- r.post('/auth/notify-test', wrap(async (req, res) => {
229
- if (!req.user) throw new Error('Войдите в аккаунт');
230
- const sent = await notifyUser(req.user.id, 'НейроАльфа: тестовое уведомление успешно доставлено.');
231
- if (!sent.tg && !sent.max) throw new Error('Сначала привяжите Telegram или MAX по вашему коду привязки');
232
- res.json({ ok: true, ...sent });
233
- }));
234
- r.post('/auth/redeem-promo', wrap(async (req, res) => {
235
- if (!req.user) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
236
- const user = redeemPromoCode(req.user.id, req.body?.code);
237
- if (IS_CENTRAL) await ensureUserCloudInstance(req.user.id).catch(() => {});
238
- res.json({ user: { ...user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } });
239
- }));
240
- r.post('/auth/accept-offer', wrap((req, res) => {
241
- if (!req.user) throw new Error('Войдите в аккаунт');
242
- res.json({ ok: true, user: acceptUserOffer(req.user.id) });
243
- }));
244
- r.post('/auth/vip-request', wrap(async (req, res) => {
245
- const item = createVipRequest({ user: req.user, ...(req.body || {}) });
246
- notifyUser(1, `👑 Заявка на тариф VIP (НейроАльфа):\nИмя: ${item.name}\nКонтакт: ${item.contact || item.email}\nКапитал: ${item.capitalRub || '—'}\nКомментарий: ${item.comment || '—'}`).catch(() => {});
247
- res.json({ ok: true, request: item });
248
- }));
249
- r.post('/auth/api-key/regenerate', wrap((req, res) => {
250
- if (!req.user) throw new Error('Войдите в аккаунт');
251
- const apiKey = ensureApiKey(req.user.id, true);
252
- res.json({ apiKey, user: publicUser({ ...req.user, api_key: apiKey }) });
253
- }));
254
- // Проверка API-ключа внешним npx-клиентом
255
- r.post('/license/verify', wrap((req, res) => {
256
- const authHeader = String(req.headers?.authorization || '');
257
- const key = String(req.body?.apiKey || (authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '') || '').trim();
258
- const u = getUserByApiKey(key);
259
- if (!u) return res.status(401).json({ valid: false, error: 'Неверный или отозванный API-ключ' });
260
- const p = effectivePlan(u);
261
- res.json({
262
- valid: true,
263
- email: u.email,
264
- name: u.name,
265
- plan: p.id,
266
- planName: p.name,
267
- planUntil: u.plan_until || 0,
268
- limits: p,
269
- });
270
- }));
271
- // Активация API-ключа облачной подписки на локальном npx-клиенте
272
- r.post('/license/activate', wrap(async (req, res) => {
273
- requireAdmin(req);
274
- const lic = await activateLocalLicense(req.body?.apiKey, req.body?.cloudUrl);
275
- llmChanged();
276
- res.json({ ok: true, license: lic, user: publicUser(req.user) });
277
- }));
278
-
279
- // ---------- оплата через ЮKassa ----------
280
- r.post('/billing/checkout', wrap(async (req, res) => {
281
- const origin = `${req.protocol}://${req.get('host')}`;
282
- res.json(await createYookassaPayment({
283
- user: req.user,
284
- plan: req.body?.plan,
285
- months: req.body?.months || 1,
286
- returnOrigin: req.body?.returnOrigin || origin,
287
- }));
288
- }));
289
- r.post('/billing/verify', wrap(async (req, res) => {
290
- if (!req.user) throw new Error('Войдите в аккаунт');
291
- const out = req.body?.paymentId
292
- ? await verifyYookassaPayment(req.body.paymentId)
293
- : await verifyUserLatestPayment(req.user.id);
294
- if (IS_CENTRAL && out?.status === 'succeeded') {
295
- await ensureUserCloudInstance(req.user.id).catch(() => {});
296
- }
297
- res.json({
298
- ...out,
299
- user: out?.user ? { ...out.user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
300
- });
301
- }));
302
- r.post('/billing/yookassa/webhook', wrap(async (req, res) => {
303
- const pid = req.body?.object?.id;
304
- if (pid) {
305
- const out = await verifyYookassaPayment(pid).catch(() => null);
306
- if (IS_CENTRAL && out?.status === 'succeeded' && out?.user?.id) {
307
- await ensureUserCloudInstance(out.user.id).catch(() => {});
308
- }
309
- }
310
- res.json({ ok: true });
311
- }));
312
- r.post('/notify/max/webhook', wrap(async (req, res) => {
313
- res.json(await handleMaxWebhook(req.body || {}, req.headers || {}));
314
- }));
315
-
316
- // ---------- публичная витрина для лендинга ----------
317
- r.get('/public/showcase', (req, res) => {
318
- const buildShowcaseItem = (x, full) => {
319
- const chosen = x.metrics?.model?.chosen || 'attention';
320
- const cmpFull = x.metrics?.model?.comparison?.[chosen]?.full || full?.extra?.comparison?.[chosen]?.full || {};
321
- const mFull = { ...(x.metrics?.full || {}), ...cmpFull };
322
- const p = x.params || {};
323
- const dir = p.modelDirection || 'long';
324
- const topK = p.modelTopK || 2;
325
- const from = x.metrics?.period?.from || 0, till = x.metrics?.period?.till || 0;
326
- const yrs = Math.max(0.5, (till - from) / (365.25 * 86400000));
327
- const ret = Number(mFull.returnPct || 0);
328
- const cagr = yrs >= 0.25 && ret > -99 ? ((Math.pow(1 + ret / 100, 1 / yrs) - 1) * 100) : (ret / yrs);
329
-
330
- let tierBadge = 'Тариф «Инвестор»';
331
- let tierColor = 'blue';
332
- // Тарифы различаются возможностями (шорты, число счетов, лимиты), а не доходностью
333
- let tierNote = 'Только покупки (без шорта) · 1 счёт до 1 млн ₽';
334
- let autoTitle = p._title;
335
- if (dir === 'long') {
336
- tierBadge = 'Для тарифа «Инвестор»';
337
- tierColor = 'blue';
338
- if (!autoTitle) autoTitle = 'Только покупки';
339
- } else {
340
- tierBadge = 'Для тарифов «Профи» и «VIP»';
341
- tierColor = 'indigo';
342
- tierNote = 'Покупки и продажи без покрытия (шорт) · до 5 счетов';
343
- if (!autoTitle || autoTitle === 'Эталонная адаптивная стратегия' || /форсаж|мощность/i.test(autoTitle)) autoTitle = 'Покупки и шорты';
344
- }
345
- void cagr;
346
- return {
347
- id: x.id,
348
- title: autoTitle,
349
- tierBadge,
350
- tierColor,
351
- tierNote,
352
- params: { modelHorizon: p.modelHorizon || 2.5, modelTopK: topK, modelDirection: dir, modelType: chosen },
353
- metrics: mFull,
354
- oos: x.metrics?.model?.comparison?.[chosen]?.outOfSample || full?.extra?.comparison?.[chosen]?.outOfSample || {},
355
- period: x.metrics?.period || {},
356
- curve: full?.extra?.curves?.[chosen]?.slice(-180) || [],
357
- marketCurve: full?.extra?.curves?.market?.slice(-180) || [],
358
- // реалистичная оценка стресс-теста (исполнение как в живом контуре, издержки 0,10% на сторону)
359
- realistic: (() => {
360
- const s = (full?.extra?.stress || x.extra?.stress)?.scenarios?.find((y) => y.key === 'liveLike10');
361
- return s ? { returnPct: s.full.returnPct, cagrPct: s.full.cagrPct, profitFactor: s.full.profitFactor, maxDrawdownPct: s.full.maxDrawdownPct, last12Pct: s.last12.returnPct } : null;
362
- })(),
363
- };
364
- };
365
- const runs = listShowcaseRuns().map((x) => buildShowcaseItem(x, getRun(x.id)));
366
- if (!runs.length) {
367
- try {
368
- const ref = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'seed-run.json'), 'utf8'));
369
- if (ref) runs.push(buildShowcaseItem({ id: 1, ...ref }, ref));
370
- } catch { /* ignore */ }
371
- }
372
- res.json({ runs, plans: PLANS, legal: getLegalInfo() });
373
- });
374
-
375
- // ---------- реквизиты оферты (для модерации ЮKassa и правовых документов) ----------
376
- const DEFAULT_LEGAL = {
377
- ownerName: 'ООО «БИС» («Бизнес Интер Софт»)',
378
- authorName: 'Перепелкин Роман Александрович',
379
- status: 'Оператор платформы PaaS · Проприетарное ПО (Все права защищены)',
380
- inn: '',
381
- ogrn: '',
382
- email: 'support@neuroalpha.ru',
383
- phone: '',
384
- address: 'Российская Федерация, г. Рязань',
385
- };
386
- const getLegalInfo = () => ({ ...DEFAULT_LEGAL, ...(kvGet('legalInfo') || {}) });
387
- r.get('/public/legal', (req, res) => res.json(getLegalInfo()));
388
-
389
- // ---------- админ-панель (пользователи, оплаты, ЮKassa, боты Telegram и MAX, витрина, реквизиты) ----------
390
- const requireAdmin = (req) => {
391
- if (!req.user || req.user.role !== 'admin') throw Object.assign(new Error('Доступно только администратору платформы'), { status: 403 });
392
- };
393
- r.get('/admin/state', wrap((req, res) => {
394
- requireAdmin(req);
395
- res.json({ users: listAllUsers(), payments: listAllPayments(), promos: listPromoCodes(), vipRequests: listVipRequests(), billing: publicBillingConfig(), notify: publicNotifyConfig(), oauth: publicOAuthConfig(), legal: getLegalInfo() });
396
- }));
397
- r.post('/admin/oauth', wrap((req, res) => {
398
- requireAdmin(req);
399
- res.json({ ok: true, oauth: saveOAuthConfig(req.body || {}) });
400
- }));
401
- r.post('/admin/legal', wrap((req, res) => {
402
- requireAdmin(req);
403
- const b = req.body || {};
404
- const next = {
405
- ownerName: String(b.ownerName ?? '').trim(),
406
- status: String(b.status ?? '').trim(),
407
- inn: String(b.inn ?? '').trim(),
408
- ogrn: String(b.ogrn ?? '').trim(),
409
- email: String(b.email ?? '').trim(),
410
- phone: String(b.phone ?? '').trim(),
411
- address: String(b.address ?? '').trim(),
412
- };
413
- kvSet('legalInfo', next);
414
- res.json({ ok: true, legal: getLegalInfo() });
415
- }));
416
- r.post('/admin/promo', wrap((req, res) => {
417
- requireAdmin(req);
418
- res.json({ promos: createPromoCode(req.body || {}) });
419
- }));
420
- r.post('/admin/user-plan', wrap(async (req, res) => {
421
- requireAdmin(req);
422
- const user = setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30);
423
- if (IS_CENTRAL) await ensureUserCloudInstance(req.body?.userId).catch(() => {});
424
- res.json({ user, users: listAllUsers() });
425
- }));
426
- r.post('/admin/create-admin', wrap((req, res) => {
427
- requireAdmin(req);
428
- res.json({ user: createOrUpdateAdminUser(req.body || {}), users: listAllUsers() });
429
- }));
430
- r.post('/admin/billing', wrap((req, res) => {
431
- requireAdmin(req);
432
- res.json(saveBillingConfig(req.body || {}));
433
- }));
434
- r.post('/admin/notify', wrap(async (req, res) => {
435
- requireAdmin(req);
436
- res.json(await saveNotifyConfig(req.body || {}));
437
- }));
438
- // ---------- машины клиентов (аренда ПО + машина + ИИ, лицензия под контролем центра) ----------
439
- const centralUrlOf = (req) => String(process.env.CENTRAL_PUBLIC_URL || `${isHttpsRequest(req) ? 'https' : req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
440
- r.get('/admin/instances', wrap((req, res) => { requireAdmin(req); res.json({ instances: listInstances() }); }));
441
- r.post('/admin/instances', wrap(async (req, res) => {
442
- requireAdmin(req);
443
- res.json(await createInstance(req.body || {}, { centralUrl: centralUrlOf(req) }));
444
- }));
445
- r.post('/admin/instances/:id/rotate-key', wrap(async (req, res) => { requireAdmin(req); res.json(await rotateInstanceKey(req.params.id, { centralUrl: centralUrlOf(req) })); }));
446
- r.post('/admin/instances/:id/trading', wrap((req, res) => { requireAdmin(req); res.json({ instance: setInstanceTrading(req.params.id, String(req.body?.trading || '')) }); }));
447
- r.post('/admin/instances/:id/revoke', wrap((req, res) => { requireAdmin(req); res.json({ instance: setInstanceRevoked(req.params.id, req.body?.revoked !== false) }); }));
448
- r.post('/admin/instances/:id/unbind', wrap((req, res) => { requireAdmin(req); res.json({ instance: unbindInstall(req.params.id) }); }));
449
- r.delete('/admin/instances/:id', wrap(async (req, res) => { requireAdmin(req); res.json({ instances: await deleteInstance(req.params.id) }); }));
450
- r.put('/runs/:id/showcase', wrap((req, res) => {
451
- requireAdmin(req);
452
- res.json(setRunShowcase(Number(req.params.id), Boolean(req.body?.showcase)));
453
- }));
454
-
455
- // ---------- модели ИИ ----------
456
- const llmState = () => ({ providers: listProviders(), selected: getSelected(), active: activeModelInfo() });
457
- const llmChanged = () => {
458
- hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
459
- hub.emit({ type: 'chat:state', state: strategist.chatState() });
460
- };
461
- // Подключения к LLM общие для всей платформы (ключи, адреса), поэтому менять и проверять их может только администратор
462
- r.get('/llm/providers', wrap((req, res) => res.json(llmState())));
463
- r.post('/llm/providers', wrap((req, res) => { requireAdmin(req); const p = addProvider(req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
464
- r.put('/llm/providers/:id', wrap((req, res) => { requireAdmin(req); const p = updateProvider(req.params.id, req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
465
- r.delete('/llm/providers/:id', wrap((req, res) => { requireAdmin(req); deleteProvider(req.params.id); llmChanged(); res.json(llmState()); }));
466
- r.get('/llm/providers/:id/remote-models', wrap(async (req, res) => { requireAdmin(req); res.json({ models: await fetchRemoteModels(req.params.id) }); }));
467
- r.post('/llm/test', wrap(async (req, res) => { requireAdmin(req); res.json(await testProvider(req.body?.providerId, req.body?.model)); }));
468
- r.post('/llm/select', wrap((req, res) => { requireAdmin(req); selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
469
-
470
- // ---------- облачный ИИ-прокси для локальных npx-клиентов по APIKey или в демо-режиме ----------
471
- const proxyUsesToday = (ident) => {
472
- const day = new Date().toISOString().slice(0, 10);
473
- const key = `llmProxy:${ident}:${day}`;
474
- return { key, count: Number(kvGet(key, 0)) || 0 };
475
- };
476
- r.get('/llm/proxy/v1/models', (req, res) => {
477
- res.json({ data: [{ id: 'Qwen/Qwen3.8-27B-FP8', object: 'model' }] });
478
- });
479
- r.post('/llm/proxy/v1/chat/completions', async (req, res) => {
480
- try {
481
- const ip = String(req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'anon').split(',')[0].trim();
482
- let maxPerDay = 5;
483
- let ident = `ip:${ip}`;
484
- let planLabel = 'Демо (без ключа)';
485
- if (req.user) {
486
- const plan = effectivePlan(req.user);
487
- maxPerDay = plan.maxChatPerDay;
488
- ident = `u:${req.user.id}`;
489
- planLabel = plan.name;
490
- }
491
- const u = proxyUsesToday(ident);
492
- if (u.count >= maxPerDay) {
493
- return res.status(429).json({
494
- error: {
495
- message: req.user
496
- ? `Исчерпан дневной лимит ИИ-запросов по тарифу «${planLabel}» (${maxPerDay} в сутки). Расширьте тариф на https://neuroalpha.odant.org`
497
- : `Исчерпан бесплатный демо-лимит (${maxPerDay} запросов в сутки). Получите бесплатный или платный APIKey на https://neuroalpha.odant.org`,
498
- },
499
- });
500
- }
501
- const t = resolveUpstreamForProxy();
502
- if (!t) {
503
- return res.status(503).json({ error: { message: 'На сервере neuroalpha.odant.org ещё не подключена апстрим-модель ИИ' } });
504
- }
505
- kvSet(u.key, u.count + 1);
506
- const body = { ...(req.body || {}), model: t.model, stream: true };
507
- if (!body.reasoning_effort && t.effort) body.reasoning_effort = t.effort;
508
- const upstream = await providerFetch(t.provider, t.url, {
509
- method: 'POST',
510
- headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
511
- body: JSON.stringify(body),
512
- signal: AbortSignal.timeout(90000),
513
- });
514
- if (!upstream.ok) {
515
- const txt = await upstream.text().catch(() => '');
516
- return res.status(upstream.status).json({ error: { message: txt.slice(0, 300) || `HTTP ${upstream.status}` } });
517
- }
518
- res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
519
- res.setHeader('Cache-Control', 'no-cache');
520
- res.setHeader('Connection', 'keep-alive');
521
- for await (const chunk of upstream.body) {
522
- res.write(chunk);
523
- }
524
- res.end();
525
- } catch (e) {
526
- if (!res.headersSent) res.status(500).json({ error: { message: e.message } });
527
- else res.end();
528
- }
529
- });
530
-
531
- // ---------- настройки ----------
532
- r.get('/settings', (req, res) => res.json(getSettings()));
533
- r.put('/settings', wrap((req, res) => res.json(updateSettings(req.body || {}, { source: 'user' }))));
534
- r.post('/settings/reset', (req, res) => res.json(resetSettings('user')));
535
- r.get('/settings/history', (req, res) => res.json(listSettingsHistory(Math.min(200, Number(req.query.limit) || 50))));
536
- r.post('/settings/history/:id/revert', wrap((req, res) => res.json(revertSettingsChange(req.params.id))));
537
-
538
- // ---------- исследования ----------
539
- const ownsRun = (req, run) => Boolean(run && req.user && (req.user.role === 'admin' || (run.user_id ?? 1) === req.user.id));
540
- const ownRunOr404 = (req) => {
541
- const run = getRun(Number(req.params.id));
542
- if (!ownsRun(req, run)) throw Object.assign(new Error('Не найдено'), { status: 404 });
543
- return run;
544
- };
545
- r.get('/runs', (req, res) => res.json(listRuns()));
546
- r.get('/runs/:id', (req, res) => {
547
- const run = getRun(Number(req.params.id));
548
- if (!run || !(ownsRun(req, run) || run.showcase)) return res.status(404).json({ error: 'Не найдено' });
549
- res.json({ ...run, trades: getTrades(run.id) });
550
- });
551
- r.put('/runs/:id/title', wrap((req, res) => {
552
- ownRunOr404(req);
553
- const updated = updateRunTitle(Number(req.params.id), req.body?.title || '');
554
- if (!updated) return res.status(404).json({ error: 'Не найдено' });
555
- res.json(updated);
556
- }));
557
- r.post('/runs/:id/stability', wrap((req, res) => { ownRunOr404(req); res.json(startStabilityCheck(Number(req.params.id))); }));
558
- r.post('/runs/:id/stability/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelStabilityCheck(Number(req.params.id)) }); }));
559
- r.delete('/runs/:id', wrap((req, res) => {
560
- const run = ownRunOr404(req);
561
- if (run?.status === 'running') throw new Error('Эксперимент ещё идёт — сначала отмените его');
562
- deleteRun(Number(req.params.id));
563
- res.json({ ok: true });
564
- }));
565
- // Очистка истории прогонов: оставить ровно keep лучших по итогу (0 — удалить все завершённые), идущие не трогать
566
- r.post('/runs/cleanup', wrap((req, res) => {
567
- const raw = Number(req.body?.keep);
568
- const keep = Number.isFinite(raw) ? Math.max(0, Math.min(50, Math.floor(raw))) : 0;
569
- const all = allRuns();
570
- const done = all.filter((r) => r.mode === 'model' && r.status === 'done');
571
- const ranked = [...done].sort((a, b) => (b.metrics?.full?.returnPct ?? -1e9) - (a.metrics?.full?.returnPct ?? -1e9));
572
- const keepSet = new Set(ranked.slice(0, keep).map((r) => r.id));
573
- let deleted = 0;
574
- for (const r of all) {
575
- if (r.mode !== 'model' || r.status === 'running' || keepSet.has(r.id)) continue;
576
- deleteRun(r.id);
577
- deleted++;
578
- }
579
- res.json({ deleted, kept: [...keepSet] });
580
- }));
581
-
582
- r.post('/model/research', wrap((req, res) => {
583
- const plan = effectivePlan(req.user);
584
- if (!plan.allowResearch && req.user?.role !== 'admin') {
585
- throw new Error('Раздел исследований доступен только в режиме «Хозяин».');
586
- }
587
- if (countUserRunsToday() >= plan.maxRunsPerDay) {
588
- throw new Error(`Достигнут дневной лимит исследований (${plan.maxRunsPerDay} в сутки).`);
589
- }
590
- const body = req.body || {};
591
- const merged = { ...getSettings(), ...body };
592
- const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
593
- res.json({ runId, reused: !!reused });
594
- }));
595
- r.post('/model/:id/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelModelResearch(Number(req.params.id)) }); }));
596
- r.get('/model/forecast', wrap(async (req, res) => {
597
- const fc = await forecastNow(getSettings(), { force: req.query.force === '1' });
598
- if (fc?.rows?.some((x) => x.signal || x.shortSignal)) {
599
- triggerBrokerFastEntryCheck();
600
- }
601
- res.json(fc);
602
- }));
603
-
604
- // ---------- описания компаний (текстовая карта) ----------
605
- r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
606
- r.post('/profiles/refresh', wrap((req, res) => res.json(startProfilesRefresh())));
607
-
608
- // ---------- живая проверка (несколько виртуальных счетов) ----------
609
- r.get('/live/status', (req, res) => res.json(live.status()));
610
- r.post('/live/start', wrap(async (req, res) => {
611
- const plan = effectivePlan(req.user);
612
- const b = req.body || {};
613
- const runId = await live.start(getSettings(), { source: 'user', baseRunId: b.baseRunId || null, name: b.name || '', capital: b.capital || null, maxAccounts: plan.maxVirtualAccounts });
614
- res.json({ runId, status: live.status() });
615
- }));
616
- r.post('/live/stop', (req, res) => res.json({ ok: live.stop(req.body?.runId || null), status: live.status() }));
617
- r.delete('/live/:id', (req, res) => res.json({ ok: live.remove(Number(req.params.id)), status: live.status() }));
618
-
619
- // ---------- брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
620
- r.get('/brokers', (req, res) => res.json(brokersState()));
621
- r.post('/brokers/discover', wrap(async (req, res) => res.json(await discoverBrokerAccounts(req.body || {}))));
622
- r.post('/brokers', wrap((req, res) => {
623
- const plan = effectivePlan(req.user);
624
- const cur = brokersState().accounts.length;
625
- if (!req.body?.id && cur >= plan.maxBrokerAccounts) {
626
- if ((plan.maxBrokerAccounts ?? 0) < 1) {
627
- throw new Error('Для подключения брокерского счёта оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP».');
628
- }
629
- throw new Error(`По вашему тарифу «${plan.name}» доступно подключение до ${plan.maxBrokerAccounts} брокерских счетов. Перейдите на тариф «Профи» или «VIP».`);
630
- }
631
- res.json(saveBrokerAccount(req.body || {}));
632
- }));
633
- r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
634
- r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
635
- r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
636
- r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id, req.body || {}))));
637
- r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
638
- r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
639
- r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
640
- r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));
641
- r.post('/brokers/:id/cancel-order', wrap(async (req, res) => res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null))));
642
- r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => res.json(resetBrokerDailyStop(req.params.id))));
643
- r.get('/brokers/:id/events', wrap((req, res) => res.json({ events: listBrokerEvents(req.params.id, req.query.limit) })));
644
- r.get('/brokers/:id/decisions', wrap((req, res) => res.json({ decisions: listTradeDecisions(req.params.id, { fromTs: req.query.from, limit: req.query.limit }) })));
645
- r.post('/brokers-stop-all', wrap(async (req, res) => res.json(await stopAllBrokerTrading())));
646
-
647
- // ---------- новости (только для прогноза «сейчас» и живой проверки) ----------
648
- r.get('/news', (req, res) => res.json({
649
- items: listNews({ symbol: req.query.symbol || undefined, hours: Number(req.query.hours) || 72, limit: Number(req.query.limit) || 30 }),
650
- }));
651
-
652
- // ---------- подробное досье акции (котировки, график, прогноз, соседи по карте, дивиденды, бизнес-профиль, сделки пользователя, новости) ----------
653
- r.get('/stock/:symbol', wrap(async (req, res) => {
654
- const sym = String(req.params.symbol || '').toUpperCase().trim();
655
- if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
656
-
657
- const nowTs = Date.now();
658
- const [fc, rawSpecs, divs, tbLiveBars, tbMinBars] = await Promise.all([
659
- forecastNow(getSettings()).catch(() => null),
660
- ensureBoardSpecs().catch(() => ({})),
661
- ensureDividends(sym).catch(() => []),
662
- fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
663
- fetchTbankCandles(sym, nowTs - DAY + 5 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null),
664
- ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
665
- ]);
666
- if (tbLiveBars?.length) {
667
- const doneBars = tbLiveBars.filter((b) => b.isComplete);
668
- if (doneBars.length) saveCandles(sym, 10, doneBars);
669
- }
670
- const tbMap = kvGet('tbankShortableMap') || {};
671
- const tbSpecs = tbMap.specMap || {};
672
- const shortableMap = tbMap.map || {};
673
- const sp = { ...(rawSpecs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2, issueSize: 0, lastPrice: 0 }), ...(tbSpecs[sym] || {}) };
674
- const vocabList = kvGet('vocab')?.list || [];
675
- const vocabHit = vocabList.find((v) => v.symbol === sym);
676
- const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
677
- const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
678
- const tierMult = symbolEdgeMult(sym);
679
-
680
- // 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
681
- const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
682
- const intraday = rawBars30d.slice(-1500).map((b) => ({
683
- ts: b.ts,
684
- o: b.open,
685
- h: b.high,
686
- l: b.low,
687
- c: b.close,
688
- v: b.value || 0,
689
- }));
690
-
691
- const allBars = db.prepare('SELECT ts, open, high, low, close, value FROM candles WHERE symbol = ? AND interval = 10 ORDER BY ts ASC').all(sym);
692
- const dailyMap = new Map();
693
- for (const b of allBars) {
694
- const dayKey = Math.floor((b.ts + MSK) / DAY) * DAY;
695
- let d = dailyMap.get(dayKey);
696
- if (!d) {
697
- d = { ts: dayKey, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0 };
698
- dailyMap.set(dayKey, d);
699
- } else {
700
- if (b.high > d.h) d.h = b.high;
701
- if (b.low > 0 && (d.l <= 0 || b.low < d.l)) d.l = b.low;
702
- d.c = b.close;
703
- d.v += b.value || 0;
704
- }
705
- }
706
- const daily = [...dailyMap.values()];
707
-
708
- const tbFresh1m = tbMinBars?.length ? tbMinBars[tbMinBars.length - 1] : null;
709
- const tbFreshBar = tbLiveBars?.length ? tbLiveBars[tbLiveBars.length - 1] : null;
710
- const curPrice = (tbFresh1m?.close > 0 ? tbFresh1m.close : 0) || (tbFreshBar?.close > 0 ? tbFreshBar.close : 0) || fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
711
- const intraday1m = (tbMinBars || []).map((b) => ({
712
- ts: b.ts,
713
- o: b.open,
714
- h: b.high,
715
- l: b.low,
716
- c: b.close,
717
- v: b.value || 0,
718
- }));
719
- if (curPrice > 0 && intraday1m.length) {
720
- const last1m = intraday1m[intraday1m.length - 1];
721
- const liveMinSlotTs = Math.floor(nowTs / 60000) * 60000;
722
- if (liveMinSlotTs > last1m.ts && liveMinSlotTs - last1m.ts <= 3600000) {
723
- intraday1m.push({ ts: liveMinSlotTs, o: last1m.c, h: Math.max(last1m.c, curPrice), l: Math.min(last1m.c, curPrice), c: curPrice, v: 0 });
724
- } else if (last1m.c !== curPrice) {
725
- last1m.c = curPrice;
726
- if (curPrice > last1m.h) last1m.h = curPrice;
727
- if (last1m.l <= 0 || curPrice < last1m.l) last1m.l = curPrice;
728
- }
729
- }
730
- if (tbFreshBar && !tbFreshBar.isComplete && intraday.length) {
731
- const lastBar = intraday[intraday.length - 1];
732
- if (tbFreshBar.ts > lastBar.ts) {
733
- intraday.push({ ts: tbFreshBar.ts, o: tbFreshBar.open, h: tbFreshBar.high, l: tbFreshBar.low, c: curPrice, v: tbFreshBar.value || 0 });
734
- } else if (tbFreshBar.ts === lastBar.ts) {
735
- lastBar.c = curPrice;
736
- if (tbFreshBar.high > lastBar.h) lastBar.h = tbFreshBar.high;
737
- if (tbFreshBar.low > 0 && (lastBar.l <= 0 || tbFreshBar.low < lastBar.l)) lastBar.l = tbFreshBar.low;
738
- }
739
- }
740
- if (curPrice > 0 && intraday.length) {
741
- const lastBar = intraday[intraday.length - 1];
742
- const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
743
- if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
744
- intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
745
- } else if (lastBar.c !== curPrice) {
746
- lastBar.c = curPrice;
747
- if (curPrice > lastBar.h) lastBar.h = curPrice;
748
- if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
749
- }
750
- if (daily.length) {
751
- const lastDay = daily[daily.length - 1];
752
- lastDay.c = curPrice;
753
- if (curPrice > lastDay.h) lastDay.h = curPrice;
754
- if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
755
- }
756
- }
757
- const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
758
- const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
759
- const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
760
- const monthChangePct = monthAgoClose > 0 && curPrice > 0 ? round(((curPrice - monthAgoClose) / monthAgoClose) * 100, 2) : 0;
761
- const yearAgoClose = daily.length >= 250 ? daily[daily.length - 250].c : (daily[0]?.o || curPrice);
762
- const yearChangePct = yearAgoClose > 0 && curPrice > 0 ? round(((curPrice - yearAgoClose) / yearAgoClose) * 100, 2) : 0;
763
- const marketCapRub = sp.issueSize > 0 && curPrice > 0 ? Math.round(sp.issueSize * curPrice) : null;
764
-
765
- // 20 факторов бизнес-профиля
766
- const profMap = loadProfiles();
767
- const profVec = profMap.get(sym) || null;
768
- const traits = profVec ? TRAITS.map((t, idx) => ({ id: t.id, label: t.label, ask: t.ask, value: round(profVec[idx] ?? 0, 2) })) : [];
769
-
770
- // Позиции и сделки пользователя по этой акции на подключённых брокерских счетах
771
- const bState = brokersState();
772
- const userPositions = [];
773
- const userOperations = [];
774
- for (const acc of bState.accounts || []) {
775
- const pos = (acc.lastSync?.positions || []).find((p) => p.symbol === sym && p.qty !== 0);
776
- if (pos) {
777
- const livePx = curPrice > 0 ? curPrice : pos.price;
778
- const avgPx = Number(pos.avgPrice) || 0;
779
- const valRub = round(pos.qty * livePx, 2);
780
- const pnlRub = avgPx > 0 ? round(pos.qty * (livePx - avgPx), 2) : pos.pnlRub;
781
- const costBasis = avgPx > 0 ? Math.abs(pos.qty) * avgPx : 0;
782
- const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : pos.pnlPct;
783
- userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos, price: livePx, valueRub: valRub, pnlRub, pnlPct });
784
- }
785
- for (const op of acc.lastHistory?.operations || []) {
786
- if (op.symbol === sym && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'div' || op.kind === 'fee')) {
787
- userOperations.push({ accountName: acc.name, ...op });
788
- }
789
- }
790
- }
791
- userOperations.sort((a, b) => (b.date || 0) - (a.date || 0));
792
-
793
- // Считаем историческую результативность по бумаге в последнем эталонном эксперименте
794
- let backtestStat = null;
795
- const lastModelRun = listRuns().find((run) => run.mode === 'model' && run.status === 'done');
796
- if (lastModelRun) {
797
- const simTrades = db.prepare('SELECT side, entry_ts, exit_ts, entry_price, exit_price, pnl, pnl_pct FROM trades WHERE run_id = ? AND symbol = ? ORDER BY entry_ts DESC').all(lastModelRun.id, sym);
798
- if (simTrades.length) {
799
- const wins = simTrades.filter((t) => t.pnl > 0).length;
800
- const avgPct = simTrades.reduce((s, t) => s + (t.pnl_pct || 0), 0) / simTrades.length;
801
- backtestStat = {
802
- runId: lastModelRun.id,
803
- tradesCount: simTrades.length,
804
- winRate: round((wins / simTrades.length) * 100, 1),
805
- avgPnlPct: round(avgPct, 2),
806
- recentTrades: simTrades.slice(0, 10),
807
- };
808
- }
809
- }
810
-
811
- const news = listNews({ symbol: sym, hours: 24 * 30, limit: 20 });
812
- const sortedDivs = [...(divs || [])].sort((a, b) => String(b.registry).localeCompare(String(a.registry)));
813
-
814
- res.json({
815
- symbol: sym,
816
- name: fcRow?.name || vocabHit?.name || sym,
817
- price: round(curPrice, curPrice < 1 ? 5 : curPrice < 10 ? 3 : 2),
818
- dayChangePct,
819
- monthChangePct,
820
- yearChangePct,
821
- tier: isTier1 ? 1 : 2,
822
- tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
823
- edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
824
- sigmaPct: fcRow?.sigmaPct ?? null,
825
- buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.40) * tierMult, 0.30), 3),
826
- shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.50) * tierMult, 0.31), 3),
827
- shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
828
- specs: {
829
- lotSize: sp.lotSize,
830
- lotCostRub: round((sp.lotSize || 1) * curPrice, 2),
831
- minStep: sp.minStep,
832
- decimals: sp.decimals,
833
- issueSize: sp.issueSize || null,
834
- marketCapRub,
835
- indexWeightPct: vocabHit?.weight ? round(vocabHit.weight, 2) : null,
836
- },
837
- forecast: fcRow ? {
838
- bar: fc?.bar,
839
- horizonHours: fc?.horizon || 2.5,
840
- expectedPct: fcRow.expectedPct,
841
- lastMovePct: fcRow.lastMovePct ?? 0,
842
- deltaPct: fcRow.deltaPct || 0,
843
- signal: Boolean(fcRow.signal),
844
- shortSignal: Boolean(fcRow.shortSignal),
845
- shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
846
- shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
847
- buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
848
- buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
849
- shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
850
- buyBlockedByNews: fcRow.buyBlockedByNews || null,
851
- shortBlockedByNews: fcRow.shortBlockedByNews || null,
852
- selfPct: fcRow.selfPct ?? 0,
853
- influences: (fcRow.influences || []).map((inf) => {
854
- const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
855
- return { ...inf, name: infRow?.name || inf.symbol, price: infRow?.price || null, expectedPct: infRow?.expectedPct ?? null };
856
- }),
857
- } : null,
858
- traits,
859
- dividends: sortedDivs,
860
- news,
861
- userPositions,
862
- userOperations: userOperations.slice(0, 60),
863
- backtestStat,
864
- intraday1m,
865
- intraday,
866
- daily,
867
- });
868
- }));
869
-
870
- // ---------- журнал гипотез ----------
871
- r.get('/journal', (req, res) => res.json(listJournal(500)));
872
- r.post('/journal', wrap((req, res) => {
873
- const id = addJournal({ ...req.body, source: 'user' });
874
- hub.emit({ type: 'journal:update' });
875
- res.json({ id });
876
- }));
877
- r.delete('/journal/:id', (req, res) => { deleteJournal(Number(req.params.id)); hub.emit({ type: 'journal:update' }); res.json({ ok: true }); });
878
-
879
- // ---------- чат со Стратегом ----------
880
- r.get('/chat', (req, res) => res.json(strategist.history()));
881
- r.post('/chat/send', wrap((req, res) => {
882
- const plan = effectivePlan(req.user);
883
- if (countUserChatToday() >= plan.maxChatPerDay) {
884
- throw new Error(`Достигнут дневной лимит сообщений ИИ-Стратегу по тарифу «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
885
- }
886
- const { text, mode, newSession, title } = req.body || {};
887
- res.json({ message: strategist.sendUserMessage(text, mode, { newSession: Boolean(newSession), title: title || null }) });
888
- }));
889
- r.post('/chat/action', wrap((req, res) => {
890
- const { msgId, callId, decision, comment } = req.body || {};
891
- strategist.resolveAction(Number(msgId), String(callId), decision, comment);
892
- res.json({ ok: true });
893
- }));
894
- r.post('/chat/stop', (req, res) => { strategist.stopAgent(); res.json({ ok: true }); });
895
- r.post('/chat/mode', wrap((req, res) => { strategist.setMode(req.body?.mode, req.body?.budget); res.json(strategist.chatState()); }));
896
- r.post('/chat/new', (req, res) => { strategist.resetConversation(); res.json(strategist.history()); });
897
- r.get('/chat/archive', (req, res) => res.json({ sessions: strategist.listArchivedSessions(120) }));
898
- r.post('/chat/archive/:id/open', wrap((req, res) => res.json(strategist.openArchivedSession(Number(req.params.id)))));
899
- r.get('/chat/archive/:id/md', wrap((req, res) => {
900
- const info = strategist.exportConversationMarkdown(Number(req.params.id));
901
- if (!info) throw Object.assign(new Error('Сессия не найдена или пуста'), { status: 404 });
902
- res.json(info);
903
- }));
904
-
905
- return r;
906
- }
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import express from 'express';
3
+ import { strategyDefaults } from '../config.js';
4
+ import { activeModelInfo } from '../llm/client.js';
5
+ import {
6
+ listProviders, getSelected, addProvider, updateProvider, deleteProvider,
7
+ fetchRemoteModels, testProvider, selectModel,
8
+ } from '../llm/providers.js';
9
+ import {
10
+ getLocalEngineFullState, startLocalModelInstall, cancelLocalModelInstall,
11
+ startLocalEngine, stopLocalEngine, deleteLocalModel,
12
+ } from '../llm/local-engine.js';
13
+ import {
14
+ listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle,
15
+ countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal,
16
+ listSettingsHistory, kvGet, db, loadCandles, saveCandles,
17
+ } from '../db.js';
18
+ import {
19
+ PLANS, effectivePlan, publicUser, createSession, deleteSession, parseAllCookieValues,
20
+ isLoopbackRequest, getTerminalLicenseState, activateTerminalByToken, detachTerminalLicense,
21
+ loginRemoteTerminal, setTerminalLocalPassword, tradingGate,
22
+ } from '../auth/index.js';
23
+ import { publicNotifyConfig, saveNotifyConfig, notifyUser } from '../notify/index.js';
24
+ import { PARAMS } from '../params-meta.js';
25
+ import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
26
+ import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
27
+ import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
28
+ import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
29
+ import { ensureDividends, ensureCandles } from '../data/moex.js';
30
+ import { live } from '../model/live.js';
31
+ import {
32
+ brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts,
33
+ freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders,
34
+ rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs,
35
+ resetBrokerDailyStop, listBrokerEvents, listTradeDecisions, stopAllBrokerTrading, fetchTbankCandles,
36
+ } from '../brokers/index.js';
37
+ import { listNews } from '../news/index.js';
38
+ import * as strategist from '../strategist/index.js';
39
+ import { round, MSK, DAY } from '../util.js';
40
+ import { hub } from '../hub.js';
41
+ import { isCrossOrigin } from '../security.js';
42
+
43
+ function clientIp(req) {
44
+ const xff = String(req?.headers?.['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
45
+ if (process.env.TRUST_PROXY === '1' && xff.length) return xff[xff.length - 1];
46
+ return String(req?.socket?.remoteAddress || '');
47
+ }
48
+
49
+ function isHttpsRequest(req) {
50
+ return Boolean(req?.secure || String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
51
+ }
52
+
53
+ export function createRouter() {
54
+ const r = express.Router();
55
+ const wrap = (fn) => (req, res) => Promise.resolve()
56
+ .then(() => fn(req, res))
57
+ .catch((e) => res.status(e.status || 400).json({ error: e.message }));
58
+
59
+ const PUBLIC_API = [/^\/local\/(status|activate|login)$/];
60
+
61
+ r.use((req, res, next) => {
62
+ const isRead = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
63
+ if (!isRead && isCrossOrigin(req)) {
64
+ return res.status(403).json({ error: 'Запрос с чужого сайта отклонён' });
65
+ }
66
+ if (req.user || PUBLIC_API.some((rx) => rx.test(req.path))) return next();
67
+ return res.status(401).json({ error: 'Требуется активация или вход в терминал' });
68
+ });
69
+
70
+ const setCookie = (res, sid, maxAgeSec = 30 * 86400) => {
71
+ const secure = isHttpsRequest(res.req) ? '; Secure' : '';
72
+ res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
73
+ };
74
+
75
+ // ---------- Состояние лицензии, активация по токену ЛК и вход на удалённый VPS ----------
76
+ r.get('/local/status', (req, res) => {
77
+ const st = getTerminalLicenseState();
78
+ res.json({
79
+ ...st,
80
+ loggedIn: Boolean(req.user),
81
+ isLoopback: isLoopbackRequest(req),
82
+ });
83
+ });
84
+
85
+ const requireLicense = () => {
86
+ const gate = tradingGate();
87
+ if (!gate.allowNew) {
88
+ throw Object.assign(new Error(gate.reason), { status: 402 });
89
+ }
90
+ };
91
+
92
+ r.post('/local/activate', wrap(async (req, res) => {
93
+ const st = getTerminalLicenseState();
94
+ if (st.activated && !req.user && !isLoopbackRequest(req)) {
95
+ throw Object.assign(new Error('Терминал уже активирован. Войдите по своему токену личного кабинета или локальному паролю.'), { status: 409 });
96
+ }
97
+ const out = await activateTerminalByToken({
98
+ token: req.body?.token,
99
+ portalUrl: req.body?.portalUrl,
100
+ slotId: req.body?.slotId,
101
+ });
102
+ if (!out.chooseSlot) {
103
+ const sid = createSession(1, 30);
104
+ setCookie(res, sid);
105
+ llmChanged();
106
+ hub.emit({ type: 'license:changed', license: out.state });
107
+ hub.emit({ type: 'brokers:update', state: brokersState() });
108
+ }
109
+ res.json(out);
110
+ }));
111
+
112
+ r.post('/local/login', wrap(async (req, res) => {
113
+ const sid = await loginRemoteTerminal(req.body?.secret, { ip: clientIp(req) });
114
+ setCookie(res, sid);
115
+ res.json({ ok: true });
116
+ }));
117
+
118
+ r.post('/local/refresh-license', wrap(async (req, res) => {
119
+ const cur = getTerminalLicenseState();
120
+ const out = await activateTerminalByToken({
121
+ token: req.body?.token || '',
122
+ portalUrl: req.body?.portalUrl || cur.portalUrl,
123
+ slotId: req.body?.slotId || cur.licId || '',
124
+ });
125
+ llmChanged();
126
+ if (!out.chooseSlot) {
127
+ hub.emit({ type: 'license:changed', license: out.state });
128
+ hub.emit({ type: 'brokers:update', state: brokersState() });
129
+ }
130
+ res.json(out);
131
+ }));
132
+
133
+ r.post('/local/detach-license', wrap((req, res) => {
134
+ const st = detachTerminalLicense();
135
+ llmChanged();
136
+ hub.emit({ type: 'license:changed', license: st });
137
+ hub.emit({ type: 'brokers:update', state: brokersState() });
138
+ res.json({ ok: true, state: st });
139
+ }));
140
+
141
+ r.post('/local/password', wrap((req, res) => {
142
+ res.json(setTerminalLocalPassword(req.body?.password || ''));
143
+ }));
144
+
145
+ // ---------- Конфигурация терминала и профиль лицензии ----------
146
+ r.get('/config', (req, res) => res.json({
147
+ defaults: strategyDefaults,
148
+ params: PARAMS,
149
+ llm: activeModelInfo(),
150
+ user: publicUser(req.user),
151
+ plans: PLANS,
152
+ notify: publicNotifyConfig(),
153
+ }));
154
+
155
+ r.get('/auth/me', (req, res) => res.json({
156
+ user: publicUser(req.user),
157
+ license: getTerminalLicenseState(),
158
+ plans: PLANS,
159
+ notify: publicNotifyConfig(),
160
+ isLoopback: isLoopbackRequest(req),
161
+ }));
162
+
163
+ r.post('/auth/logout', (req, res) => {
164
+ for (const tok of parseAllCookieValues(req.headers?.cookie, 'sid')) {
165
+ if (tok && tok !== 'logged_out') deleteSession(tok);
166
+ }
167
+ setCookie(res, 'logged_out', 365 * 86400);
168
+ res.json({ ok: true });
169
+ });
170
+
171
+ // ---------- Уведомления (шлюз портала + опционально прямой личный бот) ----------
172
+ r.post('/notify/config', wrap((req, res) => {
173
+ res.json({ ok: true, notify: saveNotifyConfig(req.body || {}) });
174
+ }));
175
+
176
+ r.post('/notify/test', wrap(async (req, res) => {
177
+ const sent = await notifyUser(1, '◈ НейроАльфа Терминал: тестовое уведомление успешно доставлено.');
178
+ if (!sent.tg && !sent.max) {
179
+ throw new Error('Уведомление не доставлено: привяжите Telegram/MAX по коду в личном кабинете на портале либо укажите токен личного бота ниже.');
180
+ }
181
+ res.json({ ok: true, ...sent });
182
+ }));
183
+
184
+ // ---------- Модели ИИ для Стратега (облачный прокси, внешние API и локальный движок llama.cpp) ----------
185
+ const llmState = () => ({ providers: listProviders(), selected: getSelected(), active: activeModelInfo() });
186
+ const llmChanged = () => {
187
+ hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
188
+ hub.emit({ type: 'chat:state', state: strategist.chatState() });
189
+ };
190
+
191
+ r.get('/llm/providers', wrap((req, res) => res.json(llmState())));
192
+ r.post('/llm/providers', wrap((req, res) => { const p = addProvider(req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
193
+ r.put('/llm/providers/:id', wrap((req, res) => { const p = updateProvider(req.params.id, req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
194
+ r.delete('/llm/providers/:id', wrap((req, res) => { deleteProvider(req.params.id); llmChanged(); res.json(llmState()); }));
195
+ r.get('/llm/providers/:id/remote-models', wrap(async (req, res) => res.json({ models: await fetchRemoteModels(req.params.id) })));
196
+ r.post('/llm/test', wrap(async (req, res) => res.json(await testProvider(req.body?.providerId, req.body?.model))));
197
+ r.post('/llm/select', wrap((req, res) => { selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
198
+
199
+ // Локальная модель ИИ (диагностика железа, скачивание и запуск llama-server + Qwen3 GGUF)
200
+ r.get('/llm/local/state', wrap(async (req, res) => {
201
+ res.json(await getLocalEngineFullState({ refresh: req.query.refresh === '1' }));
202
+ }));
203
+ r.post('/llm/local/install', wrap((req, res) => {
204
+ res.json(startLocalModelInstall(req.body || {}));
205
+ }));
206
+ r.post('/llm/local/cancel', wrap((req, res) => {
207
+ res.json(cancelLocalModelInstall());
208
+ }));
209
+ r.post('/llm/local/start', wrap(async (req, res) => {
210
+ const st = await startLocalEngine(req.body || {});
211
+ llmChanged();
212
+ res.json(st);
213
+ }));
214
+ r.post('/llm/local/stop', wrap((req, res) => {
215
+ stopLocalEngine();
216
+ llmChanged();
217
+ res.json({ ok: true });
218
+ }));
219
+ r.delete('/llm/local/model/:id', wrap((req, res) => {
220
+ res.json(deleteLocalModel(req.params.id));
221
+ }));
222
+
223
+ // ---------- Настройки стратегии ----------
224
+ r.get('/settings', (req, res) => res.json(getSettings()));
225
+ r.put('/settings', wrap((req, res) => res.json(updateSettings(req.body || {}, { source: 'user' }))));
226
+ r.post('/settings/reset', (req, res) => res.json(resetSettings('user')));
227
+ r.get('/settings/history', (req, res) => res.json(listSettingsHistory(Math.min(200, Number(req.query.limit) || 50))));
228
+ r.post('/settings/history/:id/revert', wrap((req, res) => res.json(revertSettingsChange(req.params.id))));
229
+
230
+ // ---------- Исследования и история прогонов ----------
231
+ const ownRunOr404 = (req) => {
232
+ const run = getRun(Number(req.params.id));
233
+ if (!run) throw Object.assign(new Error('Не найдено'), { status: 404 });
234
+ return run;
235
+ };
236
+ r.get('/runs', (req, res) => res.json(listRuns()));
237
+ r.get('/runs/:id', (req, res) => {
238
+ const run = getRun(Number(req.params.id));
239
+ if (!run) return res.status(404).json({ error: 'Не найдено' });
240
+ res.json({ ...run, trades: getTrades(run.id) });
241
+ });
242
+ r.put('/runs/:id/title', wrap((req, res) => {
243
+ ownRunOr404(req);
244
+ const updated = updateRunTitle(Number(req.params.id), req.body?.title || '');
245
+ if (!updated) return res.status(404).json({ error: 'Не найдено' });
246
+ res.json(updated);
247
+ }));
248
+ r.post('/runs/:id/stability', wrap((req, res) => { ownRunOr404(req); res.json(startStabilityCheck(Number(req.params.id))); }));
249
+ r.post('/runs/:id/stability/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelStabilityCheck(Number(req.params.id)) }); }));
250
+ r.delete('/runs/:id', wrap((req, res) => {
251
+ const run = ownRunOr404(req);
252
+ if (run?.status === 'running') throw new Error('Эксперимент ещё идёт — сначала отмените его');
253
+ deleteRun(Number(req.params.id));
254
+ res.json({ ok: true });
255
+ }));
256
+ r.post('/runs/cleanup', wrap((req, res) => {
257
+ const raw = Number(req.body?.keep);
258
+ const keep = Number.isFinite(raw) ? Math.max(0, Math.min(50, Math.floor(raw))) : 0;
259
+ const all = allRuns();
260
+ const done = all.filter((x) => x.mode === 'model' && x.status === 'done');
261
+ const ranked = [...done].sort((a, b) => (b.metrics?.full?.returnPct ?? -1e9) - (a.metrics?.full?.returnPct ?? -1e9));
262
+ const keepSet = new Set(ranked.slice(0, keep).map((x) => x.id));
263
+ let deleted = 0;
264
+ for (const item of all) {
265
+ if (item.mode !== 'model' || item.status === 'running' || keepSet.has(item.id)) continue;
266
+ deleteRun(item.id);
267
+ deleted++;
268
+ }
269
+ res.json({ deleted, kept: [...keepSet] });
270
+ }));
271
+
272
+ r.post('/model/research', wrap((req, res) => {
273
+ requireLicense();
274
+ const plan = effectivePlan();
275
+ if (!plan.allowResearch) {
276
+ throw new Error('Раздел исследований доступен только по лицензии «Хозяин».');
277
+ }
278
+ if (countUserRunsToday() >= plan.maxRunsPerDay) {
279
+ throw new Error(`Достигнут дневной лимит исследований (${plan.maxRunsPerDay} в сутки).`);
280
+ }
281
+ const body = req.body || {};
282
+ const merged = { ...getSettings(), ...body };
283
+ const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
284
+ res.json({ runId, reused: !!reused });
285
+ }));
286
+ r.post('/model/:id/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelModelResearch(Number(req.params.id)) }); }));
287
+ r.get('/model/forecast', wrap(async (req, res) => {
288
+ requireLicense();
289
+ const fc = await forecastNow(getSettings(), { force: req.query.force === '1' });
290
+ if (fc?.rows?.some((x) => x.signal || x.shortSignal)) {
291
+ triggerBrokerFastEntryCheck();
292
+ }
293
+ res.json(fc);
294
+ }));
295
+
296
+ // ---------- Описания компаний ----------
297
+ r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
298
+ r.post('/profiles/refresh', wrap((req, res) => { requireLicense(); return res.json(startProfilesRefresh()); }));
299
+
300
+ // ---------- Виртуальные счета (песочница живой проверки) ----------
301
+ r.get('/live/status', (req, res) => res.json(tradingGate().allowNew ? live.status() : { accounts: [], running: false }));
302
+ r.post('/live/start', wrap(async (req, res) => {
303
+ requireLicense();
304
+ const plan = effectivePlan();
305
+ const b = req.body || {};
306
+ const runId = await live.start(getSettings(), {
307
+ source: 'user',
308
+ baseRunId: b.baseRunId || null,
309
+ name: b.name || '',
310
+ capital: b.capital || null,
311
+ maxAccounts: plan.maxVirtualAccounts,
312
+ });
313
+ res.json({ runId, status: live.status() });
314
+ }));
315
+ r.post('/live/stop', (req, res) => res.json({ ok: live.stop(req.body?.runId || null), status: live.status() }));
316
+ r.delete('/live/:id', (req, res) => res.json({ ok: live.remove(Number(req.params.id)), status: live.status() }));
317
+
318
+ // ---------- Брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
319
+ r.get('/brokers', (req, res) => res.json(brokersState()));
320
+ r.post('/brokers/discover', wrap(async (req, res) => { requireLicense(); return res.json(await discoverBrokerAccounts(req.body || {})); }));
321
+ r.post('/brokers', wrap((req, res) => {
322
+ requireLicense();
323
+ const plan = effectivePlan();
324
+ const cur = brokersState().accounts.length;
325
+ if (!req.body?.id && cur >= plan.maxBrokerAccounts) {
326
+ if ((plan.maxBrokerAccounts ?? 0) < 1) {
327
+ throw new Error('Для подключения брокерского счёта активируйте оплаченную лицензию из личного кабинета.');
328
+ }
329
+ throw new Error(`По вашей лицензии «${plan.name}» доступно подключение до ${plan.maxBrokerAccounts} брокерских счетов.`);
330
+ }
331
+ res.json(saveBrokerAccount(req.body || {}));
332
+ }));
333
+ r.put('/brokers/:id', wrap((req, res) => { requireLicense(); return res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id })); }));
334
+ r.delete('/brokers/:id', wrap((req, res) => { requireLicense(); return res.json(deleteBrokerAccount(req.params.id)); }));
335
+ r.post('/brokers/:id/sync', wrap(async (req, res) => { requireLicense(); return res.json(await syncBrokerAccount(req.params.id)); }));
336
+ r.post('/brokers/:id/history', wrap(async (req, res) => { requireLicense(); return res.json(await fetchBrokerHistory(req.params.id, req.body || {})); }));
337
+ r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => { requireLicense(); return res.json(await freezeBrokerBaseline(req.params.id)); }));
338
+ r.post('/brokers/:id/prepare', wrap(async (req, res) => { requireLicense(); return res.json(await prepareBrokerOrders(req.params.id)); }));
339
+ r.post('/brokers/:id/execute', wrap(async (req, res) => { requireLicense(); return res.json(await executeBrokerOrders(req.params.id)); }));
340
+ r.post('/brokers/:id/reject', wrap((req, res) => { requireLicense(); return res.json(rejectBrokerOrders(req.params.id)); }));
341
+ r.post('/brokers/:id/cancel-order', wrap(async (req, res) => { requireLicense(); return res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null)); }));
342
+ r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => { requireLicense(); return res.json(resetBrokerDailyStop(req.params.id)); }));
343
+ r.get('/brokers/:id/events', wrap((req, res) => { requireLicense(); return res.json({ events: listBrokerEvents(req.params.id, req.query.limit) }); }));
344
+ r.get('/brokers/:id/decisions', wrap((req, res) => { requireLicense(); return res.json({ decisions: listTradeDecisions(req.params.id, { fromTs: req.query.from, limit: req.query.limit }) }); }));
345
+ r.post('/brokers-stop-all', wrap(async (req, res) => { requireLicense(); return res.json(await stopAllBrokerTrading()); }));
346
+
347
+ // ---------- Новости ----------
348
+ r.get('/news', (req, res) => res.json({
349
+ items: listNews({ symbol: req.query.symbol || undefined, hours: Number(req.query.hours) || 72, limit: Number(req.query.limit) || 30 }),
350
+ }));
351
+
352
+ // ---------- Досье акции ----------
353
+ r.get('/stock/:symbol', wrap(async (req, res) => {
354
+ requireLicense();
355
+ const sym = String(req.params.symbol || '').toUpperCase().trim();
356
+ if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
357
+
358
+ const nowTs = Date.now();
359
+ const [fc, rawSpecs, divs, tbLiveBars, tbMinBars] = await Promise.all([
360
+ forecastNow(getSettings()).catch(() => null),
361
+ ensureBoardSpecs().catch(() => ({})),
362
+ ensureDividends(sym).catch(() => []),
363
+ fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
364
+ fetchTbankCandles(sym, nowTs - DAY + 5 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null),
365
+ ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
366
+ ]);
367
+ if (tbLiveBars?.length) {
368
+ const doneBars = tbLiveBars.filter((b) => b.isComplete);
369
+ if (doneBars.length) saveCandles(sym, 10, doneBars);
370
+ }
371
+ const tbMap = kvGet('tbankShortableMap') || {};
372
+ const tbSpecs = tbMap.specMap || {};
373
+ const shortableMap = tbMap.map || {};
374
+ const sp = { ...(rawSpecs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2, issueSize: 0, lastPrice: 0 }), ...(tbSpecs[sym] || {}) };
375
+ const vocabList = kvGet('vocab')?.list || [];
376
+ const vocabHit = vocabList.find((v) => v.symbol === sym);
377
+ const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
378
+ const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
379
+ const tierMult = symbolEdgeMult(sym);
380
+
381
+ const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
382
+ const intraday = rawBars30d.slice(-1500).map((b) => ({
383
+ ts: b.ts, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0,
384
+ }));
385
+
386
+ const allBars = db.prepare('SELECT ts, open, high, low, close, value FROM candles WHERE symbol = ? AND interval = 10 ORDER BY ts ASC').all(sym);
387
+ const dailyMap = new Map();
388
+ for (const b of allBars) {
389
+ const dayKey = Math.floor((b.ts + MSK) / DAY) * DAY;
390
+ let d = dailyMap.get(dayKey);
391
+ if (!d) {
392
+ d = { ts: dayKey, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0 };
393
+ dailyMap.set(dayKey, d);
394
+ } else {
395
+ if (b.high > d.h) d.h = b.high;
396
+ if (b.low > 0 && (d.l <= 0 || b.low < d.l)) d.l = b.low;
397
+ d.c = b.close;
398
+ d.v += b.value || 0;
399
+ }
400
+ }
401
+ const daily = [...dailyMap.values()];
402
+
403
+ const tbFresh1m = tbMinBars?.length ? tbMinBars[tbMinBars.length - 1] : null;
404
+ const tbFreshBar = tbLiveBars?.length ? tbLiveBars[tbLiveBars.length - 1] : null;
405
+ const curPrice = (tbFresh1m?.close > 0 ? tbFresh1m.close : 0)
406
+ || (tbFreshBar?.close > 0 ? tbFreshBar.close : 0)
407
+ || fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
408
+ const intraday1m = (tbMinBars || []).map((b) => ({
409
+ ts: b.ts, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0,
410
+ }));
411
+ if (curPrice > 0 && intraday1m.length) {
412
+ const last1m = intraday1m[intraday1m.length - 1];
413
+ const liveMinSlotTs = Math.floor(nowTs / 60000) * 60000;
414
+ if (liveMinSlotTs > last1m.ts && liveMinSlotTs - last1m.ts <= 3600000) {
415
+ intraday1m.push({ ts: liveMinSlotTs, o: last1m.c, h: Math.max(last1m.c, curPrice), l: Math.min(last1m.c, curPrice), c: curPrice, v: 0 });
416
+ } else if (last1m.c !== curPrice) {
417
+ last1m.c = curPrice;
418
+ if (curPrice > last1m.h) last1m.h = curPrice;
419
+ if (last1m.l <= 0 || curPrice < last1m.l) last1m.l = curPrice;
420
+ }
421
+ }
422
+ if (tbFreshBar && !tbFreshBar.isComplete && intraday.length) {
423
+ const lastBar = intraday[intraday.length - 1];
424
+ if (tbFreshBar.ts > lastBar.ts) {
425
+ intraday.push({ ts: tbFreshBar.ts, o: tbFreshBar.open, h: tbFreshBar.high, l: tbFreshBar.low, c: curPrice, v: tbFreshBar.value || 0 });
426
+ } else if (tbFreshBar.ts === lastBar.ts) {
427
+ lastBar.c = curPrice;
428
+ if (tbFreshBar.high > lastBar.h) lastBar.h = tbFreshBar.high;
429
+ if (tbFreshBar.low > 0 && (lastBar.l <= 0 || tbFreshBar.low < lastBar.l)) lastBar.l = tbFreshBar.low;
430
+ }
431
+ }
432
+ if (curPrice > 0 && intraday.length) {
433
+ const lastBar = intraday[intraday.length - 1];
434
+ const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
435
+ if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
436
+ intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
437
+ } else if (lastBar.c !== curPrice) {
438
+ lastBar.c = curPrice;
439
+ if (curPrice > lastBar.h) lastBar.h = curPrice;
440
+ if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
441
+ }
442
+ if (daily.length) {
443
+ const lastDay = daily[daily.length - 1];
444
+ lastDay.c = curPrice;
445
+ if (curPrice > lastDay.h) lastDay.h = curPrice;
446
+ if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
447
+ }
448
+ }
449
+ const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
450
+ const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
451
+ const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
452
+ const monthChangePct = monthAgoClose > 0 && curPrice > 0 ? round(((curPrice - monthAgoClose) / monthAgoClose) * 100, 2) : 0;
453
+ const yearAgoClose = daily.length >= 250 ? daily[daily.length - 250].c : (daily[0]?.o || curPrice);
454
+ const yearChangePct = yearAgoClose > 0 && curPrice > 0 ? round(((curPrice - yearAgoClose) / yearAgoClose) * 100, 2) : 0;
455
+ const marketCapRub = sp.issueSize > 0 && curPrice > 0 ? Math.round(sp.issueSize * curPrice) : null;
456
+
457
+ const profMap = loadProfiles();
458
+ const profVec = profMap.get(sym) || null;
459
+ const traits = profVec ? TRAITS.map((t, idx) => ({ id: t.id, label: t.label, ask: t.ask, value: round(profVec[idx] ?? 0, 2) })) : [];
460
+
461
+ const bState = brokersState();
462
+ const userPositions = [];
463
+ const userOperations = [];
464
+ for (const acc of bState.accounts || []) {
465
+ const pos = (acc.lastSync?.positions || []).find((p) => p.symbol === sym && p.qty !== 0);
466
+ if (pos) {
467
+ const livePx = curPrice > 0 ? curPrice : pos.price;
468
+ const avgPx = Number(pos.avgPrice) || 0;
469
+ const valRub = round(pos.qty * livePx, 2);
470
+ const pnlRub = avgPx > 0 ? round(pos.qty * (livePx - avgPx), 2) : pos.pnlRub;
471
+ const costBasis = avgPx > 0 ? Math.abs(pos.qty) * avgPx : 0;
472
+ const pnlPct = costBasis > 0 ? round((pnlRub / costBasis) * 100, 2) : pos.pnlPct;
473
+ userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos, price: livePx, valueRub: valRub, pnlRub, pnlPct });
474
+ }
475
+ for (const op of acc.lastHistory?.operations || []) {
476
+ if (op.symbol === sym && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'div' || op.kind === 'fee')) {
477
+ userOperations.push({ accountName: acc.name, ...op });
478
+ }
479
+ }
480
+ }
481
+ userOperations.sort((a, b) => (b.date || 0) - (a.date || 0));
482
+
483
+ let backtestStat = null;
484
+ const lastModelRun = listRuns().find((run) => run.mode === 'model' && run.status === 'done');
485
+ if (lastModelRun) {
486
+ const simTrades = db.prepare('SELECT side, entry_ts, exit_ts, entry_price, exit_price, pnl, pnl_pct FROM trades WHERE run_id = ? AND symbol = ? ORDER BY entry_ts DESC').all(lastModelRun.id, sym);
487
+ if (simTrades.length) {
488
+ const wins = simTrades.filter((t) => t.pnl > 0).length;
489
+ const avgPct = simTrades.reduce((s, t) => s + (t.pnl_pct || 0), 0) / simTrades.length;
490
+ backtestStat = {
491
+ runId: lastModelRun.id,
492
+ tradesCount: simTrades.length,
493
+ winRate: round((wins / simTrades.length) * 100, 1),
494
+ avgPnlPct: round(avgPct, 2),
495
+ recentTrades: simTrades.slice(0, 10),
496
+ };
497
+ }
498
+ }
499
+
500
+ const news = listNews({ symbol: sym, hours: 24 * 30, limit: 20 });
501
+ const sortedDivs = [...(divs || [])].sort((a, b) => String(b.registry).localeCompare(String(a.registry)));
502
+
503
+ res.json({
504
+ symbol: sym,
505
+ name: fcRow?.name || vocabHit?.name || sym,
506
+ price: round(curPrice, curPrice < 1 ? 5 : curPrice < 10 ? 3 : 2),
507
+ dayChangePct,
508
+ monthChangePct,
509
+ yearChangePct,
510
+ tier: isTier1 ? 1 : 2,
511
+ tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
512
+ edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
513
+ sigmaPct: fcRow?.sigmaPct ?? null,
514
+ buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.40) * tierMult, 0.30), 3),
515
+ shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.50) * tierMult, 0.31), 3),
516
+ shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
517
+ specs: {
518
+ lotSize: sp.lotSize,
519
+ lotCostRub: round((sp.lotSize || 1) * curPrice, 2),
520
+ minStep: sp.minStep,
521
+ decimals: sp.decimals,
522
+ issueSize: sp.issueSize || null,
523
+ marketCapRub,
524
+ indexWeightPct: vocabHit?.weight ? round(vocabHit.weight, 2) : null,
525
+ },
526
+ forecast: fcRow ? {
527
+ bar: fc?.bar,
528
+ horizonHours: fc?.horizon || 2.5,
529
+ expectedPct: fcRow.expectedPct,
530
+ lastMovePct: fcRow.lastMovePct ?? 0,
531
+ deltaPct: fcRow.deltaPct || 0,
532
+ signal: Boolean(fcRow.signal),
533
+ shortSignal: Boolean(fcRow.shortSignal),
534
+ shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
535
+ shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
536
+ buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
537
+ buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
538
+ shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
539
+ buyBlockedByNews: fcRow.buyBlockedByNews || null,
540
+ shortBlockedByNews: fcRow.shortBlockedByNews || null,
541
+ selfPct: fcRow.selfPct ?? 0,
542
+ influences: (fcRow.influences || []).map((inf) => {
543
+ const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
544
+ return { ...inf, name: infRow?.name || inf.symbol, price: infRow?.price || null, expectedPct: infRow?.expectedPct ?? null };
545
+ }),
546
+ } : null,
547
+ traits,
548
+ dividends: sortedDivs,
549
+ news,
550
+ userPositions,
551
+ userOperations: userOperations.slice(0, 60),
552
+ backtestStat,
553
+ intraday1m,
554
+ intraday,
555
+ daily,
556
+ });
557
+ }));
558
+
559
+ // ---------- Журнал гипотез ----------
560
+ r.get('/journal', (req, res) => res.json(listJournal(500)));
561
+ r.post('/journal', wrap((req, res) => {
562
+ const id = addJournal({ ...req.body, source: 'user' });
563
+ hub.emit({ type: 'journal:update' });
564
+ res.json({ id });
565
+ }));
566
+ r.delete('/journal/:id', (req, res) => { deleteJournal(Number(req.params.id)); hub.emit({ type: 'journal:update' }); res.json({ ok: true }); });
567
+
568
+ // ---------- Чат со Стратегом ----------
569
+ r.get('/chat', (req, res) => res.json(strategist.history()));
570
+ r.post('/chat/send', wrap((req, res) => {
571
+ const plan = effectivePlan();
572
+ if (countUserChatToday() >= plan.maxChatPerDay) {
573
+ throw new Error(`Достигнут дневной лимит сообщений ИИ-Стратегу по лицензии «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
574
+ }
575
+ const { text, mode, newSession, title } = req.body || {};
576
+ res.json({ message: strategist.sendUserMessage(text, mode, { newSession: Boolean(newSession), title: title || null }) });
577
+ }));
578
+ r.post('/chat/action', wrap((req, res) => {
579
+ const { msgId, callId, decision, comment } = req.body || {};
580
+ strategist.resolveAction(Number(msgId), String(callId), decision, comment);
581
+ res.json({ ok: true });
582
+ }));
583
+ r.post('/chat/stop', (req, res) => { strategist.stopAgent(); res.json({ ok: true }); });
584
+ r.post('/chat/mode', wrap((req, res) => { strategist.setMode(req.body?.mode, req.body?.budget); res.json(strategist.chatState()); }));
585
+ r.post('/chat/new', (req, res) => { strategist.resetConversation(); res.json(strategist.history()); });
586
+ r.get('/chat/archive', (req, res) => res.json({ sessions: strategist.listArchivedSessions(120) }));
587
+ r.post('/chat/archive/:id/open', wrap((req, res) => res.json(strategist.openArchivedSession(Number(req.params.id)))));
588
+ r.get('/chat/archive/:id/md', wrap((req, res) => {
589
+ const info = strategist.exportConversationMarkdown(Number(req.params.id));
590
+ if (!info) throw Object.assign(new Error('Сессия не найдена или пуста'), { status: 404 });
591
+ res.json(info);
592
+ }));
593
+
594
+ return r;
595
+ }