neuroalpha 0.0.0-stage → 0.2.0

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 (46) hide show
  1. package/README.md +164 -3
  2. package/bin/cli.js +69 -0
  3. package/config/strategy.json +33 -0
  4. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
  5. package/package.json +43 -6
  6. package/public/app.js +2410 -0
  7. package/public/index.html +337 -0
  8. package/public/landing.html +870 -0
  9. package/public/legal.html +90 -0
  10. package/public/styles.css +343 -0
  11. package/server/api/routes.js +333 -0
  12. package/server/auth/index.js +340 -0
  13. package/server/billing/yookassa.js +132 -0
  14. package/server/brokers/index.js +645 -0
  15. package/server/config.js +44 -0
  16. package/server/data/moex.js +165 -0
  17. package/server/db.js +420 -0
  18. package/server/hub.js +14 -0
  19. package/server/index.js +92 -0
  20. package/server/llm/client.js +187 -0
  21. package/server/llm/providers.js +355 -0
  22. package/server/model/dataset.js +352 -0
  23. package/server/model/forecast.js +121 -0
  24. package/server/model/gpu-worker.html +11 -0
  25. package/server/model/gpu-worker.js +60 -0
  26. package/server/model/linalg.js +142 -0
  27. package/server/model/live.js +486 -0
  28. package/server/model/metrics.js +134 -0
  29. package/server/model/models.js +459 -0
  30. package/server/model/profiles.js +166 -0
  31. package/server/model/research.js +880 -0
  32. package/server/model/tf-node.js +26 -0
  33. package/server/model/transformer-core.js +330 -0
  34. package/server/model/transformer-gpu.js +221 -0
  35. package/server/model/transformer-pool.js +200 -0
  36. package/server/model/transformer-worker.js +28 -0
  37. package/server/model/transformer.js +57 -0
  38. package/server/model/vocab.js +28 -0
  39. package/server/news/index.js +309 -0
  40. package/server/notify/index.js +168 -0
  41. package/server/params-meta.js +63 -0
  42. package/server/settings.js +79 -0
  43. package/server/strategist/analytics.js +150 -0
  44. package/server/strategist/index.js +484 -0
  45. package/server/strategist/tools.js +302 -0
  46. package/server/util.js +9 -0
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+ export const DATA_DIR = process.env.NEUROALPHA_DATA_DIR
7
+ ? path.resolve(process.env.NEUROALPHA_DATA_DIR)
8
+ : path.join(ROOT, 'data');
9
+
10
+ const envFile = path.join(ROOT, '.env');
11
+ if (fs.existsSync(envFile)) process.loadEnvFile(envFile);
12
+ const cwdEnv = path.resolve(process.cwd(), '.env');
13
+ if (cwdEnv !== envFile && fs.existsSync(cwdEnv)) process.loadEnvFile(cwdEnv);
14
+
15
+ export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
16
+
17
+ function resolveApiKey() {
18
+ if (process.env.LLM_API_KEY) return process.env.LLM_API_KEY.trim();
19
+ const file = process.env.LLM_API_KEY_FILE;
20
+ if (!file) return '';
21
+ try {
22
+ const raw = fs.readFileSync(path.resolve(ROOT, file), 'utf8').trim();
23
+ if (raw.startsWith('{')) {
24
+ const j = JSON.parse(raw);
25
+ return String(j.apiKey || j.api_key || j.key || '').trim();
26
+ }
27
+ return raw;
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+
33
+ export const llmConfig = {
34
+ provider: (process.env.LLM_PROVIDER || 'none').toLowerCase(),
35
+ baseUrl: process.env.LLM_BASE_URL || '',
36
+ model: process.env.LLM_MODEL || '',
37
+ apiKey: resolveApiKey(),
38
+ reasoningEffort: process.env.LLM_REASONING_EFFORT || '',
39
+ maxTokens: Number(process.env.LLM_MAX_TOKENS || 4000),
40
+ timeoutMs: Number(process.env.LLM_TIMEOUT_MS || 90000),
41
+ };
42
+ llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
43
+
44
+ export const PORT = Number(process.env.PORT || 3000);
@@ -0,0 +1,165 @@
1
+ import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
2
+ import { DAY, MSK } from '../util.js';
3
+
4
+ const ISS = 'https://iss.moex.com/iss';
5
+ const INDEXES = new Set(['IMOEX', 'RGBI', 'MOEXBMI']);
6
+ // макро-инструменты: валюта (CETS) и фьючерсы (FORTS/RFUD)
7
+ const CURRENCY = new Set(['CNYRUB_TOM', 'USD000UTSTOM']);
8
+ const FUTURES = /^GLDRUBF$|^BR[A-Z]\d$/;
9
+
10
+ export const mskToTs = (s) => Date.parse(s.replace(' ', 'T') + '+03:00');
11
+ export const tsToDate = (ts) => new Date(ts + MSK).toISOString().slice(0, 10);
12
+ export const barMs = (interval) => (Number(interval) === 24 ? DAY : Number(interval) * 60000);
13
+
14
+ const basePath = (symbol) => {
15
+ if (INDEXES.has(symbol)) return `/engines/stock/markets/index/boards/SNDX/securities/${symbol}`;
16
+ if (CURRENCY.has(symbol)) return `/engines/currency/markets/selt/boards/CETS/securities/${symbol}`;
17
+ if (FUTURES.test(symbol)) return `/engines/futures/markets/forts/boards/RFUD/securities/${symbol}`;
18
+ return `/engines/stock/markets/shares/boards/TQBR/securities/${symbol}`;
19
+ };
20
+
21
+ async function getJson(url, attempt = 0, signal) {
22
+ try {
23
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
24
+ const res = await fetch(url, { headers: { 'User-Agent': 'trading-consortium/0.2' }, signal: sig });
25
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
26
+ return await res.json();
27
+ } catch (e) {
28
+ if (signal?.aborted) throw new Error('Отменено');
29
+ if (attempt < 3) {
30
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
31
+ return getJson(url, attempt + 1, signal);
32
+ }
33
+ throw new Error(`MOEX ISS: ${url}: ${e.message}`);
34
+ }
35
+ }
36
+
37
+ // Свечи с биржи; каждая страница сразу уходит в кэш (onBatch), чтобы при обрыве продолжить потом с того же места.
38
+ // pages — максимум страниц (1 для разовой проверки «есть ли история раньше»)
39
+ export async function fetchCandles(symbol, interval, from, till, { signal, onBatch, pages = Infinity } = {}) {
40
+ const rows = [];
41
+ let start = 0;
42
+ for (let fetched = 0; fetched < pages; fetched++) {
43
+ if (signal?.aborted) throw new Error('Отменено');
44
+ let url = `${ISS}${basePath(symbol)}/candles.json?iss.meta=off&interval=${interval}&from=${from}&start=${start}`;
45
+ if (till) url += `&till=${till}`;
46
+ const j = await getJson(url, 0, signal);
47
+ const idx = Object.fromEntries(j.candles.columns.map((c, i) => [c, i]));
48
+ const data = j.candles.data;
49
+ const page = data.map((r) => ({
50
+ ts: mskToTs(r[idx.begin]),
51
+ open: r[idx.open], high: r[idx.high], low: r[idx.low], close: r[idx.close],
52
+ volume: r[idx.volume] ?? 0,
53
+ value: r[idx.value] ?? 0,
54
+ }));
55
+ rows.push(...page);
56
+ onBatch?.(page);
57
+ if (data.length < 500) break;
58
+ start += data.length;
59
+ }
60
+ return rows;
61
+ }
62
+
63
+ // Догружает недостающие свечи в SQLite и возвращает их из кэша.
64
+ // Метка kv candProbe:symbol:interval — насколько раннюю историю уже спрашивали у биржи:
65
+ // у инструментов, вышедших на биржу позже начала периода, ранней истории нет, и без метки
66
+ // каждая загрузка качала бы их заново с нуля.
67
+ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal, localOnly = false } = {}) {
68
+ if (localOnly) return loadCandles(symbol, interval, fromTs, tillTs);
69
+ const range = candleRange(symbol, interval);
70
+ const now = Date.now();
71
+ const pKey = `candProbe:${symbol}:${interval}`;
72
+ const probe = kvGet(pKey);
73
+ const covered = probe && probe.fromTs <= fromTs + 5 * DAY; // раньше этой даты точно ничего нет
74
+ let fetchFrom;
75
+ if (!range.n) fetchFrom = fromTs;
76
+ else {
77
+ if (!covered && range.min > fromTs + 5 * DAY) {
78
+ // один запрос: если биржа отдаёт первую свечу не раньше нашей — истории раньше не существует
79
+ const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
80
+ if (!first || first.ts <= range.min + DAY) kvSet(pKey, { fromTs });
81
+ else fetchFrom = fromTs; // ранний кусок и правда есть — качаем всё
82
+ }
83
+ // свежие свечи догружаем в любом случае
84
+ if (fetchFrom === undefined && range.max < Math.min(tillTs, now) - barMs(interval)) fetchFrom = range.max;
85
+ }
86
+ if (fetchFrom !== undefined) {
87
+ // сохраняем постранично: при обрыве акция продолжит загрузку с того места
88
+ await fetchCandles(symbol, interval, tsToDate(fetchFrom), tillTs < now ? tsToDate(tillTs) : null, { signal, onBatch: (page) => saveCandles(symbol, interval, page) });
89
+ kvSet(pKey, { fromTs: Math.min(fromTs, probe?.fromTs ?? fromTs) });
90
+ }
91
+ return loadCandles(symbol, interval, fromTs, tillTs);
92
+ }
93
+
94
+ // Последняя свеча инструмента прямо с биржи (без кэша) — чтобы быстро понять, закрылась ли новая свеча
95
+ export async function latestBarTs(symbol, interval = 60) {
96
+ const from = tsToDate(Date.now() - 5 * DAY);
97
+ const rows = await fetchCandles(symbol, interval, from, null);
98
+ return rows.length ? rows[rows.length - 1].ts : null;
99
+ }
100
+
101
+ // ---------- дивиденды ----------
102
+ // Экс-дата (первый день торгов без права на дивиденд) по дате закрытия реестра R:
103
+ // режим T+1 (с 31.07.2023): R, если торговый день, иначе предыдущий торговый день;
104
+ // режим T+2 (раньше): торговый день перед R, а если R выходной — за два торговых дня до следующего за R.
105
+ // Дивиденд, чей реестр позже всех данных ряда, ещё не случился в ценах — пропускаем (null).
106
+ const T1_FROM = '2023-07-31';
107
+ export function exDate(registry, tradingDays) {
108
+ const i = lowerBound(tradingDays, registry); // первый торговый день >= registry
109
+ if (i >= tradingDays.length) return null; // реестр позже всех данных — отсечка ещё не случилась в ценах
110
+ const onMarket = tradingDays[i] === registry;
111
+ const k = registry >= T1_FROM ? (onMarket ? i : i - 1) : (onMarket ? i - 1 : i - 2);
112
+ return k >= 0 ? tradingDays[k] : null;
113
+ }
114
+ function lowerBound(arr, x) {
115
+ let lo = 0, hi = arr.length;
116
+ while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m] < x) lo = m + 1; else hi = m; }
117
+ return lo;
118
+ }
119
+
120
+ // Дивиденды из MOEX ISS (может требовать подписку — тогда блока нет и берём запасной источник)
121
+ async function fetchDividendsIss(symbol, signal) {
122
+ const j = await getJson(`${ISS}/securities/${symbol}/dividends.json?iss.meta=off`, 0, signal);
123
+ const d = j.dividends;
124
+ if (!d?.data?.length) return null;
125
+ const ix = (c) => d.columns.indexOf(c);
126
+ return d.data
127
+ .filter((r) => r[ix('currencyid')] === 'RUB' && r[ix('registryclosedate')] && r[ix('value')] > 0)
128
+ .map((r) => ({ registry: r[ix('registryclosedate')], value: Number(r[ix('value')]) }));
129
+ }
130
+
131
+ // Запасной источник: smart-lab.ru/q/{тикер}/dividend/ — таблица «дата отсечки» (= дата реестра) и сумма в рублях
132
+ async function fetchDividendsSmartlab(symbol, signal) {
133
+ const url = `https://smart-lab.ru/q/${symbol}/dividend/`;
134
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
135
+ const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, signal: sig });
136
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
137
+ const t = await r.text();
138
+ const m = t.match(/simple-little-table financials dividends[\s\S]*?<\/table>/);
139
+ if (!m) return null;
140
+ const list = [];
141
+ for (const row of m[0].matchAll(/<tr[^>]*>\s*<td>([A-Z0-9]+)<\/td>\s*<td>[\d.]+<\/td>\s*<td>(\d\d)\.(\d\d)\.(\d{4})<\/td>\s*<td>[^<]*<\/td>\s*<td><strong[^>]*>([\d\s,]+)<\/strong>/g)) {
142
+ if (row[1] !== symbol) continue; // на странице есть и префы
143
+ const value = Number(row[5].replace(/\s/g, '').replace(',', '.'));
144
+ if (value > 0) list.push({ registry: `${row[4]}-${row[3]}-${row[2]}`, value });
145
+ }
146
+ return list;
147
+ }
148
+
149
+ // Дивиденды акции с кэшем в kv на сутки; при ошибке сети — старый кэш
150
+ export async function ensureDividends(symbol, { signal, localOnly = false } = {}) {
151
+ const key = `dividends:${symbol}`;
152
+ const cached = kvGet(key);
153
+ if (localOnly) return cached?.list || [];
154
+ if (cached && Date.now() - cached.at < DAY) return cached.list;
155
+ let list = null;
156
+ try { list = await fetchDividendsIss(symbol, signal); } catch { /* нет подписки/сети */ }
157
+ if (!list?.length) {
158
+ try { list = await fetchDividendsSmartlab(symbol, signal); } catch { /* пропускаем */ }
159
+ }
160
+ if (list) {
161
+ kvSet(key, { at: Date.now(), list });
162
+ return list;
163
+ }
164
+ return cached?.list || [];
165
+ }
package/server/db.js ADDED
@@ -0,0 +1,420 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { ROOT, DATA_DIR } from './config.js';
5
+
6
+ let getUid = () => 1;
7
+ export function registerUidResolver(fn) { getUid = fn; }
8
+
9
+ const dir = DATA_DIR;
10
+ fs.mkdirSync(dir, { recursive: true });
11
+
12
+ export const db = new DatabaseSync(path.join(dir, 'trading.db'));
13
+
14
+ db.exec(`
15
+ PRAGMA journal_mode = WAL;
16
+ PRAGMA synchronous = NORMAL;
17
+
18
+ CREATE TABLE IF NOT EXISTS candles (
19
+ symbol TEXT NOT NULL,
20
+ interval INTEGER NOT NULL,
21
+ ts INTEGER NOT NULL,
22
+ open REAL, high REAL, low REAL, close REAL,
23
+ volume REAL, value REAL,
24
+ PRIMARY KEY (symbol, interval, ts)
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS securities (
28
+ symbol TEXT PRIMARY KEY,
29
+ lot INTEGER,
30
+ name TEXT
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS runs (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ mode TEXT NOT NULL,
36
+ status TEXT NOT NULL,
37
+ created_at INTEGER NOT NULL,
38
+ finished_at INTEGER,
39
+ params TEXT,
40
+ metrics TEXT,
41
+ equity TEXT,
42
+ error TEXT
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS trades (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ run_id INTEGER NOT NULL,
48
+ symbol TEXT NOT NULL,
49
+ side TEXT NOT NULL,
50
+ qty REAL NOT NULL,
51
+ entry_ts INTEGER, entry_price REAL,
52
+ exit_ts INTEGER, exit_price REAL,
53
+ stop REAL, take REAL,
54
+ pnl REAL, pnl_pct REAL, commission REAL,
55
+ reason_entry TEXT, reason_exit TEXT
56
+ );
57
+ CREATE INDEX IF NOT EXISTS trades_run ON trades(run_id);
58
+
59
+ CREATE TABLE IF NOT EXISTS decisions (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ run_id INTEGER NOT NULL,
62
+ ts INTEGER NOT NULL,
63
+ source TEXT,
64
+ symbol TEXT,
65
+ action TEXT,
66
+ confidence REAL,
67
+ approved INTEGER,
68
+ reason TEXT,
69
+ context TEXT
70
+ );
71
+ CREATE INDEX IF NOT EXISTS decisions_run ON decisions(run_id);
72
+
73
+ CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT);
74
+
75
+ CREATE TABLE IF NOT EXISTS conversations (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ created_at INTEGER NOT NULL,
78
+ title TEXT
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS chat_messages (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ conv_id INTEGER NOT NULL,
84
+ ts INTEGER NOT NULL,
85
+ role TEXT NOT NULL,
86
+ kind TEXT NOT NULL,
87
+ content TEXT,
88
+ reasoning TEXT,
89
+ tool_calls TEXT,
90
+ tool_call_id TEXT,
91
+ data TEXT
92
+ );
93
+ CREATE INDEX IF NOT EXISTS chat_conv ON chat_messages(conv_id);
94
+
95
+ CREATE TABLE IF NOT EXISTS journal (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ ts INTEGER NOT NULL,
98
+ hypothesis TEXT,
99
+ change TEXT,
100
+ run_ids TEXT,
101
+ result TEXT,
102
+ verdict TEXT,
103
+ source TEXT
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS settings_history (
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
108
+ ts INTEGER NOT NULL,
109
+ source TEXT NOT NULL,
110
+ reason TEXT,
111
+ changes TEXT NOT NULL
112
+ );
113
+
114
+ -- Новости и раскрытия эмитентов: только для прогноза «сейчас» и живой проверки
115
+ -- (в исторической проверке не используются: истории новостей нет)
116
+ CREATE TABLE IF NOT EXISTS news (
117
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ ts INTEGER NOT NULL,
119
+ src TEXT NOT NULL,
120
+ title TEXT NOT NULL,
121
+ link TEXT,
122
+ hash TEXT NOT NULL,
123
+ tags TEXT, -- JSON [["ТИКЕР", тон −1..1], ...]: каких акций касается
124
+ gist TEXT, -- короткая суть по-русски (составляет ИИ)
125
+ imp REAL, -- важность 0..1
126
+ tone REAL, -- средний тон
127
+ marked INTEGER DEFAULT 0, -- 0 — ждёт разметки ИИ, 1 — обработана, 2 — ИИ не справился
128
+ tries INTEGER DEFAULT 0
129
+ );
130
+ CREATE UNIQUE INDEX IF NOT EXISTS news_hash ON news(hash);
131
+ CREATE INDEX IF NOT EXISTS news_ts ON news(ts);
132
+
133
+ CREATE TABLE IF NOT EXISTS users (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ email TEXT UNIQUE,
136
+ name TEXT,
137
+ pass_hash TEXT,
138
+ role TEXT DEFAULT 'user',
139
+ plan TEXT DEFAULT 'free',
140
+ plan_until INTEGER DEFAULT 0,
141
+ tg_chat_id TEXT,
142
+ max_chat_id TEXT,
143
+ notify_code TEXT,
144
+ created_at INTEGER NOT NULL
145
+ );
146
+
147
+ CREATE TABLE IF NOT EXISTS sessions (
148
+ token TEXT PRIMARY KEY,
149
+ user_id INTEGER NOT NULL,
150
+ created_at INTEGER NOT NULL,
151
+ expires_at INTEGER NOT NULL
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS payments (
155
+ id TEXT PRIMARY KEY,
156
+ user_id INTEGER NOT NULL,
157
+ plan TEXT NOT NULL,
158
+ months INTEGER DEFAULT 1,
159
+ amount_rub REAL NOT NULL,
160
+ status TEXT NOT NULL,
161
+ created_at INTEGER NOT NULL,
162
+ paid_at INTEGER,
163
+ raw TEXT
164
+ );
165
+
166
+ CREATE TABLE IF NOT EXISTS promocodes (
167
+ code TEXT PRIMARY KEY,
168
+ plan TEXT NOT NULL,
169
+ days INTEGER NOT NULL,
170
+ max_uses INTEGER DEFAULT 100,
171
+ used_count INTEGER DEFAULT 0,
172
+ created_at INTEGER NOT NULL
173
+ );
174
+
175
+ CREATE TABLE IF NOT EXISTS promocode_uses (
176
+ code TEXT NOT NULL,
177
+ user_id INTEGER NOT NULL,
178
+ used_at INTEGER NOT NULL,
179
+ PRIMARY KEY (code, user_id)
180
+ );
181
+ `);
182
+
183
+ // Миграции мульти-аккаунтности и витрины
184
+ for (const [tbl, col, def] of [
185
+ ['runs', 'user_id', 'INTEGER DEFAULT 1'],
186
+ ['runs', 'showcase', 'INTEGER DEFAULT 0'],
187
+ ['conversations', 'user_id', 'INTEGER DEFAULT 1'],
188
+ ['journal', 'user_id', 'INTEGER DEFAULT 1'],
189
+ ['settings_history', 'user_id', 'INTEGER DEFAULT 1'],
190
+ ['users', 'api_key', 'TEXT'],
191
+ ]) {
192
+ if (!db.prepare(`PRAGMA table_info(${tbl})`).all().some((c) => c.name === col)) {
193
+ db.exec(`ALTER TABLE ${tbl} ADD COLUMN ${col} ${def}`);
194
+ }
195
+ }
196
+
197
+ // Гарантируем наличие аккаунта владельца (id = 1, admin)
198
+ if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
199
+ db.prepare(`INSERT INTO users (id, email, name, pass_hash, role, plan, plan_until, notify_code, created_at)
200
+ VALUES (1, 'owner@neuroalpha.local', 'Владелец платформы', '', 'admin', 'pro', 0, 'NA-OWNER1', ?)`).run(Date.now());
201
+ }
202
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_api_key ON users(api_key) WHERE api_key IS NOT NULL');
203
+
204
+ // Миграция: дополнительные сведения о сделке (лидер, отрасль, оценка, кто решил)
205
+ if (!db.prepare('PRAGMA table_info(trades)').all().some((c) => c.name === 'meta')) {
206
+ db.exec('ALTER TABLE trades ADD COLUMN meta TEXT');
207
+ }
208
+ // Миграция: подробности исследования модели (сравнение моделей, карта рынка) — отдельно от списка проверок
209
+ if (!db.prepare('PRAGMA table_info(runs)').all().some((c) => c.name === 'extra')) {
210
+ db.exec('ALTER TABLE runs ADD COLUMN extra TEXT');
211
+ }
212
+ export function saveRunExtra(id, extra) {
213
+ db.prepare('UPDATE runs SET extra = ? WHERE id = ?').run(JSON.stringify(extra), id);
214
+ }
215
+
216
+ export function kvGet(key, def = null) {
217
+ const r = db.prepare('SELECT value FROM kv WHERE key = ?').get(key);
218
+ return r ? JSON.parse(r.value) : def;
219
+ }
220
+ export function kvSet(key, value) {
221
+ db.prepare('INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)').run(key, JSON.stringify(value));
222
+ }
223
+
224
+ export function userKvGet(key, def = null, uid = getUid()) {
225
+ const k = (!uid || uid === 1) ? key : `u:${uid}:${key}`;
226
+ return kvGet(k, def);
227
+ }
228
+ export function userKvSet(key, value, uid = getUid()) {
229
+ const k = (!uid || uid === 1) ? key : `u:${uid}:${key}`;
230
+ kvSet(k, value);
231
+ }
232
+
233
+ // ---------- чат ----------
234
+ export function currentConversation(uid = getUid()) {
235
+ const r = db.prepare('SELECT * FROM conversations WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT 1').get(uid || 1);
236
+ return r || newConversation(uid);
237
+ }
238
+ export function newConversation(uid = getUid()) {
239
+ const id = Number(db.prepare('INSERT INTO conversations (created_at, user_id) VALUES (?, ?)').run(Date.now(), uid || 1).lastInsertRowid);
240
+ return db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
241
+ }
242
+ const parseMsg = (m) => m && ({
243
+ ...m,
244
+ tool_calls: m.tool_calls ? JSON.parse(m.tool_calls) : null,
245
+ data: m.data ? JSON.parse(m.data) : null,
246
+ });
247
+ export function addMessage(convId, m) {
248
+ const r = db.prepare(`INSERT INTO chat_messages (conv_id, ts, role, kind, content, reasoning, tool_calls, tool_call_id, data)
249
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(convId, Date.now(), m.role, m.kind || m.role, m.content ?? null, m.reasoning ?? null,
250
+ m.tool_calls ? JSON.stringify(m.tool_calls) : null, m.tool_call_id ?? null, m.data ? JSON.stringify(m.data) : null);
251
+ return getMessage(Number(r.lastInsertRowid));
252
+ }
253
+ export function updateMessage(id, patch) {
254
+ const cur = db.prepare('SELECT * FROM chat_messages WHERE id = ?').get(id);
255
+ if (!cur) return null;
256
+ const next = { ...parseMsg(cur), ...patch };
257
+ db.prepare('UPDATE chat_messages SET content = ?, reasoning = ?, tool_calls = ?, data = ? WHERE id = ?')
258
+ .run(next.content ?? null, next.reasoning ?? null, next.tool_calls ? JSON.stringify(next.tool_calls) : null, next.data ? JSON.stringify(next.data) : null, id);
259
+ return getMessage(id);
260
+ }
261
+ export function getMessage(id) {
262
+ return parseMsg(db.prepare('SELECT * FROM chat_messages WHERE id = ?').get(id));
263
+ }
264
+ export function getMessages(convId, limit = 400) {
265
+ return db.prepare('SELECT * FROM (SELECT * FROM chat_messages WHERE conv_id = ? ORDER BY id DESC LIMIT ?) ORDER BY id').all(convId, limit).map(parseMsg);
266
+ }
267
+
268
+ // ---------- журнал гипотез ----------
269
+ export function addJournal(e, uid = getUid()) {
270
+ const r = db.prepare('INSERT INTO journal (ts, hypothesis, change, run_ids, result, verdict, source, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
271
+ .run(Date.now(), e.hypothesis || '', e.change || '', JSON.stringify(e.run_ids || []), e.result || '', e.verdict || 'unclear', e.source || 'ai', uid || 1);
272
+ return Number(r.lastInsertRowid);
273
+ }
274
+ export function listJournal(limit = 200, uid = getUid()) {
275
+ 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 || '[]') }));
276
+ }
277
+ export function deleteJournal(id, uid = getUid()) {
278
+ db.prepare('DELETE FROM journal WHERE id = ? AND COALESCE(user_id, 1) = ?').run(id, uid || 1);
279
+ }
280
+
281
+ // ---------- история настроек ----------
282
+ const parseHistory = (h) => h && ({ ...h, changes: JSON.parse(h.changes || '{}') });
283
+ export function addSettingsHistory(source, reason, changes, uid = getUid()) {
284
+ return Number(db.prepare('INSERT INTO settings_history (ts, source, reason, changes, user_id) VALUES (?, ?, ?, ?, ?)')
285
+ .run(Date.now(), source, reason || '', JSON.stringify(changes), uid || 1).lastInsertRowid);
286
+ }
287
+ export function listSettingsHistory(limit = 50, uid = getUid()) {
288
+ return db.prepare('SELECT * FROM settings_history WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid || 1, limit).map(parseHistory);
289
+ }
290
+ export function getSettingsHistory(id, uid = getUid()) {
291
+ return parseHistory(db.prepare('SELECT * FROM settings_history WHERE id = ? AND COALESCE(user_id, 1) = ?').get(id, uid || 1));
292
+ }
293
+
294
+ const insertCandle = db.prepare(`INSERT OR REPLACE INTO candles
295
+ (symbol, interval, ts, open, high, low, close, volume, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
296
+
297
+ export function saveCandles(symbol, interval, rows) {
298
+ if (!rows.length) return;
299
+ db.exec('BEGIN');
300
+ try {
301
+ for (const c of rows) insertCandle.run(symbol, interval, c.ts, c.open, c.high, c.low, c.close, c.volume, c.value);
302
+ db.exec('COMMIT');
303
+ } catch (e) {
304
+ db.exec('ROLLBACK');
305
+ throw e;
306
+ }
307
+ }
308
+
309
+ export function loadCandles(symbol, interval, fromTs = 0, tillTs = Number.MAX_SAFE_INTEGER) {
310
+ return db.prepare(`SELECT ts, open, high, low, close, volume, value FROM candles
311
+ WHERE symbol = ? AND interval = ? AND ts >= ? AND ts <= ? ORDER BY ts`).all(symbol, interval, fromTs, tillTs);
312
+ }
313
+
314
+ export function candleRange(symbol, interval) {
315
+ return db.prepare('SELECT MIN(ts) AS min, MAX(ts) AS max, COUNT(*) AS n FROM candles WHERE symbol = ? AND interval = ?').get(symbol, interval);
316
+ }
317
+
318
+ export function createRun(mode, params, uid = getUid()) {
319
+ 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);
320
+ return Number(r.lastInsertRowid);
321
+ }
322
+
323
+ export function finishRun(id, { status = 'done', metrics = null, equity = null, error = null }) {
324
+ db.prepare('UPDATE runs SET status = ?, finished_at = ?, metrics = ?, equity = ?, error = ? WHERE id = ?')
325
+ .run(status, Date.now(), metrics && JSON.stringify(metrics), equity && JSON.stringify(equity), error, id);
326
+ }
327
+
328
+ export function updateRunMetrics(id, metrics, equity) {
329
+ db.prepare('UPDATE runs SET metrics = ?, equity = ? WHERE id = ?').run(JSON.stringify(metrics), JSON.stringify(equity), id);
330
+ }
331
+
332
+ export function updateRunTitle(id, title) {
333
+ const r = getRun(id);
334
+ if (!r) return null;
335
+ const params = { ...(r.params || {}), _title: String(title || '').trim() };
336
+ if (!params._title) delete params._title;
337
+ db.prepare('UPDATE runs SET params = ? WHERE id = ?').run(JSON.stringify(params), id);
338
+ return getRun(id);
339
+ }
340
+
341
+ const insertTrade = db.prepare(`INSERT INTO trades (run_id, symbol, side, qty, entry_ts, entry_price, exit_ts, exit_price, stop, take, pnl, pnl_pct, commission, reason_entry, reason_exit, meta)
342
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
343
+
344
+ export function saveTrade(runId, t) {
345
+ insertTrade.run(runId, t.symbol, t.side, t.qty, t.entryTs, t.entryPrice, t.exitTs, t.exitPrice, t.stop, t.take,
346
+ t.pnl, t.pnlPct, t.commission, t.reasonEntry || '', t.reasonExit || '', t.meta ? JSON.stringify(t.meta) : null);
347
+ }
348
+
349
+ const parseRun = (r) => r && ({
350
+ ...r,
351
+ params: r.params ? JSON.parse(r.params) : null,
352
+ metrics: r.metrics ? JSON.parse(r.metrics) : null,
353
+ equity: r.equity ? JSON.parse(r.equity) : null,
354
+ extra: r.extra ? JSON.parse(r.extra) : undefined,
355
+ });
356
+
357
+ export function listRuns(uid = getUid()) {
358
+ 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);
359
+ }
360
+ export function allRuns(uid = getUid()) {
361
+ 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);
362
+ }
363
+ export function setRunShowcase(id, showcase) {
364
+ db.prepare('UPDATE runs SET showcase = ? WHERE id = ?').run(showcase ? 1 : 0, Number(id));
365
+ return getRun(id);
366
+ }
367
+ export function listShowcaseRuns() {
368
+ const rows = db.prepare("SELECT * FROM runs WHERE mode = 'model' AND status = 'done' AND (showcase = 1 OR id IN (SELECT id FROM runs WHERE mode = 'model' AND status = 'done' ORDER BY json_extract(metrics, '$.full.returnPct') DESC LIMIT 3)) ORDER BY showcase DESC, json_extract(metrics, '$.full.returnPct') DESC LIMIT 4").all();
369
+ return rows.map(parseRun);
370
+ }
371
+ export function countUserRunsToday(uid = getUid()) {
372
+ const since = Date.now() - 86400000;
373
+ 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;
374
+ }
375
+ export function countUserChatToday(uid = getUid()) {
376
+ const since = Date.now() - 86400000;
377
+ 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;
378
+ }
379
+
380
+ export function getRun(id) {
381
+ return parseRun(db.prepare('SELECT * FROM runs WHERE id = ?').get(id));
382
+ }
383
+
384
+ export function getTrades(runId) {
385
+ return db.prepare('SELECT * FROM trades WHERE run_id = ? ORDER BY entry_ts').all(runId)
386
+ .map((t) => ({ ...t, meta: t.meta ? JSON.parse(t.meta) : null }));
387
+ }
388
+
389
+ export function deleteRun(id) {
390
+ db.prepare('DELETE FROM trades WHERE run_id = ?').run(id);
391
+ db.prepare('DELETE FROM decisions WHERE run_id = ?').run(id);
392
+ db.prepare('DELETE FROM runs WHERE id = ?').run(id);
393
+ // события в ленте Стратега об этом прогоне больше не нужны
394
+ db.prepare("DELETE FROM chat_messages WHERE kind = 'event' AND json_extract(data, '$.runId') = ?").run(id);
395
+ }
396
+
397
+ // События ленты, ссылающиеся на уже удалённые прогоны (накопились до появления очистки)
398
+ export function removeOrphanEvents() {
399
+ return db.prepare("DELETE FROM chat_messages WHERE kind = 'event' AND json_extract(data, '$.runId') IS NOT NULL AND json_extract(data, '$.runId') NOT IN (SELECT id FROM runs)").run().changes;
400
+ }
401
+
402
+ export function markStaleRuns() {
403
+ // живая проверка с сохранённым состоянием переживает перезапуск сервера — её не прерываем
404
+ const accs = kvGet('liveAccounts') || [];
405
+ const legacy = Number(kvGet('liveState')?.runId);
406
+ const keep = new Set([...(Array.isArray(accs) ? accs.filter((a) => a?.running).map((a) => Number(a.runId)) : []), ...(legacy ? [legacy] : [])]);
407
+ for (const r of db.prepare("SELECT id, mode FROM runs WHERE status = 'running'").all()) {
408
+ if (r.mode === 'paper' && keep.has(r.id)) continue;
409
+ db.prepare("UPDATE runs SET status = 'aborted' WHERE id = ?").run(r.id);
410
+ }
411
+ }
412
+
413
+ // Удалить данные старой стратегии «лидер → ведомая» (проверки, сделки, решения, её журнал) — при каждом старте
414
+ export function removeLegacyData() {
415
+ const ids = db.prepare("SELECT id FROM runs WHERE mode = 'backtest' OR (mode = 'paper' AND params NOT LIKE '%_kind%' AND params LIKE '%entryThreshold%')").all().map((r) => r.id);
416
+ if (!ids.length) return;
417
+ for (const id of ids) deleteRun(id);
418
+ db.exec('DELETE FROM decisions');
419
+ for (const j of listJournal(10000)) if (j.run_ids.some((id) => ids.includes(id))) deleteJournal(j.id);
420
+ }
package/server/hub.js ADDED
@@ -0,0 +1,14 @@
1
+ // Общий канал событий сервера → браузер (WebSocket) и подписчики внутри сервера
2
+ const listeners = new Set();
3
+ let sender = () => {};
4
+
5
+ export const hub = {
6
+ setSender(fn) { sender = fn; },
7
+ emit(msg) {
8
+ sender(msg);
9
+ for (const l of listeners) {
10
+ try { l(msg); } catch (e) { console.error(e); }
11
+ }
12
+ },
13
+ on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
14
+ };