neuroalpha 0.2.0 → 0.3.12
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/LICENSE +21 -0
- package/README.md +7 -3
- package/bin/cli.js +69 -69
- package/config/seed-run.json +1 -0
- package/config/strategy.json +4 -10
- package/deploy/install.sh +128 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
- 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
- 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
- package/package.json +15 -5
- package/public/admin.html +426 -0
- package/public/app.js +741 -251
- package/public/index.html +45 -20
- package/public/landing.html +1004 -870
- package/public/legal.html +22 -0
- package/public/styles.css +49 -15
- package/server/api/routes.js +176 -23
- package/server/auth/index.js +544 -340
- package/server/billing/yookassa.js +1 -0
- package/server/brokers/index.js +1224 -645
- package/server/config.js +6 -4
- package/server/data/moex.js +1 -0
- package/server/db.js +53 -2
- package/server/hub.js +1 -0
- package/server/index.js +10 -2
- package/server/llm/client.js +1 -0
- package/server/llm/providers.js +50 -3
- package/server/model/dataset.js +362 -352
- package/server/model/forecast.js +15 -21
- package/server/model/linalg.js +1 -0
- package/server/model/live.js +489 -486
- package/server/model/metrics.js +1 -0
- package/server/model/models.js +31 -21
- package/server/model/profiles.js +1 -0
- package/server/model/research.js +119 -172
- package/server/model/vocab.js +1 -0
- package/server/news/index.js +1 -0
- package/server/notify/index.js +304 -168
- package/server/params-meta.js +20 -25
- package/server/settings.js +1 -0
- package/server/strategist/analytics.js +1 -0
- package/server/strategist/index.js +6 -6
- package/server/strategist/tools.js +2 -1
- package/server/util.js +1 -0
- package/server/model/gpu-worker.html +0 -11
- package/server/model/gpu-worker.js +0 -60
- package/server/model/tf-node.js +0 -26
- package/server/model/transformer-core.js +0 -330
- package/server/model/transformer-gpu.js +0 -221
- package/server/model/transformer-pool.js +0 -200
- package/server/model/transformer-worker.js +0 -28
- package/server/model/transformer.js +0 -57
package/server/notify/index.js
CHANGED
|
@@ -1,168 +1,304 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
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
|
+
}
|
package/server/params-meta.js
CHANGED
|
@@ -1,35 +1,30 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
1
2
|
// Описание всех настроек: для формы в интерфейсе, для ИИ-стратега и для проверки значений.
|
|
2
3
|
// hidden — не показывать в интерфейсе; ai:false — ИИ менять запрещено (честность проверки).
|
|
3
4
|
export const PARAMS = [
|
|
4
|
-
{ key: 'modelAutoAdapt', label: 'Самообучение правил торговли (Авто)', hint: '
|
|
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: '
|
|
11
|
-
{ key: '
|
|
12
|
-
{ key: '
|
|
13
|
-
{ key: '
|
|
14
|
-
{ key: '
|
|
15
|
-
{ key: '
|
|
16
|
-
{ key: '
|
|
17
|
-
{ key: '
|
|
18
|
-
{ key: '
|
|
19
|
-
{ key: '
|
|
20
|
-
{ key: '
|
|
21
|
-
{ key: '
|
|
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: '«Только в рост (без шорта)» — классическая покупка акций и их продажа при фиксации прибыли (работает на любом счёте и ИИС). «В рост и в падение (с шортом)» — дополнительно зарабатывает на падающих акциях за счёт коротких продаж (требуется включённая маржинальная торговля у брокера; на брокерских счетах без маржиналки шорты отключаются автоматически). «Хедж 50/50 (рост + падение)» — держит позиции в рост и в падение на равные суммы, чтобы не зависеть от обвалов индекса.', type: 'select', options: [['long', 'Только в рост (без шорта)'], ['longshort', 'В рост и в падение (с шортом)'], ['neutral', 'Хедж 50/50 (рост + падение)']] },
|
|
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: '
|
|
28
|
-
{ key: 'slippagePct', label: '
|
|
29
|
-
{ key: '
|
|
30
|
-
{ key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько
|
|
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: '
|
|
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
|
];
|
package/server/settings.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
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';
|