neuroalpha 0.3.17 → 0.5.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.
- package/config/certs/russian_trusted_root_ca.pem +33 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
- 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
- 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
- package/package.json +60 -53
- package/public/activate.html +36 -0
- package/public/activate.js +33 -0
- package/public/admin.html +126 -7
- package/public/app.js +573 -66
- package/public/index.html +48 -21
- package/public/landing.html +106 -37
- package/public/legal.html +4 -4
- package/public/styles.css +152 -36
- package/server/api/routes.js +308 -37
- package/server/auth/index.js +79 -15
- package/server/brokers/index.js +1014 -168
- package/server/build-info.js +7 -0
- package/server/config.js +13 -6
- package/server/data/moex.js +6 -4
- package/server/db.js +22 -18
- package/server/desktop-entry.js +103 -0
- package/server/hub.js +28 -14
- package/server/index.js +82 -12
- package/server/instance/agent.js +259 -0
- package/server/instance/central.js +491 -0
- package/server/instance/identity.js +79 -0
- package/server/instance/provision.js +166 -0
- package/server/instance/signing.js +71 -0
- package/server/model/edge.js +19 -0
- package/server/model/forecast.js +107 -57
- package/server/model/live.js +1 -1
- package/server/model/models.js +12 -3
- package/server/model/research.js +218 -30
- package/server/notify/index.js +51 -16
- package/server/role.js +44 -0
- package/server/security.js +40 -0
- package/server/strategist/index.js +86 -47
- package/server/strategist/tools.js +231 -42
- package/server/tls-ca.js +32 -0
package/server/api/routes.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
308
|
+
// Тарифы различаются возможностями (шорты, число счетов, лимиты), а не доходностью
|
|
309
|
+
let tierNote = 'Только покупки (без шорта) · 1 счёт до 1 млн ₽';
|
|
215
310
|
let autoTitle = p._title;
|
|
216
|
-
if (dir === 'long'
|
|
311
|
+
if (dir === 'long') {
|
|
217
312
|
tierBadge = 'Для тарифа «Инвестор»';
|
|
218
313
|
tierColor = 'blue';
|
|
219
|
-
|
|
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 = '
|
|
228
|
-
tierColor = '
|
|
229
|
-
tierNote = '
|
|
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 =
|
|
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) =>
|
|
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) => {
|