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/public/app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
1
2
|
const $ = (s, root = document) => root.querySelector(s);
|
|
2
3
|
const MSK = 3 * 3600000;
|
|
3
4
|
|
|
@@ -38,15 +39,14 @@ function toast(text, type = 'info', action = null) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const state = { config: null, settings: null, runs: [], run: null, active: null, forecast: null, live: null, chat: { state: null, msgs: new Map(), stream: new Map() }, mapSel: null, mapMode: 'behav', profiles: null, profilesBusy: false };
|
|
41
|
-
const MODELS = ['inertia', 'leadlag', 'attention'
|
|
42
|
-
const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту'
|
|
42
|
+
const MODELS = ['inertia', 'leadlag', 'attention'];
|
|
43
|
+
const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту' };
|
|
43
44
|
const MODEL_HINT = {
|
|
44
45
|
inertia: 'Простейшая планка: акция предсказывается только по своему недавнему движению.',
|
|
45
46
|
leadlag: 'Каждая акция предсказывается по недавним движениям всех остальных, без карты.',
|
|
46
47
|
attention: 'Влияние соседей по карте рынка через 12 «голов внимания».',
|
|
47
|
-
transformer: 'Нейросеть с вниманием между акциями; учится заново в каждом окне.',
|
|
48
48
|
};
|
|
49
|
-
const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7'
|
|
49
|
+
const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7' };
|
|
50
50
|
const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '#0891b2', '#db2777', '#64748b'];
|
|
51
51
|
const labelOf = (k) => state.config?.params.find((p) => p.key === k)?.label || k;
|
|
52
52
|
|
|
@@ -166,40 +166,41 @@ const eqLines = {
|
|
|
166
166
|
inertia: eqChart.addLineSeries({ color: COLORS.inertia, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
167
167
|
leadlag: eqChart.addLineSeries({ color: COLORS.leadlag, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
168
168
|
attention: eqChart.addLineSeries({ color: COLORS.attention, lineWidth: 2, priceLineVisible: false, lastValueVisible: false }),
|
|
169
|
-
transformer: eqChart.addLineSeries({ color: COLORS.transformer, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
170
169
|
};
|
|
171
170
|
const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
|
|
172
171
|
const icSeries = icChart.addHistogramSeries({ priceFormat: { type: 'price', precision: 3, minMove: 0.001 }, priceLineVisible: false, lastValueVisible: false });
|
|
173
172
|
|
|
174
173
|
const RUN_PARTS = [
|
|
175
|
-
['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' :
|
|
176
|
-
['modelHorizon', (v) => `Прогноз: ${fmtNum(v, 3)}ч`],
|
|
177
|
-
['modelMinEdge', (v) => `Порог: ${fmtNum(v, 3)}`],
|
|
178
|
-
['modelExitRatio', (v) => (
|
|
179
|
-
['modelMinHoldHours', (v) => (v > 0 ? `Удерж.: ${fmtNum(v, 2)}ч` : null)],
|
|
174
|
+
['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : 'Авто (мульти-горизонт 1,5–3,5ч)')],
|
|
175
|
+
['modelHorizon', (v, p) => (p.modelAutoAdapt === false ? `Прогноз: ${fmtNum(v, 3)}ч` : null)],
|
|
176
|
+
['modelMinEdge', (v, p) => (p.modelAutoAdapt === false ? `Порог: ${fmtNum(v, 3)}` : null)],
|
|
177
|
+
['modelExitRatio', (v, p) => (p.modelAutoAdapt === false ? `Выход: ${fmtNum(v, 2)}` : null)],
|
|
180
178
|
['modelTopK', (v) => `Акции: ${v}`],
|
|
181
|
-
['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' :
|
|
179
|
+
['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : 'Доли поровну')],
|
|
180
|
+
['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : v === 'neutral' ? 'Хедж 50/50 (рост + падение)' : 'Только в рост (без шорта)')],
|
|
181
|
+
['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
|
|
182
182
|
['modelTrainDays', (v) => `Окно: ${v}дн`],
|
|
183
|
+
['modelHistoryDays', (v) => `История: ${v}дн`],
|
|
183
184
|
['modelEmbedDim', (v) => `Эмбд.: ${v}`],
|
|
184
|
-
['modelUseMacro', (v) => (v ? 'Макро' : null)],
|
|
185
185
|
['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
|
|
186
|
-
['
|
|
187
|
-
['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : null)],
|
|
188
|
-
['modelUseTransformer', (v, p) => (v || p.modelType === 'transformer' ? 'Трансформер' : null)],
|
|
189
|
-
['transformerEpochs', (v, p) => (usesTf(p) && v !== state.config?.defaults?.transformerEpochs ? `Эпох: ${v}` : null)],
|
|
190
|
-
['transformerGpu', (v, p) => (usesTf(p) && v ? 'Видеокарта' : null)],
|
|
186
|
+
['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
|
|
191
187
|
];
|
|
192
|
-
function usesTf(p) { return p.modelUseTransformer || p.modelType === 'transformer'; }
|
|
193
188
|
function runTitle(r, html = false) {
|
|
194
189
|
const p = r.params || {}, d = state.config?.defaults || {};
|
|
195
|
-
const
|
|
196
|
-
|
|
190
|
+
const m = r.metrics?.full || {};
|
|
191
|
+
const ps = r.metrics?.period ? calcPeriodStats(r.metrics.period, m.returnPct, 0) : null;
|
|
192
|
+
const status = r.status === 'done'
|
|
193
|
+
? `${fmtPct(m.returnPct)}${ps ? ` (~${fmtPct(ps.cagr, 0)}/год)` : ''}${m.maxDrawdownPct ? `, просадка ${fmtNum(m.maxDrawdownPct, 1)}%` : ''}`
|
|
194
|
+
: r.status === 'running' ? 'идёт…' : 'ошибка';
|
|
195
|
+
const diffs = RUN_PARTS.map(([k, f]) => {
|
|
196
|
+
if (p[k] == null || JSON.stringify(p[k]) === JSON.stringify(d[k])) return null;
|
|
197
197
|
const t = f(p[k], p);
|
|
198
|
-
|
|
198
|
+
if (!t) return null;
|
|
199
|
+
return html ? `<span style="color:var(--yellow)">${t}</span>` : t;
|
|
199
200
|
}).filter(Boolean);
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
return [head, ...
|
|
201
|
+
const head = p._title ? `№${r.id} (${html ? esc(p._title) : p._title})` : `№${r.id}`;
|
|
202
|
+
const diffPart = diffs.length ? diffs : ['базовые настройки'];
|
|
203
|
+
return [head, ...diffPart, ...(p._source === 'ai' ? ['Стратег'] : []), status].join(' — ');
|
|
203
204
|
}
|
|
204
205
|
const runTip = (r) => {
|
|
205
206
|
const p = r.params || {};
|
|
@@ -222,31 +223,30 @@ async function loadRuns(selectId) {
|
|
|
222
223
|
? state.runs.map((r) => `<option value="${r.id}" title="${runTip(r)}">${runTitle(r)}</option>`).join('')
|
|
223
224
|
: '<option value="">Исследований пока нет</option>';
|
|
224
225
|
sel.innerHTML = optHtml;
|
|
226
|
+
const brkSel = $('#broker-run-select');
|
|
227
|
+
if (brkSel) {
|
|
228
|
+
const curVal = brkSel.value;
|
|
229
|
+
brkSel.innerHTML = '<option value="">Текущие настройки</option>' + state.runs.filter((r) => r.status === 'done').map((r) => `<option value="${r.id}">${runTitle(r)}</option>`).join('');
|
|
230
|
+
brkSel.value = curVal;
|
|
231
|
+
}
|
|
225
232
|
for (const m of state.chat.msgs.values()) if (m.kind === 'event' && m.data?.eventType === 'model') upsertMsg(m);
|
|
226
|
-
if ($('#btn-clean')) $('#btn-clean').disabled = state.runs.length < 1;
|
|
227
|
-
$('#run-bar').hidden = !state.run;
|
|
228
|
-
const doneRun = state.run?.status === 'done';
|
|
229
|
-
$('#btn-stability').hidden = !doneRun;
|
|
230
|
-
$('#btn-to-live').hidden = !doneRun;
|
|
231
|
-
$('#btn-cmp-run').hidden = state.runs.length < 2;
|
|
232
|
-
syncApplyBtn();
|
|
233
233
|
const id = selectId ?? state.run?.id ?? state.runs.find((r) => r.status === 'done')?.id;
|
|
234
234
|
updateTabBadges();
|
|
235
235
|
if (id && state.runs.some((r) => r.id === id)) {
|
|
236
236
|
sel.value = String(id);
|
|
237
237
|
if (state.run?.id !== id || selectId) await openRun(id);
|
|
238
|
-
|
|
238
|
+
else syncUiAvailability();
|
|
239
|
+
} else {
|
|
240
|
+
state.run = null;
|
|
241
|
+
syncUiAvailability();
|
|
242
|
+
renderOverview();
|
|
243
|
+
}
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
async function openRun(id) {
|
|
242
247
|
state.run = await api(`/api/runs/${id}`);
|
|
243
248
|
$('#run-select').value = String(id);
|
|
244
|
-
|
|
245
|
-
const doneRun = state.run?.status === 'done';
|
|
246
|
-
$('#btn-stability').hidden = !doneRun;
|
|
247
|
-
$('#btn-to-live').hidden = !doneRun;
|
|
248
|
-
$('#btn-cmp-run').hidden = state.runs.length < 2;
|
|
249
|
-
syncApplyBtn();
|
|
249
|
+
syncUiAvailability();
|
|
250
250
|
renderOverview();
|
|
251
251
|
setupMapSlider();
|
|
252
252
|
if (!$('#tab-map').hidden) drawMap();
|
|
@@ -283,29 +283,88 @@ $('#btn-del').addEventListener('click', async () => {
|
|
|
283
283
|
toast(`Эксперимент №${r.id} удалён`);
|
|
284
284
|
} catch (e) { toast(e.message, 'error'); }
|
|
285
285
|
});
|
|
286
|
-
// Проверить устойчивость открытого прогона (5 сдвигов периода −8…+8 дней)
|
|
287
286
|
function stabilityUi(runningId) {
|
|
288
287
|
state.stabilityActive = runningId || null;
|
|
289
288
|
const busy = Boolean(runningId);
|
|
290
|
-
$('#btn-stability').disabled = busy;
|
|
291
|
-
$('#btn-stability').textContent = busy ? 'Идёт проверка устойчивости…' : 'Проверить устойчивость';
|
|
292
|
-
$('#btn-cancel').hidden = !busy && !state.active;
|
|
293
289
|
if (!busy && !state.active) setProgress(null);
|
|
294
290
|
updateTabBadges();
|
|
291
|
+
syncUiAvailability();
|
|
295
292
|
}
|
|
296
|
-
|
|
293
|
+
// Единая синхронизация видимости и доступности элементов управления от текущего состояния системы
|
|
294
|
+
function syncUiAvailability() {
|
|
297
295
|
const r = state.run;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
296
|
+
const isRunning = r?.status === 'running' || (r && r.id === state.active);
|
|
297
|
+
const isDone = r?.status === 'done' && !isRunning;
|
|
298
|
+
const aiReady = Boolean(state.chat.state?.llm && !state.chat.state?.busy);
|
|
299
|
+
const aiBusy = Boolean(state.chat.state?.busy);
|
|
300
|
+
|
|
301
|
+
// 1. Панель действий эксперимента (#run-bar): скрыта во время расчёта
|
|
302
|
+
if ($('#run-bar')) $('#run-bar').hidden = !r || isRunning;
|
|
303
|
+
if ($('#btn-to-live')) $('#btn-to-live').hidden = !isDone;
|
|
304
|
+
if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
|
|
305
|
+
if ($('#btn-ask-run')) {
|
|
306
|
+
$('#btn-ask-run').hidden = !isDone;
|
|
307
|
+
$('#btn-ask-run').disabled = !aiReady;
|
|
308
|
+
$('#btn-ask-run').title = !state.chat.state?.llm
|
|
309
|
+
? 'ИИ не подключён (Настройки → Модели ИИ)'
|
|
310
|
+
: aiBusy
|
|
311
|
+
? 'Дождитесь завершения текущего ответа Стратега'
|
|
312
|
+
: 'Попросить Стратега разобрать этот эксперимент';
|
|
306
313
|
}
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
|
|
315
|
+
syncApplyBtn();
|
|
316
|
+
|
|
317
|
+
// 2. Выпадающий список экспериментов и кнопка «Исследовать»
|
|
318
|
+
const anyJobRunning = Boolean(state.active || state.stabilityActive);
|
|
319
|
+
if ($('#btn-model')) $('#btn-model').hidden = anyJobRunning;
|
|
320
|
+
if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
|
|
321
|
+
if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
|
|
322
|
+
|
|
323
|
+
// 3. Кнопки обращения к Стратегу в прогнозе и виртуальных счетах
|
|
324
|
+
if ($('#btn-ask-forecast')) {
|
|
325
|
+
$('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
|
|
326
|
+
$('#btn-ask-forecast').title = !state.forecast
|
|
327
|
+
? 'Сначала дождитесь расчёта прогноза'
|
|
328
|
+
: !aiReady
|
|
329
|
+
? 'Стратег сейчас занят или ИИ не подключён'
|
|
330
|
+
: 'Попросить Стратега разобрать текущий прогноз';
|
|
331
|
+
}
|
|
332
|
+
document.querySelectorAll('[data-live-ask]').forEach((b) => {
|
|
333
|
+
b.disabled = !aiReady;
|
|
334
|
+
b.title = !aiReady ? 'Стратег сейчас занят или ИИ не подключён' : 'Попросить Стратега разобрать этот виртуальный счёт';
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// 4. Зависимые настройки в форме (при включённом Авто-режиме или выключенном Трансформере)
|
|
338
|
+
syncSettingsDependencies();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function syncSettingsDependencies() {
|
|
342
|
+
const form = $('#params');
|
|
343
|
+
if (!form || !state.settings) return;
|
|
344
|
+
const s = state.settings;
|
|
345
|
+
const autoOn = s.modelAutoAdapt !== false;
|
|
346
|
+
const dir = s.modelDirection || 'long';
|
|
347
|
+
|
|
348
|
+
const setRowEnabled = (key, enabled, whyDisabled = '') => {
|
|
349
|
+
const row = form.querySelector(`.srow[data-key="${key}"]`);
|
|
350
|
+
if (!row) return;
|
|
351
|
+
row.style.opacity = enabled ? '1' : '0.45';
|
|
352
|
+
const ctrl = form.elements[key];
|
|
353
|
+
if (ctrl) {
|
|
354
|
+
ctrl.disabled = !enabled;
|
|
355
|
+
ctrl.title = enabled ? '' : whyDisabled;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
|
|
360
|
+
setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
361
|
+
setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
362
|
+
setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение» или «Хедж 50/50»)');
|
|
363
|
+
setRowEnabled('modelMarketFilter', dir !== 'neutral', 'В режиме «Хедж 50/50 (рост + падение)» портфель уже захеджирован от направления индекса');
|
|
364
|
+
|
|
365
|
+
const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
|
|
366
|
+
if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
|
|
367
|
+
}
|
|
309
368
|
$('#btn-to-live').addEventListener('click', async () => {
|
|
310
369
|
const r = state.run;
|
|
311
370
|
if (!r || r.status !== 'done') return;
|
|
@@ -322,7 +381,7 @@ $('#btn-to-live').addEventListener('click', async () => {
|
|
|
322
381
|
} catch (e) { toast(e.message, 'error'); }
|
|
323
382
|
});
|
|
324
383
|
// «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
|
|
325
|
-
const APPLY_KEYS = ['modelAutoAdapt', '
|
|
384
|
+
const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
|
|
326
385
|
function runSettingsDiff(r = state.run) {
|
|
327
386
|
if (!r?.params || !state.settings) return { entries: [], lines: [] };
|
|
328
387
|
const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
|
|
@@ -359,8 +418,9 @@ function syncApplyBtn() {
|
|
|
359
418
|
syncShowcaseBtn();
|
|
360
419
|
const btn = $('#btn-apply');
|
|
361
420
|
if (!btn) return;
|
|
421
|
+
const isDone = state.run?.status === 'done' && state.run.id !== state.active;
|
|
362
422
|
const { lines } = runSettingsDiff();
|
|
363
|
-
btn.hidden = !
|
|
423
|
+
btn.hidden = !isDone || !lines.length;
|
|
364
424
|
if (lines.length) {
|
|
365
425
|
btn.textContent = `✓ Применить эти настройки (${lines.length})`;
|
|
366
426
|
btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
|
|
@@ -378,10 +438,28 @@ $('#btn-apply').addEventListener('click', async () => {
|
|
|
378
438
|
toast(`Настройки из эксперимента №${r.id} применены`);
|
|
379
439
|
});
|
|
380
440
|
|
|
381
|
-
|
|
441
|
+
// Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
|
|
442
|
+
function calcPeriodStats(period, returnPct, marketPct) {
|
|
443
|
+
const from = period?.from || 0, till = period?.till || 0;
|
|
444
|
+
const calDays = Math.max(1, Math.round((till - from) / 86400000));
|
|
445
|
+
const years = calDays / 365.25;
|
|
446
|
+
const months = Math.max(1, Math.round(calDays / 30.44));
|
|
447
|
+
const yrsLabel = years >= 1 ? `${fmtNum(years, 1)} г. (${months} мес.)` : `${months} мес.`;
|
|
448
|
+
const dateRange = from && till ? `${fmtDate(from)} — ${fmtDate(till)}` : '';
|
|
449
|
+
const r = Number(returnPct ?? 0);
|
|
450
|
+
const m = Number(marketPct ?? 0);
|
|
451
|
+
// CAGR (среднегодовой темп роста с реинвестированием) и простое среднее в год
|
|
452
|
+
const cagr = years >= 0.25 && r > -99 ? ((Math.pow(1 + r / 100, 1 / years) - 1) * 100) : (r / Math.max(years, 0.1));
|
|
453
|
+
const avgSimple = r / Math.max(years, 0.1);
|
|
454
|
+
const mktAnnual = years >= 0.25 && m > -99 ? ((Math.pow(1 + m / 100, 1 / years) - 1) * 100) : (m / Math.max(years, 0.1));
|
|
455
|
+
return { calDays, years, months, yrsLabel, dateRange, cagr, avgSimple, mktAnnual };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function verdictHtml(c, dir, pStats) {
|
|
382
459
|
const a = c.attention, base = c.inertia;
|
|
383
460
|
if (!a) return '';
|
|
384
461
|
const neutral = dir === 'neutral';
|
|
462
|
+
const spanInfo = pStats?.dateRange ? ` за <b>${pStats.yrsLabel}</b> (${pStats.dateRange}, <b>${fmtPct(pStats.cagr, 1)} годовых</b> с реинвестированием / ${fmtPct(pStats.avgSimple, 1)} в среднем в год)` : '';
|
|
385
463
|
// помогает ли карта рынка — по торговому результату против простейшей модели
|
|
386
464
|
let vs = '';
|
|
387
465
|
if (base) {
|
|
@@ -394,9 +472,9 @@ function verdictHtml(c, dir) {
|
|
|
394
472
|
let unreliable = a.trades < 30 || (base && base.trades < 30) ? ' <span class="muted small">Сделок мало, сравнение ненадёжно.</span>' : '';
|
|
395
473
|
if (c.transformer) unreliable += `<div class="muted small">Трансформер: итог ${fmtPct(c.transformer.returnPct)} против ${fmtPct(a.returnPct)} у внимания через карту.</div>`;
|
|
396
474
|
if (a.trades < 30) return `<b>Слишком мало сделок для выводов (${a.trades}).</b> <span class="muted">${vs}</span>${unreliable}`;
|
|
397
|
-
if (neutral && a.returnPct > 0 && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после
|
|
398
|
-
if (!neutral && a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после
|
|
399
|
-
if ((a.grossReturnPct ?? 0) > 0) return `<b style="color:var(--yellow)">Сигнал есть, но комиссии его съедают:</b> без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}. Попробуйте увеличить «Порог выгоды». ${vs}${unreliable}`;
|
|
475
|
+
if (neutral && a.returnPct > 0 && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий${spanInfo}; нейтрально к рынку: рынок почти не влияет. ${vs}${unreliable}`;
|
|
476
|
+
if (!neutral && a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий${spanInfo}, рынок за то же время ${fmtPct(a.marketPct)}. ${vs}${unreliable}`;
|
|
477
|
+
if ((a.grossReturnPct ?? 0) > 0) return `<b style="color:var(--yellow)">Сигнал есть, но комиссии его съедают:</b> без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}${spanInfo}. Попробуйте увеличить «Порог выгоды». ${vs}${unreliable}`;
|
|
400
478
|
return `<b class="neg">Стратегия не заработала даже без комиссий</b> (${fmtPct(a.grossReturnPct)}). ${vs}${unreliable}`;
|
|
401
479
|
}
|
|
402
480
|
|
|
@@ -456,37 +534,208 @@ function renderStability(st) {
|
|
|
456
534
|
box.innerHTML = `<div class="stab-head"><span class="tag ${vCls}">${vLbl}</span><span><b>Медиана ${fmtPct(st.medianPct)}</b> · разброс от <span class="${cls(st.minPct)}">${fmtPct(st.minPct)}</span> до <span class="${cls(st.maxPct)}">${fmtPct(st.maxPct)}</span> · в плюсе ${st.positive} из ${st.count} · худшая просадка ${fmtNum(st.maxDrawdownPct, 1)}%</span><span class="muted small">(${kindLbl})</span></div><div class="stab-chips">${chips}</div>`;
|
|
457
535
|
}
|
|
458
536
|
|
|
537
|
+
// Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
|
|
538
|
+
const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
|
|
539
|
+
|
|
540
|
+
function resetLiveTrain(runId) {
|
|
541
|
+
liveTrainState.runId = runId;
|
|
542
|
+
liveTrainState.stage = 'data';
|
|
543
|
+
liveTrainState.loadedSymbols = [];
|
|
544
|
+
liveTrainState.foldIcs = [];
|
|
545
|
+
liveTrainState.lastViz = null;
|
|
546
|
+
liveTrainState.text = '';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function drawLiveTrainMap(viz) {
|
|
550
|
+
const cv = $('#ld-map-cv');
|
|
551
|
+
if (!cv || !viz?.pts?.length) return;
|
|
552
|
+
const W = cv.clientWidth || 440, H = 250;
|
|
553
|
+
const dpr = window.devicePixelRatio || 1;
|
|
554
|
+
cv.width = W * dpr; cv.height = H * dpr;
|
|
555
|
+
const ctx = cv.getContext('2d');
|
|
556
|
+
ctx.scale(dpr, dpr);
|
|
557
|
+
const syms = liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
|
|
558
|
+
const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
|
|
559
|
+
const xy = layout(f, W, H);
|
|
560
|
+
const light = document.body.classList.contains('light');
|
|
561
|
+
// лёгкие соединительные линии внутри одного кластера к ближайшему соседу
|
|
562
|
+
ctx.lineWidth = 1;
|
|
563
|
+
for (let i = 0; i < xy.length; i++) {
|
|
564
|
+
let bestJ = -1, bestD = 68;
|
|
565
|
+
for (let j = i + 1; j < xy.length; j++) {
|
|
566
|
+
if (f.cl[i] !== f.cl[j]) continue;
|
|
567
|
+
const d = Math.hypot(xy[i][0] - xy[j][0], xy[i][1] - xy[j][1]);
|
|
568
|
+
if (d < bestD) { bestD = d; bestJ = j; }
|
|
569
|
+
}
|
|
570
|
+
if (bestJ >= 0) {
|
|
571
|
+
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
572
|
+
ctx.strokeStyle = `${col}33`;
|
|
573
|
+
ctx.beginPath(); ctx.moveTo(...xy[i]); ctx.lineTo(...xy[bestJ]); ctx.stroke();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
f.symbols.forEach((s, i) => {
|
|
577
|
+
const [x, y] = xy[i];
|
|
578
|
+
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
579
|
+
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2);
|
|
580
|
+
ctx.fillStyle = col;
|
|
581
|
+
ctx.fill();
|
|
582
|
+
ctx.fillStyle = light ? '#57606a' : '#8b949e';
|
|
583
|
+
ctx.font = '10px system-ui';
|
|
584
|
+
ctx.fillText(s, x + 6, y + 3);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function renderLiveTraining() {
|
|
589
|
+
const empty = $('#overview-empty');
|
|
590
|
+
if (!empty || empty.hidden) return;
|
|
591
|
+
const st = liveTrainState;
|
|
592
|
+
empty.classList.add('live-dash');
|
|
593
|
+
const v = st.lastViz;
|
|
594
|
+
if (!v) {
|
|
595
|
+
// Этап 1: загрузка котировок и дивидендов с Мосбиржи
|
|
596
|
+
const chips = st.loadedSymbols.slice(-36).map((s) => `<span class="ld-sym ok">✓ ${esc(s)}</span>`).join('');
|
|
597
|
+
empty.innerHTML = `<div class="ld-card">
|
|
598
|
+
<div class="ld-title"><span>Этап 1 из 2 · Загрузка и сверка исторических свечей и дивидендов МосБиржи</span><span class="muted small">${esc(st.text || 'Подготовка датасета…')}</span></div>
|
|
599
|
+
<div class="muted small">Для каждой из ~100 акций индекса широкого рынка загружаются 10-минутные свечи, сверяются дивидендные отсечки по фактическим утренним разрывам цены и рассчитывается почасовой суточный профиль объёма.</div>
|
|
600
|
+
${chips ? `<div class="ld-syms">${chips}</div>` : ''}
|
|
601
|
+
</div>`;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Этап 2: пошаговое скользящее обучение (Walk-Forward)
|
|
606
|
+
const span = Math.max(1, (v.periodTill || 1) - (v.periodFrom || 0));
|
|
607
|
+
const pctPos = (ts) => Math.max(0, Math.min(100, ((ts - v.periodFrom) / span) * 100));
|
|
608
|
+
const trLeft = pctPos(v.trainFrom), trRight = pctPos(v.testFrom), teRight = pctPos(v.testTill);
|
|
609
|
+
const firstTestLeft = st.firstTestLeft ?? trRight;
|
|
610
|
+
if (st.firstTestLeft === undefined) st.firstTestLeft = trRight;
|
|
611
|
+
|
|
612
|
+
const ics = st.foldIcs;
|
|
613
|
+
const posFolds = ics.filter((x) => x > 0).length;
|
|
614
|
+
const meanIc = ics.length ? ics.reduce((a, b) => a + b, 0) / ics.length : 0;
|
|
615
|
+
const maxIc = Math.max(0.05, ...ics.map(Math.abs));
|
|
616
|
+
const icBars = Array.from({ length: v.folds }, (_, idx) => {
|
|
617
|
+
const val = ics[idx];
|
|
618
|
+
if (val === undefined) return `<div class="ld-ic-bar" style="height:3px;background:var(--line)" title="Шаг ${idx + 1}: ещё впереди"></div>`;
|
|
619
|
+
const h = Math.max(5, Math.round((Math.abs(val) / maxIc) * 54));
|
|
620
|
+
const col = val >= 0 ? 'var(--green)' : 'var(--red)';
|
|
621
|
+
return `<div class="ld-ic-bar" style="height:${h}px;background:${col}" title="Шаг ${idx + 1}: совпадение прогноза ${fmtNum(val, 3)}"></div>`;
|
|
622
|
+
}).join('');
|
|
623
|
+
|
|
624
|
+
let headsHtml = '';
|
|
625
|
+
if (v.heads?.length) {
|
|
626
|
+
const maxC = Math.max(...v.heads.map((h) => Math.abs(h.coef)), ...(v.self || []).map(Math.abs), 1e-6);
|
|
627
|
+
const bar = (label, val) => {
|
|
628
|
+
const wd = (Math.abs(val) / maxC) * 50;
|
|
629
|
+
return `<div class="headbar"><span>${esc(label)}</span><div class="b"><div style="left:${val >= 0 ? 50 : 50 - wd}%;width:${wd}%;background:${val >= 0 ? 'var(--green)' : 'var(--red)'}"></div></div><span class="num ${cls(val)}">${val > 0 ? '+' : ''}${fmtNum(val, 3)}</span></div>`;
|
|
630
|
+
};
|
|
631
|
+
headsHtml = [
|
|
632
|
+
...[...v.heads].sort((x, y) => Math.abs(y.coef) - Math.abs(x.coef)).slice(0, 7).map((h) => bar(h.label, h.coef)),
|
|
633
|
+
...(v.self?.length ? [bar('своё движение за последний час', v.self[0])] : []),
|
|
634
|
+
].join('');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
empty.innerHTML = `
|
|
638
|
+
<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
|
639
|
+
<div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
|
|
640
|
+
<div class="kpi"><div class="v ${cls(meanIc)}">${meanIc > 0 ? '+' : ''}${fmtNum(meanIc, 3)}</div><div class="l">среднее совпадение (IC)</div><div class="sub">шагов в плюсе: ${posFolds} из ${ics.length} (${ics.length ? Math.round((posFolds / ics.length) * 100) : 0}%)</div></div>
|
|
641
|
+
<div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
|
|
642
|
+
<div class="kpi"><div class="v ${cls(v.foldIc ?? 0)}">${v.foldIc !== null ? `${v.foldIc > 0 ? '+' : ''}${fmtNum(v.foldIc, 3)}` : '—'}</div><div class="l">точность текущего месяца</div><div class="sub">окно обучения до ${fmtDate(v.testFrom)}</div></div>
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
<div class="ld-card">
|
|
646
|
+
<div class="ld-title">
|
|
647
|
+
<span>Движение скользящего окна по истории (без заглядывания в будущее)</span>
|
|
648
|
+
<span class="muted small">Обучение: ${fmtDate(v.trainFrom)} — ${fmtDate(v.testFrom)} → Прогноз: до ${fmtDate(v.testTill)}</span>
|
|
649
|
+
</div>
|
|
650
|
+
<div class="ld-tl">
|
|
651
|
+
<div class="ld-tl-done" style="left:${firstTestLeft.toFixed(1)}%;width:${Math.max(0, trRight - firstTestLeft).toFixed(1)}%"></div>
|
|
652
|
+
<div class="ld-tl-train" style="left:${trLeft.toFixed(1)}%;width:${Math.max(3, trRight - trLeft).toFixed(1)}%">Обучение</div>
|
|
653
|
+
<div class="ld-tl-test" style="left:${trRight.toFixed(1)}%;width:${Math.max(1.5, teRight - trRight).toFixed(1)}%" title="Прогнозируемый месяц"></div>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="ld-title" style="margin-top:10px;margin-bottom:2px"><span class="muted small">Точность угадывания порядка акций (IC) по каждому пройденному месяцу</span><span class="muted small">выше нуля — лучше случайного</span></div>
|
|
656
|
+
<div class="ld-ics">${icBars}</div>
|
|
657
|
+
</div>
|
|
658
|
+
|
|
659
|
+
<div class="ld-grid">
|
|
660
|
+
<div class="ld-card">
|
|
661
|
+
<div class="ld-title"><span>Живая карта рынка на ${fmtDate(v.testFrom)}</span><span class="muted small">кластеры совместного движения</span></div>
|
|
662
|
+
<canvas id="ld-map-cv" style="width:100%;height:250px;display:block"></canvas>
|
|
663
|
+
</div>
|
|
664
|
+
<div class="ld-card">
|
|
665
|
+
<div class="ld-title"><span>Веса голов внимания (текущее окно)</span><span class="muted small"><span class="pos">переток</span> / <span class="neg">возврат</span></span></div>
|
|
666
|
+
${headsHtml || '<div class="muted small">Расчёт весов…</div>'}
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
`;
|
|
670
|
+
drawLiveTrainMap(v);
|
|
671
|
+
}
|
|
672
|
+
|
|
459
673
|
function renderOverview() {
|
|
460
674
|
const run = state.run;
|
|
461
675
|
const empty = $('#overview-empty');
|
|
462
676
|
if (!run?.extra || run.status !== 'done') {
|
|
463
677
|
$('#overview').hidden = true;
|
|
464
678
|
empty.hidden = false;
|
|
679
|
+
if (run?.status === 'running' || state.active) {
|
|
680
|
+
renderLiveTraining();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
empty.classList.remove('live-dash');
|
|
465
684
|
empty.innerHTML = run && run.status !== 'done'
|
|
466
|
-
?
|
|
685
|
+
? `Исследование не завершилось: ${esc(run.error || run.status)}`
|
|
467
686
|
: `<b>Идея.</b> Каждая акция — как слово со своим местом на карте рынка. Недавние движения всех акций — контекст.
|
|
468
687
|
Стратегия, как языковая модель, предсказывает «следующее слово» — какие акции сдвинутся в ближайшие часы.<br><br>
|
|
469
688
|
Нажмите <b>«Исследовать»</b>: стратегия пошагово проверится на двух годах истории (около минуты; первый раз — несколько минут на загрузку цен).
|
|
470
689
|
Или попросите Стратега слева.`;
|
|
471
690
|
return;
|
|
472
691
|
}
|
|
692
|
+
empty.classList.remove('live-dash');
|
|
473
693
|
empty.hidden = true;
|
|
474
694
|
$('#overview').hidden = false;
|
|
475
695
|
const ex = run.extra;
|
|
476
696
|
const c = Object.fromEntries(MODELS.map((n) => [n, ex.comparison[n]?.full]));
|
|
477
697
|
const chosen = run.metrics?.model?.chosen || 'attention';
|
|
478
698
|
const a = c[chosen] || {};
|
|
699
|
+
const fullM = run.metrics?.full || {};
|
|
700
|
+
const oosA = ex.comparison?.[chosen]?.outOfSample || run.metrics?.outOfSample || {};
|
|
701
|
+
const pStats = calcPeriodStats(run.metrics?.period, a.returnPct, a.marketPct);
|
|
479
702
|
const dir = run.params?.modelDirection || 'long';
|
|
480
703
|
const adaptNote = ex.adaptation
|
|
481
|
-
? `<div class="muted small">Авто-адаптация: порог выхода ${fmtNum(ex.adaptation.exitRatio, 2)}${ex.adaptation.quarantined?.length ? ` · отсеяны убыточные бумаги (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 10).join(', '))}${ex.adaptation.quarantined.length > 10 ? '…' : ''}` : ''}</div>`
|
|
704
|
+
? `<div class="muted small">Авто-адаптация: порог выхода ${fmtNum(ex.adaptation.exitRatio, 2)}${ex.adaptation.quarantined?.length ? ` · отсеяны убыточные бумаги (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 10).join(', '))}${ex.adaptation.quarantined.length > 10 ? '…' : ''}` : ''}${oosA.returnPct !== undefined ? ` · контрольный период (последние 30%): <b class="${cls(oosA.returnPct)}">${fmtPct(oosA.returnPct)}</b> (${oosA.trades ?? 0} сделок)` : ''}</div>`
|
|
482
705
|
: '';
|
|
483
|
-
$('#verdict').innerHTML = verdictHtml(c, dir) + adaptNote + (run.params?._note ? `<div class="muted small">Гипотеза: ${esc(run.params._note)}</div>` : '');
|
|
484
|
-
const kpi = (v, l, h) => `<div class="kpi" title="${esc(h)}"><div class="v">${v}</div><div class="l">${l}</div
|
|
706
|
+
$('#verdict').innerHTML = verdictHtml(c, dir, pStats) + adaptNote + (run.params?._note ? `<div class="muted small">Гипотеза: ${esc(run.params._note)}</div>` : '');
|
|
707
|
+
const kpi = (v, l, sub, h) => `<div class="kpi" title="${esc(h)}"><div class="v">${v}</div><div class="l">${l}</div>${sub ? `<div class="sub">${sub}</div>` : ''}</div>`;
|
|
485
708
|
$('#kpis').innerHTML = [
|
|
486
|
-
kpi(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
709
|
+
kpi(
|
|
710
|
+
`<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`,
|
|
711
|
+
`итог за ${pStats.yrsLabel}`,
|
|
712
|
+
`${pStats.dateRange || 'весь период'} · без ком. ${fmtPct(a.grossReturnPct)}`,
|
|
713
|
+
'Общий чистый доход портфеля после вычета комиссий, проскальзывания и платы за шорт',
|
|
714
|
+
),
|
|
715
|
+
kpi(
|
|
716
|
+
`<span class="${cls(pStats.cagr)}">${fmtPct(pStats.cagr, 1)}</span>`,
|
|
717
|
+
'средний годовой (CAGR)',
|
|
718
|
+
`простое среднее: ${fmtPct(pStats.avgSimple, 1)}/год`,
|
|
719
|
+
'Среднегодовая доходность с учётом реинвестирования прибыли (CAGR) и простое арифметическое среднее в год',
|
|
720
|
+
),
|
|
721
|
+
kpi(
|
|
722
|
+
`<span class="${cls(a.marketPct)}">${fmtPct(a.marketPct)}</span>`,
|
|
723
|
+
'индекс МосБиржи',
|
|
724
|
+
`${fmtPct(pStats.mktAnnual, 1)}/год · альфа ${fmtPct((a.returnPct ?? 0) - (a.marketPct ?? 0))}`,
|
|
725
|
+
'Изменение индекса МосБиржи (IMOEX) за точно такой же период и преимущество стратегии над рынком',
|
|
726
|
+
),
|
|
727
|
+
kpi(
|
|
728
|
+
fmtNum(a.trades, 0),
|
|
729
|
+
'сделок за период',
|
|
730
|
+
`плюсовых ${fmtNum(a.winRate, 1)}% · на 1 ₽ уб.: ${fmtNum(a.profitFactor)} ₽`,
|
|
731
|
+
'Общее количество сделок, доля прибыльных сделок (Win Rate) и отношение общей прибыли к общему убытку (Profit Factor)',
|
|
732
|
+
),
|
|
733
|
+
kpi(
|
|
734
|
+
`${fmtNum(a.maxDrawdownPct, 1)}%`,
|
|
735
|
+
'худшее падение (просадка)',
|
|
736
|
+
`коэф. Шарпа: ${fmtNum(fullM.sharpe, 2)}`,
|
|
737
|
+
'Максимальная просадка капитала от пика и годовой коэффициент Шарпа (стабильность кривой доходности)',
|
|
738
|
+
),
|
|
490
739
|
].join('');
|
|
491
740
|
$('#sides').hidden = dir === 'long' || a.shortPct === undefined;
|
|
492
741
|
$('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
|
|
@@ -545,16 +794,6 @@ $('#run-bar').addEventListener('click', async (e) => {
|
|
|
545
794
|
}
|
|
546
795
|
if (e.target.closest('#btn-ask-run')) {
|
|
547
796
|
sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
if (e.target.closest('#btn-cmp-run')) {
|
|
551
|
-
const others = state.runs.filter((x) => x.id !== r.id && x.status === 'done');
|
|
552
|
-
const defId = others[0]?.id || '';
|
|
553
|
-
const raw = prompt(`С каким номером эксперимента сравнить №${r.id}? (доступны: ${others.slice(0, 8).map((x) => `№${x.id}`).join(', ')})`, String(defId));
|
|
554
|
-
if (!raw) return;
|
|
555
|
-
const otherId = Number(String(raw).replace(/\D/g, ''));
|
|
556
|
-
if (!otherId) return;
|
|
557
|
-
sendChat(`Сравни исследования №${r.id} и №${otherId} через compare_runs и объясни простыми словами, почему отличается результат и какой вариант надёжнее.`);
|
|
558
797
|
}
|
|
559
798
|
});
|
|
560
799
|
$('#btn-ask-forecast').addEventListener('click', () => {
|
|
@@ -568,10 +807,9 @@ function setProgress(pct, text) {
|
|
|
568
807
|
$('#progress span').textContent = text || '';
|
|
569
808
|
}
|
|
570
809
|
function researchUi(running) {
|
|
571
|
-
$('#btn-model').disabled = running;
|
|
572
|
-
$('#btn-cancel').hidden = !running && !state.stabilityActive;
|
|
573
810
|
if (!running && !state.stabilityActive) setProgress(null);
|
|
574
811
|
updateTabBadges();
|
|
812
|
+
syncUiAvailability();
|
|
575
813
|
}
|
|
576
814
|
async function startResearch(force = false) {
|
|
577
815
|
try {
|
|
@@ -632,6 +870,7 @@ async function loadForecast(force, silent = false) {
|
|
|
632
870
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
633
871
|
} finally {
|
|
634
872
|
$('#btn-forecast').disabled = false;
|
|
873
|
+
syncUiAvailability();
|
|
635
874
|
}
|
|
636
875
|
}
|
|
637
876
|
$('#btn-forecast').addEventListener('click', () => loadForecast(true));
|
|
@@ -665,7 +904,7 @@ function renderLive(st) {
|
|
|
665
904
|
const retPct = a.returnPct ?? 0;
|
|
666
905
|
const step = a.recent?.[0];
|
|
667
906
|
const p = a.params || {};
|
|
668
|
-
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ` · ${p.modelDirection === 'neutral' ? '
|
|
907
|
+
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ` · ${p.modelDirection === 'neutral' ? 'хедж 50/50' : 'в рост и в падение'}` : ' · только в рост'}`;
|
|
669
908
|
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
670
909
|
if (a.corridor) {
|
|
671
910
|
const c = a.corridor;
|
|
@@ -703,6 +942,7 @@ function renderLive(st) {
|
|
|
703
942
|
${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
|
|
704
943
|
</div>`;
|
|
705
944
|
}).join('');
|
|
945
|
+
syncUiAvailability();
|
|
706
946
|
}
|
|
707
947
|
$('#btn-live').addEventListener('click', async () => {
|
|
708
948
|
const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
|
|
@@ -945,12 +1185,12 @@ async function loadJournal() {
|
|
|
945
1185
|
}));
|
|
946
1186
|
}
|
|
947
1187
|
|
|
948
|
-
//
|
|
1188
|
+
// Логические группы настроек по смыслу:
|
|
1189
|
+
// g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
|
|
949
1190
|
const GROUPS = [
|
|
950
|
-
['
|
|
951
|
-
['
|
|
952
|
-
['
|
|
953
|
-
['Дополнительно', []],
|
|
1191
|
+
['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
|
|
1192
|
+
['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
|
|
1193
|
+
['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
|
|
954
1194
|
];
|
|
955
1195
|
function buildForm() {
|
|
956
1196
|
const field = (p) => {
|
|
@@ -965,7 +1205,7 @@ function buildForm() {
|
|
|
965
1205
|
const cleanRow = `<div class="srow" data-key="_cleanup"><div class="stxt"><div class="slbl">Очистка истории экспериментов</div><div class="hint">Удалить старые эксперименты, оставив ровно указанное число лучших по итогу (0 — удалить все завершённые эксперименты).</div></div><div class="row-gap" style="align-items:center"><span class="muted small">оставить</span><input id="keep-n" type="number" value="0" min="0" max="50" class="sctrl" style="width:68px"><button id="btn-clean" type="button">Очистить историю</button></div></div>`;
|
|
966
1206
|
$('#params').innerHTML = GROUPS.map(([name, keys], i) => {
|
|
967
1207
|
const items = keys.length ? keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean) : shown.filter((p) => !listed.has(p.key));
|
|
968
|
-
const extra = i ===
|
|
1208
|
+
const extra = i === 2 ? cleanRow : '';
|
|
969
1209
|
return items.length || extra ? `<section class="ssec" data-sec="g${i}"${i ? ' hidden' : ''}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}${extra}</div></section>` : '';
|
|
970
1210
|
}).join('');
|
|
971
1211
|
$('#btn-clean')?.addEventListener('click', cleanRunsHistory);
|
|
@@ -1013,6 +1253,7 @@ function markDefaults(s) {
|
|
|
1013
1253
|
b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
|
|
1014
1254
|
});
|
|
1015
1255
|
syncApplyBtn();
|
|
1256
|
+
syncSettingsDependencies();
|
|
1016
1257
|
updateTabBadges();
|
|
1017
1258
|
}
|
|
1018
1259
|
const saveSettings = async (body) => {
|
|
@@ -1222,27 +1463,21 @@ function syncActionBtn() {
|
|
|
1222
1463
|
btn.hidden = Boolean(s?.busy);
|
|
1223
1464
|
if (speechActive) {
|
|
1224
1465
|
// 1. Микрофон включён -> в любом случае Стоп
|
|
1225
|
-
|
|
1226
|
-
$('#ico-send').hidden = true;
|
|
1227
|
-
$('#ico-stop').hidden = false;
|
|
1466
|
+
btn.dataset.icon = 'stop';
|
|
1228
1467
|
btn.classList.remove('send-mode');
|
|
1229
1468
|
btn.classList.add('rec');
|
|
1230
1469
|
btn.disabled = false;
|
|
1231
1470
|
btn.title = 'Остановить запись голоса';
|
|
1232
1471
|
} else if (hasText) {
|
|
1233
1472
|
// 2. Микрофон выключен, есть текст -> Стрелка
|
|
1234
|
-
|
|
1235
|
-
$('#ico-send').hidden = false;
|
|
1236
|
-
$('#ico-stop').hidden = true;
|
|
1473
|
+
btn.dataset.icon = 'send';
|
|
1237
1474
|
btn.classList.add('send-mode');
|
|
1238
1475
|
btn.classList.remove('rec');
|
|
1239
1476
|
btn.disabled = !s?.llm;
|
|
1240
1477
|
btn.title = 'Отправить (Enter)';
|
|
1241
1478
|
} else {
|
|
1242
1479
|
// 3. Микрофон выключен, нет текста -> Микрофон
|
|
1243
|
-
|
|
1244
|
-
$('#ico-send').hidden = true;
|
|
1245
|
-
$('#ico-stop').hidden = true;
|
|
1480
|
+
btn.dataset.icon = 'mic';
|
|
1246
1481
|
btn.classList.remove('send-mode', 'rec');
|
|
1247
1482
|
btn.disabled = false;
|
|
1248
1483
|
btn.title = 'Голосовой ввод (русский язык)';
|
|
@@ -1277,6 +1512,7 @@ function renderChatState() {
|
|
|
1277
1512
|
$('#chat-status').textContent = st;
|
|
1278
1513
|
for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
|
|
1279
1514
|
renderSuggest();
|
|
1515
|
+
syncUiAvailability();
|
|
1280
1516
|
}
|
|
1281
1517
|
|
|
1282
1518
|
const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
|
|
@@ -1711,6 +1947,21 @@ addForm.addEventListener('submit', async (e) => {
|
|
|
1711
1947
|
// ======================================================================
|
|
1712
1948
|
const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
|
|
1713
1949
|
const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
|
|
1950
|
+
const LEGACY_MODES = {
|
|
1951
|
+
isolate: 'Изолированный карман (старые бумаги не трогаются)',
|
|
1952
|
+
smooth: 'Плавный переход (продажа старых только под новые сигналы)',
|
|
1953
|
+
takeover: 'Полное управление (все акции под стратегией)',
|
|
1954
|
+
};
|
|
1955
|
+
const INST_KIND_LABEL = {
|
|
1956
|
+
share: ['Акция', 'ok'],
|
|
1957
|
+
metal: ['Драгметалл', 'mid'],
|
|
1958
|
+
currency: ['Валюта', 'mid'],
|
|
1959
|
+
bond: ['Облигация', ''],
|
|
1960
|
+
etf: ['Фонд', ''],
|
|
1961
|
+
futures: ['Фьючерс', 'mid'],
|
|
1962
|
+
};
|
|
1963
|
+
const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
|
|
1964
|
+
const brokerTabById = new Map(); // accId -> 'assets' | 'trade' | 'history'
|
|
1714
1965
|
let editingBrokerId = null;
|
|
1715
1966
|
|
|
1716
1967
|
async function loadBrokers() {
|
|
@@ -1733,6 +1984,8 @@ function renderBrokers() {
|
|
|
1733
1984
|
<b>${esc(a.name)}</b>
|
|
1734
1985
|
<span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
|
|
1735
1986
|
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
1987
|
+
<span class="tag" title="Режим работы с начальным портфелем на счёте">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
|
|
1988
|
+
${a.lastSync?.marginEnabled === false ? '<span class="tag" title="На этом счёте у брокера отключена маржинальная торговля — стратегия автоматически работает в режиме «Только в рост (без шорта)»">только в рост (маржиналка выкл.)</span>' : a.lastSync?.marginEnabled === true ? '<span class="tag ok" title="Маржинальная торговля включена — доступны сделки в рост и в падение (с шортом)">рост + шорт доступны</span>' : ''}
|
|
1736
1989
|
${a.autoSchedule !== false ? '<span class="tag ok" title="Заявки рассчитываются автоматически раз в горизонт прогноза и приходят в Telegram / MAX">авто-расписание</span>' : ''}
|
|
1737
1990
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
1738
1991
|
${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
|
|
@@ -1748,29 +2001,106 @@ function renderBrokers() {
|
|
|
1748
2001
|
</div>`).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
|
|
1749
2002
|
}
|
|
1750
2003
|
|
|
1751
|
-
// 2) карточки во вкладке
|
|
2004
|
+
// 2) карточки во вкладке «Торговля → Брокерские счета» (с 3 вкладками: Активы · Торговля · История)
|
|
1752
2005
|
const tradeEl = $('#brokers-trade');
|
|
1753
2006
|
if (!tradeEl) return;
|
|
1754
2007
|
if (!accs.length) {
|
|
1755
2008
|
tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
|
|
1756
2009
|
return;
|
|
1757
2010
|
}
|
|
2011
|
+
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
1758
2012
|
tradeEl.innerHTML = accs.map((a) => {
|
|
1759
2013
|
const ls = a.lastSync;
|
|
1760
2014
|
const pb = a.pendingBatch;
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
|
|
2015
|
+
const lh = a.lastHistory;
|
|
2016
|
+
const lMode = a.legacyMode || 'isolate';
|
|
2017
|
+
const baseMap = a.baselineQty || {};
|
|
2018
|
+
const curSubTab = brokerTabById.get(a.id) || 'assets';
|
|
2019
|
+
|
|
2020
|
+
const algoQtyOf = (p) => {
|
|
2021
|
+
if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
|
|
2022
|
+
if (lMode === 'takeover') return p.qty;
|
|
2023
|
+
const bq = Number(baseMap[p.symbol] || 0);
|
|
2024
|
+
if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
|
|
2025
|
+
if (p.qty < 0 && bq < 0) return Math.min(0, p.qty - bq);
|
|
2026
|
+
return p.qty;
|
|
2027
|
+
};
|
|
2028
|
+
|
|
2029
|
+
const posStatus = (p) => {
|
|
2030
|
+
if (p.instrumentType && p.instrumentType !== 'share') {
|
|
2031
|
+
const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
|
|
2032
|
+
return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
|
|
2033
|
+
}
|
|
2034
|
+
if (fcRowMap && !fcRowMap.has(p.symbol)) return '<span class="tag" title="Вне индекса широкого рынка — стратегия не трогает">вне словаря (защищена)</span>';
|
|
2035
|
+
if (lMode === 'takeover') return '<span class="tag ok">под управлением стратегии</span>';
|
|
2036
|
+
const bq = Number(baseMap[p.symbol] || 0);
|
|
2037
|
+
const aq = algoQtyOf(p);
|
|
2038
|
+
if (lMode === 'isolate') {
|
|
2039
|
+
if (aq === 0) return `<span class="tag" title="Начальная позиция счёта — робот её не продаёт">защищена (старый портфель: ${fmtNum(bq, 0)} шт.)</span>`;
|
|
2040
|
+
if (bq !== 0) return `<span class="tag ok">старый: ${fmtNum(bq, 0)} шт. · стратегия: ${fmtNum(aq, 0)} шт.</span>`;
|
|
2041
|
+
return '<span class="tag ok">открыта стратегией</span>';
|
|
2042
|
+
}
|
|
2043
|
+
if (aq === 0) return `<span class="tag mid" title="Будет плавно замещена при появлении новых сигналов покупки">плавный переход (${fmtNum(bq, 0)} шт.)</span>`;
|
|
2044
|
+
return '<span class="tag ok">под управлением стратегии</span>';
|
|
2045
|
+
};
|
|
1764
2046
|
|
|
2047
|
+
// Полоса структуры активов (Allocation Bar)
|
|
2048
|
+
const totalVal = Math.max(1, Number(ls?.totalRub || 0));
|
|
2049
|
+
const allocSegments = [];
|
|
2050
|
+
(ls?.positions || []).forEach((p, idx) => {
|
|
2051
|
+
const pct = Math.max(0, Math.min(100, (Math.abs(p.valueRub || 0) / totalVal) * 100));
|
|
2052
|
+
if (pct >= 0.3) allocSegments.push({ label: `${p.name || p.symbol} (${p.symbol})`, valRub: p.valueRub, pct: Number(pct.toFixed(1)), color: ALLOC_PALETTE[idx % ALLOC_PALETTE.length] });
|
|
2053
|
+
});
|
|
2054
|
+
if ((ls?.cashRub || 0) > 10) {
|
|
2055
|
+
const cPct = Math.max(0, Math.min(100, (ls.cashRub / totalVal) * 100));
|
|
2056
|
+
allocSegments.push({ label: 'Свободные рубли (RUB)', valRub: ls.cashRub, pct: Number(cPct.toFixed(1)), color: '#16a34a' });
|
|
2057
|
+
}
|
|
2058
|
+
const allocBarHtml = allocSegments.length ? `<div class="brk-alloc-wrap">
|
|
2059
|
+
<div class="brk-alloc-bar">${allocSegments.map((s) => `<div style="width:${s.pct}%;background:${s.color}" title="${esc(s.label)}: ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)"></div>`).join('')}</div>
|
|
2060
|
+
<div class="brk-alloc-legend">${allocSegments.map((s) => `<span><i style="background:${s.color}"></i><b>${esc(s.label)}</b> — ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)</span>`).join('')}</div>
|
|
2061
|
+
</div>` : '';
|
|
2062
|
+
|
|
2063
|
+
// Считаем сколько ₽ в стратегии и сколько ₽ защищено
|
|
2064
|
+
let strategyRub = 0, protectedRub = 0;
|
|
2065
|
+
for (const p of ls?.positions || []) {
|
|
2066
|
+
const aq = algoQtyOf(p);
|
|
2067
|
+
const aVal = Math.abs(aq * (p.price || 0));
|
|
2068
|
+
strategyRub += aVal;
|
|
2069
|
+
protectedRub += Math.max(0, Math.abs(p.valueRub || 0) - aVal);
|
|
2070
|
+
}
|
|
2071
|
+
const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
|
|
2072
|
+
|
|
2073
|
+
// Вкладка 1: Активы портфеля
|
|
2074
|
+
const posHtml = ls?.positions?.length
|
|
2075
|
+
? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
|
|
2076
|
+
const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
|
|
2077
|
+
const fcRow = fcRowMap?.get(p.symbol);
|
|
2078
|
+
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
2079
|
+
return `<tr>
|
|
2080
|
+
<td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
|
|
2081
|
+
<td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
|
|
2082
|
+
<td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
|
|
2083
|
+
<td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
|
|
2084
|
+
<td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
|
|
2085
|
+
<td class="num ${cls(p.pnlRub)}"><b>${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</b>${p.pnlPct ? ` <span class="small">(${p.pnlPct >= 0 ? '+' : ''}${fmtNum(p.pnlPct, 1)}%)</span>` : ''}</td>
|
|
2086
|
+
<td>${posStatus(p)}${fcBadge}</td>
|
|
2087
|
+
</tr>`;
|
|
2088
|
+
}).join('')}</tbody></table>`
|
|
2089
|
+
: `<p class="muted small">${ls?.ok ? 'Открытых позиций на счёте сейчас нет (все средства в рублях).' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и активы с брокера.'}</p>`;
|
|
2090
|
+
|
|
2091
|
+
// Вкладка 2: Торговля и заявки стратегии
|
|
2092
|
+
const activeOrdHtml = ls?.activeOrders?.length
|
|
2093
|
+
? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b></div><table><thead><tr><th>Бумага</th><th>Направление</th><th class="num">Лотов (исп./всего)</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${ls.activeOrders.map((o) => `<tr><td><b>${esc(o.symbol)}</b> ${o.name ? `<span class="muted small">${esc(o.name)}</span>` : ''}</td><td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td><td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td><td class="num">${fmtNum(o.price, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td></tr>`).join('')}</tbody></table></div>`
|
|
2094
|
+
: '';
|
|
1765
2095
|
let batchHtml = '';
|
|
1766
2096
|
if (pb) {
|
|
1767
2097
|
const ordRows = pb.orders?.length
|
|
1768
|
-
? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
|
|
1769
|
-
:
|
|
2098
|
+
? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
|
|
2099
|
+
: `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
|
|
1770
2100
|
const resHtml = pb.results?.length
|
|
1771
2101
|
? `<div class="muted small" style="margin-top:6px">Итог отправки (${fmtTs(pb.executedAt)}): ${pb.results.map((r) => `${esc(r.symbol)}: ${r.ok ? `<span class="pos">отправлена (${esc(r.orderId)})</span>` : `<span class="neg">${esc(r.error)}</span>`}`).join(' · ')}</div>`
|
|
1772
2102
|
: '';
|
|
1773
|
-
batchHtml = `<div class="stab-box"
|
|
2103
|
+
batchHtml = `<div class="stab-box">
|
|
1774
2104
|
<div class="stab-head">
|
|
1775
2105
|
<b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
|
|
1776
2106
|
${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
|
|
@@ -1781,27 +2111,68 @@ function renderBrokers() {
|
|
|
1781
2111
|
${ordRows}
|
|
1782
2112
|
${resHtml}
|
|
1783
2113
|
</div>`;
|
|
2114
|
+
} else {
|
|
2115
|
+
batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
|
|
1784
2116
|
}
|
|
1785
2117
|
|
|
2118
|
+
// Вкладка 3: История операций и динамика счёта
|
|
2119
|
+
const eqHist = Array.isArray(a.equityHistory) ? a.equityHistory : [];
|
|
2120
|
+
const eqHistHtml = eqHist.length > 1
|
|
2121
|
+
? `<div class="muted small" style="margin-bottom:8px">Точки синхронизации баланса (переживают перезапуск сервера): ${eqHist.slice(-6).map((pt) => `${fmtTs(pt.ts)} — <b>${fmtNum(pt.totalRub, 0)} ₽</b>`).join(' · ')}</div>`
|
|
2122
|
+
: '';
|
|
2123
|
+
const histHtml = lh?.operations?.length
|
|
2124
|
+
? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
|
|
2125
|
+
<div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
|
|
2126
|
+
<div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
|
|
2127
|
+
<div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
|
|
2128
|
+
<div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
|
|
2129
|
+
</div>
|
|
2130
|
+
<table><thead><tr><th>Дата</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${lh.operations.map((op) => `<tr>
|
|
2131
|
+
<td class="muted small">${op.date ? fmtTs(op.date) : '—'}</td>
|
|
2132
|
+
<td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span> <span class="muted small">${esc(op.title !== op.kindLabel ? op.title : '')}</span></td>
|
|
2133
|
+
<td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
|
|
2134
|
+
<td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2135
|
+
<td class="num">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
|
|
2136
|
+
<td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
2137
|
+
</tr>`).join('')}</tbody></table>`
|
|
2138
|
+
: `${eqHistHtml}<p class="muted small">Нажмите <b>«Загрузить историю операций»</b>, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
|
|
2139
|
+
|
|
1786
2140
|
return `<div class="live-card" data-bid="${esc(a.id)}">
|
|
1787
2141
|
<div class="live-head">
|
|
1788
2142
|
<div>
|
|
1789
|
-
<b>${esc(a.name)}</b
|
|
2143
|
+
<b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? ` <span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
|
|
1790
2144
|
<span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
|
|
1791
2145
|
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2146
|
+
<span class="tag">${esc(LEGACY_MODES[lMode] || lMode)}</span>
|
|
2147
|
+
${ls?.marginEnabled === false ? '<span class="tag" title="На этом счёте у брокера отключена маржинальная торговля — стратегия автоматически работает в режиме «Только в рост (без шорта)»">только в рост (маржиналка выкл.)</span>' : ls?.marginEnabled === true ? '<span class="tag ok" title="Маржинальная торговля на счёте включена — доступны сделки в рост и в падение (с шортом)">рост + шорт доступны</span>' : ''}
|
|
2148
|
+
${a.autoSchedule !== false ? '<span class="tag ok" title="Активно и автоматически восстанавливается после перезапуска сервера">авто-торговля вкл.</span>' : ''}
|
|
1792
2149
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
1793
2150
|
${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
|
|
1794
|
-
<div class="muted small"
|
|
2151
|
+
<div class="muted small">Счёт: <b>${esc(a.accountId || ls?.accountId || 'авто')}</b> · Лимит стратегии: ${fmtNum(a.maxCapitalRub, 0)} ₽ · Дневной стоп: −${fmtNum(a.maxDailyDrawdownPct, 1)}% · Отступ лимитки: ${fmtNum(a.limitOffsetPct, 2)}%${a.baseRunLabel ? ` · Настройки ${esc(a.baseRunLabel)}` : ''}${ls?.at ? ` · Обновлён ${fmtTs(ls.at)}` : ''}</div>
|
|
1795
2152
|
</div>
|
|
1796
|
-
<div class="row-gap">
|
|
2153
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
1797
2154
|
<button type="button" data-btrade="sync" data-bid="${esc(a.id)}">Обновить портфель</button>
|
|
2155
|
+
<button type="button" data-btrade="hist" data-bid="${esc(a.id)}">История операций</button>
|
|
2156
|
+
${lMode !== 'takeover' && ls?.positions?.length ? `<button type="button" data-btrade="freeze" data-bid="${esc(a.id)}" title="Запомнить все текущие бумаги на счёте как ваш личный старый портфель, который стратегия не будет продавать в режиме «Изолированный карман»">Защитить текущие бумаги</button>` : ''}
|
|
2157
|
+
<button type="button" data-btrade="cfg" data-bid="${esc(a.id)}" title="Открыть настройки и предохранители этого брокерского счёта">⚙ Настроить счёт</button>
|
|
1798
2158
|
<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}">Рассчитать заявки (в лотах)</button>
|
|
1799
2159
|
</div>
|
|
1800
2160
|
</div>
|
|
1801
|
-
${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(
|
|
2161
|
+
${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
2162
|
+
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2163
|
+
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2164
|
+
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
|
|
2165
|
+
<div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
|
|
2166
|
+
</div>` : ''}
|
|
1802
2167
|
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
1803
|
-
|
|
1804
|
-
|
|
2168
|
+
<div class="brk-subtabs">
|
|
2169
|
+
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
|
|
2170
|
+
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
|
2171
|
+
<button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
|
|
2172
|
+
</div>
|
|
2173
|
+
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2174
|
+
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
2175
|
+
<div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
|
|
1805
2176
|
</div>`;
|
|
1806
2177
|
}).join('');
|
|
1807
2178
|
}
|
|
@@ -1812,15 +2183,141 @@ $('#btn-brokers-cfg').addEventListener('click', () => {
|
|
|
1812
2183
|
});
|
|
1813
2184
|
|
|
1814
2185
|
const brokerForm = $('#broker-form');
|
|
2186
|
+
let brokerInitialSnap = '';
|
|
2187
|
+
|
|
2188
|
+
function getBrokerFormSnap() {
|
|
2189
|
+
const f = brokerForm.elements;
|
|
2190
|
+
return JSON.stringify({
|
|
2191
|
+
name: f.name.value.trim(),
|
|
2192
|
+
broker: f.broker.value,
|
|
2193
|
+
accountId: f.accountId.value.trim(),
|
|
2194
|
+
token: f.token.value.trim(),
|
|
2195
|
+
mode: f.mode.value,
|
|
2196
|
+
legacyMode: f.legacyMode?.value || 'isolate',
|
|
2197
|
+
baseRunId: f.baseRunId.value || '',
|
|
2198
|
+
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
2199
|
+
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
2200
|
+
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2201
|
+
autoSchedule: Boolean(f.autoSchedule.checked),
|
|
2202
|
+
skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
|
|
2203
|
+
sandbox: Boolean(f.sandbox.checked),
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function updateBrokerSaveBtnState() {
|
|
2208
|
+
const btn = $('#broker-save-btn');
|
|
2209
|
+
if (!btn) return;
|
|
2210
|
+
if (!editingBrokerId) {
|
|
2211
|
+
const f = brokerForm.elements;
|
|
2212
|
+
const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim());
|
|
2213
|
+
btn.disabled = !hasNewInput;
|
|
2214
|
+
btn.classList.toggle('primary', hasNewInput);
|
|
2215
|
+
btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const changed = getBrokerFormSnap() !== brokerInitialSnap;
|
|
2219
|
+
btn.disabled = !changed;
|
|
2220
|
+
btn.classList.toggle('primary', changed);
|
|
2221
|
+
btn.title = changed ? 'Сохранить внесённые изменения' : 'Нет изменений для сохранения';
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
brokerForm.addEventListener('input', updateBrokerSaveBtnState);
|
|
2225
|
+
brokerForm.addEventListener('change', updateBrokerSaveBtnState);
|
|
2226
|
+
|
|
2227
|
+
function startBrokerEdit(acc) {
|
|
2228
|
+
editingBrokerId = acc.id;
|
|
2229
|
+
const f = brokerForm.elements;
|
|
2230
|
+
f.id.value = acc.id;
|
|
2231
|
+
f.name.value = acc.name;
|
|
2232
|
+
f.broker.value = acc.broker;
|
|
2233
|
+
f.accountId.value = acc.accountId || '';
|
|
2234
|
+
f.token.value = '';
|
|
2235
|
+
f.mode.value = acc.mode || 'confirm';
|
|
2236
|
+
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2237
|
+
if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
|
|
2238
|
+
const opt = document.createElement('option');
|
|
2239
|
+
opt.value = String(acc.baseRunId);
|
|
2240
|
+
opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
|
|
2241
|
+
f.baseRunId.appendChild(opt);
|
|
2242
|
+
}
|
|
2243
|
+
f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
|
|
2244
|
+
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
2245
|
+
f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
|
|
2246
|
+
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2247
|
+
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2248
|
+
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
2249
|
+
f.sandbox.checked = !!acc.sandbox;
|
|
2250
|
+
$('#broker-add-box').open = true;
|
|
2251
|
+
$('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
|
|
2252
|
+
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
2253
|
+
$('#broker-cancel-btn').hidden = false;
|
|
2254
|
+
brokerInitialSnap = getBrokerFormSnap();
|
|
2255
|
+
updateBrokerSaveBtnState();
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1815
2258
|
function stopBrokerEdit() {
|
|
1816
2259
|
editingBrokerId = null;
|
|
1817
2260
|
brokerForm.reset();
|
|
1818
2261
|
brokerForm.elements.id.value = '';
|
|
2262
|
+
if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
|
|
1819
2263
|
$('#broker-add-sum').textContent = 'Добавить брокерский счёт';
|
|
1820
2264
|
$('#broker-save-btn').textContent = 'Сохранить счёт';
|
|
1821
2265
|
$('#broker-cancel-btn').hidden = true;
|
|
2266
|
+
brokerInitialSnap = getBrokerFormSnap();
|
|
2267
|
+
updateBrokerSaveBtnState();
|
|
1822
2268
|
}
|
|
1823
2269
|
$('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
|
|
2270
|
+
updateBrokerSaveBtnState();
|
|
2271
|
+
|
|
2272
|
+
// Авто-поиск всех субсчетов в Т-Инвестициях по токену
|
|
2273
|
+
$('#btn-broker-discover')?.addEventListener('click', async () => {
|
|
2274
|
+
const btn = $('#btn-broker-discover');
|
|
2275
|
+
const box = $('#broker-discovered');
|
|
2276
|
+
const f = brokerForm.elements;
|
|
2277
|
+
btn.disabled = true;
|
|
2278
|
+
const old = btn.textContent;
|
|
2279
|
+
btn.textContent = 'Ищем счета…';
|
|
2280
|
+
try {
|
|
2281
|
+
const res = await post('/api/brokers/discover', {
|
|
2282
|
+
id: editingBrokerId || undefined,
|
|
2283
|
+
broker: f.broker.value,
|
|
2284
|
+
token: f.token.value,
|
|
2285
|
+
sandbox: f.sandbox.checked,
|
|
2286
|
+
});
|
|
2287
|
+
const list = res.accounts || [];
|
|
2288
|
+
if (!list.length) {
|
|
2289
|
+
box.hidden = false;
|
|
2290
|
+
box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
|
|
2291
|
+
} else {
|
|
2292
|
+
box.hidden = false;
|
|
2293
|
+
box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg1)">
|
|
2294
|
+
<div>
|
|
2295
|
+
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">шорт доступен</span>' : '<span class="tag">только в рост (без шорта)</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
|
|
2296
|
+
<div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
|
|
2297
|
+
</div>
|
|
2298
|
+
<button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
|
|
2299
|
+
</div>`).join('');
|
|
2300
|
+
}
|
|
2301
|
+
} catch (err) {
|
|
2302
|
+
toast(err.message, 'error');
|
|
2303
|
+
} finally {
|
|
2304
|
+
btn.disabled = false;
|
|
2305
|
+
btn.textContent = old;
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
$('#broker-discovered')?.addEventListener('click', (e) => {
|
|
2310
|
+
const b = e.target.closest('button[data-pick-acc]');
|
|
2311
|
+
if (!b) return;
|
|
2312
|
+
const f = brokerForm.elements;
|
|
2313
|
+
f.accountId.value = b.dataset.pickAcc;
|
|
2314
|
+
if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
|
|
2315
|
+
f.name.value = `Т-Банк · ${b.dataset.pickName}`;
|
|
2316
|
+
}
|
|
2317
|
+
updateBrokerSaveBtnState();
|
|
2318
|
+
toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
|
|
2319
|
+
});
|
|
2320
|
+
|
|
1824
2321
|
brokerForm.addEventListener('submit', async (e) => {
|
|
1825
2322
|
e.preventDefault();
|
|
1826
2323
|
const f = brokerForm.elements;
|
|
@@ -1831,6 +2328,7 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
1831
2328
|
accountId: f.accountId.value,
|
|
1832
2329
|
token: f.token.value,
|
|
1833
2330
|
mode: f.mode.value,
|
|
2331
|
+
legacyMode: f.legacyMode?.value || 'isolate',
|
|
1834
2332
|
baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
|
|
1835
2333
|
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
1836
2334
|
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
@@ -1857,25 +2355,8 @@ $('#brokers-list').addEventListener('click', async (e) => {
|
|
|
1857
2355
|
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
1858
2356
|
if (!acc) return;
|
|
1859
2357
|
if (b.dataset.bact === 'edit') {
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
f.id.value = acc.id;
|
|
1863
|
-
f.name.value = acc.name;
|
|
1864
|
-
f.broker.value = acc.broker;
|
|
1865
|
-
f.accountId.value = acc.accountId || '';
|
|
1866
|
-
f.token.value = '';
|
|
1867
|
-
f.mode.value = acc.mode || 'confirm';
|
|
1868
|
-
f.baseRunId.value = acc.baseRunId || '';
|
|
1869
|
-
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
1870
|
-
f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
|
|
1871
|
-
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
1872
|
-
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
1873
|
-
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
1874
|
-
f.sandbox.checked = !!acc.sandbox;
|
|
1875
|
-
$('#broker-add-box').open = true;
|
|
1876
|
-
$('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
|
|
1877
|
-
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
1878
|
-
$('#broker-cancel-btn').hidden = false;
|
|
2358
|
+
startBrokerEdit(acc);
|
|
2359
|
+
$('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
1879
2360
|
return;
|
|
1880
2361
|
}
|
|
1881
2362
|
if (b.dataset.bact === 'del') {
|
|
@@ -1901,10 +2382,34 @@ $('#brokers-list').addEventListener('click', async (e) => {
|
|
|
1901
2382
|
});
|
|
1902
2383
|
|
|
1903
2384
|
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
2385
|
+
const tabBtn = e.target.closest('button[data-btab]');
|
|
2386
|
+
if (tabBtn) {
|
|
2387
|
+
const id = tabBtn.dataset.bid;
|
|
2388
|
+
const t = tabBtn.dataset.btab;
|
|
2389
|
+
brokerTabById.set(id, t);
|
|
2390
|
+
renderBrokers();
|
|
2391
|
+
if (t === 'history') {
|
|
2392
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2393
|
+
if (acc && !acc.lastHistory) {
|
|
2394
|
+
post(`/api/brokers/${encodeURIComponent(id)}/history`).then((st) => { state.brokers = st; renderBrokers(); }).catch((err) => toast(err.message, 'error'));
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
1904
2399
|
const b = e.target.closest('button[data-btrade]');
|
|
1905
2400
|
if (!b) return;
|
|
1906
2401
|
const id = b.dataset.bid;
|
|
1907
2402
|
const act = b.dataset.btrade;
|
|
2403
|
+
if (act === 'cfg') {
|
|
2404
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2405
|
+
switchTab('settings');
|
|
2406
|
+
showSettingsSec('brokers');
|
|
2407
|
+
if (acc) {
|
|
2408
|
+
startBrokerEdit(acc);
|
|
2409
|
+
setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
|
2410
|
+
}
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
1908
2413
|
b.disabled = true;
|
|
1909
2414
|
const old = b.textContent;
|
|
1910
2415
|
try {
|
|
@@ -1912,8 +2417,18 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
1912
2417
|
b.textContent = 'Обновляем…';
|
|
1913
2418
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
1914
2419
|
toast('Портфель обновлён');
|
|
2420
|
+
} else if (act === 'hist') {
|
|
2421
|
+
b.textContent = 'Загружаем…';
|
|
2422
|
+
brokerTabById.set(id, 'history');
|
|
2423
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
|
|
2424
|
+
toast('История операций загружена');
|
|
2425
|
+
} else if (act === 'freeze') {
|
|
2426
|
+
b.textContent = 'Фиксируем…';
|
|
2427
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
|
|
2428
|
+
toast('Текущие бумаги счёта зафиксированы как неприкосновенный старый портфель');
|
|
1915
2429
|
} else if (act === 'prep') {
|
|
1916
2430
|
b.textContent = 'Считаем лоты…';
|
|
2431
|
+
brokerTabById.set(id, 'trade');
|
|
1917
2432
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
|
|
1918
2433
|
toast('Пакет заявок по прогнозу рассчитан');
|
|
1919
2434
|
} else if (act === 'exec') {
|
|
@@ -2004,6 +2519,8 @@ function connectWs() {
|
|
|
2004
2519
|
case 'journal:update': loadJournal().catch(() => {}); break;
|
|
2005
2520
|
case 'research:start':
|
|
2006
2521
|
state.active = m.runId;
|
|
2522
|
+
resetLiveTrain(m.runId);
|
|
2523
|
+
delete liveTrainState.firstTestLeft;
|
|
2007
2524
|
researchUi(true);
|
|
2008
2525
|
setProgress(1, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}: начинаем…`);
|
|
2009
2526
|
liveLine(`r-${m.runId}`, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}${m.note ? ` — ${esc(m.note)}` : ''}: <span class="pg">начинаем…</span><div class="bar-s"><div></div></div>`);
|
|
@@ -2015,7 +2532,19 @@ function connectWs() {
|
|
|
2015
2532
|
$('.pg', el).textContent = m.text;
|
|
2016
2533
|
$('.bar-s div', el).style.width = `${pct}%`;
|
|
2017
2534
|
if (!state.active) { state.active = m.runId; researchUi(true); }
|
|
2018
|
-
if (m.runId === state.active)
|
|
2535
|
+
if (m.runId === state.active) {
|
|
2536
|
+
setProgress(pct, m.text);
|
|
2537
|
+
if (liveTrainState.runId !== m.runId) { resetLiveTrain(m.runId); delete liveTrainState.firstTestLeft; }
|
|
2538
|
+
liveTrainState.stage = m.stage;
|
|
2539
|
+
liveTrainState.text = m.text || '';
|
|
2540
|
+
if (m.symbol && !liveTrainState.loadedSymbols.includes(m.symbol)) liveTrainState.loadedSymbols.push(m.symbol);
|
|
2541
|
+
if (m.viz) {
|
|
2542
|
+
if (m.viz.symbols) liveTrainState.symbols = m.viz.symbols;
|
|
2543
|
+
if (m.viz.foldIc !== undefined && m.viz.fold) liveTrainState.foldIcs[m.viz.fold - 1] = m.viz.foldIc;
|
|
2544
|
+
liveTrainState.lastViz = m.viz;
|
|
2545
|
+
}
|
|
2546
|
+
if (state.run?.id === m.runId && state.run?.status !== 'done') renderLiveTraining();
|
|
2547
|
+
}
|
|
2019
2548
|
break;
|
|
2020
2549
|
}
|
|
2021
2550
|
case 'research:done':
|
|
@@ -2061,7 +2590,6 @@ function connectWs() {
|
|
|
2061
2590
|
case 'stability:progress':
|
|
2062
2591
|
stabilityUi(m.runId);
|
|
2063
2592
|
setProgress(m.pct, m.text);
|
|
2064
|
-
$('#btn-stability').textContent = `${m.pct}% · проверяем…`;
|
|
2065
2593
|
break;
|
|
2066
2594
|
case 'stability:done':
|
|
2067
2595
|
stabilityUi(null);
|
|
@@ -2096,11 +2624,19 @@ async function renderCloudPane() {
|
|
|
2096
2624
|
const me = await api('/api/auth/me').catch(() => state.config || {});
|
|
2097
2625
|
state.config = { ...(state.config || {}), ...me };
|
|
2098
2626
|
const u = me.user;
|
|
2627
|
+
const lic = u?.localLicense;
|
|
2628
|
+
const isDefaultLocal = !me.cloudMode && u?.id === 1 && u?.email === 'owner@neuroalpha.local';
|
|
2629
|
+
const dispName = (lic?.name && lic.name !== 'Владелец платформы' && lic.name !== 'Пользователь')
|
|
2630
|
+
? lic.name
|
|
2631
|
+
: (isDefaultLocal ? 'Пользователь' : (u?.name || 'Пользователь'));
|
|
2632
|
+
const dispEmail = (lic?.email && lic.email !== 'owner@neuroalpha.local')
|
|
2633
|
+
? lic.email
|
|
2634
|
+
: (isDefaultLocal ? '' : (u?.email || ''));
|
|
2099
2635
|
const userBtn = $('#btn-user');
|
|
2100
|
-
if (userBtn) userBtn.textContent = u ? `${
|
|
2636
|
+
if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Войти';
|
|
2101
2637
|
|
|
2102
2638
|
if (!u) {
|
|
2103
|
-
box.innerHTML = `<div class="srow"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><a class="btn primary" href="/landing">Войти / Регистрация</a></div>`;
|
|
2639
|
+
box.innerHTML = `<div class="srow sblock"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><div class="row-gap"><a class="btn primary" href="/landing">Войти / Регистрация</a></div></div>`;
|
|
2104
2640
|
return;
|
|
2105
2641
|
}
|
|
2106
2642
|
|
|
@@ -2109,87 +2645,72 @@ async function renderCloudPane() {
|
|
|
2109
2645
|
const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2110
2646
|
const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2111
2647
|
|
|
2112
|
-
let
|
|
2648
|
+
let customBotsHtml = '';
|
|
2649
|
+
let adminLinkHtml = '';
|
|
2113
2650
|
if (u.role === 'admin') {
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
<
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
<div class="muted small"><b>Боты уведомлений и подтверждения заявок (Telegram и MAX)</b></div>
|
|
2651
|
+
if (me.cloudMode) {
|
|
2652
|
+
adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
|
|
2653
|
+
<div class="stxt">
|
|
2654
|
+
<div class="slbl">Консоль администратора платформы</div>
|
|
2655
|
+
<div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, OAuth-провайдерами (Яндекс, Сбер, Т-Банк, VK) и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
|
|
2656
|
+
</div>
|
|
2657
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2658
|
+
<a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
|
|
2659
|
+
</div>
|
|
2660
|
+
</div>`;
|
|
2661
|
+
} else {
|
|
2662
|
+
customBotsHtml = `<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
|
|
2663
|
+
<summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX (необязательно — для автономного локального сервера) и консоль админа</summary>
|
|
2664
|
+
<form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
|
|
2665
|
+
<div class="muted small">Если вы запускаете сервер локально и хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
|
|
2130
2666
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2131
|
-
<input name="tgBotName" value="${esc(
|
|
2132
|
-
<input name="tgToken" type="
|
|
2667
|
+
<input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
|
|
2668
|
+
<input name="tgToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="${nt.hasTgToken ? 'Токен Telegram задан (оставьте пустым)' : 'Токен Telegram Bot API'}" style="flex:1;min-width:220px">
|
|
2133
2669
|
</div>
|
|
2134
2670
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2135
|
-
<input name="maxBotName" value="${esc(
|
|
2136
|
-
<input name="maxToken" type="
|
|
2671
|
+
<input name="maxBotName" value="${esc(nt.maxBotName || '')}" placeholder="Имя бота MAX" style="width:200px">
|
|
2672
|
+
<input name="maxToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="${nt.hasMaxToken ? 'Токен MAX задан (оставьте пустым)' : 'Токен MAX Bot API'}" style="flex:1;min-width:220px">
|
|
2137
2673
|
<button type="submit" class="primary">Сохранить ботов</button>
|
|
2138
2674
|
</div>
|
|
2675
|
+
<div class="muted small" style="margin-top:4px">Администрирование SaaS-портала (ЮKassa, пользователи, промокоды, OAuth) доступно в отдельном приложении: <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
|
|
2139
2676
|
</form>
|
|
2140
|
-
|
|
2141
|
-
<div class="muted small"><b>Промокоды (выдача пробного доступа)</b></div>
|
|
2142
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
2143
|
-
<input name="code" placeholder="Код (напр. ALPHA14)" style="width:150px" required>
|
|
2144
|
-
<select name="plan" style="width:150px"><option value="investor">Инвестор</option><option value="pro">Профи</option></select>
|
|
2145
|
-
<input name="days" type="number" value="14" min="1" max="365" style="width:90px" title="На сколько дней">
|
|
2146
|
-
<input name="maxUses" type="number" value="50" min="1" max="10000" style="width:90px" title="Макс. активаций">
|
|
2147
|
-
<button type="submit">Создать промокод</button>
|
|
2148
|
-
</div>
|
|
2149
|
-
${adm.promos?.length ? `<div class="muted small">Активные коды: ${adm.promos.map((p) => `<code>${esc(p.code)}</code> (${esc(p.plan)}, ${p.days} дн., исп. ${p.used_count}/${p.max_uses})`).join(' · ')}</div>` : ''}
|
|
2150
|
-
</form>
|
|
2151
|
-
<div class="muted small" style="margin-bottom:6px"><b>Пользователи платформы (${adm.users?.length || 0})</b></div>
|
|
2152
|
-
<table><thead><tr><th>ID</th><th>Имя / Email</th><th>Тариф</th><th>Мессенджеры</th><th>Сменить тариф</th></tr></thead><tbody>
|
|
2153
|
-
${(adm.users || []).map((usr) => `<tr>
|
|
2154
|
-
<td>${usr.id}</td>
|
|
2155
|
-
<td><b>${esc(usr.name)}</b> <span class="muted small">${esc(usr.email)}</span></td>
|
|
2156
|
-
<td><span class="tag ok">${esc(usr.planName)}</span></td>
|
|
2157
|
-
<td class="small">${usr.tgConnected ? '<span class="pos">TG ✓</span> ' : ''}${usr.maxConnected ? '<span class="pos">MAX ✓</span>' : (!usr.tgConnected ? '—' : '')}</td>
|
|
2158
|
-
<td>
|
|
2159
|
-
<select data-adm-plan="${usr.id}" class="sctrl" style="padding:2px 6px">
|
|
2160
|
-
<option value="free"${usr.plan === 'free' ? ' selected' : ''}>Старт (Free)</option>
|
|
2161
|
-
<option value="investor"${usr.plan === 'investor' ? ' selected' : ''}>Инвестор</option>
|
|
2162
|
-
<option value="pro"${usr.plan === 'pro' ? ' selected' : ''}>Профи</option>
|
|
2163
|
-
</select>
|
|
2164
|
-
</td>
|
|
2165
|
-
</tr>`).join('')}
|
|
2166
|
-
</tbody></table>
|
|
2167
|
-
</div>`;
|
|
2677
|
+
</details>`;
|
|
2168
2678
|
}
|
|
2169
2679
|
}
|
|
2170
2680
|
|
|
2171
2681
|
const apiKey = u.apiKey || '';
|
|
2172
2682
|
const npxCmd = apiKey ? `npx neuroalpha --key ${apiKey}` : 'npx neuroalpha';
|
|
2173
|
-
const lic = u.localLicense;
|
|
2174
2683
|
|
|
2175
2684
|
box.innerHTML = `
|
|
2176
|
-
<div class="srow">
|
|
2685
|
+
<div class="srow sblock">
|
|
2177
2686
|
<div class="stxt">
|
|
2178
|
-
<div class="slbl">${esc(
|
|
2687
|
+
<div class="slbl">${esc(dispName)}${dispEmail ? ` · <span class="muted">${esc(dispEmail)}</span>` : ''} <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
|
|
2179
2688
|
<div class="hint">Лимиты тарифа: до ${u.limits?.maxRunsPerDay ?? 2} экспериментов в сутки · виртуальных счетов: до ${u.limits?.maxVirtualAccounts ?? 1} · брокерских счетов: до ${u.limits?.maxBrokerAccounts ?? 0}${u.planUntil ? ` · оплачен до ${fmtDate(u.planUntil)}` : ''}.</div>
|
|
2180
2689
|
</div>
|
|
2181
|
-
|
|
2690
|
+
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
2182
2691
|
<button type="button" id="btn-cloud-verify">Проверить оплату</button>
|
|
2183
2692
|
<button type="button" id="btn-cloud-logout">Выйти</button>
|
|
2184
|
-
</div
|
|
2693
|
+
</div>` : ''}
|
|
2185
2694
|
</div>
|
|
2186
|
-
|
|
2695
|
+
${!me.cloudMode ? `<div class="srow sblock">
|
|
2187
2696
|
<div class="stxt">
|
|
2188
|
-
<div class="slbl"
|
|
2697
|
+
<div class="slbl">Лицензионный ключ подписки с <a href="https://neuroalpha.odant.org" target="_blank" rel="noopener" style="color:var(--accent)">neuroalpha.odant.org</a></div>
|
|
2698
|
+
<div class="hint">
|
|
2699
|
+
Вставьте персональный ключ <code>na_live_…</code> из личного кабинета на сайте для активации вашего тарифа и облачного ИИ на этом компьютере.
|
|
2700
|
+
${lic ? `<br><span class="pos">✓ Активирована лицензия: <b>${esc(lic.planName)}</b>${dispEmail ? ` (${esc(dispEmail)})` : ''}</span>` : ''}
|
|
2701
|
+
</div>
|
|
2702
|
+
</div>
|
|
2703
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2704
|
+
<input id="lic-key-in" value="${esc(lic?.apiKey || '')}" placeholder="na_live_…" style="width:280px">
|
|
2705
|
+
<button type="button" id="btn-lic-activate" class="primary">Активировать ключ</button>
|
|
2706
|
+
<a class="btn" href="https://neuroalpha.odant.org/#pricing" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Получить ключ →</a>
|
|
2707
|
+
</div>
|
|
2708
|
+
</div>` : `<div class="srow sblock">
|
|
2709
|
+
<div class="stxt">
|
|
2710
|
+
<div class="slbl">Ваш персональный API-ключ (для запуска на вашем ПК через <code>npx</code>)</div>
|
|
2189
2711
|
<div class="hint">
|
|
2190
2712
|
Ключ: <b><code id="user-api-key">${esc(apiKey || 'не создан')}</code></b><br>
|
|
2191
2713
|
Команда для запуска на вашем ПК (ключи брокера остаются локально): <code>${esc(npxCmd)}</code>
|
|
2192
|
-
${lic ? `<br><span class="pos">✓ Активирована лицензия: ${esc(lic.planName)} (${esc(lic.email || '')})</span>` : ''}
|
|
2193
2714
|
</div>
|
|
2194
2715
|
</div>
|
|
2195
2716
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -2197,41 +2718,22 @@ async function renderCloudPane() {
|
|
|
2197
2718
|
<button type="button" id="btn-copy-npx">Скопировать npx-команду</button>
|
|
2198
2719
|
<button type="button" id="btn-regen-apikey" title="Перевыпустить API-ключ (старый перестанет работать)">Перевыпустить</button>
|
|
2199
2720
|
</div>
|
|
2200
|
-
</div
|
|
2201
|
-
|
|
2721
|
+
</div>`}
|
|
2722
|
+
<div class="srow sblock">
|
|
2202
2723
|
<div class="stxt">
|
|
2203
|
-
<div class="slbl"
|
|
2204
|
-
<div class="hint"
|
|
2724
|
+
<div class="slbl">Тариф и промокод</div>
|
|
2725
|
+
<div class="hint">Подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram / MAX и расширенных лимитов ИИ.</div>
|
|
2205
2726
|
</div>
|
|
2206
2727
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2207
|
-
<input id="
|
|
2208
|
-
<
|
|
2209
|
-
<button type="button"
|
|
2210
|
-
|
|
2211
|
-
</div>` : ''}
|
|
2212
|
-
<div class="srow">
|
|
2213
|
-
<div class="stxt">
|
|
2214
|
-
<div class="slbl">Тариф и подписка (карты МИР и СБП)</div>
|
|
2215
|
-
<div class="hint">Мгновенное подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram и MAX и расширенных лимитов.</div>
|
|
2216
|
-
</div>
|
|
2217
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
2218
|
-
<button type="button" class="primary" data-checkout="investor">Оформить «Инвестор» · 3 900 ₽/мес</button>
|
|
2219
|
-
<button type="button" class="primary" data-checkout="pro">Оформить «Профи» · 9 900 ₽/мес</button>
|
|
2728
|
+
<input id="promo-code-in" placeholder="Промокод" style="width:150px">
|
|
2729
|
+
<button type="button" id="btn-promo-redeem">Применить код</button>
|
|
2730
|
+
<button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
|
|
2731
|
+
<button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
|
|
2220
2732
|
</div>
|
|
2221
2733
|
</div>
|
|
2222
|
-
<div class="srow">
|
|
2734
|
+
<div class="srow sblock">
|
|
2223
2735
|
<div class="stxt">
|
|
2224
|
-
<div class="slbl"
|
|
2225
|
-
<div class="hint">Введите промокод партнёра или пробного периода для подключения тарифа «Инвестор» или «Профи».</div>
|
|
2226
|
-
</div>
|
|
2227
|
-
<div class="row-gap">
|
|
2228
|
-
<input id="promo-code-in" placeholder="Промокод" style="width:140px">
|
|
2229
|
-
<button type="button" id="btn-promo-redeem">Активировать</button>
|
|
2230
|
-
</div>
|
|
2231
|
-
</div>
|
|
2232
|
-
<div class="srow">
|
|
2233
|
-
<div class="stxt">
|
|
2234
|
-
<div class="slbl">Боты Telegram и MAX (уведомления и исполнение заявок в 1 клик)</div>
|
|
2736
|
+
<div class="slbl">Уведомления в Telegram и MAX (подтверждение заявок в 1 клик)</div>
|
|
2235
2737
|
<div class="hint">
|
|
2236
2738
|
Ваш код привязки: <b><code>${esc(code)}</code></b> — отправьте этот код боту в Telegram или в мессенджер MAX.<br>
|
|
2237
2739
|
Статус: Telegram — ${u.tgConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'} · MAX — ${u.maxConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'}
|
|
@@ -2243,7 +2745,8 @@ async function renderCloudPane() {
|
|
|
2243
2745
|
<button type="button" id="btn-notify-test">Тест уведомления</button>
|
|
2244
2746
|
</div>
|
|
2245
2747
|
</div>
|
|
2246
|
-
${
|
|
2748
|
+
${adminLinkHtml}
|
|
2749
|
+
${customBotsHtml}
|
|
2247
2750
|
`;
|
|
2248
2751
|
|
|
2249
2752
|
box.querySelector('#btn-copy-apikey')?.addEventListener('click', () => {
|
|
@@ -2265,10 +2768,9 @@ async function renderCloudPane() {
|
|
|
2265
2768
|
});
|
|
2266
2769
|
box.querySelector('#btn-lic-activate')?.addEventListener('click', async () => {
|
|
2267
2770
|
const k = box.querySelector('#lic-key-in')?.value.trim();
|
|
2268
|
-
const uUrl = box.querySelector('#lic-url-in')?.value.trim();
|
|
2269
2771
|
if (!k) return toast('Введите API-ключ (na_live_…)', 'error');
|
|
2270
2772
|
try {
|
|
2271
|
-
const r = await post('/api/license/activate', { apiKey: k
|
|
2773
|
+
const r = await post('/api/license/activate', { apiKey: k });
|
|
2272
2774
|
toast(`Ключ активирован! Тариф: ${r.license?.planName}`);
|
|
2273
2775
|
renderCloudPane();
|
|
2274
2776
|
} catch (e) { toast(e.message, 'error'); }
|
|
@@ -2306,24 +2808,6 @@ async function renderCloudPane() {
|
|
|
2306
2808
|
renderCloudPane();
|
|
2307
2809
|
} catch (e) { toast(e.message, 'error'); }
|
|
2308
2810
|
});
|
|
2309
|
-
box.querySelector('#adm-promo-form')?.addEventListener('submit', async (e) => {
|
|
2310
|
-
e.preventDefault();
|
|
2311
|
-
const f = e.target.elements;
|
|
2312
|
-
try {
|
|
2313
|
-
await post('/api/admin/promo', { code: f.code.value, plan: f.plan.value, days: f.days.value, maxUses: f.maxUses.value });
|
|
2314
|
-
toast('Промокод создан');
|
|
2315
|
-
renderCloudPane();
|
|
2316
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
2317
|
-
});
|
|
2318
|
-
box.querySelector('#adm-billing-form')?.addEventListener('submit', async (e) => {
|
|
2319
|
-
e.preventDefault();
|
|
2320
|
-
const f = e.target.elements;
|
|
2321
|
-
try {
|
|
2322
|
-
await post('/api/admin/billing', { shopId: f.shopId.value, secretKey: f.secretKey.value, sendReceipt: f.sendReceipt.checked });
|
|
2323
|
-
toast('Настройки ЮKassa сохранены');
|
|
2324
|
-
renderCloudPane();
|
|
2325
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
2326
|
-
});
|
|
2327
2811
|
box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
|
|
2328
2812
|
e.preventDefault();
|
|
2329
2813
|
const f = e.target.elements;
|
|
@@ -2333,13 +2817,6 @@ async function renderCloudPane() {
|
|
|
2333
2817
|
renderCloudPane();
|
|
2334
2818
|
} catch (err) { toast(err.message, 'error'); }
|
|
2335
2819
|
});
|
|
2336
|
-
box.querySelectorAll('[data-adm-plan]').forEach((sel) => sel.addEventListener('change', async () => {
|
|
2337
|
-
try {
|
|
2338
|
-
await post('/api/admin/user-plan', { userId: Number(sel.dataset.admPlan), plan: sel.value, days: 30 });
|
|
2339
|
-
toast('Тариф пользователя обновлён');
|
|
2340
|
-
renderCloudPane();
|
|
2341
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
2342
|
-
}));
|
|
2343
2820
|
}
|
|
2344
2821
|
|
|
2345
2822
|
// ---------- тема (тёмная / светлая) ----------
|
|
@@ -2366,15 +2843,28 @@ $('#btn-theme')?.addEventListener('click', () => {
|
|
|
2366
2843
|
if (localStorage.getItem('theme') === 'light') applyTheme(true);
|
|
2367
2844
|
|
|
2368
2845
|
// ---------- справка ----------
|
|
2369
|
-
|
|
2370
|
-
|
|
2846
|
+
const helpCache = new Map();
|
|
2847
|
+
async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
|
|
2371
2848
|
$('#help').hidden = false;
|
|
2372
|
-
|
|
2849
|
+
document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
|
|
2850
|
+
if (helpCache.has(docName)) {
|
|
2851
|
+
$('#help-body').innerHTML = helpCache.get(docName);
|
|
2852
|
+
$('#help-body').scrollTop = 0;
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
$('#help-body').textContent = 'Загрузка…';
|
|
2373
2856
|
try {
|
|
2374
|
-
const res = await fetch(`/docs/${encodeURIComponent(
|
|
2375
|
-
|
|
2376
|
-
|
|
2857
|
+
const res = await fetch(`/docs/${encodeURIComponent(docName)}`);
|
|
2858
|
+
const html = md(await res.text());
|
|
2859
|
+
helpCache.set(docName, html);
|
|
2860
|
+
$('#help-body').innerHTML = html;
|
|
2861
|
+
$('#help-body').scrollTop = 0;
|
|
2377
2862
|
} catch (e) { $('#help-body').textContent = e.message; }
|
|
2863
|
+
}
|
|
2864
|
+
$('#btn-help').addEventListener('click', () => openHelpDoc('ИНСТРУКЦИЯ.md'));
|
|
2865
|
+
$('#help-tabs')?.addEventListener('click', (e) => {
|
|
2866
|
+
const b = e.target.closest('button[data-hdoc]');
|
|
2867
|
+
if (b) openHelpDoc(b.dataset.hdoc);
|
|
2378
2868
|
});
|
|
2379
2869
|
$('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
|
|
2380
2870
|
$('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
|