neuroalpha 0.3.17 → 0.5.2

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 (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +2 -2
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +589 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1095 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. package/server/tls-ca.js +32 -0
@@ -5,24 +5,58 @@ import express from 'express';
5
5
  import { ROOT, strategyDefaults } from '../config.js';
6
6
  import { activeModelInfo } from '../llm/client.js';
7
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 } from '../db.js';
8
+ import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
9
9
  import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
10
  import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
11
11
  import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
12
12
  import { PARAMS } from '../params-meta.js';
13
13
  import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
14
14
  import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
15
- import { forecastNow } from '../model/forecast.js';
16
- import { profilesInfo, startProfilesRefresh } from '../model/profiles.js';
15
+ import { forecastNow, TIER1_LIQUID_SYMBOLS } from '../model/forecast.js';
16
+ import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
17
+ import { ensureDividends } from '../data/moex.js';
17
18
  import { live } from '../model/live.js';
18
- import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder } from '../brokers/index.js';
19
+ import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading } from '../brokers/index.js';
19
20
  import { listNews } from '../news/index.js';
20
21
  import * as strategist from '../strategist/index.js';
22
+ import { round, MSK, DAY } from '../util.js';
21
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, 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';
22
31
 
23
32
  export function createRouter() {
24
33
  const r = express.Router();
25
- const wrap = (fn) => (req, res) => Promise.resolve().then(() => fn(req, res)).catch((e) => res.status(400).json({ error: e.message }));
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
+ });
26
60
 
