neuroalpha 0.2.0 → 0.3.13

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 (52) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +9 -3
  3. package/bin/cli.js +69 -69
  4. package/config/seed-run.json +1 -0
  5. package/config/strategy.json +4 -10
  6. package/deploy/install.sh +128 -0
  7. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
  8. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
  9. 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 +171 -0
  10. package/package.json +15 -5
  11. package/public/admin.html +449 -0
  12. package/public/app.js +1182 -379
  13. package/public/index.html +87 -22
  14. package/public/landing.html +1217 -870
  15. package/public/legal.html +51 -8
  16. package/public/styles.css +71 -16
  17. package/server/api/routes.js +220 -35
  18. package/server/auth/index.js +633 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1369 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +69 -4
  24. package/server/hub.js +1 -0
  25. package/server/index.js +10 -2
  26. package/server/llm/client.js +1 -0
  27. package/server/llm/providers.js +50 -3
  28. package/server/model/dataset.js +362 -352
  29. package/server/model/forecast.js +202 -37
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +476 -486
  32. package/server/model/metrics.js +1 -0
  33. package/server/model/models.js +31 -21
  34. package/server/model/profiles.js +1 -0
  35. package/server/model/research.js +128 -205
  36. package/server/model/vocab.js +1 -0
  37. package/server/news/index.js +1 -0
  38. package/server/notify/index.js +304 -168
  39. package/server/params-meta.js +22 -26
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +2 -1
  42. package/server/strategist/index.js +7 -7
  43. package/server/strategist/tools.js +2 -1
  44. package/server/util.js +1 -0
  45. package/server/model/gpu-worker.html +0 -11
  46. package/server/model/gpu-worker.js +0 -60
  47. package/server/model/tf-node.js +0 -26
  48. package/server/model/transformer-core.js +0 -330
  49. package/server/model/transformer-gpu.js +0 -221
  50. package/server/model/transformer-pool.js +0 -200
  51. package/server/model/transformer-worker.js +0 -28
  52. package/server/model/transformer.js +0 -57
