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.
- package/README.md +164 -3
- package/bin/cli.js +69 -0
- package/config/strategy.json +33 -0
- 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
- package/package.json +43 -6
- package/public/app.js +2410 -0
- package/public/index.html +337 -0
- package/public/landing.html +870 -0
- package/public/legal.html +90 -0
- package/public/styles.css +343 -0
- package/server/api/routes.js +333 -0
- package/server/auth/index.js +340 -0
- package/server/billing/yookassa.js +132 -0
- package/server/brokers/index.js +645 -0
- package/server/config.js +44 -0
- package/server/data/moex.js +165 -0
- package/server/db.js +420 -0
- package/server/hub.js +14 -0
- package/server/index.js +92 -0
- package/server/llm/client.js +187 -0
- package/server/llm/providers.js +355 -0
- package/server/model/dataset.js +352 -0
- package/server/model/forecast.js +121 -0
- package/server/model/gpu-worker.html +11 -0
- package/server/model/gpu-worker.js +60 -0
- package/server/model/linalg.js +142 -0
- package/server/model/live.js +486 -0
- package/server/model/metrics.js +134 -0
- package/server/model/models.js +459 -0
- package/server/model/profiles.js +166 -0
- package/server/model/research.js +880 -0
- package/server/model/tf-node.js +26 -0
- package/server/model/transformer-core.js +330 -0
- package/server/model/transformer-gpu.js +221 -0
- package/server/model/transformer-pool.js +200 -0
- package/server/model/transformer-worker.js +28 -0
- package/server/model/transformer.js +57 -0
- package/server/model/vocab.js +28 -0
- package/server/news/index.js +309 -0
- package/server/notify/index.js +168 -0
- package/server/params-meta.js +63 -0
- package/server/settings.js +79 -0
- package/server/strategist/analytics.js +150 -0
- package/server/strategist/index.js +484 -0
- package/server/strategist/tools.js +302 -0
- package/server/util.js +9 -0
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Описание всех настроек: для формы в интерфейсе, для ИИ-стратега и для проверки значений.
|
|
2
|
+
// hidden — не показывать в интерфейсе; ai:false — ИИ менять запрещено (честность проверки).
|
|
3
|
+
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 },
|
|
9
|
+
{ 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' },
|
|
25
|
+
{ key: 'modelUseDividends', label: 'Учитывать дивиденды', hint: 'Цены до даты отсечки уменьшаются на размер дивиденда со сверкой по реальному падению цены.', type: 'checkbox', hidden: true, fixed: true },
|
|
26
|
+
{ 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 },
|
|
31
|
+
{ key: 'newsEnabled', label: 'Следить за новостями', hint: 'Раз в 10 минут собирает новости и раскрытия компаний.', type: 'checkbox', hidden: true, fixed: true },
|
|
32
|
+
{ key: 'newsFilter', label: 'Учитывать новости в торговле', hint: 'Раз в 10 минут собирает новости компаний (ИИ определяет затронутые акции и тон) и в живой проверке не открывает сделки против важной свежей новости.', type: 'checkbox' },
|
|
33
|
+
{ key: 'oosPct', label: 'Контрольная часть, %', hint: 'Доля периода в конце, не используемая для подбора настроек.', min: 0, max: 60, ai: false, hidden: true },
|
|
34
|
+
{ key: 'paperPollSec', label: 'Проверять биржу раз в, сек', hint: 'Как часто живая проверка смотрит, не закрылась ли новая свеча.', min: 15, max: 3600, hidden: true },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const PARAM_MAP = Object.fromEntries(PARAMS.map((p) => [p.key, p]));
|
|
38
|
+
|
|
39
|
+
// Проверка и приведение значения. Возвращает { value } или { error }
|
|
40
|
+
export function validateParam(key, raw) {
|
|
41
|
+
const m = PARAM_MAP[key];
|
|
42
|
+
if (!m) return { error: `нет такой настройки «${key}»` };
|
|
43
|
+
if (m.type === 'checkbox') return { value: raw === true || raw === 'true' || raw === 1 };
|
|
44
|
+
if (m.type === 'select') {
|
|
45
|
+
const opt = m.options.find(([v]) => String(v) === String(raw));
|
|
46
|
+
return opt ? { value: opt[0] } : { error: `${key}: допустимо ${m.options.map(([v]) => v).join(', ')}` };
|
|
47
|
+
}
|
|
48
|
+
// число; запятая допустима как десятичный разделитель («2,5» = 2.5)
|
|
49
|
+
const n = Number(String(raw).trim().replace(',', '.'));
|
|
50
|
+
if (!Number.isFinite(n)) return { error: `${key}: нужно число` };
|
|
51
|
+
if (m.min !== undefined && n < m.min) return { error: `${key}: минимум ${m.min}` };
|
|
52
|
+
if (m.max !== undefined && n > m.max) return { error: `${key}: максимум ${m.max}` };
|
|
53
|
+
return { value: m.int ? Math.round(n) : n };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Приводит ключи вида model_horizon к modelHorizon
|
|
57
|
+
export function normalizeKey(k) {
|
|
58
|
+
if (PARAM_MAP[k]) return k;
|
|
59
|
+
const camel = String(k).replace(/[_-](\w)/g, (_, c) => c.toUpperCase());
|
|
60
|
+
if (PARAM_MAP[camel]) return camel;
|
|
61
|
+
const lower = String(k).toLowerCase().replace(/[_-]/g, '');
|
|
62
|
+
return PARAMS.find((p) => p.key.toLowerCase() === lower)?.key || k;
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { strategyDefaults } from './config.js';
|
|
2
|
+
import { userKvGet, userKvSet, addSettingsHistory, getSettingsHistory } from './db.js';
|
|
3
|
+
import { validateParam, normalizeKey, PARAM_MAP } from './params-meta.js';
|
|
4
|
+
import { hub } from './hub.js';
|
|
5
|
+
import { fmtBarMsk } from './util.js';
|
|
6
|
+
|
|
7
|
+
// Приводит произвольный набор значений к полному набору настроек (неизвестные ключи отбрасываются)
|
|
8
|
+
export function normalizeParams(input = {}) {
|
|
9
|
+
const p = { ...strategyDefaults };
|
|
10
|
+
for (const [k, v] of Object.entries(input || {})) {
|
|
11
|
+
if (!(k in strategyDefaults) || v === '' || v === undefined || v === null) continue;
|
|
12
|
+
const r = validateParam(k, v);
|
|
13
|
+
if (!r.error) p[k] = r.value;
|
|
14
|
+
}
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Текущие рабочие настройки — общие для интерфейса и ИИ-стратега
|
|
19
|
+
export function getSettings() {
|
|
20
|
+
const saved = { ...(userKvGet('settings') || {}) };
|
|
21
|
+
for (const k of Object.keys(saved)) if (PARAM_MAP[k]?.fixed) delete saved[k];
|
|
22
|
+
return normalizeParams(saved);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// changes: { key: value }. Возвращает { settings, changed, errors }
|
|
26
|
+
export function updateSettings(changes, { source = 'user', reason = '' } = {}) {
|
|
27
|
+
const cur = getSettings();
|
|
28
|
+
const next = { ...cur };
|
|
29
|
+
const changed = {};
|
|
30
|
+
const errors = [];
|
|
31
|
+
for (const [rawKey, raw] of Object.entries(changes || {})) {
|
|
32
|
+
const key = normalizeKey(rawKey);
|
|
33
|
+
if (!PARAM_MAP[key]) { errors.push(`нет такой настройки «${rawKey}»`); continue; }
|
|
34
|
+
const v = validateParam(key, raw);
|
|
35
|
+
if (v.error) { errors.push(v.error); continue; }
|
|
36
|
+
if (JSON.stringify(cur[key]) !== JSON.stringify(v.value)) { changed[key] = { было: cur[key], стало: v.value }; next[key] = v.value; }
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys(changed).length) {
|
|
39
|
+
userKvSet('settings', next);
|
|
40
|
+
addSettingsHistory(source, reason, changed);
|
|
41
|
+
hub.emit({ type: 'settings:update', settings: next, changed, source, reason });
|
|
42
|
+
}
|
|
43
|
+
return { settings: next, changed, errors };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resetSettings(source = 'user') {
|
|
47
|
+
const cur = getSettings();
|
|
48
|
+
const changed = {};
|
|
49
|
+
for (const k of Object.keys(strategyDefaults)) {
|
|
50
|
+
if (JSON.stringify(cur[k]) !== JSON.stringify(strategyDefaults[k])) changed[k] = { было: cur[k], стало: strategyDefaults[k] };
|
|
51
|
+
}
|
|
52
|
+
if (Object.keys(changed).length) addSettingsHistory(source, 'сброс по умолчанию', changed);
|
|
53
|
+
userKvSet('settings', {});
|
|
54
|
+
hub.emit({ type: 'settings:update', settings: getSettings(), changed, source, reason: 'сброс по умолчанию', reset: true });
|
|
55
|
+
return getSettings();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Откат одной записи истории: возвращает значения «было» (сам откат тоже попадает в историю)
|
|
59
|
+
export function revertSettingsChange(id) {
|
|
60
|
+
const h = getSettingsHistory(Number(id));
|
|
61
|
+
if (!h) throw new Error('нет такой записи в истории');
|
|
62
|
+
const back = {};
|
|
63
|
+
for (const [k, v] of Object.entries(h.changes || {})) back[k] = v.было;
|
|
64
|
+
return updateSettings(back, { source: 'user', reason: `откат изменения от ${fmtBarMsk(h.ts)}` });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Проверяет набор изменений без сохранения: { values, errors }
|
|
68
|
+
export function validateChanges(changes) {
|
|
69
|
+
const values = {};
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const [rawKey, raw] of Object.entries(changes || {})) {
|
|
72
|
+
const key = normalizeKey(rawKey);
|
|
73
|
+
if (!PARAM_MAP[key]) { errors.push(`нет такой настройки «${rawKey}»`); continue; }
|
|
74
|
+
const v = validateParam(key, raw);
|
|
75
|
+
if (v.error) errors.push(v.error);
|
|
76
|
+
else values[key] = v.value;
|
|
77
|
+
}
|
|
78
|
+
return { values, errors };
|
|
79
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { strategyDefaults } from '../config.js';
|
|
2
|
+
import { getRun, getTrades, listRuns } from '../db.js';
|
|
3
|
+
import { round, MSK } from '../util.js';
|
|
4
|
+
|
|
5
|
+
const r2 = (v) => (v === null || v === undefined ? null : round(v, 2));
|
|
6
|
+
const LBL = { inertia: 'своя_инерция', leadlag: 'кто_за_кем_ходит', attention: 'внимание_через_карту', transformer: 'трансформер' };
|
|
7
|
+
|
|
8
|
+
export function compactMetrics(m) {
|
|
9
|
+
if (!m) return null;
|
|
10
|
+
return { сделок: m.trades, итог_проц: m.returnPct, на_1р_убытка: m.profitFactor, удачных_проц: m.winRate, худшее_падение_проц: m.maxDrawdownPct };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function paramDiff(params, base = strategyDefaults) {
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [k, v] of Object.entries(params || {})) {
|
|
16
|
+
if (k.startsWith('_')) continue;
|
|
17
|
+
if (k in base && JSON.stringify(base[k]) !== JSON.stringify(v)) out[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const seg = (c, k, dir = 'long') => c?.[k] && {
|
|
23
|
+
совпадение_прогноза: c[k].ic, недель_с_плюсом_проц: c[k].weeksPositivePct, итог_проц: c[k].returnPct,
|
|
24
|
+
без_комиссий_проц: c[k].grossReturnPct, рынок_проц: c[k].marketPct, сделок: c[k].trades, на_1р_убытка: c[k].profitFactor, худшее_падение_проц: c[k].maxDrawdownPct,
|
|
25
|
+
...(dir !== 'long' ? { вклад_покупок_проц: c[k].longPct, вклад_продаж_проц: c[k].shortPct } : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function runSummary(run, { revealOOS = false } = {}) {
|
|
29
|
+
if (!run) return null;
|
|
30
|
+
const m = run.metrics || {};
|
|
31
|
+
const s = {
|
|
32
|
+
id: run.id,
|
|
33
|
+
тип: run.mode === 'paper' ? 'живая проверка' : 'исследование модели',
|
|
34
|
+
кто_запустил: run.params?._source === 'ai' ? 'ИИ' : 'пользователь',
|
|
35
|
+
заметка: run.params?._note || undefined,
|
|
36
|
+
статус: run.status,
|
|
37
|
+
отличия_от_умолчаний: paramDiff(run.params),
|
|
38
|
+
};
|
|
39
|
+
if (run.error) s.ошибка = run.error;
|
|
40
|
+
if (run.mode === 'paper') { s.результат = compactMetrics(m.full); return s; }
|
|
41
|
+
const cmp = m.model?.comparison;
|
|
42
|
+
if (cmp) {
|
|
43
|
+
s.торгует_модель = m.model.chosen;
|
|
44
|
+
s.горизонт_часов = m.model.horizon;
|
|
45
|
+
s.акций = m.model.tokens;
|
|
46
|
+
const dir = run.params?.modelDirection || 'long';
|
|
47
|
+
if (dir !== 'long') s.направление = dir === 'neutral' ? 'нейтрально к рынку (покупки и продажи парами; сравнивать с нулём, а не с рынком)' : 'покупки и продажи';
|
|
48
|
+
s.период_настройки = Object.fromEntries(Object.entries(cmp).map(([n, c]) => [LBL[n] || n, seg(c, 'inSample', dir)]));
|
|
49
|
+
s.контрольный_период = revealOOS
|
|
50
|
+
? Object.fromEntries(Object.entries(cmp).map(([n, c]) => [LBL[n] || n, seg(c, 'outOfSample', dir)]))
|
|
51
|
+
: 'скрыт (используй final_check)';
|
|
52
|
+
}
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stats(trades) {
|
|
57
|
+
const n = trades.length;
|
|
58
|
+
if (!n) return { сделок: 0 };
|
|
59
|
+
const wins = trades.filter((t) => t.pnl > 0);
|
|
60
|
+
const gp = wins.reduce((a, t) => a + t.pnl, 0);
|
|
61
|
+
const gl = -trades.filter((t) => t.pnl <= 0).reduce((a, t) => a + t.pnl, 0);
|
|
62
|
+
return {
|
|
63
|
+
сделок: n,
|
|
64
|
+
удачных_проц: round((wins.length / n) * 100, 0),
|
|
65
|
+
на_1р_убытка: gl > 0 ? r2(gp / gl) : (gp > 0 ? 'нет убытков' : 0),
|
|
66
|
+
итог_р: round(gp - gl, 0),
|
|
67
|
+
средняя_проц: r2(trades.reduce((a, t) => a + t.pnl_pct, 0) / n),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function groupBy(trades, keyFn, { minN = 1, top = 12 } = {}) {
|
|
72
|
+
const g = new Map();
|
|
73
|
+
for (const t of trades) {
|
|
74
|
+
const k = keyFn(t);
|
|
75
|
+
if (k === null || k === undefined) continue;
|
|
76
|
+
if (!g.has(k)) g.set(k, []);
|
|
77
|
+
g.get(k).push(t);
|
|
78
|
+
}
|
|
79
|
+
return [...g.entries()].filter(([, a]) => a.length >= minN).map(([k, a]) => ({ группа: k, ...stats(a) })).sort((a, b) => b.сделок - a.сделок).slice(0, top);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hourOf = (ts) => new Date(ts + MSK).getUTCHours();
|
|
83
|
+
|
|
84
|
+
const pair = (m, r) => `модель ${r2(m)}%, рынок ${r2(r)}%`;
|
|
85
|
+
const chain = (xs) => (xs.reduce((a, v) => a * (1 + (v ?? 0) / 100), 1) - 1) * 100;
|
|
86
|
+
|
|
87
|
+
// Доходность по месяцам и годам; без revealOOS — только месяцы периода настройки (годы считаются по ним же)
|
|
88
|
+
function periodsView(pr, revealOOS) {
|
|
89
|
+
if (!pr?.months?.length) return {};
|
|
90
|
+
const months = pr.months.filter((m) => revealOOS || !m.oos);
|
|
91
|
+
const out = { по_месяцам: Object.fromEntries(months.map((m) => [m.ym, pair(m.model, m.market)])) };
|
|
92
|
+
if (revealOOS) {
|
|
93
|
+
out.по_годам = Object.fromEntries(pr.years.map((y) => [`${y.y} г.${y.partial ? ` (${y.months} мес.)` : ''}`, pair(y.model, y.market)]));
|
|
94
|
+
} else {
|
|
95
|
+
const ys = new Map();
|
|
96
|
+
for (const m of months) {
|
|
97
|
+
const y = m.ym.slice(0, 4);
|
|
98
|
+
if (!ys.has(y)) ys.set(y, []);
|
|
99
|
+
ys.get(y).push(m);
|
|
100
|
+
}
|
|
101
|
+
out.по_годам = Object.fromEntries([...ys].map(([y, ms]) => [`${y} г. (${ms.length} мес. настройки)`, pair(chain(ms.map((m) => m.model)), chain(ms.map((m) => m.market)))]));
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Разбор исследования: по умолчанию только период настройки (защита от подгонки)
|
|
107
|
+
export function analyzeRun(runId, { revealOOS = false } = {}) {
|
|
108
|
+
const run = getRun(runId);
|
|
109
|
+
if (!run) return { ошибка: `исследования №${runId} нет` };
|
|
110
|
+
let trades = getTrades(runId);
|
|
111
|
+
const split = run.metrics?.splitTs;
|
|
112
|
+
let scope = 'весь период';
|
|
113
|
+
if (split && !revealOOS && run.mode !== 'paper') { trades = trades.filter((t) => t.entry_ts < split); scope = 'только период настройки'; }
|
|
114
|
+
const ex = run.extra || {};
|
|
115
|
+
return {
|
|
116
|
+
проверка: runSummary(run, { revealOOS }),
|
|
117
|
+
охват: scope,
|
|
118
|
+
...periodsView(ex.periods, revealOOS),
|
|
119
|
+
сделки_торгующей_модели: stats(trades),
|
|
120
|
+
по_стороне: groupBy(trades, (t) => (t.side === 'short' ? 'продажа (short)' : 'покупка (long)')),
|
|
121
|
+
по_акциям: groupBy(trades, (t) => t.symbol, { minN: 3, top: 15 }),
|
|
122
|
+
по_часу_входа: groupBy(trades, (t) => `${hourOf(t.entry_ts)}:00`, { top: 15 }).sort((a, b) => parseInt(a.группа) - parseInt(b.группа)),
|
|
123
|
+
// barsHeld хранится в минутах (в старых исследованиях — в часах, поэтому порог 8 отделяет старые данные)
|
|
124
|
+
по_сроку_удержания: groupBy(trades, (t) => {
|
|
125
|
+
const m = t.meta?.barsHeld ?? t.barsHeld;
|
|
126
|
+
if (m === undefined) return null;
|
|
127
|
+
if (m <= 8) return m <= 3 ? '1–3 часа' : '4–8 часов'; // старые записи в часах
|
|
128
|
+
if (m <= 60) return 'до 1 часа';
|
|
129
|
+
if (m <= 180) return '1–3 часа';
|
|
130
|
+
if (m <= 480) return '3–8 часов';
|
|
131
|
+
return '8 часов и дольше';
|
|
132
|
+
}),
|
|
133
|
+
головы_внимания_последнего_окна: (ex.heads || []).map((h) => ({ голова: h.label, вес: h.coef })),
|
|
134
|
+
вес_собственного_движения: ex.self,
|
|
135
|
+
пояснение: 'Положительный вес головы: движение соседей по карте «тянет» акцию в ту же сторону (переток); отрицательный — акция склонна двигаться против соседей (возврат).',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function compareRuns(a, b) {
|
|
140
|
+
const ra = getRun(a), rb = getRun(b);
|
|
141
|
+
if (!ra || !rb) return { ошибка: 'исследование не найдено' };
|
|
142
|
+
const changed = {};
|
|
143
|
+
for (const k of Object.keys(strategyDefaults)) {
|
|
144
|
+
const va = ra.params?.[k] ?? strategyDefaults[k], vb = rb.params?.[k] ?? strategyDefaults[k];
|
|
145
|
+
if (JSON.stringify(va) !== JSON.stringify(vb)) changed[k] = { [`№${a}`]: va, [`№${b}`]: vb };
|
|
146
|
+
}
|
|
147
|
+
return { различия_настроек: changed, [`№${a}`]: runSummary(ra), [`№${b}`]: runSummary(rb) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const recentRuns = (limit = 10) => listRuns().slice(0, limit).map((r) => runSummary(r));
|