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.
- 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 +589 -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 +1095 -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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Параметры, вшитые в защищённую сборку для компьютера клиента (scripts/build-desktop.mjs подставляет __NA_BUILD__).
|
|
3
|
+
// В собранной программе режим, адрес центра и открытый ключ центра НЕ берутся из .env и переменных окружения —
|
|
4
|
+
// иначе можно было бы запустить её в режиме разработчика или подписать лицензию своим ключом.
|
|
5
|
+
/* global __NA_BUILD__ */
|
|
6
|
+
export const BUILD = typeof __NA_BUILD__ !== 'undefined' && __NA_BUILD__ ? __NA_BUILD__ : null;
|
|
7
|
+
export const IS_PACKAGED = Boolean(BUILD);
|
package/server/config.js
CHANGED
|
@@ -3,15 +3,22 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { IS_PACKAGED, BUILD } from './build-info.js';
|
|
7
|
+
|
|
8
|
+
// В защищённой сборке файлы интерфейса распаковываются во временный каталог (NEUROALPHA_ROOT задаёт загрузчик)
|
|
9
|
+
export const ROOT = IS_PACKAGED
|
|
10
|
+
? path.resolve(process.env.NEUROALPHA_ROOT || process.cwd())
|
|
11
|
+
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
12
|
export const DATA_DIR = process.env.NEUROALPHA_DATA_DIR
|
|
8
13
|
? path.resolve(process.env.NEUROALPHA_DATA_DIR)
|
|
9
14
|
: path.join(ROOT, 'data');
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
if (!IS_PACKAGED) {
|
|
17
|
+
const envFile = path.join(ROOT, '.env');
|
|
18
|
+
if (fs.existsSync(envFile)) process.loadEnvFile(envFile);
|
|
19
|
+
const cwdEnv = path.resolve(process.cwd(), '.env');
|
|
20
|
+
if (cwdEnv !== envFile && fs.existsSync(cwdEnv)) process.loadEnvFile(cwdEnv);
|
|
21
|
+
}
|
|
15
22
|
|
|
16
23
|
export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
|
|
17
24
|
|
|
@@ -43,4 +50,4 @@ export const llmConfig = {
|
|
|
43
50
|
llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
|
|
44
51
|
|
|
45
52
|
export const PORT = Number(process.env.PORT || 3000);
|
|
46
|
-
export const DEFAULT_CLOUD_URL = (process.env.CLOUD_URL || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
|
|
53
|
+
export const DEFAULT_CLOUD_URL = ((IS_PACKAGED ? BUILD.centralUrl : process.env.CLOUD_URL) || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
|
package/server/data/moex.js
CHANGED
|
@@ -120,16 +120,18 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
120
120
|
const mskDow = mskDate.getUTCDay();
|
|
121
121
|
const mskHour = mskDate.getUTCHours();
|
|
122
122
|
const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskHour >= 9 && mskHour < 24;
|
|
123
|
-
const checkCooldownMs = isTradingHours ?
|
|
124
|
-
const dbIsAlreadyFresh = (now - range.max <
|
|
123
|
+
const checkCooldownMs = isTradingHours ? 8 * 60000 : 4 * 3600000;
|
|
124
|
+
const dbIsAlreadyFresh = (now - range.max < 15 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
|
|
125
125
|
|
|
126
126
|
if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
|
|
127
|
+
// Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
|
|
128
|
+
// чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
|
|
129
|
+
lastTailCheckAt.set(tailKey, now);
|
|
130
|
+
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
|
|
127
131
|
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now ? tsToDate(tillTs) : null, {
|
|
128
132
|
signal,
|
|
129
133
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
130
134
|
});
|
|
131
|
-
lastTailCheckAt.set(tailKey, now);
|
|
132
|
-
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
package/server/db.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ROOT, DATA_DIR } from './config.js';
|
|
|
6
6
|
|
|
7
7
|
let getUid = () => 1;
|
|
8
8
|
export function registerUidResolver(fn) { getUid = fn; }
|
|
9
|
+
export const currentUid = () => getUid();
|
|
9
10
|
|
|
10
11
|
const dir = DATA_DIR;
|
|
11
12
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -197,10 +198,13 @@ for (const [tbl, col, def] of [
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
// Гарантируем наличие локального пользователя по умолчанию (id = 1, режим «Хозяин»)
|
|
201
|
+
const randomNotifyCode = () => `NA-${Array.from(crypto.getRandomValues(new Uint8Array(3)), (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
200
202
|
if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
|
|
201
203
|
db.prepare(`INSERT INTO users (id, email, name, pass_hash, role, plan, plan_until, notify_code, created_at)
|
|
202
|
-
VALUES (1, 'owner@neuroalpha.local', 'Хозяин', '', 'admin', 'owner', 0,
|
|
204
|
+
VALUES (1, 'owner@neuroalpha.local', 'Хозяин', '', 'admin', 'owner', 0, ?, ?)`).run(randomNotifyCode(), Date.now());
|
|
203
205
|
} else {
|
|
206
|
+
// Общеизвестный код привязки владельца заменяется на случайный (привязанные ранее чаты сохраняются)
|
|
207
|
+
db.prepare("UPDATE users SET notify_code = ? WHERE id = 1 AND (notify_code IS NULL OR notify_code = '' OR UPPER(notify_code) = 'NA-OWNER1')").run(randomNotifyCode());
|
|
204
208
|
db.prepare(`UPDATE users SET plan = 'owner' WHERE id = 1 AND role = 'admin' AND plan IN ('free', 'pro')`).run();
|
|
205
209
|
db.prepare("UPDATE users SET name = 'Пользователь' WHERE id = 1 AND name = 'Владелец платформы'").run();
|
|
206
210
|
}
|
|
@@ -226,22 +230,22 @@ export function kvSet(key, value) {
|
|
|
226
230
|
db.prepare('INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)').run(key, JSON.stringify(value));
|
|
227
231
|
}
|
|
228
232
|
|
|
233
|
+
// uid = 1 — исторические ключи владельца без префикса; uid = 0 (аноним) — отдельное пустое пространство
|
|
234
|
+
const userKey = (key, uid) => (Number(uid) === 1 ? key : `u:${Number(uid) || 0}:${key}`);
|
|
229
235
|
export function userKvGet(key, def = null, uid = getUid()) {
|
|
230
|
-
|
|
231
|
-
return kvGet(k, def);
|
|
236
|
+
return kvGet(userKey(key, uid), def);
|
|
232
237
|
}
|
|
233
238
|
export function userKvSet(key, value, uid = getUid()) {
|
|
234
|
-
|
|
235
|
-
kvSet(k, value);
|
|
239
|
+
kvSet(userKey(key, uid), value);
|
|
236
240
|
}
|
|
237
241
|
|
|
238
242
|
// ---------- чат ----------
|
|
239
243
|
export function currentConversation(uid = getUid()) {
|
|
240
|
-
const r = db.prepare('SELECT * FROM conversations WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT 1').get(uid
|
|
244
|
+
const r = db.prepare('SELECT * FROM conversations WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT 1').get(uid ?? 1);
|
|
241
245
|
return r || newConversation(uid);
|
|
242
246
|
}
|
|
243
247
|
export function newConversation(uid = getUid()) {
|
|
244
|
-
const id = Number(db.prepare('INSERT INTO conversations (created_at, user_id) VALUES (?, ?)').run(Date.now(), uid
|
|
248
|
+
const id = Number(db.prepare('INSERT INTO conversations (created_at, user_id) VALUES (?, ?)').run(Date.now(), uid ?? 1).lastInsertRowid);
|
|
245
249
|
return db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
|
|
246
250
|
}
|
|
247
251
|
const parseMsg = (m) => m && ({
|
|
@@ -273,27 +277,27 @@ export function getMessages(convId, limit = 400) {
|
|
|
273
277
|
// ---------- журнал гипотез ----------
|
|
274
278
|
export function addJournal(e, uid = getUid()) {
|
|
275
279
|
const r = db.prepare('INSERT INTO journal (ts, hypothesis, change, run_ids, result, verdict, source, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
276
|
-
.run(Date.now(), e.hypothesis || '', e.change || '', JSON.stringify(e.run_ids || []), e.result || '', e.verdict || 'unclear', e.source || 'ai', uid
|
|
280
|
+
.run(Date.now(), e.hypothesis || '', e.change || '', JSON.stringify(e.run_ids || []), e.result || '', e.verdict || 'unclear', e.source || 'ai', uid ?? 1);
|
|
277
281
|
return Number(r.lastInsertRowid);
|
|
278
282
|
}
|
|
279
283
|
export function listJournal(limit = 200, uid = getUid()) {
|
|
280
|
-
return db.prepare('SELECT * FROM journal WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid
|
|
284
|
+
return db.prepare('SELECT * FROM journal WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid ?? 1, limit).map((j) => ({ ...j, run_ids: JSON.parse(j.run_ids || '[]') }));
|
|
281
285
|
}
|
|
282
286
|
export function deleteJournal(id, uid = getUid()) {
|
|
283
|
-
db.prepare('DELETE FROM journal WHERE id = ? AND COALESCE(user_id, 1) = ?').run(id, uid
|
|
287
|
+
db.prepare('DELETE FROM journal WHERE id = ? AND COALESCE(user_id, 1) = ?').run(id, uid ?? 1);
|
|
284
288
|
}
|
|
285
289
|
|
|
286
290
|
// ---------- история настроек ----------
|
|
287
291
|
const parseHistory = (h) => h && ({ ...h, changes: JSON.parse(h.changes || '{}') });
|
|
288
292
|
export function addSettingsHistory(source, reason, changes, uid = getUid()) {
|
|
289
293
|
return Number(db.prepare('INSERT INTO settings_history (ts, source, reason, changes, user_id) VALUES (?, ?, ?, ?, ?)')
|
|
290
|
-
.run(Date.now(), source, reason || '', JSON.stringify(changes), uid
|
|
294
|
+
.run(Date.now(), source, reason || '', JSON.stringify(changes), uid ?? 1).lastInsertRowid);
|
|
291
295
|
}
|
|
292
296
|
export function listSettingsHistory(limit = 50, uid = getUid()) {
|
|
293
|
-
return db.prepare('SELECT * FROM settings_history WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid
|
|
297
|
+
return db.prepare('SELECT * FROM settings_history WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid ?? 1, limit).map(parseHistory);
|
|
294
298
|
}
|
|
295
299
|
export function getSettingsHistory(id, uid = getUid()) {
|
|
296
|
-
return parseHistory(db.prepare('SELECT * FROM settings_history WHERE id = ? AND COALESCE(user_id, 1) = ?').get(id, uid
|
|
300
|
+
return parseHistory(db.prepare('SELECT * FROM settings_history WHERE id = ? AND COALESCE(user_id, 1) = ?').get(id, uid ?? 1));
|
|
297
301
|
}
|
|
298
302
|
|
|
299
303
|
const insertCandle = db.prepare(`INSERT OR REPLACE INTO candles
|
|
@@ -321,7 +325,7 @@ export function candleRange(symbol, interval) {
|
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
export function createRun(mode, params, uid = getUid()) {
|
|
324
|
-
const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid
|
|
328
|
+
const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
|
|
325
329
|
return Number(r.lastInsertRowid);
|
|
326
330
|
}
|
|
327
331
|
|
|
@@ -360,10 +364,10 @@ const parseRun = (r) => r && ({
|
|
|
360
364
|
});
|
|
361
365
|
|
|
362
366
|
export function listRuns(uid = getUid()) {
|
|
363
|
-
return db.prepare('SELECT id, mode, status, created_at, finished_at, params, metrics, error, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? OR showcase = 1 ORDER BY id DESC LIMIT 100').all(uid
|
|
367
|
+
return db.prepare('SELECT id, mode, status, created_at, finished_at, params, metrics, error, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? OR showcase = 1 ORDER BY id DESC LIMIT 100').all(uid ?? 1).map(parseRun);
|
|
364
368
|
}
|
|
365
369
|
export function allRuns(uid = getUid()) {
|
|
366
|
-
return db.prepare('SELECT id, mode, status, params, metrics, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC').all(uid
|
|
370
|
+
return db.prepare('SELECT id, mode, status, params, metrics, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC').all(uid ?? 1).map(parseRun);
|
|
367
371
|
}
|
|
368
372
|
export function setRunShowcase(id, showcase) {
|
|
369
373
|
db.prepare('UPDATE runs SET showcase = ? WHERE id = ?').run(showcase ? 1 : 0, Number(id));
|
|
@@ -388,11 +392,11 @@ export function listShowcaseRuns() {
|
|
|
388
392
|
}
|
|
389
393
|
export function countUserRunsToday(uid = getUid()) {
|
|
390
394
|
const since = Date.now() - 86400000;
|
|
391
|
-
return db.prepare("SELECT COUNT(*) AS n FROM runs WHERE mode = 'model' AND COALESCE(user_id, 1) = ? AND created_at >= ?").get(uid
|
|
395
|
+
return db.prepare("SELECT COUNT(*) AS n FROM runs WHERE mode = 'model' AND COALESCE(user_id, 1) = ? AND created_at >= ?").get(uid ?? 1, since)?.n || 0;
|
|
392
396
|
}
|
|
393
397
|
export function countUserChatToday(uid = getUid()) {
|
|
394
398
|
const since = Date.now() - 86400000;
|
|
395
|
-
return db.prepare("SELECT COUNT(*) AS n FROM chat_messages m JOIN conversations c ON c.id = m.conv_id WHERE m.role = 'user' AND COALESCE(c.user_id, 1) = ? AND m.ts >= ?").get(uid
|
|
399
|
+
return db.prepare("SELECT COUNT(*) AS n FROM chat_messages m JOIN conversations c ON c.id = m.conv_id WHERE m.role = 'user' AND COALESCE(c.user_id, 1) = ? AND m.ts >= ?").get(uid ?? 1, since)?.n || 0;
|
|
396
400
|
}
|
|
397
401
|
|
|
398
402
|
export function getRun(id) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Загрузчик защищённой сборки НейроАльфа для компьютера клиента (Windows / Linux, один исполняемый файл).
|
|
3
|
+
// neuroalpha — запустить терминал и открыть его в браузере
|
|
4
|
+
// neuroalpha --install — добавить в автозапуск при входе в систему (и запустить)
|
|
5
|
+
// neuroalpha --uninstall — убрать из автозапуска (данные и активация сохраняются)
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
|
|
12
|
+
const req = createRequire(process.execPath);
|
|
13
|
+
const sea = req('node:sea');
|
|
14
|
+
const BUILD_INFO = __NA_BUILD__; // eslint-disable-line no-undef
|
|
15
|
+
const APP = 'NeuroAlpha';
|
|
16
|
+
const exe = process.execPath;
|
|
17
|
+
|
|
18
|
+
function dataHome() {
|
|
19
|
+
if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), APP);
|
|
20
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'neuroalpha');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Файлы интерфейса и справочники (public/, config/, docs/) вшиты в программу и распаковываются при смене версии
|
|
24
|
+
function extractAssets(home) {
|
|
25
|
+
const dir = path.join(home, 'runtime', BUILD_INFO.version);
|
|
26
|
+
const marker = path.join(dir, '.complete');
|
|
27
|
+
if (fs.existsSync(marker)) return dir;
|
|
28
|
+
const manifest = JSON.parse(Buffer.from(sea.getAsset('__manifest.json')).toString('utf8'));
|
|
29
|
+
for (const rel of manifest) {
|
|
30
|
+
const dst = path.join(dir, rel);
|
|
31
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
32
|
+
fs.writeFileSync(dst, Buffer.from(sea.getAsset(rel)));
|
|
33
|
+
}
|
|
34
|
+
fs.writeFileSync(marker, String(Date.now()));
|
|
35
|
+
// старые версии удаляются
|
|
36
|
+
for (const v of fs.readdirSync(path.join(home, 'runtime'))) {
|
|
37
|
+
if (v !== BUILD_INFO.version) fs.rmSync(path.join(home, 'runtime', v), { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function install() {
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
execFileSync('schtasks', ['/Create', '/F', '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TN', APP, '/TR', `"${exe}" --background`], { stdio: 'inherit' });
|
|
45
|
+
console.log('✓ НейроАльфа добавлена в автозапуск при входе в Windows');
|
|
46
|
+
} else {
|
|
47
|
+
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
48
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(unitDir, 'neuroalpha.service'), [
|
|
50
|
+
'[Unit]', 'Description=NeuroAlpha trading terminal', 'After=network-online.target', '',
|
|
51
|
+
'[Service]', `ExecStart=${exe} --background`, 'Restart=on-failure', 'RestartSec=10', '',
|
|
52
|
+
'[Install]', 'WantedBy=default.target', '',
|
|
53
|
+
].join('\n'));
|
|
54
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
55
|
+
execFileSync('systemctl', ['--user', 'enable', '--now', 'neuroalpha.service'], { stdio: 'inherit' });
|
|
56
|
+
console.log('✓ НейроАльфа запущена как служба пользователя (systemctl --user status neuroalpha)');
|
|
57
|
+
console.log(' Чтобы терминал работал без входа в систему: sudo loginctl enable-linger $USER');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function uninstall() {
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
try { execFileSync('schtasks', ['/Delete', '/F', '/TN', APP], { stdio: 'inherit' }); } catch { /* ignore */ }
|
|
64
|
+
} else {
|
|
65
|
+
try { execFileSync('systemctl', ['--user', 'disable', '--now', 'neuroalpha.service'], { stdio: 'inherit' }); } catch { /* ignore */ }
|
|
66
|
+
fs.rmSync(path.join(os.homedir(), '.config', 'systemd', 'user', 'neuroalpha.service'), { force: true });
|
|
67
|
+
}
|
|
68
|
+
console.log('✓ НейроАльфа убрана из автозапуска. Данные и активация сохранены в', dataHome());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const args = process.argv.slice(2);
|
|
72
|
+
if (args.includes('--version')) {
|
|
73
|
+
console.log(`НейроАльфа ${BUILD_INFO.version} (${BUILD_INFO.builtAt})`);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
if (args.includes('--uninstall')) { uninstall(); process.exit(0); }
|
|
77
|
+
if (args.includes('--install')) {
|
|
78
|
+
install();
|
|
79
|
+
if (process.platform === 'win32') spawn(exe, ['--background'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const home = dataHome();
|
|
84
|
+
fs.mkdirSync(home, { recursive: true });
|
|
85
|
+
process.env.NEUROALPHA_ROOT = extractAssets(home);
|
|
86
|
+
process.env.NEUROALPHA_DATA_DIR = process.env.NEUROALPHA_DATA_DIR || path.join(home, 'data');
|
|
87
|
+
process.env.PORT = process.env.PORT || '3000';
|
|
88
|
+
if (!args.includes('--background')) process.env.OPEN_BROWSER = '1';
|
|
89
|
+
process.chdir(process.env.NEUROALPHA_ROOT);
|
|
90
|
+
|
|
91
|
+
// Повторный запуск (двойной щелчок), когда терминал уже работает: просто открываем его в браузере
|
|
92
|
+
const url = `http://localhost:${process.env.PORT}`;
|
|
93
|
+
fetch(`${url}/healthz`, { signal: AbortSignal.timeout(1500) })
|
|
94
|
+
.then((r) => {
|
|
95
|
+
if (!r.ok) throw new Error('not ours');
|
|
96
|
+
if (!args.includes('--background')) {
|
|
97
|
+
const [cmd, a] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]] : ['xdg-open', [url]];
|
|
98
|
+
spawn(cmd, a, { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
|
|
99
|
+
}
|
|
100
|
+
console.log(`НейроАльфа уже запущена: ${url}`);
|
|
101
|
+
process.exit(0);
|
|
102
|
+
})
|
|
103
|
+
.catch(() => import('./index.js'));
|
package/server/hub.js
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
// Общий канал событий сервера → браузер (WebSocket) и подписчики внутри
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
2
|
+
// Общий канал событий сервера → браузер (WebSocket) и подписчики внутри сервера.
|
|
3
|
+
// Каждое событие адресуется пользователю, в контексте которого оно возникло (uid),
|
|
4
|
+
// и уходит только в его WebSocket-соединения. Общерыночные события рассылаются всем.
|
|
5
|
+
import { currentUid } from './db.js';
|
|
6
|
+
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
let sender = () => {};
|
|
9
|
+
|
|
10
|
+
const GLOBAL_TYPES = new Set(['news:update', 'profiles:progress', 'profiles:done', 'llm:changed']);
|
|
11
|
+
|
|
12
|
+
function isGlobal(msg) {
|
|
13
|
+
if (GLOBAL_TYPES.has(msg.type)) return true;
|
|
14
|
+
// пульс радара/обучения общий; пульс по конкретному счёту — только владельцу счёта
|
|
15
|
+
return msg.type === 'trading:pulse' && !msg.accountId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const hub = {
|
|
19
|
+
setSender(fn) { sender = fn; },
|
|
20
|
+
emit(msg) {
|
|
21
|
+
const uid = msg.uid ?? currentUid();
|
|
22
|
+
const global = isGlobal(msg);
|
|
23
|
+
try { sender(msg, { uid, global }); } catch (e) { console.error(e); }
|
|
24
|
+
for (const l of listeners) {
|
|
25
|
+
try { l(msg, { uid, global }); } catch (e) { console.error(e); }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
29
|
+
};
|
package/server/index.js
CHANGED
|
@@ -6,15 +6,21 @@ import path from 'node:path';
|
|
|
6
6
|
import express from 'express';
|
|
7
7
|
import { WebSocketServer } from 'ws';
|
|
8
8
|
import { ROOT, PORT } from './config.js';
|
|
9
|
+
import { ROLE, IS_INSTANCE, IS_DESKTOP, IS_CENTRAL, INSTANCE_KIND, LISTEN_HOST } from './role.js';
|
|
10
|
+
import { startInstanceAgent, setInstanceCommandHandler } from './instance/agent.js';
|
|
11
|
+
import { getIdentity, isActivated } from './instance/identity.js';
|
|
12
|
+
import { startCentralReconciler } from './instance/central.js';
|
|
13
|
+
import { DEFAULT_CLOUD_URL } from './config.js';
|
|
9
14
|
import { activeModelInfo } from './llm/client.js';
|
|
10
15
|
import { markStaleRuns, removeLegacyData, removeOrphanEvents, ensureSeedRun } from './db.js';
|
|
11
16
|
import { createRouter } from './api/routes.js';
|
|
12
|
-
import { authMiddleware, activateLocalLicense } from './auth/index.js';
|
|
17
|
+
import { authMiddleware, activateLocalLicense, userFromRequest, runWithUser, getUserById } from './auth/index.js';
|
|
18
|
+
import { securityHeaders, setAppCsp, isCrossOrigin } from './security.js';
|
|
13
19
|
import { live } from './model/live.js';
|
|
14
20
|
import { runningResearch, runningStability } from './model/research.js';
|
|
15
21
|
import { startNews } from './news/index.js';
|
|
16
22
|
import { startNotifyPolling } from './notify/index.js';
|
|
17
|
-
import { startBrokerScheduler } from './brokers/index.js';
|
|
23
|
+
import { startBrokerScheduler, brokersState, executeBrokerOrders, rejectBrokerOrders } from './brokers/index.js';
|
|
18
24
|
import { chatState } from './strategist/index.js';
|
|
19
25
|
import { hub } from './hub.js';
|
|
20
26
|
|
|
@@ -24,31 +30,93 @@ removeOrphanEvents();
|
|
|
24
30
|
ensureSeedRun();
|
|
25
31
|
|
|
26
32
|
const app = express();
|
|
33
|
+
app.disable('x-powered-by');
|
|
27
34
|
app.use(express.json({ limit: '1mb' }));
|
|
35
|
+
app.use(securityHeaders);
|
|
28
36
|
app.use(authMiddleware);
|
|
29
37
|
const server = http.createServer(app);
|
|
30
38
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
31
39
|
|
|
32
|
-
hub.setSender((msg) => {
|
|
40
|
+
hub.setSender((msg, { uid, global }) => {
|
|
33
41
|
const data = JSON.stringify(msg);
|
|
34
|
-
for (const c of wss.clients)
|
|
42
|
+
for (const c of wss.clients) {
|
|
43
|
+
if (c.readyState !== 1) continue;
|
|
44
|
+
if (global || c.uid === uid) c.send(data);
|
|
45
|
+
}
|
|
35
46
|
});
|
|
36
47
|
|
|
37
|
-
wss.on('connection', (ws) => {
|
|
38
|
-
|
|
48
|
+
wss.on('connection', (ws, req) => {
|
|
49
|
+
if (isCrossOrigin(req)) {
|
|
50
|
+
ws.close(4403, 'forbidden origin');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const user = userFromRequest(req);
|
|
54
|
+
if (!user) {
|
|
55
|
+
ws.close(4401, 'unauthorized');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
ws.uid = user.id;
|
|
59
|
+
runWithUser(user, () => {
|
|
60
|
+
ws.send(JSON.stringify({ type: 'hello', live: live.status(), chat: chatState(), running: runningResearch(), stabilityRunning: runningStability(), llm: activeModelInfo() }));
|
|
61
|
+
});
|
|
39
62
|
});
|
|
40
63
|
|
|
64
|
+
// Кнопки «Исполнить / Отклонить» из Telegram / MAX, пришедшие с центра: только для того же самого ожидающего пакета
|
|
65
|
+
setInstanceCommandHandler((cmd) => runWithUser(getUserById(1), async () => {
|
|
66
|
+
if (cmd.type !== 'batch') return null;
|
|
67
|
+
const acc = brokersState().accounts.find((a) => a.id === cmd.accountId);
|
|
68
|
+
if (!acc) return 'Команда из мессенджера: счёт не найден.';
|
|
69
|
+
const pb = acc.pendingBatch;
|
|
70
|
+
if (!pb || pb.status !== 'pending' || (cmd.batchId && pb.id !== cmd.batchId)) {
|
|
71
|
+
return `Счёт «${acc.name}»: этот пакет заявок уже неактуален (исполнен, отклонён или заменён новым) — команда не выполнена.`;
|
|
72
|
+
}
|
|
73
|
+
if (cmd.action === 'exec') {
|
|
74
|
+
const st = await executeBrokerOrders(acc.id);
|
|
75
|
+
const res = st.accounts.find((a) => a.id === acc.id)?.pendingBatch?.results || [];
|
|
76
|
+
const ok = res.filter((r) => r.ok).length;
|
|
77
|
+
return `✓ Счёт «${acc.name}»: отправлено брокеру ${ok} из ${res.length} заявок.${ok < res.length ? ` Ошибки: ${res.filter((r) => !r.ok).map((r) => `${r.symbol} — ${r.error}`).join('; ').slice(0, 600)}` : ''}`;
|
|
78
|
+
}
|
|
79
|
+
if (cmd.action === 'rej') {
|
|
80
|
+
rejectBrokerOrders(acc.id);
|
|
81
|
+
return `✕ Счёт «${acc.name}»: пакет заявок отклонён.`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}));
|
|
85
|
+
startInstanceAgent();
|
|
86
|
+
if (IS_CENTRAL) startCentralReconciler({ centralUrl: String(process.env.CENTRAL_PUBLIC_URL || DEFAULT_CLOUD_URL).replace(/\/+$/, '') });
|
|
41
87
|
live.resume();
|
|
42
88
|
startNews();
|
|
43
89
|
startNotifyPolling();
|
|
44
90
|
startBrokerScheduler();
|
|
45
91
|
|
|
46
|
-
if (process.env.NEUROALPHA_API_KEY) {
|
|
92
|
+
if (process.env.NEUROALPHA_API_KEY && !IS_INSTANCE) {
|
|
47
93
|
activateLocalLicense(process.env.NEUROALPHA_API_KEY, process.env.CLOUD_URL || '')
|
|
48
94
|
.then((lic) => console.log(`✓ Активирован API-ключ: тариф «${lic.planName}» (${lic.email})`))
|
|
49
95
|
.catch((e) => console.warn(`⚠ Проверка API-ключа: ${e.message}`));
|
|
50
96
|
}
|
|
51
97
|
|
|
98
|
+
// Проверка живости для Kubernetes (liveness/readiness), без данных
|
|
99
|
+
app.get('/healthz', (req, res) => res.json({ ok: true, role: ROLE }));
|
|
100
|
+
|
|
101
|
+
// Машина клиента: витрина, оферта, админка и вход живут в центре; без сессии — вход через кабинет центра
|
|
102
|
+
if (IS_INSTANCE) {
|
|
103
|
+
const central = () => getIdentity().centralUrl;
|
|
104
|
+
const toCentral = (p) => (req, res) => res.redirect(`${central()}${p}`);
|
|
105
|
+
app.get(['/landing', '/landing.html'], toCentral('/landing'));
|
|
106
|
+
app.get(['/legal', '/legal.html'], toCentral('/legal'));
|
|
107
|
+
app.get(['/admin', '/admin.html'], toCentral('/landing'));
|
|
108
|
+
app.get(['/', '/index.html'], (req, res, next) => {
|
|
109
|
+
// установка на компьютер до активации — экран ввода кода из личного кабинета
|
|
110
|
+
if (IS_DESKTOP && !isActivated()) {
|
|
111
|
+
setAppCsp(res);
|
|
112
|
+
return res.sendFile(path.join(ROOT, 'public', 'activate.html'));
|
|
113
|
+
}
|
|
114
|
+
if (!req.user) return res.redirect(`${central()}/api/instances/open?i=${encodeURIComponent(getIdentity().id)}`);
|
|
115
|
+
setAppCsp(res);
|
|
116
|
+
next();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
52
120
|
app.get('/landing', (req, res) => res.sendFile(path.join(ROOT, 'public', 'landing.html')));
|
|
53
121
|
app.get('/legal', (req, res) => res.sendFile(path.join(ROOT, 'public', 'legal.html')));
|
|
54
122
|
app.get('/admin', (req, res) => {
|
|
@@ -56,11 +124,12 @@ app.get('/admin', (req, res) => {
|
|
|
56
124
|
return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
|
|
57
125
|
});
|
|
58
126
|
app.get('/', (req, res, next) => {
|
|
59
|
-
//
|
|
127
|
+
// Неавторизованным гостям показывается лендинг со входом (витрина PaaS),
|
|
60
128
|
// а авторизованным пользователям открывается их изолированный облачный терминал (или витрина при ?landing=1)
|
|
61
|
-
if (process.env.CLOUD_MODE === '1' &&
|
|
129
|
+
if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1')) {
|
|
62
130
|
return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
|
|
63
131
|
}
|
|
132
|
+
setAppCsp(res);
|
|
64
133
|
next();
|
|
65
134
|
});
|
|
66
135
|
app.use('/api', createRouter());
|
|
@@ -80,11 +149,12 @@ server.on('error', (e) => {
|
|
|
80
149
|
process.exit(1);
|
|
81
150
|
});
|
|
82
151
|
|
|
83
|
-
server.listen(PORT, () => {
|
|
84
|
-
|
|
152
|
+
server.listen(PORT, LISTEN_HOST, () => {
|
|
153
|
+
const ident = IS_INSTANCE ? getIdentity() : null;
|
|
154
|
+
console.log(`Консорциум агентов запущен · режим ${ROLE}${IS_INSTANCE ? ` (${INSTANCE_KIND}) · машина ${ident.id || 'не активирована'} · центр ${ident.centralUrl}` : ''}`);
|
|
85
155
|
const llm = activeModelInfo();
|
|
86
156
|
console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
|
|
87
|
-
for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
|
|
157
|
+
if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
|
|
88
158
|
console.log(`Клиент: http://localhost:${PORT}`);
|
|
89
159
|
if (process.env.OPEN_BROWSER === '1') openBrowser(`http://localhost:${PORT}`);
|
|
90
160
|
});
|