@@ -1,168 +1,304 @@
1
- import { db, kvGet, kvSet } from '../db.js';
2
- import { encryptSecret, decryptSecret, getUserById, runWithUser } from '../auth/index.js';
3
- import { round } from '../util.js';
4
-
5
- let actionHandlers = { execute: null, reject: null };
6
- export function registerBrokerHandlers(h) { actionHandlers = h; }
7
-
8
- export function getNotifyConfig() {
9
- const saved = kvGet('notifyConfig') || {};
10
- const tgToken = decryptSecret(saved.tgToken || '') || String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
11
- const tgBotName = String(saved.tgBotName || process.env.TELEGRAM_BOT_NAME || '').trim().replace(/^@/, '');
12
- const maxToken = decryptSecret(saved.maxToken || '') || String(process.env.MAX_BOT_TOKEN || '').trim();
13
- const maxBotName = String(saved.maxBotName || process.env.MAX_BOT_NAME || '').trim().replace(/^@/, '');
14
- return { tgToken, tgBotName, maxToken, maxBotName, tgEnabled: Boolean(tgToken), maxEnabled: Boolean(maxToken) };
15
- }
16
-
17
- export function publicNotifyConfig() {
18
- const c = getNotifyConfig();
19
- return { tgBotName: c.tgBotName, maxBotName: c.maxBotName, hasTgToken: c.tgEnabled, hasMaxToken: c.maxEnabled };
20
- }
21
-
22
- export function saveNotifyConfig(input = {}) {
23
- const prev = kvGet('notifyConfig') || {};
24
- const next = {
25
- tgBotName: String(input.tgBotName ?? prev.tgBotName ?? '').trim().replace(/^@/, ''),
26
- tgToken: input.clearTg ? '' : (input.tgToken && String(input.tgToken).trim() ? encryptSecret(input.tgToken) : (prev.tgToken || '')),
27
- maxBotName: String(input.maxBotName ?? prev.maxBotName ?? '').trim().replace(/^@/, ''),
28
- maxToken: input.clearMax ? '' : (input.maxToken && String(input.maxToken).trim() ? encryptSecret(input.maxToken) : (prev.maxToken || '')),
29
- };
30
- kvSet('notifyConfig', next);
31
- return publicNotifyConfig();
32
- }
33
-
34
- // ---------- отправка сообщений в Telegram и MAX ----------
35
- export async function sendTelegram(chatId, text, buttons = null) {
36
- const { tgToken } = getNotifyConfig();
37
- if (!tgToken || !chatId) return false;
38
- const body = {
39
- chat_id: chatId,
40
- text,
41
- ...(buttons?.length ? { reply_markup: { inline_keyboard: [buttons.map((b) => ({ text: b.label, callback_data: b.data }))] } } : {}),
42
- };
43
- const r = await fetch(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
44
- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(12000),
45
- }).catch(() => null);
46
- return Boolean(r?.ok);
47
- }
48
-
49
- export async function sendMax(chatId, text, buttons = null) {
50
- const { maxToken } = getNotifyConfig();
51
- if (!maxToken || !chatId) return false;
52
- const attachments = buttons?.length ? [{
53
- type: 'inline_keyboard',
54
- payload: {
55
- buttons: [buttons.map((b) => ({ type: 'callback', text: b.label, payload: b.data }))],
56
- },
57
- }] : undefined;
58
- const r = await fetch(`https://botapi.max.ru/messages?access_token=${encodeURIComponent(maxToken)}&chat_id=${encodeURIComponent(chatId)}`, {
59
- method: 'POST',
60
- headers: { 'Content-Type': 'application/json' },
61
- body: JSON.stringify({ text, ...(attachments ? { attachments } : {}) }),
62
- signal: AbortSignal.timeout(12000),
63
- }).catch(() => null);
64
- return Boolean(r?.ok);
65
- }
66
-
67
- export async function notifyUser(userId, text, buttons = null) {
68
- const u = getUserById(userId);
69
- if (!u) return { tg: false, max: false };
70
- const [tg, max] = await Promise.all([
71
- u.tg_chat_id ? sendTelegram(u.tg_chat_id, text, buttons) : false,
72
- u.max_chat_id ? sendMax(u.max_chat_id, text, buttons) : false,
73
- ]);
74
- return { tg, max };
75
- }
76
-
77
- export async function notifyBrokerBatch(userId, acc, batch) {
78
- if (!batch?.orders?.length) return;
79
- const lines = batch.orders.map((o) => `• ${o.action === 'buy' ? 'Купить' : 'Продать'} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽`);
80
- const text = `НейроАльфа · Счёт «${acc.name}»\nСформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):\n${lines.join('\n')}`;
81
- const buttons = acc.mode === 'confirm' ? [
82
- { label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}` },
83
- { label: '✕ Отклонить', data: `rej:${userId}:${acc.id}` },
84
- ] : null;
85
- await notifyUser(userId, text, buttons);
86
- }
87
-
88
- // ---------- обработка команд привязки и кнопок подтверждения в Telegram и MAX ----------
89
- function tryLinkByCode(text, channel, chatId) {
90
- const m = String(text || '').toUpperCase().match(/NA-[A-Z0-9]{6}/);
91
- if (!m) return null;
92
- const u = db.prepare('SELECT * FROM users WHERE UPPER(notify_code) = ?').get(m[0]);
93
- if (!u) return null;
94
- const col = channel === 'tg' ? 'tg_chat_id' : 'max_chat_id';
95
- db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(String(chatId), u.id);
96
- return u;
97
- }
98
-
99
- async function handleCallbackAction(data, replyFn) {
100
- const [act, uidStr, accId] = String(data || '').split(':');
101
- const u = getUserById(Number(uidStr));
102
- if (!u || !accId) return;
103
- try {
104
- if (act === 'exec' && actionHandlers.execute) {
105
- await runWithUser(u, () => actionHandlers.execute(accId));
106
- await replyFn(`✓ Заявки по счёту отправлены брокеру.`);
107
- } else if (act === 'rej' && actionHandlers.reject) {
108
- await runWithUser(u, () => actionHandlers.reject(accId));
109
- await replyFn(`✕ Пакет заявок отклонён.`);
110
- }
111
- } catch (e) {
112
- await replyFn(`Ошибка: ${e.message}`);
113
- }
114
- }
115
-
116
- let tgOffset = 0;
117
- let maxMarker = null;
118
-
119
- async function pollTelegram() {
120
- const { tgToken } = getNotifyConfig();
121
- if (!tgToken) return;
122
- try {
123
- const r = await fetch(`https://api.telegram.org/bot${tgToken}/getUpdates?offset=${tgOffset}&timeout=1`, { signal: AbortSignal.timeout(8000) });
124
- if (!r.ok) return;
125
- const j = await r.json();
126
- for (const upd of j.result || []) {
127
- tgOffset = Math.max(tgOffset, upd.update_id + 1);
128
- if (upd.message?.text) {
129
- const chatId = upd.message.chat.id;
130
- const u = tryLinkByCode(upd.message.text, 'tg', chatId);
131
- if (u) await sendTelegram(chatId, `✓ Telegram успешно привязан к аккаунту НейроАльфа (${u.email}). Сюда будут приходить сигналы и кнопки подтверждения брокерских заявок.`);
132
- } else if (upd.callback_query?.data) {
133
- const chatId = upd.callback_query.message?.chat?.id;
134
- await handleCallbackAction(upd.callback_query.data, (msg) => sendTelegram(chatId, msg));
135
- }
136
- }
137
- } catch { /* ignore */ }
138
- }
139
-
140
- async function pollMax() {
141
- const { maxToken } = getNotifyConfig();
142
- if (!maxToken) return;
143
- try {
144
- const url = `https://botapi.max.ru/updates?access_token=${encodeURIComponent(maxToken)}&timeout=1${maxMarker ? `&marker=${maxMarker}` : ''}`;
145
- const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
146
- if (!r.ok) return;
147
- const j = await r.json();
148
- if (j.marker) maxMarker = j.marker;
149
- for (const upd of j.updates || []) {
150
- if (upd.message?.body?.text) {
151
- const chatId = upd.message.recipient?.chat_id || upd.message.sender?.user_id;
152
- const u = tryLinkByCode(upd.message.body.text, 'max', chatId);
153
- if (u) await sendMax(chatId, `✓ Мессенджер MAX успешно привязан к аккаунту НейроАльфа (${u.email}). Сюда будут приходить сигналы и кнопки подтверждения заявок.`);
154
- } else if (upd.callback?.payload) {
155
- const chatId = upd.message?.recipient?.chat_id || upd.callback?.user?.user_id;
156
- await handleCallbackAction(upd.callback.payload, (msg) => sendMax(chatId, msg));
157
- }
158
- }
159
- } catch { /* ignore */ }
160
- }
161
-
162
- export function startNotifyPolling() {
163
- const timer = setInterval(() => {
164
- pollTelegram();
165
- pollMax();
166
- }, 6000);
167
- timer.unref?.();
168
- }
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import crypto from 'node:crypto';
3
+ import https from 'node:https';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import tls from 'node:tls';
7
+ import { ROOT } from '../config.js';
8
+ import { db, kvGet, kvSet } from '../db.js';
9
+ import { encryptSecret, decryptSecret, getUserById, runWithUser, generateApiKey, hashPassword, ensureApiKey } from '../auth/index.js';
10
+ import { round } from '../util.js';
11
+
12
+ let actionHandlers = { execute: null, reject: null };
13
+ export function registerBrokerHandlers(h) { actionHandlers = h; }
14
+
15
+ // Надёжный HTTP-клиент для platform-api2.max.ru (поддерживает сертификат НУЦ Минцифры РФ)
16
+ let maxCa = undefined;
17
+ try {
18
+ const certPath = process.env.GIGACHAT_CA_FILE || path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem');
19
+ if (fs.existsSync(certPath)) maxCa = [...tls.rootCertificates, fs.readFileSync(certPath, 'utf8')];
20
+ } catch { /* ignore */ }
21
+
22
+ function maxRequest(urlPath, { method = 'GET', token = '', body = null, timeoutMs = 12000 } = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ const u = new URL(urlPath, 'https://platform-api2.max.ru');
25
+ const payload = body ? Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)) : null;
26
+ const req = https.request({
27
+ hostname: u.hostname,
28
+ port: u.port || 443,
29
+ path: u.pathname + u.search,
30
+ method,
31
+ ca: maxCa,
32
+ rejectUnauthorized: Boolean(maxCa),
33
+ headers: {
34
+ Authorization: token,
35
+ Accept: 'application/json',
36
+ ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}),
37
+ },
38
+ timeout: timeoutMs,
39
+ }, (res) => {
40
+ let raw = '';
41
+ res.setEncoding('utf8');
42
+ res.on('data', (chunk) => { raw += chunk; });
43
+ res.on('end', () => {
44
+ let json = null;
45
+ try { json = JSON.parse(raw); } catch { /* ignore */ }
46
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json, text: raw });
47
+ });
48
+ });
49
+ req.on('timeout', () => req.destroy(new Error('MAX API timeout')));
50
+ req.on('error', reject);
51
+ if (payload) req.write(payload);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ // Одноразовые токены входа через Telegram-бота (t.me/bot?start=LOGIN_xxx)
57
+ const tgLoginChallenges = new Map(); // code -> { status: 'pending' | 'done', userId: null | number, createdAt }
58
+
59
+ export function createTelegramLoginChallenge() {
60
+ const now = Date.now();
61
+ for (const [k, v] of tgLoginChallenges) if (now - v.createdAt > 10 * 60000) tgLoginChallenges.delete(k);
62
+ const code = `LOGIN_${crypto.randomBytes(6).toString('hex').toUpperCase()}`;
63
+ tgLoginChallenges.set(code, { status: 'pending', userId: null, createdAt: now });
64
+ return code;
65
+ }
66
+
67
+ export function checkTelegramLoginChallenge(code) {
68
+ const ch = tgLoginChallenges.get(String(code || '').trim());
69
+ if (!ch) return { status: 'expired' };
70
+ if (ch.status === 'done' && ch.userId) {
71
+ tgLoginChallenges.delete(code);
72
+ return { status: 'done', userId: ch.userId };
73
+ }
74
+ return { status: 'pending' };
75
+ }
76
+
77
+ function handleBotLogin(text, channel, chatId, senderName = '', senderUsername = '') {
78
+ const m = String(text || '').toUpperCase().match(/LOGIN_[A-Z0-9]{12}/);
79
+ if (!m) return null;
80
+ const ch = tgLoginChallenges.get(m[0]);
81
+ if (!ch || ch.status !== 'pending') return null;
82
+ const cid = String(chatId);
83
+ const col = channel === 'max' ? 'max_chat_id' : 'tg_chat_id';
84
+ const dom = channel === 'max' ? 'max.neuroalpha.org' : 'tg.neuroalpha.org';
85
+ const name = String(senderName || '').trim() || senderUsername || `${channel.toUpperCase()} ${cid}`;
86
+ const email = senderUsername ? `${String(senderUsername).toLowerCase()}@${dom}` : `${channel}_${cid}@${dom}`;
87
+
88
+ let user = db.prepare(`SELECT * FROM users WHERE ${col} = ? OR email = ?`).get(cid, email);
89
+ if (!user) {
90
+ const notifyCode = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
91
+ const apiKey = generateApiKey();
92
+ const rndPass = hashPassword(crypto.randomBytes(16).toString('hex'));
93
+ const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, ${col}, notify_code, api_key, created_at)
94
+ VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?, ?)`).run(email, name, rndPass, cid, notifyCode, apiKey, Date.now());
95
+ user = getUserById(Number(r.lastInsertRowid));
96
+ } else {
97
+ if (!user[col]) db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(cid, user.id);
98
+ if (!user.api_key) ensureApiKey(user.id);
99
+ user = getUserById(user.id);
100
+ }
101
+ ch.status = 'done';
102
+ ch.userId = user.id;
103
+ return user;
104
+ }
105
+
106
+ export function getNotifyConfig() {
107
+ const saved = kvGet('notifyConfig') || {};
108
+ const tgToken = decryptSecret(saved.tgToken || '') || String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
109
+ const tgBotName = String(saved.tgBotName || process.env.TELEGRAM_BOT_NAME || 'neuroalpha_trade_bot').trim().replace(/^@/, '');
110
+ const maxToken = decryptSecret(saved.maxToken || '') || String(process.env.MAX_BOT_TOKEN || '').trim();
111
+ const maxBotName = String(saved.maxBotName || process.env.MAX_BOT_NAME || 'id6234049193_bot').trim().replace(/^@/, '');
112
+ return { tgToken, tgBotName, maxToken, maxBotName, tgEnabled: Boolean(tgToken), maxEnabled: Boolean(maxToken) };
113
+ }
114
+
115
+ export function publicNotifyConfig() {
116
+ const c = getNotifyConfig();
117
+ return { tgBotName: c.tgBotName, maxBotName: c.maxBotName, hasTgToken: c.tgEnabled, hasMaxToken: c.maxEnabled };
118
+ }
119
+
120
+ export async function saveNotifyConfig(input = {}) {
121
+ const prev = kvGet('notifyConfig') || {};
122
+ const rawMaxToken = input.clearMax ? '' : (input.maxToken && String(input.maxToken).trim() ? String(input.maxToken).trim() : decryptSecret(prev.maxToken || ''));
123
+ let maxBotName = String(input.maxBotName ?? prev.maxBotName ?? '').trim().replace(/^@/, '');
124
+ if (rawMaxToken && !maxBotName) {
125
+ try {
126
+ const me = await maxRequest('/me', { token: rawMaxToken, timeoutMs: 8000 });
127
+ if (me.ok && me.json?.username) maxBotName = String(me.json.username).trim().replace(/^@/, '');
128
+ } catch { /* ignore */ }
129
+ }
130
+ const next = {
131
+ tgBotName: String(input.tgBotName ?? prev.tgBotName ?? '').trim().replace(/^@/, ''),
132
+ tgToken: input.clearTg ? '' : (input.tgToken && String(input.tgToken).trim() ? encryptSecret(input.tgToken) : (prev.tgToken || '')),
133
+ maxBotName,
134
+ maxToken: input.clearMax ? '' : (input.maxToken && String(input.maxToken).trim() ? encryptSecret(input.maxToken) : (prev.maxToken || '')),
135
+ };
136
+ kvSet('notifyConfig', next);
137
+ return publicNotifyConfig();
138
+ }
139
+
140
+ // ---------- отправка сообщений в Telegram и MAX ----------
141
+ export async function sendTelegram(chatId, text, buttons = null) {
142
+ const { tgToken } = getNotifyConfig();
143
+ if (!tgToken || !chatId) return false;
144
+ const body = {
145
+ chat_id: chatId,
146
+ text,
147
+ ...(buttons?.length ? { reply_markup: { inline_keyboard: [buttons.map((b) => ({ text: b.label, callback_data: b.data }))] } } : {}),
148
+ };
149
+ const r = await fetch(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
150
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(12000),
151
+ }).catch(() => null);
152
+ return Boolean(r?.ok);
153
+ }
154
+
155
+ export async function sendMax(chatId, text, buttons = null) {
156
+ const { maxToken } = getNotifyConfig();
157
+ if (!maxToken || !chatId) return false;
158
+ const attachments = buttons?.length ? [{
159
+ type: 'inline_keyboard',
160
+ payload: {
161
+ buttons: [buttons.map((b) => ({ type: 'callback', text: b.label, payload: b.data }))],
162
+ },
163
+ }] : undefined;
164
+ const r = await maxRequest(`/messages?chat_id=${encodeURIComponent(chatId)}`, {
165
+ method: 'POST',
166
+ token: maxToken,
167
+ body: { text, ...(attachments ? { attachments } : {}) },
168
+ timeoutMs: 12000,
169
+ }).catch(() => null);
170
+ return Boolean(r?.ok);
171
+ }
172
+
173
+ export async function notifyUser(userId, text, buttons = null) {
174
+ const u = getUserById(userId);
175
+ if (!u) return { tg: false, max: false };
176
+ const [tg, max] = await Promise.all([
177
+ u.tg_chat_id ? sendTelegram(u.tg_chat_id, text, buttons) : false,
178
+ u.max_chat_id ? sendMax(u.max_chat_id, text, buttons) : false,
179
+ ]);
180
+ return { tg, max };
181
+ }
182
+
183
+ export async function notifyBrokerBatch(userId, acc, batch) {
184
+ if (!batch?.orders?.length) return;
185
+ const lines = batch.orders.map((o) => `• ${o.action === 'buy' ? 'Купить' : 'Продать'} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽`);
186
+ const text = `НейроАльфа · Счёт «${acc.name}»\nСформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):\n${lines.join('\n')}`;
187
+ const buttons = acc.mode === 'confirm' ? [
188
+ { label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}` },
189
+ { label: '✕ Отклонить', data: `rej:${userId}:${acc.id}` },
190
+ ] : null;
191
+ await notifyUser(userId, text, buttons);
192
+ }
193
+
194
+ // ---------- обработка команд привязки и кнопок подтверждения в Telegram и MAX ----------
195
+ function tryLinkByCode(text, channel, chatId) {
196
+ const m = String(text || '').toUpperCase().match(/NA-[A-Z0-9]{6}/);
197
+ if (!m) return null;
198
+ const u = db.prepare('SELECT * FROM users WHERE UPPER(notify_code) = ?').get(m[0]);
199
+ if (!u) return null;
200
+ const col = channel === 'tg' ? 'tg_chat_id' : 'max_chat_id';
201
+ db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(String(chatId), u.id);
202
+ return u;
203
+ }
204
+
205
+ async function handleCallbackAction(data, replyFn) {
206
+ const [act, uidStr, accId] = String(data || '').split(':');
207
+ const u = getUserById(Number(uidStr));
208
+ if (!u || !accId) return;
209
+ try {
210
+ if (act === 'exec' && actionHandlers.execute) {
211
+ await runWithUser(u, () => actionHandlers.execute(accId));
212
+ await replyFn(`✓ Заявки по счёту отправлены брокеру.`);
213
+ } else if (act === 'rej' && actionHandlers.reject) {
214
+ await runWithUser(u, () => actionHandlers.reject(accId));
215
+ await replyFn(`✕ Пакет заявок отклонён.`);
216
+ }
217
+ } catch (e) {
218
+ await replyFn(`Ошибка: ${e.message}`);
219
+ }
220
+ }
221
+
222
+ let tgOffset = 0;
223
+ let maxMarker = null;
224
+
225
+ async function pollTelegram() {
226
+ const { tgToken } = getNotifyConfig();
227
+ if (!tgToken) return;
228
+ try {
229
+ const r = await fetch(`https://api.telegram.org/bot${tgToken}/getUpdates?offset=${tgOffset}&timeout=1`, { signal: AbortSignal.timeout(8000) });
230
+ if (!r.ok) return;
231
+ const j = await r.json();
232
+ for (const upd of j.result || []) {
233
+ tgOffset = Math.max(tgOffset, upd.update_id + 1);
234
+ if (upd.message?.text) {
235
+ const chatId = upd.message.chat.id;
236
+ const txt = upd.message.text.trim();
237
+ const from = upd.message.from || {};
238
+ const loginUser = handleBotLogin(txt, 'tg', chatId, `${from.first_name || ''} ${from.last_name || ''}`.trim(), from.username || '');
239
+ if (loginUser) {
240
+ await sendTelegram(chatId, `✓ Вход в НейроАльфа выполнен (${loginUser.name})!\n\nВаш персональный API-ключ:\n${loginUser.api_key}\n\nЗапуск на компьютере:\nnpx neuroalpha --key ${loginUser.api_key}\n\nВернитесь в браузер — личный кабинет уже открыт.`);
241
+ continue;
242
+ }
243
+ const u = tryLinkByCode(txt, 'tg', chatId);
244
+ if (u) {
245
+ await sendTelegram(chatId, `✓ Telegram успешно привязан к аккаунту НейроАльфа (${u.email}). Сюда будут приходить сигналы и кнопки подтверждения брокерских заявок.`);
246
+ continue;
247
+ }
248
+ if (/^\/start$/i.test(txt)) {
249
+ await sendTelegram(chatId, `◈ НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи.\n\n• Для входа на сайте нажмите «Войти через Telegram» на https://neuroalpha.odant.org\n• Для привязки уведомлений отправьте сюда ваш код привязки (вида NA-XXXXXX) из раздела «Настройки → Профиль, тариф и боты».`);
250
+ }
251
+ } else if (upd.callback_query?.data) {
252
+ const chatId = upd.callback_query.message?.chat?.id;
253
+ await handleCallbackAction(upd.callback_query.data, (msg) => sendTelegram(chatId, msg));
254
+ }
255
+ }
256
+ } catch { /* ignore */ }
257
+ }
258
+
259
+ async function processMaxUpdate(upd) {
260
+ const chatId = upd.chat_id || upd.message?.recipient?.chat_id || upd.message?.sender?.user_id || upd.user?.user_id;
261
+ const sender = upd.message?.sender || upd.user || {};
262
+ const txt = String(upd.message?.body?.text || upd.payload || '').trim();
263
+ if (txt && chatId) {
264
+ const loginUser = handleBotLogin(txt, 'max', chatId, sender.name || '', sender.username || '');
265
+ if (loginUser) {
266
+ await sendMax(chatId, `✓ Вход в НейроАльфа выполнен (${loginUser.name})!\n\nВаш персональный API-ключ:\n${loginUser.api_key}\n\nЗапуск на компьютере:\nnpx neuroalpha --key ${loginUser.api_key}\n\nВернитесь в браузер — личный кабинет уже открыт.`);
267
+ return;
268
+ }
269
+ const u = tryLinkByCode(txt, 'max', chatId);
270
+ if (u) {
271
+ await sendMax(chatId, `✓ Мессенджер MAX успешно привязан к аккаунту НейроАльфа (${u.email}). Сюда будут приходить сигналы и кнопки подтверждения заявок.`);
272
+ return;
273
+ }
274
+ }
275
+ if (upd.callback?.payload) {
276
+ const cbChatId = upd.chat_id || upd.message?.recipient?.chat_id || upd.callback?.user?.user_id;
277
+ await handleCallbackAction(upd.callback.payload, (msg) => sendMax(cbChatId, msg));
278
+ }
279
+ }
280
+
281
+ export async function handleMaxWebhook(body = {}) {
282
+ const list = Array.isArray(body.updates) ? body.updates : (body.update_type ? [body] : []);
283
+ for (const upd of list) await processMaxUpdate(upd);
284
+ return { ok: true };
285
+ }
286
+
287
+ async function pollMax() {
288
+ const { maxToken } = getNotifyConfig();
289
+ if (!maxToken) return;
290
+ try {
291
+ const r = await maxRequest(`/updates?timeout=1${maxMarker ? `&marker=${maxMarker}` : ''}`, { token: maxToken, timeoutMs: 8000 });
292
+ if (!r.ok || !r.json) return;
293
+ if (r.json.marker) maxMarker = r.json.marker;
294
+ for (const upd of r.json.updates || []) await processMaxUpdate(upd);
295
+ } catch { /* ignore */ }
296
+ }
297
+
298
+ export function startNotifyPolling() {
299
+ const timer = setInterval(() => {
300
+ pollTelegram();
301
+ pollMax();
302
+ }, 6000);
303
+ timer.unref?.();
304
+ }
@@ -1,35 +1,30 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  // Описание всех настроек: для формы в интерфейсе, для ИИ-стратега и для проверки значений.
2
3
  // hidden — не показывать в интерфейсе; ai:false — ИИ менять запрещено (честность проверки).