27
61
  r.get('/config', (req, res) => res.json({
28
62
  defaults: strategyDefaults, params: PARAMS,
@@ -36,10 +70,18 @@ export function createRouter() {
36
70
 
37
71
  // ---------- авторизация, профиль и тарифы ----------
38
72
  const setCookie = (res, sid, maxAgeSec = 30 * 86400) => {
39
- res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}`);
73
+ const secure = isHttpsRequest(res.req) ? '; Secure' : '';
74
+ res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
40
75
  };
41
76
  r.get('/auth/me', (req, res) => res.json({
42
- user: publicUser(req.user),
77
+ user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [] } : null,
78
+ downloads: IS_CENTRAL ? {
79
+ windows: process.env.DESKTOP_DOWNLOAD_WINDOWS || '',
80
+ linux: process.env.DESKTOP_DOWNLOAD_LINUX || '',
81
+ docker: process.env.DESKTOP_DOCKER_IMAGE || '',
82
+ } : null,
83
+ role: IS_INSTANCE ? 'instance' : IS_CENTRAL ? 'central' : 'dev',
84
+ centralUrl: IS_INSTANCE ? CENTRAL_URL : '',
43
85
  cloudMode: process.env.CLOUD_MODE === '1',
44
86
  plans: PLANS,
45
87
  notify: publicNotifyConfig(),
@@ -105,8 +147,8 @@ export function createRouter() {
105
147
  setCookie(res, token);
106
148
  res.json({ user: publicUser(u), token });
107
149
  }));
108
- r.post('/auth/login', wrap((req, res) => {
109
- const u = loginUser(req.body || {});
150
+ r.post('/auth/login', wrap(async (req, res) => {
151
+ const u = await loginUser(req.body || {}, { ip: clientIp(req) });
110
152
  const token = createSession(u.id);
111
153
  setCookie(res, token);
112
154
  res.json({ user: publicUser(u), token });
@@ -115,7 +157,58 @@ export function createRouter() {
115
157
  const cookies = parseCookies(req.headers?.cookie);
116
158
  if (cookies.sid) deleteSession(cookies.sid);
117
159
  setCookie(res, 'logged_out', 365 * 86400);
118
- res.json({ ok: true });
160
+ res.json({ ok: true, ...(IS_INSTANCE ? { redirect: CENTRAL_URL } : {}) });
161
+ });
162
+
163
+ // ---------- установка на компьютер: состояние и активация кодом (до активации входа ещё нет) ----------
164
+ r.get('/local/status', (req, res) => res.json({ ...instanceInfo(), loggedIn: Boolean(req.user) }));
165
+ r.post('/local/activate', wrap(async (req, res) => {
166
+ if (!IS_DESKTOP) throw new Error('Активация кодом доступна только для установки на компьютер');
167
+ if (isActivated() && !req.user) throw Object.assign(new Error('Программа уже активирована'), { status: 409 });
168
+ res.json(await activateDesktop(req.body?.code));
169
+ }));
170
+
171
+ // ---------- машина клиента: вход по одноразовой ссылке из кабинета центра ----------
172
+ r.get('/auth/sso', (req, res) => {
173
+ if (!IS_INSTANCE) return res.status(404).json({ error: 'Вход через центр доступен только на машине клиента' });
174
+ try {
175
+ consumeSsoToken(String(req.query.t || ''));
176
+ const token = createSession(1, 7);
177
+ setCookie(res, token, 7 * 86400);
178
+ res.redirect('/');
179
+ } catch (e) {
180
+ res.redirect(`${CENTRAL_URL}/landing?oauth_error=${encodeURIComponent(e.message)}`);
181
+ }
182
+ });
183
+
184
+ // ---------- центр: связь с машинами клиентов ----------
185
+ const machineAuth = (req) => {
186
+ const h = String(req.headers?.authorization || '');
187
+ const inst = instanceByKey(h.startsWith('Bearer ') ? h.slice(7).trim() : '');
188
+ if (!inst) throw Object.assign(new Error('Неизвестная машина или неверный ключ'), { status: 401 });
189
+ return inst;
190
+ };
191
+ r.post('/instance/heartbeat', wrap((req, res) => res.json(instanceHeartbeat(machineAuth(req), req.body || {}))));
192
+ // Активация установки на компьютер одноразовым кодом из кабинета (ключа у установки ещё нет)
193
+ r.post('/instance/activate', wrap((req, res) => res.json(activateDesktopInstall(req.body || {}, { ip: clientIp(req) }))));
194
+ // Кабинет клиента: код активации для установки на компьютер и отвязка компьютера
195
+ r.post('/instances/desktop-code', wrap((req, res) => res.json(createDesktopActivationCode(req.user))));
196
+ r.post('/instances/:id/unbind', wrap((req, res) => res.json({ instance: unbindInstall(req.params.id, { userId: req.user.id }) })));
197
+ r.post('/instance/notify', wrap(async (req, res) => {
198
+ const inst = machineAuth(req);
199
+ if (inst.revoked) throw Object.assign(new Error('Аренда машины завершена'), { status: 403 });
200
+ res.json(await relayInstanceNotify(inst, req.body?.text, req.body?.buttons));
201
+ }));
202
+ // Кнопка «Открыть терминал» в кабинете: одноразовая подписанная ссылка входа на машину клиента
203
+ r.get('/instances/open', (req, res) => {
204
+ if (!req.user) return res.redirect('/landing?login=1');
205
+ const url = ssoRedirectUrl(req.user, req.query.i || '');
206
+ if (!url) return res.redirect(`/landing?oauth_error=${encodeURIComponent('Для вашего аккаунта ещё не создана машина — обратитесь в поддержку')}`);
207
+ res.redirect(url);
208
+ });
209
+ r.get('/public/license-key', (req, res) => {
210
+ if (!IS_CENTRAL) return res.status(404).json({ error: 'Только на центральном сервере' });
211
+ res.type('text/plain').send(centralPublicKeyPem());
119
212
  });
120
213
  r.post('/auth/notify-code', wrap((req, res) => {
121
214
  if (!req.user) throw new Error('Войдите в аккаунт');
@@ -166,6 +259,7 @@ export function createRouter() {
166
259
  }));
167
260
  // Активация API-ключа облачной подписки на локальном npx-клиенте
168
261
  r.post('/license/activate', wrap(async (req, res) => {
262
+ requireAdmin(req);
169
263
  const lic = await activateLocalLicense(req.body?.apiKey, req.body?.cloudUrl);
170
264
  llmChanged();
171
265
  res.json({ ok: true, license: lic, user: publicUser(req.user) });
@@ -192,7 +286,7 @@ export function createRouter() {
192
286
  res.json({ ok: true });
193
287
  }));
194
288
  r.post('/notify/max/webhook', wrap(async (req, res) => {
195
- res.json(await handleMaxWebhook(req.body || {}));
289
+ res.json(await handleMaxWebhook(req.body || {}, req.headers || {}));
196
290
  }));
197
291
 
198
292
  // ---------- публичная витрина для лендинга ----------
@@ -211,24 +305,20 @@ export function createRouter() {
211
305
 
212
306
  let tierBadge = 'Тариф «Инвестор»';
213
307
  let tierColor = 'blue';
214
- let tierNote = 'Режим «Только в рост (без шорта)» · лимит до 50% годовых · до 1 млн ₽ на счёт';
308
+ // Тарифы различаются возможностями (шорты, число счетов, лимиты), а не доходностью
309
+ let tierNote = 'Только покупки (без шорта) · 1 счёт до 1 млн ₽';
215
310
  let autoTitle = p._title;
216
- if (dir === 'long' || cagr <= 52) {
311
+ if (dir === 'long') {
217
312
  tierBadge = 'Для тарифа «Инвестор»';
218
313
  tierColor = 'blue';
219
- tierNote = 'Торговля только от покупки (без шортов) · потолок тарифа до 50% годовых · до 1 млн ₽/счёт';
220
- if (!autoTitle) autoTitle = 'Консервативный рост · Только покупки';
221
- } else if (cagr <= 105) {
222
- tierBadge = 'Для тарифа «Профи»';
223
- tierColor = 'indigo';
224
- tierNote = 'Заработок на росте и падении акций (с шортами) · потолок тарифа до 100% годовых · до 10 млн ₽/счёт';
225
- if (!autoTitle) autoTitle = 'Активный рост и падение · С шортами';
314
+ if (!autoTitle) autoTitle = 'Только покупки';
226
315
  } else {
227
- tierBadge = '👑 Для тарифа «VIP»';
228
- tierColor = 'gold';
229
- tierNote = 'Полная мощность без потолка доходности · форсаж H/2 + концентрация слота Conviction Sizing';
230
- if (!autoTitle || autoTitle === 'Эталонная адаптивная стратегия') autoTitle = 'Институциональный форсаж · Полная мощность';
316
+ tierBadge = 'Для тарифов «Профи» и «VIP»';
317
+ tierColor = 'indigo';
318
+ tierNote = 'Покупки и продажи без покрытия (шорт) · до 5 счетов';
319
+ if (!autoTitle || autoTitle === 'Эталонная адаптивная стратегия' || /форсаж|мощность/i.test(autoTitle)) autoTitle = 'Покупки и шорты';
231
320
  }
321
+ void cagr;
232
322
  return {
233
323
  id: x.id,
234
324
  title: autoTitle,
@@ -241,6 +331,11 @@ export function createRouter() {
241
331
  period: x.metrics?.period || {},
242
332
  curve: full?.extra?.curves?.[chosen]?.slice(-180) || [],
243
333
  marketCurve: full?.extra?.curves?.market?.slice(-180) || [],
334
+ // реалистичная оценка стресс-теста (исполнение как в живом контуре, издержки 0,10% на сторону)
335
+ realistic: (() => {
336
+ const s = (full?.extra?.stress || x.extra?.stress)?.scenarios?.find((y) => y.key === 'liveLike10');
337
+ return s ? { returnPct: s.full.returnPct, cagrPct: s.full.cagrPct, profitFactor: s.full.profitFactor, maxDrawdownPct: s.full.maxDrawdownPct, last12Pct: s.last12.returnPct } : null;
338
+ })(),
244
339
  };
245
340
  };
246
341
  const runs = listShowcaseRuns().map((x) => buildShowcaseItem(x, getRun(x.id)));
@@ -269,7 +364,7 @@ export function createRouter() {
269
364
 
270
365
  // ---------- админ-панель (пользователи, оплаты, ЮKassa, боты Telegram и MAX, витрина, реквизиты) ----------
271
366
  const requireAdmin = (req) => {
272
- if (!req.user || req.user.role !== 'admin') throw new Error('Доступно только администратору платформы');
367
+ if (!req.user || req.user.role !== 'admin') throw Object.assign(new Error('Доступно только администратору платформы'), { status: 403 });
273
368
  };
274
369
  r.get('/admin/state', wrap((req, res) => {
275
370
  requireAdmin(req);
@@ -310,6 +405,18 @@ export function createRouter() {
310
405
  requireAdmin(req);
311
406
  res.json(await saveNotifyConfig(req.body || {}));
312
407
  }));
408
+ // ---------- машины клиентов (аренда ПО + машина + ИИ, лицензия под контролем центра) ----------
409
+ const centralUrlOf = (req) => String(process.env.CENTRAL_PUBLIC_URL || `${isHttpsRequest(req) ? 'https' : req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
410
+ r.get('/admin/instances', wrap((req, res) => { requireAdmin(req); res.json({ instances: listInstances() }); }));
411
+ r.post('/admin/instances', wrap(async (req, res) => {
412
+ requireAdmin(req);
413
+ res.json(await createInstance(req.body || {}, { centralUrl: centralUrlOf(req) }));
414
+ }));
415
+ r.post('/admin/instances/:id/rotate-key', wrap(async (req, res) => { requireAdmin(req); res.json(await rotateInstanceKey(req.params.id, { centralUrl: centralUrlOf(req) })); }));
416
+ r.post('/admin/instances/:id/trading', wrap((req, res) => { requireAdmin(req); res.json({ instance: setInstanceTrading(req.params.id, String(req.body?.trading || '')) }); }));
417
+ r.post('/admin/instances/:id/revoke', wrap((req, res) => { requireAdmin(req); res.json({ instance: setInstanceRevoked(req.params.id, req.body?.revoked !== false) }); }));
418
+ r.post('/admin/instances/:id/unbind', wrap((req, res) => { requireAdmin(req); res.json({ instance: unbindInstall(req.params.id) }); }));
419
+ r.delete('/admin/instances/:id', wrap(async (req, res) => { requireAdmin(req); res.json({ instances: await deleteInstance(req.params.id) }); }));
313
420
  r.put('/runs/:id/showcase', wrap((req, res) => {
314
421
  requireAdmin(req);
315
422
  res.json(setRunShowcase(Number(req.params.id), Boolean(req.body?.showcase)));
@@ -321,13 +428,14 @@ export function createRouter() {
321
428
  hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
322
429
  hub.emit({ type: 'chat:state', state: strategist.chatState() });
323
430
  };
431
+ // Подключения к LLM общие для всей платформы (ключи, адреса), поэтому менять и проверять их может только администратор
324
432
  r.get('/llm/providers', wrap((req, res) => res.json(llmState())));
325
- r.post('/llm/providers', wrap((req, res) => { const p = addProvider(req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
326
- r.put('/llm/providers/:id', wrap((req, res) => { const p = updateProvider(req.params.id, req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
327
- r.delete('/llm/providers/:id', wrap((req, res) => { deleteProvider(req.params.id); llmChanged(); res.json(llmState()); }));
328
- r.get('/llm/providers/:id/remote-models', wrap(async (req, res) => res.json({ models: await fetchRemoteModels(req.params.id) })));
329
- r.post('/llm/test', wrap(async (req, res) => res.json(await testProvider(req.body?.providerId, req.body?.model))));
330
- r.post('/llm/select', wrap((req, res) => { selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
433
+ r.post('/llm/providers', wrap((req, res) => { requireAdmin(req); const p = addProvider(req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
434
+ r.put('/llm/providers/:id', wrap((req, res) => { requireAdmin(req); const p = updateProvider(req.params.id, req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
435
+ r.delete('/llm/providers/:id', wrap((req, res) => { requireAdmin(req); deleteProvider(req.params.id); llmChanged(); res.json(llmState()); }));
436
+ r.get('/llm/providers/:id/remote-models', wrap(async (req, res) => { requireAdmin(req); res.json({ models: await fetchRemoteModels(req.params.id) }); }));
437
+ r.post('/llm/test', wrap(async (req, res) => { requireAdmin(req); res.json(await testProvider(req.body?.providerId, req.body?.model)); }));
438
+ r.post('/llm/select', wrap((req, res) => { requireAdmin(req); selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
331
439
 
332
440
  // ---------- облачный ИИ-прокси для локальных npx-клиентов по APIKey или в демо-режиме ----------
333
441
  const proxyUsesToday = (ident) => {
@@ -398,21 +506,28 @@ export function createRouter() {
398
506
  r.post('/settings/history/:id/revert', wrap((req, res) => res.json(revertSettingsChange(req.params.id))));
399
507
 
400
508
  // ---------- исследования ----------
509
+ const ownsRun = (req, run) => Boolean(run && req.user && (req.user.role === 'admin' || (run.user_id ?? 1) === req.user.id));
510
+ const ownRunOr404 = (req) => {
511
+ const run = getRun(Number(req.params.id));
512
+ if (!ownsRun(req, run)) throw Object.assign(new Error('Не найдено'), { status: 404 });
513
+ return run;
514
+ };
401
515
  r.get('/runs', (req, res) => res.json(listRuns()));
402
516
  r.get('/runs/:id', (req, res) => {
403
517
  const run = getRun(Number(req.params.id));
404
- if (!run) return res.status(404).json({ error: 'Не найдено' });
518
+ if (!run || !(ownsRun(req, run) || run.showcase)) return res.status(404).json({ error: 'Не найдено' });
405
519
  res.json({ ...run, trades: getTrades(run.id) });
406
520
  });
407
521
  r.put('/runs/:id/title', wrap((req, res) => {
522
+ ownRunOr404(req);
408
523
  const updated = updateRunTitle(Number(req.params.id), req.body?.title || '');
409
524
  if (!updated) return res.status(404).json({ error: 'Не найдено' });
410
525
  res.json(updated);
411
526
  }));
412
- r.post('/runs/:id/stability', wrap((req, res) => res.json(startStabilityCheck(Number(req.params.id)))));
413
- r.post('/runs/:id/stability/cancel', (req, res) => res.json({ ok: cancelStabilityCheck(Number(req.params.id)) }));
527
+ r.post('/runs/:id/stability', wrap((req, res) => { ownRunOr404(req); res.json(startStabilityCheck(Number(req.params.id))); }));
528
+ r.post('/runs/:id/stability/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelStabilityCheck(Number(req.params.id)) }); }));
414
529
  r.delete('/runs/:id', wrap((req, res) => {
415
- const run = getRun(Number(req.params.id));
530
+ const run = ownRunOr404(req);
416
531
  if (run?.status === 'running') throw new Error('Эксперимент ещё идёт — сначала отмените его');
417
532
  deleteRun(Number(req.params.id));
418
533
  res.json({ ok: true });
@@ -447,8 +562,14 @@ export function createRouter() {
447
562
  const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
448
563
  res.json({ runId, reused: !!reused });
449
564
  }));
450
- r.post('/model/:id/cancel', (req, res) => res.json({ ok: cancelModelResearch(Number(req.params.id)) }));
451
- r.get('/model/forecast', wrap(async (req, res) => res.json(await forecastNow(getSettings(), { force: req.query.force === '1' }))));
565
+ r.post('/model/:id/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelModelResearch(Number(req.params.id)) }); }));
566
+ r.get('/model/forecast', wrap(async (req, res) => {
567
+ const fc = await forecastNow(getSettings(), { force: req.query.force === '1' });
568
+ if (fc?.rows?.some((x) => x.signal || x.shortSignal)) {
569
+ triggerBrokerFastEntryCheck();
570
+ }
571
+ res.json(fc);
572
+ }));
452
573
 
453
574
  // ---------- описания компаний (текстовая карта) ----------
454
575
  r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
@@ -488,12 +609,162 @@ export function createRouter() {
488
609
  r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
489
610
  r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));
490
611
  r.post('/brokers/:id/cancel-order', wrap(async (req, res) => res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null))));
612
+ r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => res.json(resetBrokerDailyStop(req.params.id))));
613
+ r.get('/brokers/:id/events', wrap((req, res) => res.json({ events: listBrokerEvents(req.params.id, req.query.limit) })));
614
+ r.post('/brokers-stop-all', wrap(async (req, res) => res.json(await stopAllBrokerTrading())));
491
615
 
492
616
  // ---------- новости (только для прогноза «сейчас» и живой проверки) ----------
493
617
  r.get('/news', (req, res) => res.json({
494
618
  items: listNews({ symbol: req.query.symbol || undefined, hours: Number(req.query.hours) || 72, limit: Number(req.query.limit) || 30 }),
495
619
  }));
496
620
 
621
+ // ---------- подробное досье акции (котировки, график, прогноз, соседи по карте, дивиденды, бизнес-профиль, сделки пользователя, новости) ----------
622
+ r.get('/stock/:symbol', wrap(async (req, res) => {
623
+ const sym = String(req.params.symbol || '').toUpperCase().trim();
624
+ if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
625
+
626
+ const [fc, rawSpecs, divs] = await Promise.all([
627
+ forecastNow(getSettings()).catch(() => null),
628
+ ensureBoardSpecs().catch(() => ({})),
629
+ ensureDividends(sym).catch(() => []),
630
+ ]);
631
+ const tbMap = kvGet('tbankShortableMap') || {};
632
+ const tbSpecs = tbMap.specMap || {};
633
+ const shortableMap = tbMap.map || {};
634
+ const sp = { ...(rawSpecs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2, issueSize: 0, lastPrice: 0 }), ...(tbSpecs[sym] || {}) };
635
+ const vocabList = kvGet('vocab')?.list || [];
636
+ const vocabHit = vocabList.find((v) => v.symbol === sym);
637
+ const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
638
+ const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
639
+
640
+ // 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
641
+ const nowTs = Date.now();
642
+ const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
643
+ const intraday = rawBars30d.slice(-1500).map((b) => ({
644
+ ts: b.ts,
645
+ o: b.open,
646
+ h: b.high,
647
+ l: b.low,
648
+ c: b.close,
649
+ v: b.value || 0,
650
+ }));
651
+
652
+ const allBars = db.prepare('SELECT ts, open, high, low, close, value FROM candles WHERE symbol = ? AND interval = 10 ORDER BY ts ASC').all(sym);
653
+ const dailyMap = new Map();
654
+ for (const b of allBars) {
655
+ const dayKey = Math.floor((b.ts + MSK) / DAY) * DAY;
656
+ let d = dailyMap.get(dayKey);
657
+ if (!d) {
658
+ d = { ts: dayKey, o: b.open, h: b.high, l: b.low, c: b.close, v: b.value || 0 };
659
+ dailyMap.set(dayKey, d);
660
+ } else {
661
+ if (b.high > d.h) d.h = b.high;
662
+ if (b.low > 0 && (d.l <= 0 || b.low < d.l)) d.l = b.low;
663
+ d.c = b.close;
664
+ d.v += b.value || 0;
665
+ }
666
+ }
667
+ const daily = [...dailyMap.values()];
668
+
669
+ const curPrice = fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
670
+ const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
671
+ const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
672
+ const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
673
+ const monthChangePct = monthAgoClose > 0 && curPrice > 0 ? round(((curPrice - monthAgoClose) / monthAgoClose) * 100, 2) : 0;
674
+ const yearAgoClose = daily.length >= 250 ? daily[daily.length - 250].c : (daily[0]?.o || curPrice);
675
+ const yearChangePct = yearAgoClose > 0 && curPrice > 0 ? round(((curPrice - yearAgoClose) / yearAgoClose) * 100, 2) : 0;
676
+ const marketCapRub = sp.issueSize > 0 && curPrice > 0 ? Math.round(sp.issueSize * curPrice) : null;
677
+
678
+ // 20 факторов бизнес-профиля
679
+ const profMap = loadProfiles();
680
+ const profVec = profMap.get(sym) || null;
681
+ const traits = profVec ? TRAITS.map((t, idx) => ({ id: t.id, label: t.label, ask: t.ask, value: round(profVec[idx] ?? 0, 2) })) : [];
682
+
683
+ // Позиции и сделки пользователя по этой акции на подключённых брокерских счетах
684
+ const bState = brokersState();
685
+ const userPositions = [];
686
+ const userOperations = [];
687
+ for (const acc of bState.accounts || []) {
688
+ const pos = (acc.lastSync?.positions || []).find((p) => p.symbol === sym && p.qty !== 0);
689
+ if (pos) {
690
+ userPositions.push({ accountName: acc.name, broker: acc.broker, ...pos });
691
+ }
692
+ for (const op of acc.lastHistory?.operations || []) {
693
+ if (op.symbol === sym && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'div' || op.kind === 'fee')) {
694
+ userOperations.push({ accountName: acc.name, ...op });
695
+ }
696
+ }
697
+ }
698
+ userOperations.sort((a, b) => (b.date || 0) - (a.date || 0));
699
+
700
+ // Считаем историческую результативность по бумаге в последнем эталонном эксперименте
701
+ let backtestStat = null;
702
+ const lastModelRun = listRuns().find((run) => run.mode === 'model' && run.status === 'done');
703
+ if (lastModelRun) {
704
+ 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);
705
+ if (simTrades.length) {
706
+ const wins = simTrades.filter((t) => t.pnl > 0).length;
707
+ const avgPct = simTrades.reduce((s, t) => s + (t.pnl_pct || 0), 0) / simTrades.length;
708
+ backtestStat = {
709
+ runId: lastModelRun.id,
710
+ tradesCount: simTrades.length,
711
+ winRate: round((wins / simTrades.length) * 100, 1),
712
+ avgPnlPct: round(avgPct, 2),
713
+ recentTrades: simTrades.slice(0, 10),
714
+ };
715
+ }
716
+ }
717
+
718
+ const news = listNews({ symbol: sym, hours: 24 * 30, limit: 20 });
719
+ const sortedDivs = [...(divs || [])].sort((a, b) => String(b.registry).localeCompare(String(a.registry)));
720
+
721
+ res.json({
722
+ symbol: sym,
723
+ name: fcRow?.name || vocabHit?.name || sym,
724
+ price: round(curPrice, curPrice < 1 ? 5 : curPrice < 10 ? 3 : 2),
725
+ dayChangePct,
726
+ monthChangePct,
727
+ yearChangePct,
728
+ tier: isTier1 ? 1 : 2,
729
+ tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
730
+ buyThresholdPct: isTier1 ? Math.max(fc?.buyThresholdPct || 0.30, 0.30) : Math.max(fc?.buyThresholdPct || 0.30, 0.35),
731
+ shortThresholdPct: isTier1 ? Math.max(fc?.shortThresholdPct || 0.31, 0.31) : Math.max(fc?.shortThresholdPct || 0.31, 0.36),
732
+ shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
733
+ specs: {
734
+ lotSize: sp.lotSize,
735
+ lotCostRub: round((sp.lotSize || 1) * curPrice, 2),
736
+ minStep: sp.minStep,
737
+ decimals: sp.decimals,
738
+ issueSize: sp.issueSize || null,
739
+ marketCapRub,
740
+ indexWeightPct: vocabHit?.weight ? round(vocabHit.weight, 2) : null,
741
+ },
742
+ forecast: fcRow ? {
743
+ bar: fc?.bar,
744
+ horizonHours: fc?.horizon || 2.5,
745
+ expectedPct: fcRow.expectedPct,
746
+ lastMovePct: fcRow.lastMovePct ?? 0,
747
+ deltaPct: fcRow.deltaPct || 0,
748
+ signal: Boolean(fcRow.signal),
749
+ shortSignal: Boolean(fcRow.shortSignal),
750
+ shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
751
+ selfPct: fcRow.selfPct ?? 0,
752
+ influences: (fcRow.influences || []).map((inf) => {
753
+ const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
754
+ return { ...inf, name: infRow?.name || inf.symbol, price: infRow?.price || null, expectedPct: infRow?.expectedPct ?? null };
755
+ }),
756
+ } : null,
757
+ traits,
758
+ dividends: sortedDivs,
759
+ news,
760
+ userPositions,
761
+ userOperations: userOperations.slice(0, 60),
762
+ backtestStat,
763
+ intraday,
764
+ daily,
765
+ });
766
+ }));
767
+
497
768
  // ---------- журнал гипотез ----------
498
769
  r.get('/journal', (req, res) => res.json(listJournal(500)));
499
770
  r.post('/journal', wrap((req, res) => {