3
4
  export const PARAMS = [
4
- { key: 'modelAutoAdapt', label: 'Самообучение правил торговли (Авто)', hint: 'Включено: стратегия сама подбирает порог выхода под вашу комиссию, вовремя закрывает угасающие сделки и автоматически ставит на карантин убыточные «акции-убийцы». Ручная настройка порогов не требуется.', type: 'checkbox' },
5
- { key: 'modelHorizon', label: 'Горизонт прогноза, часов', hint: 'На сколько часов вперёд стратегия предсказывает движение и примерно столько держит купленные акции. Можно дробно, с точностью до 10 минут (например, 1,5).', min: 0.5, max: 8 },
6
- { key: 'modelMinEdge', label: 'Порог выгоды на вход', hint: 'Покупать (или продавать), только если ожидаемое движение больше стоимости покупки и продажи, умноженной на это число. Больше — реже, но выгоднее сделки. 0 — без порога.', min: 0, max: 10 },
7
- { key: 'modelExitRatio', label: 'Порог на выход (доля от входа)', hint: 'До какой доли от порога входа может ослабнуть прогноз, прежде чем закрыть позицию (0,5 — закрывать ниже половины порога входа; 0 — держать, пока прогноз остаётся в нашу сторону).', min: 0, max: 1, step: 0.05 },
8
- { key: 'modelMinHoldHours', label: 'Минимальное удержание, часов', hint: 'Не закрывать позицию раньше этого времени при обычном ослаблении прогноза (0 — без ограничения). Снижает число сделок и расходы на комиссию.', min: 0, max: 48, step: 0.5 },
5
+ { key: 'modelAutoAdapt', label: 'Самообучение правил торговли (Авто)', hint: 'Полный автомат: стратегия сама учится на мульти-горизонте (1,5–3,5 ч), автоматически управляет порогами входа и выхода под вашу комиссию, масштабирует слоты по силе сигнала и временно ставит на карантин убыточные акции. Ручная настройка горизонта и порогов не требуется.', type: 'checkbox' },
9
6
  { key: 'modelTopK', label: 'Сколько акций держать', hint: 'Сколько акций с лучшим прогнозом можно держать одновременно (при продажах — и столько же акций на продажу).', min: 1, max: 20 },
10
- { key: 'modelAllocMode', label: 'Распределение суммы между акциями', hint: '«Поровну» — одинаковая сумма на каждую позицию; «Обратно размаху акции» — спокойным бумагам больше, волатильным — меньше (снижает просадку).', type: 'select', options: [['equal', 'Поровну'], ['invvol', 'Обратно размаху акции']] },
11
- { key: 'modelDirection', label: 'Направление торговли', hint: 'Продажа без покупки (шорт) — заработок на падении акции относительно рынка. «Нейтрально к рынку» — покупаем сильные акции и продаём слабые на равные суммы: общий рост или падение рынка почти не влияет на результат. При продажах сумма на одну акцию вдвое меньше.', type: 'select', options: [['long', 'Только покупки'], ['longshort', 'Покупки и продажи'], ['neutral', 'Нейтрально к рынку']] },
12
- { key: 'modelMarketFilter', label: 'Фильтр падающего рынка', hint: 'Не открывать новые покупки, когда индекс Мосбиржи одновременно ниже уровня 2 часов назад и ниже уровня торгового дня назад.', type: 'select', options: [['off', 'Выключен'], ['ma', 'Не покупать при падении индекса']] },
13
- { key: 'modelShortFeePct', label: 'Плата за заём акций, % годовых', hint: 'Сколько брокер берёт за заём акций для продажи без покупки. Начисляется за всё время, пока продажа открыта (например, 20% годовых — около 0,05% за сутки).', min: 0, max: 100 },
14
- { key: 'modelTrainDays', label: 'Окно обучения, торговых дней', hint: 'На скольких последних днях стратегия учится перед каждым месяцем прогноза.', min: 30, max: 400 },
15
- { key: 'modelHistoryDays', label: 'Период исследования, дней', hint: 'За сколько последних календарных дней проверять стратегию.', min: 120, max: 1100 },
16
- { key: 'modelEmbedDim', label: 'Размер эмбеддинга', hint: 'Сколько «скрытых свойств» описывают каждую акцию на карте рынка. Больше — тоньше различия, но больше шума.', min: 2, max: 20 },
17
- { key: 'modelUseMacro', label: 'Учитывать нефть, валюту, облигации', hint: 'Стратегия дополнительно смотрит на движение нефти Brent, юаня, доллара, золота и индекса гособлигаций: акции, чувствительные к ним, получают поправку к прогнозу.', type: 'checkbox' },
18
- { key: 'modelTextWeight', label: 'Вес описаний компаний на карте', hint: '0 — карта рынка строится только по поведению акций (как они двигались вместе); 1 — только по устойчивым описаниям бизнеса (экспортёр, банк, дивиденды и т.д.); значения между — смесь того и другого. Описания заранее составляет ИИ.', min: 0, max: 1, step: 0.1 },
19
- { key: 'modelType', label: 'Какая стратегия торгует', hint: 'Три стратегии всегда сравниваются (трансформер — если включён); эта торгует в результатах и в живой проверке. Если выбран трансформер, он обучается и без галочки ниже.', type: 'select', options: [['attention', 'Внимание через карту'], ['leadlag', 'Кто за кем ходит'], ['inertia', 'Своя инерция'], ['transformer', 'Трансформер']] },
20
- { key: 'modelUseTransformer', label: 'Добавить трансформер (медленно)', hint: 'Нейросеть с вниманием между акциями: добавляет в сравнение четвёртую стратегию. Учится заново в каждом окне, поэтому исследование идёт заметно дольше.', type: 'checkbox' },
21
- { key: 'transformerEpochs', label: 'Эпох обучения трансформера', hint: 'Сколько раз трансформер проходит по данным окна обучения. Больше — дольше учится и может найти больше закономерностей (обучение само останавливается, если становится хуже).', min: 2, max: 40, step: 1, int: true },
22
- { key: 'transformerStride', label: 'Шаг свечей при обучении трансформера', hint: 'В каждый проход берётся каждая N-я свеча (со сдвигом от прохода к проходу).', min: 1, max: 24, step: 1, int: true, hidden: true, fixed: true },
23
- { key: 'transformerParallel', label: 'Обучать окна параллельно', hint: 'Месяцы проверки трансформера обучаются одновременно на нескольких ядрах процессора.', type: 'checkbox', hidden: true, fixed: true },
24
- { key: 'transformerGpu', label: 'Обучать трансформер на видеокарте', hint: 'Экспериментально. Через встроенный в браузер (Chrome, Edge) движок. Если видеокарта недоступна — обучение пойдёт на процессоре. Результат может чуть отличаться в последних знаках.', type: 'checkbox' },
7
+ { key: 'modelDirection', label: 'Направление торговли', hint: '«Только в рост (без шорта)» — классическая покупка акций и их продажа при фиксации прибыли (работает на любом счёте и ИИС). «В рост и в падение (с шортом)» — дополнительно зарабатывает на падающих акциях за счёт коротких продаж (требуется включённая маржинальная торговля у брокера; на брокерских счетах без маржиналки шорты отключаются автоматически).', type: 'select', options: [['long', 'Только в рост (без шорта)'], ['longshort', 'В рост и в падение (с шортом)']] },
8
+ { key: 'modelHorizon', label: 'Горизонт прогноза, часов (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (мульти-горизонт 1,5–3,5 ч). При выключенном Авто — на сколько часов вперёд предсказывать движение.', min: 0.5, max: 8 },
9
+ { key: 'modelMinEdge', label: 'Порог выгоды на вход (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (2,5× издержек). При выключенном Авто — во сколько раз ожидаемое движение должно превышать стоимость сделки.', min: 0, max: 10 },
10
+ { key: 'modelExitRatio', label: 'Порог на выход (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (двухступенчатый выход 0,35 / 0,95). При выключенном Авто — до какой доли от порога входа держать позицию.', min: 0, max: 1, step: 0.05 },
11
+ { key: 'modelAllocMode', label: 'Распределение суммы между акциями', hint: '«Поровну» — одинаковая доля на каждую позицию; «Обратно размаху акции» — спокойным бумагам больше, волатильным — меньше.', type: 'select', options: [['equal', 'Поровну'], ['invvol', 'Обратно размаху акции']] },
12
+ { key: 'modelMarketFilter', label: 'Фильтр падающего рынка', hint: 'Не открывать новые покупки, когда индекс Мосбиржи находится в среднесрочном нисходящем тренде.', type: 'select', options: [['off', 'Выключен'], ['ma', 'Не покупать при падении индекса']] },
13
+ { key: 'modelTrainDays', label: 'Окно обучения, торговых дней', hint: 'На скольких последних торговых днях стратегия строит карту рынка и учится перед каждым месяцем прогноза.', min: 30, max: 400 },
14
+ { key: 'modelHistoryDays', label: 'Период исследования, дней', hint: 'За сколько последних календарных дней проверять стратегию на истории.', min: 120, max: 1100 },
15
+ { key: 'modelEmbedDim', label: 'Размерность карты рынка (эмбеддинг)', hint: 'Сколько скрытых факторов совместного движения описывают каждую акцию на карте рынка.', min: 2, max: 20 },
16
+ { key: 'modelTextWeight', label: 'Вес бизнес-профилей компаний на карте', hint: '0 — карта рынка строится только по совместному поведению цен; 0,1…0,3 — примешивает фундаментальное сходство бизнеса компаний от ИИ.', min: 0, max: 1, step: 0.1 },
17
+ { key: 'modelUseMacro', label: 'Учитывать нефть, валюту, облигации', hint: 'Дополнительно учитывает чувствительность акций к Brent, юаню, доллару, золоту и RGBI.', type: 'checkbox', hidden: true },
18
+ { key: 'modelType', label: 'Какая стратегия торгует', hint: 'Внимание через карту.', type: 'select', options: [['attention', 'Внимание через карту'], ['leadlag', 'Кто за кем ходит'], ['inertia', 'Своя инерция']], hidden: true, fixed: true },
25
19
  { key: 'modelUseDividends', label: 'Учитывать дивиденды', hint: 'Цены до даты отсечки уменьшаются на размер дивиденда со сверкой по реальному падению цены.', type: 'checkbox', hidden: true, fixed: true },
26
20
  { key: 'capital', label: 'Стартовая сумма, ₽', hint: 'С какой суммой начинает виртуальный счёт.', min: 10000, max: 1e9, hidden: true },
27
- { key: 'commissionPct', label: 'Комиссия, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false },
28
- { key: 'slippagePct', label: 'Потери при исполнении, %', hint: 'На сколько реальная цена сделки обычно хуже ожидаемой.', min: 0, max: 1, ai: false },
29
- { key: 'paperDailyReview', label: 'ИИ разбирает каждый торговый день живой проверки', hint: 'После окончания торгового дня Стратег сам разбирает сделки и пишет короткую сводку в чат.', type: 'checkbox' },
30
- { key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько исследований Стратег может провести подряд за одно задание в режиме «Автопилот».', min: 1, max: 30, step: 1, int: true, ai: false },
21
+ { key: 'commissionPct', label: 'Комиссия брокера и биржи, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false },
22
+ { key: 'slippagePct', label: 'Проскальзывание при исполнении, %', hint: 'На сколько реальная цена сделки в стакане обычно хуже ожидаемой.', min: 0, max: 1, ai: false },
23
+ { key: 'modelShortFeePct', label: 'Плата брокеру за заём акций (шорт), % годовых', hint: 'Ставка брокера за удержание коротких позиций (начисляется пропорционально времени удержания сделки).', min: 0, max: 100 },
24
+ { key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько экспериментов Стратег может провести подряд за одно задание в режиме «⚡ Авто».', min: 1, max: 30, step: 1, int: true, ai: false },
25
+ { key: 'paperDailyReview', label: 'ИИ разбирает каждый торговый день', hint: 'После окончания торгового дня Стратег сам разбирает сделки виртуального счёта и пишет сводку в чат.', type: 'checkbox' },
31
26
  { key: 'newsEnabled', label: 'Следить за новостями', hint: 'Раз в 10 минут собирает новости и раскрытия компаний.', type: 'checkbox', hidden: true, fixed: true },
32
- { key: 'newsFilter', label: 'Учитывать новости в торговле', hint: 'Раз в 10 минут собирает новости компаний (ИИ определяет затронутые акции и тон) и в живой проверке не открывает сделки против важной свежей новости.', type: 'checkbox' },
27
+ { key: 'newsFilter', label: 'Блокировать сделки против важных свежих новостей', hint: 'В живой торговле и брокерских заявках не покупать бумагу на сильном негативе и не шортить на сильном позитиве за последние 6 часов.', type: 'checkbox' },
33
28
  { key: 'oosPct', label: 'Контрольная часть, %', hint: 'Доля периода в конце, не используемая для подбора настроек.', min: 0, max: 60, ai: false, hidden: true },
34
29
  { key: 'paperPollSec', label: 'Проверять биржу раз в, сек', hint: 'Как часто живая проверка смотрит, не закрылась ли новая свеча.', min: 15, max: 3600, hidden: true },
35
30
  ];
@@ -42,7 +37,8 @@ export function validateParam(key, raw) {
42
37
  if (!m) return { error: `нет такой настройки «${key}»` };
43
38
  if (m.type === 'checkbox') return { value: raw === true || raw === 'true' || raw === 1 };
44
39
  if (m.type === 'select') {
45
- const opt = m.options.find(([v]) => String(v) === String(raw));
40
+ const normRaw = key === 'modelDirection' && String(raw) === 'neutral' ? 'longshort' : raw;
41
+ const opt = m.options.find(([v]) => String(v) === String(normRaw));
46
42
  return opt ? { value: opt[0] } : { error: `${key}: допустимо ${m.options.map(([v]) => v).join(', ')}` };
47
43
  }
48
44
  // число; запятая допустима как десятичный разделитель («2,5» = 2.5)
@@ -1,3 +1,4 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
2
  import { strategyDefaults } from './config.js';
2
3
  import { userKvGet, userKvSet, addSettingsHistory, getSettingsHistory } from './db.js';
3
4
  import { validateParam, normalizeKey, PARAM_MAP } from './params-meta.js';