neuroalpha 0.3.12 → 0.3.14
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 +13 -17
- package/README.md +4 -2
- package/bin/cli.js +1 -1
- package/deploy/install.sh +1 -1
- 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 +1 -1
- package/package.json +2 -2
- package/public/admin.html +43 -20
- package/public/app.js +780 -226
- package/public/index.html +43 -3
- package/public/landing.html +328 -115
- package/public/legal.html +37 -16
- package/public/styles.css +24 -3
- package/server/api/routes.js +62 -30
- package/server/auth/index.js +109 -20
- package/server/billing/yookassa.js +1 -1
- package/server/brokers/index.js +256 -53
- package/server/config.js +1 -1
- package/server/data/moex.js +1 -1
- package/server/db.js +17 -3
- package/server/hub.js +1 -1
- package/server/index.js +4 -4
- package/server/llm/client.js +1 -1
- package/server/llm/providers.js +1 -1
- package/server/model/dataset.js +1 -1
- package/server/model/forecast.js +354 -25
- package/server/model/linalg.js +1 -1
- package/server/model/live.js +36 -25
- package/server/model/metrics.js +1 -1
- package/server/model/models.js +1 -1
- package/server/model/profiles.js +1 -1
- package/server/model/research.js +14 -38
- package/server/model/vocab.js +1 -1
- package/server/news/index.js +1 -1
- package/server/notify/index.js +1 -1
- package/server/params-meta.js +4 -3
- package/server/settings.js +1 -1
- package/server/strategist/analytics.js +2 -2
- package/server/strategist/index.js +2 -2
- package/server/strategist/tools.js +1 -1
- package/server/util.js +1 -1
package/public/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
const $ = (s, root = document) => root.querySelector(s);
|
|
3
3
|
const MSK = 3 * 3600000;
|
|
4
4
|
|
|
@@ -13,7 +13,34 @@ const post = (url, body = {}) => api(url, { method: 'POST', body });
|
|
|
13
13
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
14
14
|
const fmtNum = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v) ? '—' : Number(v).toLocaleString('ru-RU', { maximumFractionDigits: d }));
|
|
15
15
|
const fmtPct = (v, d = 1) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${fmtNum(v, d)}%`);
|
|
16
|
-
const fmtTs = (ts) => {
|
|
16
|
+
const fmtTs = (ts) => {
|
|
17
|
+
if (!ts) return '—';
|
|
18
|
+
const d = new Date(ts + MSK).toISOString();
|
|
19
|
+
const y = d.slice(0, 4);
|
|
20
|
+
const curY = new Date(Date.now() + MSK).toISOString().slice(0, 4);
|
|
21
|
+
return `${d.slice(8, 10)}.${d.slice(5, 7)}${y !== curY ? `.${y}` : ''} ${d.slice(11, 16)}`;
|
|
22
|
+
};
|
|
23
|
+
const fmtTimeOnly = (ts) => { if (!ts) return '—'; return new Date(ts + MSK).toISOString().slice(11, 16); };
|
|
24
|
+
const MONTHS_GEN = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
|
25
|
+
const DOW_SHORT = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
|
26
|
+
function fmtDayGroup(ts) {
|
|
27
|
+
if (!ts) return 'Без даты';
|
|
28
|
+
const dt = new Date(ts + MSK);
|
|
29
|
+
const iso = dt.toISOString();
|
|
30
|
+
const dayKey = iso.slice(0, 10);
|
|
31
|
+
const nowIso = new Date(Date.now() + MSK).toISOString();
|
|
32
|
+
const todayKey = nowIso.slice(0, 10);
|
|
33
|
+
const yestKey = new Date(Date.now() + MSK - 86400000).toISOString().slice(0, 10);
|
|
34
|
+
const dNum = Number(iso.slice(8, 10));
|
|
35
|
+
const mName = MONTHS_GEN[Number(iso.slice(5, 7)) - 1] || '';
|
|
36
|
+
const y = iso.slice(0, 4);
|
|
37
|
+
const curY = nowIso.slice(0, 4);
|
|
38
|
+
const dow = DOW_SHORT[dt.getUTCDay()] || '';
|
|
39
|
+
const base = `${dNum} ${mName}${y !== curY ? ` ${y} г.` : ''}`;
|
|
40
|
+
if (dayKey === todayKey) return `Сегодня, ${base}`;
|
|
41
|
+
if (dayKey === yestKey) return `Вчера, ${base}`;
|
|
42
|
+
return `${base} (${dow})`;
|
|
43
|
+
}
|
|
17
44
|
const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
|
|
18
45
|
const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
|
|
19
46
|
const md = (s) => {
|
|
@@ -51,12 +78,47 @@ const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '
|
|
|
51
78
|
const labelOf = (k) => state.config?.params.find((p) => p.key === k)?.label || k;
|
|
52
79
|
|
|
53
80
|
// ======================================================================
|
|
54
|
-
// СПЛИТТЕР
|
|
81
|
+
// СПЛИТТЕР И ПЕРЕКЛЮЧАТЕЛЬ ЧАТА СО СТРАТЕГОМ + СОХРАНЕНИЕ НАВИГАЦИИ
|
|
55
82
|
// ======================================================================
|
|
83
|
+
const NAV_STORAGE_KEY = 'na_ui_nav_v1';
|
|
84
|
+
function loadSavedNav() {
|
|
85
|
+
try { return JSON.parse(localStorage.getItem(NAV_STORAGE_KEY) || '{}') || {}; } catch { return {}; }
|
|
86
|
+
}
|
|
87
|
+
function saveNavPatch(patch = {}) {
|
|
88
|
+
try {
|
|
89
|
+
const cur = loadSavedNav();
|
|
90
|
+
localStorage.setItem(NAV_STORAGE_KEY, JSON.stringify({ ...cur, ...patch }));
|
|
91
|
+
} catch { /* ignore */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setChatClosed(closed) {
|
|
95
|
+
document.body.classList.toggle('chat-closed', Boolean(closed));
|
|
96
|
+
const btn = $('#btn-chat-toggle');
|
|
97
|
+
if (btn) {
|
|
98
|
+
btn.classList.toggle('active', !closed);
|
|
99
|
+
btn.title = closed ? 'Открыть панель чата с ИИ-Стратегом' : 'Свернуть панель чата с ИИ-Стратегом';
|
|
100
|
+
}
|
|
101
|
+
saveNavPatch({ chatClosed: Boolean(closed) });
|
|
102
|
+
requestAnimationFrame(() => {
|
|
103
|
+
try {
|
|
104
|
+
eqChart.timeScale().fitContent();
|
|
105
|
+
icChart.timeScale().fitContent();
|
|
106
|
+
if (!$('#tab-map')?.hidden) drawMap();
|
|
107
|
+
} catch { /* ignore */ }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
$('#btn-chat-toggle')?.addEventListener('click', () => {
|
|
112
|
+
setChatClosed(!document.body.classList.contains('chat-closed'));
|
|
113
|
+
});
|
|
114
|
+
$('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
|
|
115
|
+
|
|
56
116
|
(function splitter() {
|
|
57
117
|
const sp = $('#splitter');
|
|
58
118
|
const saved = localStorage.getItem('chatWidth');
|
|
59
119
|
if (saved) document.documentElement.style.setProperty('--chat-w', saved);
|
|
120
|
+
const initNav = loadSavedNav();
|
|
121
|
+
setChatClosed(Boolean(initNav.chatClosed));
|
|
60
122
|
let dragging = false;
|
|
61
123
|
sp.addEventListener('mousedown', (e) => { dragging = true; sp.classList.add('drag'); document.body.classList.add('dragging'); e.preventDefault(); });
|
|
62
124
|
window.addEventListener('mousemove', (e) => {
|
|
@@ -138,6 +200,13 @@ function switchTab(t) {
|
|
|
138
200
|
if (t === 'journal') t = 'map';
|
|
139
201
|
$('#tabs').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.tab === t));
|
|
140
202
|
document.querySelectorAll('.right > .tab').forEach((el) => { el.hidden = el.id !== `tab-${t}`; });
|
|
203
|
+
saveNavPatch({ tab: t });
|
|
204
|
+
if (t === 'overview') {
|
|
205
|
+
requestAnimationFrame(() => {
|
|
206
|
+
eqChart.timeScale().fitContent();
|
|
207
|
+
icChart.timeScale().fitContent();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
141
210
|
if (t === 'settings') loadHistory().catch(() => {});
|
|
142
211
|
if (t === 'now') { refreshForecastIfStale(); loadNews(); }
|
|
143
212
|
if (t === 'map') {
|
|
@@ -158,7 +227,11 @@ $('#tabs').addEventListener('click', (e) => {
|
|
|
158
227
|
const opts = () => ({
|
|
159
228
|
layout: { background: { color: 'transparent' }, textColor: '#8b949e' },
|
|
160
229
|
grid: { vertLines: { visible: false }, horzLines: { color: '#1b2029' } },
|
|
161
|
-
timeScale: { borderVisible: false
|
|
230
|
+
timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
|
|
231
|
+
rightPriceScale: { borderVisible: false },
|
|
232
|
+
crosshair: { mode: 0 },
|
|
233
|
+
autoSize: true,
|
|
234
|
+
attributionLogo: false,
|
|
162
235
|
});
|
|
163
236
|
const eqChart = LightweightCharts.createChart($('#eq-chart'), opts());
|
|
164
237
|
const eqLines = {
|
|
@@ -169,6 +242,13 @@ const eqLines = {
|
|
|
169
242
|
};
|
|
170
243
|
const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
|
|
171
244
|
const icSeries = icChart.addHistogramSeries({ priceFormat: { type: 'price', precision: 3, minMove: 0.001 }, priceLineVisible: false, lastValueVisible: false });
|
|
245
|
+
if (window.ResizeObserver) {
|
|
246
|
+
const ro = new ResizeObserver(() => {
|
|
247
|
+
eqChart.timeScale().fitContent();
|
|
248
|
+
icChart.timeScale().fitContent();
|
|
249
|
+
});
|
|
250
|
+
if ($('#eq-chart')) ro.observe($('#eq-chart'));
|
|
251
|
+
}
|
|
172
252
|
|
|
173
253
|
const RUN_PARTS = [
|
|
174
254
|
['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : 'Авто (мульти-горизонт 1,5–3,5ч)')],
|
|
@@ -177,7 +257,7 @@ const RUN_PARTS = [
|
|
|
177
257
|
['modelExitRatio', (v, p) => (p.modelAutoAdapt === false ? `Выход: ${fmtNum(v, 2)}` : null)],
|
|
178
258
|
['modelTopK', (v) => `Акции: ${v}`],
|
|
179
259
|
['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : 'Доли поровну')],
|
|
180
|
-
['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' :
|
|
260
|
+
['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : 'Только в рост (без шорта)')],
|
|
181
261
|
['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
|
|
182
262
|
['modelTrainDays', (v) => `Окно: ${v}дн`],
|
|
183
263
|
['modelHistoryDays', (v) => `История: ${v}дн`],
|
|
@@ -246,6 +326,7 @@ async function loadRuns(selectId) {
|
|
|
246
326
|
async function openRun(id) {
|
|
247
327
|
state.run = await api(`/api/runs/${id}`);
|
|
248
328
|
$('#run-select').value = String(id);
|
|
329
|
+
saveNavPatch({ runId: Number(id) });
|
|
249
330
|
syncUiAvailability();
|
|
250
331
|
renderOverview();
|
|
251
332
|
setupMapSlider();
|
|
@@ -359,8 +440,8 @@ function syncSettingsDependencies() {
|
|
|
359
440
|
setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
|
|
360
441
|
setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
361
442
|
setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
362
|
-
setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в
|
|
363
|
-
setRowEnabled('modelMarketFilter',
|
|
443
|
+
setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
|
|
444
|
+
setRowEnabled('modelMarketFilter', true, '');
|
|
364
445
|
|
|
365
446
|
const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
|
|
366
447
|
if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
|
|
@@ -455,27 +536,27 @@ function calcPeriodStats(period, returnPct, marketPct) {
|
|
|
455
536
|
return { calDays, years, months, yrsLabel, dateRange, cagr, avgSimple, mktAnnual };
|
|
456
537
|
}
|
|
457
538
|
|
|
458
|
-
function verdictHtml(c,
|
|
539
|
+
function verdictHtml(c, oosA) {
|
|
459
540
|
const a = c.attention, base = c.inertia;
|
|
460
541
|
if (!a) return '';
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
542
|
+
if (a.trades < 30) return `<span class="tag mid">Мало сделок (${a.trades})</span> Слишком мало сделок для статистически надёжных выводов.`;
|
|
543
|
+
const oosPart = oosA?.returnPct !== undefined
|
|
544
|
+
? ` · Контрольный период (последние 30%): <b class="${cls(oosA.returnPct)}">${fmtPct(oosA.returnPct)}</b> (${oosA.trades ?? 0} сд.)`
|
|
545
|
+
: '';
|
|
464
546
|
let vs = '';
|
|
465
547
|
if (base) {
|
|
466
548
|
const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
|
|
467
|
-
if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs =
|
|
468
|
-
else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs =
|
|
469
|
-
else vs =
|
|
470
|
-
|
|
549
|
+
if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = ` · Карта рынка даёт преимущество над простой инерцией (<b>${fmtPct(a.returnPct)}</b> против <b>${fmtPct(base.returnPct)}</b>)`;
|
|
550
|
+
else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = ` · Карта рынка и инерция дали близкий результат (${fmtPct(a.returnPct)} и ${fmtPct(base.returnPct)})`;
|
|
551
|
+
else vs = ` · Простая инерция опередила карту рынка (${fmtPct(base.returnPct)} против ${fmtPct(a.returnPct)})`;
|
|
552
|
+
}
|
|
553
|
+
if (a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) {
|
|
554
|
+
return `<span class="tag ok">Стратегия прибыльна</span> Превосходит индекс Мосбиржи на <b class="pos">${fmtPct(a.returnPct - a.marketPct)}</b>${vs}${oosPart}`;
|
|
471
555
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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}`;
|
|
478
|
-
return `<b class="neg">Стратегия не заработала даже без комиссий</b> (${fmtPct(a.grossReturnPct)}). ${vs}${unreliable}`;
|
|
556
|
+
if ((a.grossReturnPct ?? 0) > 0) {
|
|
557
|
+
return `<span class="tag mid">Съедено комиссией</span> Без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}${vs}${oosPart}`;
|
|
558
|
+
}
|
|
559
|
+
return `<span class="tag no">Убыток</span> Стратегия не заработала (${fmtPct(a.returnPct)})${vs}${oosPart}`;
|
|
479
560
|
}
|
|
480
561
|
|
|
481
562
|
const MONTHS = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
|
|
@@ -700,17 +781,21 @@ function renderOverview() {
|
|
|
700
781
|
const oosA = ex.comparison?.[chosen]?.outOfSample || run.metrics?.outOfSample || {};
|
|
701
782
|
const pStats = calcPeriodStats(run.metrics?.period, a.returnPct, a.marketPct);
|
|
702
783
|
const dir = run.params?.modelDirection || 'long';
|
|
703
|
-
const
|
|
704
|
-
?
|
|
784
|
+
const quarNote = ex.adaptation?.quarantined?.length
|
|
785
|
+
? ` · <span class="muted small" title="Временно отсеяны убыточные акции">Карантин (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 8).join(', '))}${ex.adaptation.quarantined.length > 8 ? '…' : ''}</span>`
|
|
705
786
|
: '';
|
|
706
|
-
|
|
787
|
+
const hypNote = run.params?._note ? `<div class="muted small" style="margin-top:2px">Гипотеза: ${esc(run.params._note)}</div>` : '';
|
|
788
|
+
$('#verdict').innerHTML = verdictHtml(c, oosA) + quarNote + hypNote;
|
|
707
789
|
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>`;
|
|
790
|
+
const sideSub = (dir !== 'long' && a.shortPct !== undefined)
|
|
791
|
+
? `рост ${fmtPct(a.longPct)} · шорт ${fmtPct(a.shortPct)}`
|
|
792
|
+
: `${pStats.dateRange || 'весь период'} · без ком. ${fmtPct(a.grossReturnPct)}`;
|
|
708
793
|
$('#kpis').innerHTML = [
|
|
709
794
|
kpi(
|
|
710
795
|
`<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`,
|
|
711
796
|
`итог за ${pStats.yrsLabel}`,
|
|
712
|
-
|
|
713
|
-
|
|
797
|
+
sideSub,
|
|
798
|
+
`Общий чистый доход портфеля за ${pStats.dateRange || 'период'} после вычета комиссий (без комиссий: ${fmtPct(a.grossReturnPct)})`,
|
|
714
799
|
),
|
|
715
800
|
kpi(
|
|
716
801
|
`<span class="${cls(pStats.cagr)}">${fmtPct(pStats.cagr, 1)}</span>`,
|
|
@@ -737,8 +822,7 @@ function renderOverview() {
|
|
|
737
822
|
'Максимальная просадка капитала от пика и годовой коэффициент Шарпа (стабильность кривой доходности)',
|
|
738
823
|
),
|
|
739
824
|
].join('');
|
|
740
|
-
$('#sides').hidden =
|
|
741
|
-
$('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
|
|
825
|
+
$('#sides').hidden = true;
|
|
742
826
|
renderStability(ex.stability);
|
|
743
827
|
|
|
744
828
|
const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
|
|
@@ -746,6 +830,7 @@ function renderOverview() {
|
|
|
746
830
|
// трансформер — только если он был в прогоне
|
|
747
831
|
for (const n of MODELS) eqLines[n].applyOptions({ visible: !!ex.curves[n] && (n === chosen || $('#more').open) });
|
|
748
832
|
eqChart.timeScale().fitContent();
|
|
833
|
+
requestAnimationFrame(() => eqChart.timeScale().fitContent());
|
|
749
834
|
$('#legend').innerHTML = [chosen, 'market', ...($('#more').open ? MODELS.filter((n) => n !== chosen && ex.curves[n]) : [])]
|
|
750
835
|
.map((n) => `<span><i style="background:${COLORS[n]}"></i>${n === 'market' ? 'Рынок' : MODEL_LABEL[n]}</span>`).join('') + `<span>· ${fmtDate(run.metrics.period.from)} — ${fmtDate(run.metrics.period.till)}, прогноз на ${run.params.modelHorizon} ч, ${run.metrics.model.tokens} акций</span>`;
|
|
751
836
|
|
|
@@ -849,46 +934,98 @@ $('#btn-cancel').addEventListener('click', async () => {
|
|
|
849
934
|
});
|
|
850
935
|
|
|
851
936
|
// ======================================================================
|
|
852
|
-
// СЕЙЧАС:
|
|
937
|
+
// СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
|
|
853
938
|
// ======================================================================
|
|
939
|
+
let forecastBusy = false;
|
|
940
|
+
const prevClientExp = new Map();
|
|
941
|
+
const prevClientRank = new Map();
|
|
942
|
+
|
|
854
943
|
async function loadForecast(force, silent = false) {
|
|
944
|
+
if (forecastBusy) return;
|
|
945
|
+
forecastBusy = true;
|
|
855
946
|
const box = $('#forecast');
|
|
856
|
-
if (!silent) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
|
|
857
|
-
$('#btn-forecast').disabled = true;
|
|
947
|
+
if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
|
|
858
948
|
try {
|
|
859
|
-
const f =
|
|
949
|
+
const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
|
|
950
|
+
// Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
|
|
951
|
+
f.rows.forEach((r, idx) => {
|
|
952
|
+
const rank = idx + 1;
|
|
953
|
+
if (prevClientExp.has(r.symbol)) {
|
|
954
|
+
const d = Number((r.expectedPct - prevClientExp.get(r.symbol)).toFixed(3));
|
|
955
|
+
if (Math.abs(d) >= 0.001) r.deltaPct = d;
|
|
956
|
+
const rd = prevClientRank.get(r.symbol) - rank;
|
|
957
|
+
if (rd !== 0) r.rankDelta = rd;
|
|
958
|
+
}
|
|
959
|
+
prevClientExp.set(r.symbol, r.expectedPct);
|
|
960
|
+
prevClientRank.set(r.symbol, rank);
|
|
961
|
+
});
|
|
962
|
+
state.forecast = f;
|
|
963
|
+
const liveTimeStr = new Date((f.liveTs || f.at || Date.now()) + MSK).toISOString().slice(11, 19);
|
|
860
964
|
$('#fc-title').textContent = `Прогноз на ${f.horizon} ч · свеча ${f.bar.slice(8, 10)}.${f.bar.slice(5, 7)} ${f.bar.slice(11)}`;
|
|
861
|
-
|
|
965
|
+
const badge = $('#fc-live-badge');
|
|
966
|
+
if (badge) badge.textContent = `● живой радар · ${liveTimeStr}`;
|
|
967
|
+
|
|
862
968
|
const dot = (n) => (n ? ` <span class="${n.tone >= 0.15 ? 'pos' : n.tone <= -0.15 ? 'neg' : 'muted'}" title="${esc(`${n.top}${n.count > 1 ? ` (и ещё ${n.count - 1})` : ''} — ${n.src}, ${fmtTs(n.ts)}`)}">●</span>` : '');
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
<
|
|
867
|
-
<
|
|
969
|
+
const arrowOf = (r) => {
|
|
970
|
+
const d = Number(r.deltaPct || 0);
|
|
971
|
+
if (d > 0.002) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">▲ +${fmtNum(d, 2)}%</span>`;
|
|
972
|
+
if (d < -0.002) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">▼ ${fmtNum(d, 2)}%</span>`;
|
|
973
|
+
return '<span class="muted small" title="Без изменений">—</span>';
|
|
974
|
+
};
|
|
975
|
+
const rankBadge = (r) => {
|
|
976
|
+
const rd = Number(r.rankDelta || 0);
|
|
977
|
+
if (rd > 0) return ` <span class="pos small" title="Поднялась на ${rd} поз. в рейтинге">↑${rd}</span>`;
|
|
978
|
+
if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
|
|
979
|
+
return '';
|
|
980
|
+
};
|
|
981
|
+
const row = (r) => `<tr>
|
|
982
|
+
<td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
|
|
983
|
+
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
|
|
984
|
+
<td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
|
|
985
|
+
<td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
|
|
986
|
+
<td>${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">шорт</span>' : ''}</td>
|
|
987
|
+
<td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
|
|
988
|
+
</tr>`;
|
|
989
|
+
box.innerHTML = `<table><thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
|
|
990
|
+
<table style="margin-top:10px"><thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
|
|
991
|
+
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога ${fmtPct(f.thresholdPct, 2)}.${f.direction && f.direction !== 'long' ? ' «Шорт» — ожидание ниже −порога.' : ''}</p>`;
|
|
868
992
|
setupMapSlider();
|
|
993
|
+
if (state.brokers?.accounts?.length) renderBrokers();
|
|
869
994
|
} catch (e) {
|
|
870
995
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
871
996
|
} finally {
|
|
872
|
-
|
|
997
|
+
forecastBusy = false;
|
|
873
998
|
syncUiAvailability();
|
|
874
999
|
}
|
|
875
1000
|
}
|
|
876
|
-
|
|
877
|
-
const FORECAST_MAX_AGE = 3600000;
|
|
1001
|
+
const FORECAST_TICK_MS = 45000; // живое обновление каждые 45 секунд
|
|
878
1002
|
function refreshForecastIfStale() {
|
|
879
|
-
if (
|
|
1003
|
+
if (forecastBusy) return;
|
|
1004
|
+
if (!state.forecast?.at) {
|
|
1005
|
+
loadForecast(false, false);
|
|
1006
|
+
} else if (Date.now() - state.forecast.at >= 20000) {
|
|
1007
|
+
loadForecast(false, true);
|
|
1008
|
+
}
|
|
880
1009
|
}
|
|
881
|
-
setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); },
|
|
1010
|
+
setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
|
|
882
1011
|
|
|
883
1012
|
function showNowSec(sec) {
|
|
884
1013
|
document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
|
|
885
1014
|
document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
|
|
1015
|
+
saveNavPatch({ nowSec: sec });
|
|
886
1016
|
}
|
|
887
1017
|
$('#now-nav').addEventListener('click', (e) => {
|
|
888
1018
|
const b = e.target.closest('[data-nsec]');
|
|
889
1019
|
if (b) showNowSec(b.dataset.nsec);
|
|
890
1020
|
});
|
|
891
1021
|
|
|
1022
|
+
let openLiveId = loadSavedNav().openLiveId ?? null;
|
|
1023
|
+
const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
|
|
1024
|
+
function setLiveSubTab(id, tab) {
|
|
1025
|
+
liveTabById.set(String(id), tab);
|
|
1026
|
+
saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
892
1029
|
function renderLive(st) {
|
|
893
1030
|
state.live = st;
|
|
894
1031
|
const accs = st?.accounts || [];
|
|
@@ -898,13 +1035,25 @@ function renderLive(st) {
|
|
|
898
1035
|
$('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
|
|
899
1036
|
return;
|
|
900
1037
|
}
|
|
1038
|
+
if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
|
|
1039
|
+
openLiveId = accs[0].runId;
|
|
1040
|
+
}
|
|
901
1041
|
$('#live').innerHTML = accs.map((a) => {
|
|
902
|
-
const
|
|
1042
|
+
const isOpen = String(a.runId) === String(openLiveId);
|
|
1043
|
+
const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
|
|
1044
|
+
const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
|
|
903
1045
|
const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
|
|
904
1046
|
const retPct = a.returnPct ?? 0;
|
|
905
1047
|
const step = a.recent?.[0];
|
|
906
1048
|
const p = a.params || {};
|
|
907
|
-
const
|
|
1049
|
+
const mf = a.metrics?.full || {};
|
|
1050
|
+
const trades = Array.isArray(a.trades) ? a.trades : [];
|
|
1051
|
+
const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
|
|
1052
|
+
const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
|
|
1053
|
+
const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
|
|
1054
|
+
const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
|
|
1055
|
+
const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
|
|
1056
|
+
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
|
|
908
1057
|
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
909
1058
|
if (a.corridor) {
|
|
910
1059
|
const c = a.corridor;
|
|
@@ -917,29 +1066,99 @@ function renderLive(st) {
|
|
|
917
1066
|
corrHtml = `<div class="kpi" title="Ожидание по темпу эксперимента №${c.runId} (история: ${fmtPct(c.histReturnPct)}, макс. просадка ${fmtNum(c.histMaxDdPct, 1)}%)"><div class="v ${cls(c.expectedPct)}">${fmtPct(c.expectedPct, 2)} <span class="muted small">(${fmtPct(c.minPct, 1)}…${fmtPct(c.maxPct, 1)})</span></div><div class="l">ориентир №${c.runId} · <span class="${sCls === 'no' ? 'neg' : 'pos'}">${sLbl}</span></div></div>`;
|
|
918
1067
|
}
|
|
919
1068
|
const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1069
|
+
|
|
1070
|
+
// Вкладка 1: Текущие позиции и ожидающие заявки
|
|
1071
|
+
const posPaneHtml = `
|
|
1072
|
+
${s.pending?.length ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Ожидают исполнения на открытии следующей свечи (${s.pending.length})</b></div><div class="muted small">${s.pending.map((o) => `<span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${{ buy: 'купить', short: 'открыть шорт', cover: 'закрыть шорт' }[o.action] || 'продать'} <b>${esc(o.symbol)}</b></span>`).join(' ')}</div></div>` : ''}
|
|
1073
|
+
${s.positions.length ? `<table><thead><tr><th>Бумага</th><th>Направление</th><th>Открыта</th><th class="num">Кол-во</th><th class="num">Цена входа → Сейчас</th><th class="num">Текущий доход</th></tr></thead><tbody>${s.positions.map((pos) => {
|
|
1074
|
+
const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
|
|
1075
|
+
return `<tr>
|
|
1076
|
+
<td><b>${esc(pos.symbol)}</b></td>
|
|
1077
|
+
<td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
|
|
1078
|
+
<td class="muted small">${fmtTs(pos.entryTs)}</td>
|
|
1079
|
+
<td class="num">${fmtNum(pos.qty, 0)} шт.</td>
|
|
1080
|
+
<td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
|
|
1081
|
+
<td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
|
|
1082
|
+
</tr>`;
|
|
1083
|
+
}).join('')}</tbody></table>` : `<p class="muted small" style="margin:6px 0">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'открытых позиций нет' : 'ничего не куплено'} (все <b>${fmtNum(s.cash ?? s.equity, 0)} ₽</b> в свободном кэше) — стратегия ждёт сильного сигнала.</p>`}
|
|
1084
|
+
${step ? `<p class="muted small" style="margin:8px 0 0">Последняя проверка рынка (${fmtTs(step.ts)}): ${step.decisions?.length ? step.decisions.map((d) => `<b>${esc(d.symbol)}</b>: ${esc(d.text)}`).join(' · ') : 'без изменений'}${step.top?.length ? ` · лидеры прогноза: ${step.top.slice(0, 3).map((x) => `${esc(x.symbol)} (${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%)`).join(', ')}` : ''}</p>` : ''}
|
|
1085
|
+
`;
|
|
1086
|
+
|
|
1087
|
+
// Вкладка 2: История закрытых сделок и по-дневной доход
|
|
1088
|
+
let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
|
|
1089
|
+
if (trades.length) {
|
|
1090
|
+
const byDay = new Map();
|
|
1091
|
+
for (const t of trades) {
|
|
1092
|
+
const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
|
|
1093
|
+
if (!byDay.has(dKey)) byDay.set(dKey, []);
|
|
1094
|
+
byDay.get(dKey).push(t);
|
|
1095
|
+
}
|
|
1096
|
+
const rows = [];
|
|
1097
|
+
for (const [, dayTrades] of byDay) {
|
|
1098
|
+
const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
|
|
1099
|
+
const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
|
|
1100
|
+
const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
|
|
1101
|
+
rows.push(`<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTrades[0]?.exitTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">сделок: <b>${dayTrades.length}</b> (в плюс: ${dayWins}) · комиссия: ${fmtNum(dayFee, 0)} ₽ · доход за день: <b class="${cls(dayPnl)}">${dayPnl >= 0 ? '+' : ''}${fmtNum(dayPnl, 0)} ₽</b></span></div></td></tr>`);
|
|
1102
|
+
for (const t of dayTrades) {
|
|
1103
|
+
rows.push(`<tr>
|
|
1104
|
+
<td><b>${esc(t.symbol)}</b></td>
|
|
1105
|
+
<td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
|
|
1106
|
+
<td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
|
|
1107
|
+
<td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
|
|
1108
|
+
<td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
|
|
1109
|
+
<td class="num ${cls(t.pnl)}" style="white-space:nowrap"><b>${t.pnl >= 0 ? '+' : ''}${fmtNum(t.pnl, 0)} ₽</b> <span class="small">(${fmtPct(t.pnlPct, 2)})</span></td>
|
|
1110
|
+
<td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
|
|
1111
|
+
</tr>`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
tradesPaneHtml = `<table><thead><tr><th>Бумага</th><th>Тип</th><th>Вход → Выход</th><th class="num">Кол-во</th><th class="num">Цена входа → выхода</th><th class="num">Чистый доход</th><th>Причина</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Вкладка 3: Журнал шагов и решений ИИ по свечам
|
|
1118
|
+
const stepsList = Array.isArray(a.recent) ? a.recent : [];
|
|
1119
|
+
const stepsPaneHtml = stepsList.length
|
|
1120
|
+
? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
|
|
1121
|
+
<td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
|
|
1122
|
+
<td>${stItem.decisions?.length ? stItem.decisions.map((d) => `<span class="tag ${d.action === 'buy' ? 'ok' : d.action === 'short' || d.action === 'sell' ? 'mid' : ''}">${esc(d.symbol)}</span> <span class="small">${esc(d.text)}</span>`).join('<br>') : '<span class="muted small">Позиции без изменений</span>'}</td>
|
|
1123
|
+
<td class="small muted">${stItem.top?.length ? stItem.top.slice(0, 3).map((x) => `<b>${esc(x.symbol)}</b> <span class="${cls(x.expectedPct)}">${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%</span>`).join(' · ') : '—'}</td>
|
|
1124
|
+
</tr>`).join('')}</tbody></table>`
|
|
1125
|
+
: '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
|
|
1126
|
+
|
|
1127
|
+
return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
|
|
1128
|
+
<div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
1129
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1130
|
+
<span class="acc-caret">▶</span>
|
|
923
1131
|
<b>${esc(dispName)}</b>
|
|
924
|
-
<span class="tag ${a.running ? 'ok' : ''}"
|
|
1132
|
+
<span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
|
|
925
1133
|
${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
|
|
926
|
-
<div class="muted small">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
|
|
927
1134
|
</div>
|
|
928
|
-
<div class="row-gap">
|
|
1135
|
+
<div class="row-gap" style="align-items:center">
|
|
929
1136
|
<button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
|
|
930
1137
|
${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
|
|
931
1138
|
<button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
|
|
932
1139
|
</div>
|
|
933
1140
|
</div>
|
|
934
|
-
<div
|
|
935
|
-
<div class="
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1141
|
+
<div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
|
|
1142
|
+
<div class="brk-specs" style="margin-bottom:10px">
|
|
1143
|
+
<span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
|
|
1144
|
+
<span class="k">Статус и время работы:</span><span class="v">запущен <b>${fmtTs(a.startedAt)}</b>${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча <b>${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}</b>${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}</span>
|
|
1145
|
+
<span class="k">Структура дохода:</span><span class="v">зафиксировано по сделкам: <b class="${cls(realizedPnl)}">${realizedPnl >= 0 ? '+' : ''}${fmtNum(realizedPnl, 0)} ₽</b> · в открытых позициях: <b class="${cls(openPnl)}">${openPnl >= 0 ? '+' : ''}${fmtNum(openPnl, 0)} ₽</b> · учтено комиссий: <span class="muted">−${fmtNum(totalFee, 0)} ₽</span> · макс. просадка: <b>${fmtNum(mf.maxDrawdownPct || 0, 2)}%</b></span>
|
|
1146
|
+
</div>
|
|
1147
|
+
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
1148
|
+
<div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
|
|
1149
|
+
<div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">чистый доход с начала</div></div>
|
|
1150
|
+
${corrHtml}
|
|
1151
|
+
<div class="kpi"><div class="v">${trades.length || (mf.trades ?? 0)} <span class="muted small">(${winRate}% в плюс)</span></div><div class="l">закрыто сделок · профит-фактор ${fmtNum(mf.profitFactor || 0, 2)}</div></div>
|
|
1152
|
+
</div>
|
|
1153
|
+
<div class="brk-subtabs" style="margin-top:10px">
|
|
1154
|
+
<button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
|
|
1155
|
+
<button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
|
|
1156
|
+
<button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
|
|
1157
|
+
</div>
|
|
1158
|
+
<div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
|
|
1159
|
+
<div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
|
|
1160
|
+
<div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
|
|
939
1161
|
</div>
|
|
940
|
-
<p class="muted small">Запущен ${fmtTs(a.startedAt)}${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча ${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}${s.pending.length ? ` · ждут исполнения: ${s.pending.map((o) => `${{ buy: 'купить', short: 'продать без покупки', cover: 'закрыть продажу' }[o.action] || 'продать'} ${o.symbol}`).join(', ')}` : ''}</p>
|
|
941
|
-
${s.positions.length ? `<table><thead><tr><th>Держим</th><th>С</th><th class="num">Цена входа</th><th class="num">Сейчас</th><th class="num">Результат</th></tr></thead><tbody>${s.positions.map((pos) => `<tr><td><b>${pos.symbol}</b>${pos.side === 'short' ? ' <span class="tag no">продажа</span>' : ''}</td><td>${fmtTs(pos.entryTs)}</td><td class="num">${fmtNum(pos.entryPrice, 3)}</td><td class="num">${fmtNum(pos.lastPrice, 3)}</td><td class="num ${cls(pos.pnl)}">${fmtNum(pos.pnl, 0)} ₽</td></tr>`).join('')}</tbody></table>` : `<p class="muted small">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'позиций нет' : 'ничего не куплено'} — стратегия ждёт сильного сигнала.</p>`}
|
|
942
|
-
${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
|
|
943
1162
|
</div>`;
|
|
944
1163
|
}).join('');
|
|
945
1164
|
syncUiAvailability();
|
|
@@ -955,6 +1174,12 @@ $('#btn-live').addEventListener('click', async () => {
|
|
|
955
1174
|
} catch (e) { toast(e.message, 'error'); }
|
|
956
1175
|
});
|
|
957
1176
|
$('#live').addEventListener('click', async (e) => {
|
|
1177
|
+
const tabBtn = e.target.closest('button[data-ltab]');
|
|
1178
|
+
if (tabBtn) {
|
|
1179
|
+
setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
|
|
1180
|
+
renderLive(state.live);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
958
1183
|
const stopBtn = e.target.closest('[data-live-stop]');
|
|
959
1184
|
if (stopBtn) {
|
|
960
1185
|
const id = Number(stopBtn.dataset.liveStop);
|
|
@@ -981,6 +1206,14 @@ $('#live').addEventListener('click', async (e) => {
|
|
|
981
1206
|
const id = Number(askBtn.dataset.liveAsk);
|
|
982
1207
|
const acc = (state.live?.accounts || []).find((x) => x.runId === id);
|
|
983
1208
|
sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const foldHead = e.target.closest('[data-live-fold]');
|
|
1212
|
+
if (foldHead) {
|
|
1213
|
+
const id = foldHead.dataset.liveFold;
|
|
1214
|
+
openLiveId = String(openLiveId) === String(id) ? null : id;
|
|
1215
|
+
saveNavPatch({ openLiveId });
|
|
1216
|
+
renderLive(state.live);
|
|
984
1217
|
}
|
|
985
1218
|
});
|
|
986
1219
|
|
|
@@ -1138,6 +1371,7 @@ function drawProfilesMap() {
|
|
|
1138
1371
|
}
|
|
1139
1372
|
async function showMapSec(sec) {
|
|
1140
1373
|
document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
|
|
1374
|
+
saveNavPatch({ mapSec: sec });
|
|
1141
1375
|
if (sec === 'journal') {
|
|
1142
1376
|
$('#map-sec').hidden = true;
|
|
1143
1377
|
$('#journal-box').hidden = false;
|
|
@@ -1215,6 +1449,8 @@ function showSettingsSec(sec) {
|
|
|
1215
1449
|
document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
|
|
1216
1450
|
document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
1217
1451
|
document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
1452
|
+
saveNavPatch({ settingsSec: sec });
|
|
1453
|
+
if (sec === 'cloud') renderCloudPane().catch(() => {});
|
|
1218
1454
|
}
|
|
1219
1455
|
document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
|
|
1220
1456
|
const b = e.target.closest('[data-sec]');
|
|
@@ -1961,8 +2197,70 @@ const INST_KIND_LABEL = {
|
|
|
1961
2197
|
futures: ['Фьючерс', 'mid'],
|
|
1962
2198
|
};
|
|
1963
2199
|
const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
|
|
1964
|
-
const
|
|
2200
|
+
const HIST_ALL_KINDS = ['buy', 'sell', 'fee', 'div', 'other'];
|
|
2201
|
+
const brokerTabById = new Map(Object.entries(loadSavedNav().brokerTabs || {})); // accId -> 'assets' | 'trade' | 'history'
|
|
2202
|
+
const brokerHistFilterById = new Map(Object.entries(loadSavedNav().brokerHistFilters || {})); // accId -> Array of kinds
|
|
2203
|
+
const openHistYears = new Set(); // `${accId}:${year}`
|
|
2204
|
+
const loadingHistYears = new Set(); // `${accId}:${year}`
|
|
2205
|
+
const preparingBrokerIds = new Set(); // accId
|
|
2206
|
+
function setBrokerSubTab(id, tab) {
|
|
2207
|
+
brokerTabById.set(id, tab);
|
|
2208
|
+
saveNavPatch({ brokerTabs: Object.fromEntries(brokerTabById) });
|
|
2209
|
+
}
|
|
2210
|
+
function getBrokerHistFilterSet(id) {
|
|
2211
|
+
const saved = brokerHistFilterById.get(id);
|
|
2212
|
+
if (Array.isArray(saved)) return new Set(saved);
|
|
2213
|
+
return new Set(HIST_ALL_KINDS);
|
|
2214
|
+
}
|
|
2215
|
+
function toggleBrokerHistFilter(id, flt) {
|
|
2216
|
+
const cur = getBrokerHistFilterSet(id);
|
|
2217
|
+
if (flt === 'all') {
|
|
2218
|
+
brokerHistFilterById.set(id, [...HIST_ALL_KINDS]);
|
|
2219
|
+
} else {
|
|
2220
|
+
if (cur.has(flt)) cur.delete(flt);
|
|
2221
|
+
else cur.add(flt);
|
|
2222
|
+
brokerHistFilterById.set(id, [...cur]);
|
|
2223
|
+
}
|
|
2224
|
+
saveNavPatch({ brokerHistFilters: Object.fromEntries(brokerHistFilterById) });
|
|
2225
|
+
}
|
|
2226
|
+
let openBrokerId = loadSavedNav().openBrokerId ?? null;
|
|
2227
|
+
let openBrokerCfgId = loadSavedNav().openBrokerCfgId ?? null;
|
|
1965
2228
|
let editingBrokerId = null;
|
|
2229
|
+
let brokerPollBusy = false;
|
|
2230
|
+
let lastBrokerPollTs = 0;
|
|
2231
|
+
|
|
2232
|
+
async function pollActiveBrokerAccount(force = false) {
|
|
2233
|
+
if (brokerPollBusy || document.hidden) return;
|
|
2234
|
+
const nowPageHidden = $('#tab-now')?.hidden;
|
|
2235
|
+
const brokersSecHidden = $('#nsec-brokers')?.hidden;
|
|
2236
|
+
if (nowPageHidden || brokersSecHidden) return;
|
|
2237
|
+
const accs = state.brokers?.accounts || [];
|
|
2238
|
+
if (!accs.length) return;
|
|
2239
|
+
const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
|
|
2240
|
+
const activeAcc = accs.find((a) => a.id === activeId);
|
|
2241
|
+
if (!activeAcc?.hasToken) return;
|
|
2242
|
+
const now = Date.now();
|
|
2243
|
+
if (!force && now - lastBrokerPollTs < 25000) return;
|
|
2244
|
+
brokerPollBusy = true;
|
|
2245
|
+
lastBrokerPollTs = now;
|
|
2246
|
+
try {
|
|
2247
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/sync`);
|
|
2248
|
+
const curSubTab = brokerTabById.get(activeId) || 'assets';
|
|
2249
|
+
if (curSubTab === 'history') {
|
|
2250
|
+
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
2251
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(activeId)}/history`, { year: curYear });
|
|
2252
|
+
}
|
|
2253
|
+
renderBrokers();
|
|
2254
|
+
} catch { /* ignore background poll error */ } finally {
|
|
2255
|
+
brokerPollBusy = false;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
setInterval(() => pollActiveBrokerAccount(false), 30000);
|
|
2260
|
+
window.addEventListener('focus', () => pollActiveBrokerAccount(true));
|
|
2261
|
+
document.addEventListener('visibilitychange', () => {
|
|
2262
|
+
if (!document.hidden) pollActiveBrokerAccount(true);
|
|
2263
|
+
});
|
|
1966
2264
|
|
|
1967
2265
|
async function loadBrokers() {
|
|
1968
2266
|
state.brokers = await api('/api/brokers');
|
|
@@ -1976,40 +2274,58 @@ function renderBrokers() {
|
|
|
1976
2274
|
if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
1977
2275
|
updateTabBadges();
|
|
1978
2276
|
|
|
1979
|
-
// 1) список в Настройках
|
|
2277
|
+
// 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
1980
2278
|
const listEl = $('#brokers-list');
|
|
1981
2279
|
if (listEl) {
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2280
|
+
if (accs.length && !accs.some((a) => a.id === openBrokerCfgId)) {
|
|
2281
|
+
openBrokerCfgId = accs[0].id;
|
|
2282
|
+
}
|
|
2283
|
+
listEl.innerHTML = accs.length ? accs.map((a) => {
|
|
2284
|
+
const ls = a.lastSync;
|
|
2285
|
+
const isCfgOpen = a.id === openBrokerCfgId;
|
|
2286
|
+
return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2287
|
+
<div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
|
|
2288
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2289
|
+
<span class="acc-caret">▶</span>
|
|
2290
|
+
<b>${esc(a.name)}</b>
|
|
2291
|
+
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2292
|
+
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2293
|
+
${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
|
|
2294
|
+
</div>
|
|
2295
|
+
<div class="row-gap" style="align-items:center">
|
|
2296
|
+
${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
|
|
2297
|
+
<button type="button" data-bact="sync">Проверить связь</button>
|
|
2298
|
+
<button type="button" data-bact="edit" class="icon">Изменить</button>
|
|
2299
|
+
<button type="button" data-bact="del" class="icon">Удалить</button>
|
|
2300
|
+
</div>
|
|
1996
2301
|
</div>
|
|
1997
|
-
<div class="
|
|
1998
|
-
|
|
1999
|
-
${a.
|
|
2302
|
+
<div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
|
|
2303
|
+
<span class="k">Брокер и субсчёт:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · ID <code>${esc(a.accountId || ls?.accountId || 'авто')}</code> (токен: ${a.hasToken ? '<span class="pos">задан ✓</span>' : '<span class="nokey">не задан</span>'})</span>
|
|
2304
|
+
<span class="k">Режим управления:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-расписание: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
|
|
2305
|
+
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
|
|
2306
|
+
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2307
|
+
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2308
|
+
<span class="k">Предохранители:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b></span>
|
|
2309
|
+
${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
|
|
2310
|
+
${ls ? `<span class="k">Синхронизация (${fmtTs(ls.at)}):</span><span class="v">${ls.ok ? `<span class="pos">на счёте <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽, бумаг ${ls.positions?.length || 0})</span>` : `<span class="neg">${esc(ls.error)}</span>`}</span>` : ''}
|
|
2000
2311
|
</div>
|
|
2001
|
-
</div
|
|
2312
|
+
</div>`;
|
|
2313
|
+
}).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
|
|
2002
2314
|
}
|
|
2003
2315
|
|
|
2004
|
-
// 2) карточки во вкладке «Торговля → Брокерские счета» (
|
|
2316
|
+
// 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
2005
2317
|
const tradeEl = $('#brokers-trade');
|
|
2006
2318
|
if (!tradeEl) return;
|
|
2007
2319
|
if (!accs.length) {
|
|
2008
2320
|
tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
|
|
2009
2321
|
return;
|
|
2010
2322
|
}
|
|
2323
|
+
if (!accs.some((a) => a.id === openBrokerId)) {
|
|
2324
|
+
openBrokerId = accs[0].id;
|
|
2325
|
+
}
|
|
2011
2326
|
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
2012
2327
|
tradeEl.innerHTML = accs.map((a) => {
|
|
2328
|
+
const isOpen = a.id === openBrokerId;
|
|
2013
2329
|
const ls = a.lastSync;
|
|
2014
2330
|
const pb = a.pendingBatch;
|
|
2015
2331
|
const lh = a.lastHistory;
|
|
@@ -2100,79 +2416,232 @@ function renderBrokers() {
|
|
|
2100
2416
|
const resHtml = pb.results?.length
|
|
2101
2417
|
? `<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>`
|
|
2102
2418
|
: '';
|
|
2419
|
+
const throttleHtml = pb.planThrottleNote
|
|
2420
|
+
? `<div class="muted small" style="margin-top:6px;padding:6px 10px;border-left:2px solid var(--accent);background:rgba(59,130,246,0.06)">ℹ ${esc(pb.planThrottleNote)}</div>`
|
|
2421
|
+
: '';
|
|
2103
2422
|
batchHtml = `<div class="stab-box">
|
|
2104
2423
|
<div class="stab-head">
|
|
2105
2424
|
<b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
|
|
2425
|
+
${preparingBrokerIds.has(a.id) ? '<span class="muted small">· обновляем расчёт…</span>' : ''}
|
|
2106
2426
|
${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
|
|
2107
2427
|
<span class="grow"></span>
|
|
2108
|
-
${pb.status === 'pending' && pb.orders?.length ? `<button type="button" class="primary" data-btrade="exec" data-bid="${esc(a.id)}"${a.mode === 'readonly' ? ' disabled title="В режиме «Только чтение» отправка отключена"' : ''}>Исполнить заявки (${pb.orders.length})</button>` :
|
|
2109
|
-
<button type="button" data-btrade="rej" data-bid="${esc(a.id)}">${pb.status === 'pending' ? 'Отклонить' : 'Скрыть'}</button>
|
|
2428
|
+
${pb.status === 'pending' && pb.orders?.length ? `<button type="button" class="primary" data-btrade="exec" data-bid="${esc(a.id)}"${a.mode === 'readonly' ? ' disabled title="В режиме «Только чтение» отправка отключена"' : ''}>Исполнить заявки (${pb.orders.length})</button><button type="button" data-btrade="rej" data-bid="${esc(a.id)}">Отклонить заявки</button>` : `<button type="button" class="icon small" data-btrade="rej" data-bid="${esc(a.id)}" title="Скрыть сводку">Скрыть</button>`}
|
|
2110
2429
|
</div>
|
|
2111
2430
|
${ordRows}
|
|
2112
2431
|
${resHtml}
|
|
2432
|
+
${throttleHtml}
|
|
2113
2433
|
</div>`;
|
|
2114
2434
|
} else {
|
|
2115
|
-
batchHtml = `<p class="muted small"
|
|
2435
|
+
batchHtml = `<p class="muted small">${preparingBrokerIds.has(a.id) ? 'Считаем пакет лимитных заявок по текущему прогнозу…' : 'Переключитесь на вкладку <b>«⚡ Торговля и заявки»</b> для внеочередного пересчёта заявок (или дождитесь закрытия свечи при включённом авто-расписании).'}</p>`;
|
|
2116
2436
|
}
|
|
2117
2437
|
|
|
2118
|
-
// Вкладка 3: История операций и динамика счёта
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2438
|
+
// Вкладка 3: История операций и динамика счёта (по умолчанию текущий год, прошлые года — по клику)
|
|
2439
|
+
const renderOpsTable = (opsList) => {
|
|
2440
|
+
const byDay = new Map();
|
|
2441
|
+
for (const op of opsList) {
|
|
2442
|
+
const dayKey = op.date ? new Date(op.date + MSK).toISOString().slice(0, 10) : 'none';
|
|
2443
|
+
if (!byDay.has(dayKey)) byDay.set(dayKey, []);
|
|
2444
|
+
byDay.get(dayKey).push(op);
|
|
2445
|
+
}
|
|
2446
|
+
const rows = [];
|
|
2447
|
+
for (const [, dayOps] of byDay) {
|
|
2448
|
+
let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
|
|
2449
|
+
const symQty = new Map(), symCash = new Map();
|
|
2450
|
+
for (const op of dayOps) {
|
|
2451
|
+
const pay = Number(op.paymentRub) || 0;
|
|
2452
|
+
if (op.kind === 'buy') {
|
|
2453
|
+
tradesCnt++;
|
|
2454
|
+
buyRub += Math.abs(pay);
|
|
2455
|
+
if (op.symbol) {
|
|
2456
|
+
symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2457
|
+
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2458
|
+
}
|
|
2459
|
+
} else if (op.kind === 'sell') {
|
|
2460
|
+
tradesCnt++;
|
|
2461
|
+
sellRub += Math.abs(pay);
|
|
2462
|
+
if (op.symbol) {
|
|
2463
|
+
symQty.set(op.symbol, (symQty.get(op.symbol) || 0) - (Number(op.qty) || 0));
|
|
2464
|
+
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2465
|
+
}
|
|
2466
|
+
} else if (op.kind === 'fee') {
|
|
2467
|
+
if (/дивиденд|купон/i.test(op.title || '')) divTaxRub += pay;
|
|
2468
|
+
else feeRub += pay;
|
|
2469
|
+
} else if (op.kind === 'div') {
|
|
2470
|
+
divTaxRub += pay;
|
|
2471
|
+
} else if (op.kind === 'in' || op.kind === 'out') {
|
|
2472
|
+
inOutRub += pay;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
// Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
|
|
2476
|
+
let closedTradePnl = 0, closedSyms = 0;
|
|
2477
|
+
for (const [sym, netQ] of symQty) {
|
|
2478
|
+
if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
|
|
2479
|
+
closedTradePnl += symCash.get(sym);
|
|
2480
|
+
closedSyms++;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
if (closedSyms > 0) closedTradePnl += feeRub;
|
|
2484
|
+
|
|
2485
|
+
const summaryParts = [];
|
|
2486
|
+
if (closedSyms > 0) {
|
|
2487
|
+
summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
|
|
2488
|
+
} else if (tradesCnt > 0) {
|
|
2489
|
+
const tParts = [];
|
|
2490
|
+
if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
|
|
2491
|
+
if (sellRub > 0) tParts.push(`продано на ${fmtNum(sellRub, 0)} ₽`);
|
|
2492
|
+
if (feeRub !== 0) tParts.push(`комиссия ${fmtNum(feeRub, 2)} ₽`);
|
|
2493
|
+
summaryParts.push(tParts.join(' · '));
|
|
2494
|
+
} else if (feeRub !== 0) {
|
|
2495
|
+
summaryParts.push(`комиссии и налоги: <span class="neg">${fmtNum(feeRub, 2)} ₽</span>`);
|
|
2496
|
+
}
|
|
2497
|
+
if (divTaxRub !== 0) {
|
|
2498
|
+
summaryParts.push(`дивиденды/купоны (чистыми): <b class="${cls(divTaxRub)}">${divTaxRub > 0 ? '+' : ''}${fmtNum(divTaxRub, 2)} ₽</b>`);
|
|
2499
|
+
}
|
|
2500
|
+
if (inOutRub !== 0) {
|
|
2501
|
+
summaryParts.push(`${inOutRub > 0 ? 'пополнение' : 'вывод'}: <b class="${cls(inOutRub)}">${inOutRub > 0 ? '+' : ''}${fmtNum(inOutRub, 0)} ₽</b>`);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
rows.push(`<tr class="day-group-row"><td colspan="6"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayOps[0]?.date))}</span><span class="small" style="font-weight:500;color:var(--muted)">${summaryParts.join(' · ')}</span></div></td></tr>`);
|
|
2505
|
+
for (const op of dayOps) {
|
|
2506
|
+
rows.push(`<tr>
|
|
2507
|
+
<td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
|
|
2508
|
+
<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>
|
|
2509
|
+
<td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
|
|
2510
|
+
<td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2511
|
+
<td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
|
|
2512
|
+
<td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
2513
|
+
</tr>`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
return `<table><thead><tr><th style="width:64px">Время</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
2517
|
+
};
|
|
2518
|
+
|
|
2519
|
+
const activeHistKinds = getBrokerHistFilterSet(a.id);
|
|
2520
|
+
const isAllHistActive = HIST_ALL_KINDS.every((k) => activeHistKinds.has(k));
|
|
2521
|
+
const opBucketOf = (op) => (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee' || op.kind === 'div' ? op.kind : 'other');
|
|
2522
|
+
const matchHistFilter = (op) => activeHistKinds.has(opBucketOf(op));
|
|
2523
|
+
|
|
2524
|
+
const byYear = new Map();
|
|
2525
|
+
const byYearFiltered = new Map();
|
|
2526
|
+
const filterCounts = { all: 0, buy: 0, sell: 0, fee: 0, div: 0, other: 0 };
|
|
2527
|
+
for (const op of lh?.operations || []) {
|
|
2528
|
+
filterCounts.all++;
|
|
2529
|
+
const b = opBucketOf(op);
|
|
2530
|
+
filterCounts[b] = (filterCounts[b] || 0) + 1;
|
|
2531
|
+
|
|
2532
|
+
const y = op.date ? new Date(op.date + MSK).toISOString().slice(0, 4) : 'Прочее';
|
|
2533
|
+
if (!byYear.has(y)) byYear.set(y, []);
|
|
2534
|
+
byYear.get(y).push(op);
|
|
2535
|
+
if (matchHistFilter(op)) {
|
|
2536
|
+
if (!byYearFiltered.has(y)) byYearFiltered.set(y, []);
|
|
2537
|
+
byYearFiltered.get(y).push(op);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
const histFilterBarHtml = `<div class="row-gap" style="margin-bottom:10px;align-items:center;flex-wrap:wrap">
|
|
2542
|
+
<span class="muted small" style="margin-right:4px">Фильтры:</span>
|
|
2543
|
+
${[
|
|
2544
|
+
['buy', 'Покупка', filterCounts.buy],
|
|
2545
|
+
['sell', 'Продажа', filterCounts.sell],
|
|
2546
|
+
['fee', 'Комиссия', filterCounts.fee],
|
|
2547
|
+
['div', 'Дивиденды', filterCounts.div],
|
|
2548
|
+
['other', 'Операция', filterCounts.other],
|
|
2549
|
+
].map(([fKey, fLbl, fCnt]) => {
|
|
2550
|
+
const on = activeHistKinds.has(fKey);
|
|
2551
|
+
return `<button type="button" class="brk-toggle small${on ? ' on' : ''}" data-hfilter="${fKey}" data-bid="${esc(a.id)}" title="${on ? 'Включён (нажмите, чтобы скрыть)' : 'Выключен (нажмите, чтобы показать)'}">${on ? '✓ ' : ''}${fLbl} (${fCnt})</button>`;
|
|
2552
|
+
}).join('')}
|
|
2553
|
+
${!isAllHistActive ? `<button type="button" class="icon small" data-hfilter="all" data-bid="${esc(a.id)}" title="Включить обратно все типы операций">Показать все</button>` : ''}
|
|
2554
|
+
</div>`;
|
|
2555
|
+
|
|
2556
|
+
const curYearNum = new Date(Date.now() + MSK).getUTCFullYear();
|
|
2557
|
+
const curYearStr = String(curYearNum);
|
|
2558
|
+
openHistYears.add(`${a.id}:${curYearStr}`);
|
|
2559
|
+
const loadedYearsSet = new Set(Array.isArray(lh?.loadedYears) ? lh.loadedYears.map(String) : [...byYear.keys()]);
|
|
2560
|
+
const allYears = [];
|
|
2561
|
+
for (let y = curYearNum; y >= curYearNum - 4; y--) allYears.push(String(y));
|
|
2562
|
+
for (const y of byYear.keys()) if (!allYears.includes(y)) allYears.push(y);
|
|
2563
|
+
|
|
2564
|
+
const yearsHtml = allYears.map((y) => {
|
|
2565
|
+
const yKey = `${a.id}:${y}`;
|
|
2566
|
+
const isYearOpen = openHistYears.has(yKey);
|
|
2567
|
+
const isLoaded = loadedYearsSet.has(y) || byYear.has(y);
|
|
2568
|
+
const isLoading = loadingHistYears.has(yKey);
|
|
2569
|
+
const yOpsAll = byYear.get(y) || [];
|
|
2570
|
+
const yOps = byYearFiltered.get(y) || [];
|
|
2571
|
+
let yMeta = '';
|
|
2572
|
+
if (isLoading) {
|
|
2573
|
+
yMeta = 'загружаем операции с брокера…';
|
|
2574
|
+
} else if (isLoaded) {
|
|
2575
|
+
const yTrades = yOpsAll.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
|
|
2576
|
+
const yDivs = yOpsAll.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
|
|
2577
|
+
const fltSuffix = !isAllHistActive ? ` (показано: ${yOps.length})` : '';
|
|
2578
|
+
yMeta = yOpsAll.length
|
|
2579
|
+
? `операций: ${yOpsAll.length}${fltSuffix}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`
|
|
2580
|
+
: 'операций за этот год нет';
|
|
2581
|
+
} else {
|
|
2582
|
+
yMeta = 'нажмите, чтобы подгрузить операции за этот год';
|
|
2583
|
+
}
|
|
2584
|
+
const bodyContent = isLoading
|
|
2585
|
+
? '<p class="muted small" style="padding:10px 12px;margin:0">Запрашиваем выписку у брокера…</p>'
|
|
2586
|
+
: yOps.length
|
|
2587
|
+
? renderOpsTable(yOps)
|
|
2588
|
+
: `<p class="muted small" style="padding:10px 12px;margin:0">${!isAllHistActive && yOpsAll.length ? 'Все операции за этот год скрыты фильтрами выше.' : 'За выбранный год операций по счёту не найдено.'}</p>`;
|
|
2589
|
+
return `<details class="hist-year-box" data-hyear="${esc(y)}" data-bid="${esc(a.id)}"${isYearOpen ? ' open' : ''}>
|
|
2590
|
+
<summary><span>📁 ${esc(y)} год</span><span class="muted small">${yMeta}</span></summary>
|
|
2591
|
+
${bodyContent}
|
|
2592
|
+
</details>`;
|
|
2593
|
+
}).join('');
|
|
2594
|
+
|
|
2595
|
+
const histHtml = lh?.operations?.length || lh?.loadedYears?.length
|
|
2596
|
+
? `<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
|
|
2597
|
+
<div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
|
|
2126
2598
|
<div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
|
|
2127
2599
|
<div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
|
|
2128
2600
|
<div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
|
|
2129
2601
|
</div>
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
return `<div class="live-card" data-bid="${esc(a.id)}">
|
|
2141
|
-
<div class="live-head">
|
|
2142
|
-
<div>
|
|
2143
|
-
<b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? ` <span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
|
|
2144
|
-
<span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
|
|
2602
|
+
${histFilterBarHtml}
|
|
2603
|
+
${yearsHtml}`
|
|
2604
|
+
: '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
|
|
2605
|
+
|
|
2606
|
+
return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2607
|
+
<div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
2608
|
+
<div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2609
|
+
<span class="acc-caret">▶</span>
|
|
2610
|
+
<b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
|
|
2145
2611
|
<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>' : ''}
|
|
2149
2612
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2150
2613
|
${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
|
|
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>
|
|
2152
2614
|
</div>
|
|
2153
|
-
<div class="row-gap" style="
|
|
2154
|
-
|
|
2155
|
-
<button type="button" data-btrade="
|
|
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>
|
|
2158
|
-
<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}">Рассчитать заявки (в лотах)</button>
|
|
2615
|
+
<div class="row-gap" style="align-items:center">
|
|
2616
|
+
${ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
|
|
2617
|
+
<button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
|
|
2159
2618
|
</div>
|
|
2160
2619
|
</div>
|
|
2161
|
-
${
|
|
2162
|
-
<div class="
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2620
|
+
<div ${isOpen ? '' : 'hidden'}>
|
|
2621
|
+
<div class="brk-specs">
|
|
2622
|
+
<span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
|
|
2623
|
+
<span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
|
|
2624
|
+
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
|
|
2625
|
+
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2626
|
+
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2627
|
+
<span class="k">Предохранители и настройки:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
|
|
2628
|
+
</div>
|
|
2629
|
+
${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
2630
|
+
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2631
|
+
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2632
|
+
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
|
|
2633
|
+
<div class="kpi"><div class="v" style="white-space:nowrap">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
|
|
2634
|
+
</div>` : ''}
|
|
2635
|
+
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2636
|
+
<div class="brk-subtabs">
|
|
2637
|
+
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
|
|
2638
|
+
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
|
2639
|
+
<button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
|
|
2640
|
+
</div>
|
|
2641
|
+
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2642
|
+
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
2643
|
+
<div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
|
|
2172
2644
|
</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>
|
|
2176
2645
|
</div>`;
|
|
2177
2646
|
}).join('');
|
|
2178
2647
|
}
|
|
@@ -2292,7 +2761,7 @@ $('#btn-broker-discover')?.addEventListener('click', async () => {
|
|
|
2292
2761
|
box.hidden = false;
|
|
2293
2762
|
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
2763
|
<div>
|
|
2295
|
-
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok"
|
|
2764
|
+
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
|
|
2296
2765
|
<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
2766
|
</div>
|
|
2298
2767
|
<button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
|
|
@@ -2339,18 +2808,35 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2339
2808
|
resetHalt: true,
|
|
2340
2809
|
};
|
|
2341
2810
|
try {
|
|
2811
|
+
const savedId = editingBrokerId;
|
|
2342
2812
|
state.brokers = editingBrokerId
|
|
2343
2813
|
? await api(`/api/brokers/${encodeURIComponent(editingBrokerId)}`, { method: 'PUT', body })
|
|
2344
2814
|
: await post('/api/brokers', body);
|
|
2815
|
+
const targetId = savedId || state.brokers?.accounts?.[state.brokers.accounts.length - 1]?.id;
|
|
2345
2816
|
stopBrokerEdit();
|
|
2346
2817
|
renderBrokers();
|
|
2347
|
-
toast('
|
|
2818
|
+
toast('Настройки счёта сохранены — пересчитываем портфель и заявки…');
|
|
2819
|
+
if (targetId) {
|
|
2820
|
+
post(`/api/brokers/${encodeURIComponent(targetId)}/sync`)
|
|
2821
|
+
.then(() => post(`/api/brokers/${encodeURIComponent(targetId)}/prepare`))
|
|
2822
|
+
.then((st) => { state.brokers = st; renderBrokers(); })
|
|
2823
|
+
.catch(() => {});
|
|
2824
|
+
}
|
|
2348
2825
|
} catch (err) { toast(err.message, 'error'); }
|
|
2349
2826
|
});
|
|
2350
2827
|
|
|
2351
2828
|
$('#brokers-list').addEventListener('click', async (e) => {
|
|
2352
2829
|
const b = e.target.closest('button[data-bact]');
|
|
2353
|
-
if (!b)
|
|
2830
|
+
if (!b) {
|
|
2831
|
+
const fold = e.target.closest('[data-bcfg-fold]');
|
|
2832
|
+
if (fold) {
|
|
2833
|
+
const fid = fold.dataset.bcfgFold;
|
|
2834
|
+
openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
|
|
2835
|
+
saveNavPatch({ openBrokerCfgId });
|
|
2836
|
+
renderBrokers();
|
|
2837
|
+
}
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2354
2840
|
const id = b.closest('[data-bid]').dataset.bid;
|
|
2355
2841
|
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2356
2842
|
if (!acc) return;
|
|
@@ -2382,22 +2868,74 @@ $('#brokers-list').addEventListener('click', async (e) => {
|
|
|
2382
2868
|
});
|
|
2383
2869
|
|
|
2384
2870
|
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
2871
|
+
const fltBtn = e.target.closest('button[data-hfilter]');
|
|
2872
|
+
if (fltBtn) {
|
|
2873
|
+
toggleBrokerHistFilter(fltBtn.dataset.bid, fltBtn.dataset.hfilter);
|
|
2874
|
+
renderBrokers();
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
const yearSummary = e.target.closest('.hist-year-box > summary');
|
|
2878
|
+
if (yearSummary) {
|
|
2879
|
+
const det = yearSummary.parentElement;
|
|
2880
|
+
const id = det?.dataset?.bid;
|
|
2881
|
+
const y = det?.dataset?.hyear;
|
|
2882
|
+
if (id && y) {
|
|
2883
|
+
setTimeout(() => {
|
|
2884
|
+
const yKey = `${id}:${y}`;
|
|
2885
|
+
if (det.open) {
|
|
2886
|
+
openHistYears.add(yKey);
|
|
2887
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2888
|
+
const loaded = Array.isArray(acc?.lastHistory?.loadedYears) && acc.lastHistory.loadedYears.map(String).includes(String(y));
|
|
2889
|
+
const hasOps = (acc?.lastHistory?.operations || []).some((op) => op.date && new Date(op.date + MSK).toISOString().slice(0, 4) === String(y));
|
|
2890
|
+
if (!loaded && !hasOps && !loadingHistYears.has(yKey)) {
|
|
2891
|
+
loadingHistYears.add(yKey);
|
|
2892
|
+
renderBrokers();
|
|
2893
|
+
post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: Number(y) })
|
|
2894
|
+
.then((st) => { state.brokers = st; })
|
|
2895
|
+
.catch((err) => toast(err.message, 'error'))
|
|
2896
|
+
.finally(() => { loadingHistYears.delete(yKey); renderBrokers(); });
|
|
2897
|
+
}
|
|
2898
|
+
} else {
|
|
2899
|
+
openHistYears.delete(yKey);
|
|
2900
|
+
}
|
|
2901
|
+
}, 0);
|
|
2902
|
+
}
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2385
2905
|
const tabBtn = e.target.closest('button[data-btab]');
|
|
2386
2906
|
if (tabBtn) {
|
|
2387
2907
|
const id = tabBtn.dataset.bid;
|
|
2388
2908
|
const t = tabBtn.dataset.btab;
|
|
2389
|
-
|
|
2909
|
+
setBrokerSubTab(id, t);
|
|
2910
|
+
if (t === 'trade') {
|
|
2911
|
+
preparingBrokerIds.add(id);
|
|
2912
|
+
renderBrokers();
|
|
2913
|
+
post(`/api/brokers/${encodeURIComponent(id)}/prepare`)
|
|
2914
|
+
.then((st) => { state.brokers = st; })
|
|
2915
|
+
.catch((err) => toast(err.message, 'error'))
|
|
2916
|
+
.finally(() => { preparingBrokerIds.delete(id); renderBrokers(); });
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2390
2919
|
renderBrokers();
|
|
2391
2920
|
if (t === 'history') {
|
|
2392
|
-
const
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2921
|
+
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
2922
|
+
post(`/api/brokers/${encodeURIComponent(id)}/history`, { year: curYear })
|
|
2923
|
+
.then((st) => { state.brokers = st; renderBrokers(); })
|
|
2924
|
+
.catch((err) => toast(err.message, 'error'));
|
|
2396
2925
|
}
|
|
2397
2926
|
return;
|
|
2398
2927
|
}
|
|
2399
2928
|
const b = e.target.closest('button[data-btrade]');
|
|
2400
|
-
if (!b)
|
|
2929
|
+
if (!b) {
|
|
2930
|
+
const fold = e.target.closest('[data-brk-fold]');
|
|
2931
|
+
if (fold) {
|
|
2932
|
+
const fid = fold.dataset.brkFold;
|
|
2933
|
+
openBrokerId = openBrokerId === fid ? null : fid;
|
|
2934
|
+
saveNavPatch({ openBrokerId });
|
|
2935
|
+
renderBrokers();
|
|
2936
|
+
}
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2401
2939
|
const id = b.dataset.bid;
|
|
2402
2940
|
const act = b.dataset.btrade;
|
|
2403
2941
|
if (act === 'cfg') {
|
|
@@ -2414,21 +2952,24 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2414
2952
|
const old = b.textContent;
|
|
2415
2953
|
try {
|
|
2416
2954
|
if (act === 'sync') {
|
|
2417
|
-
b.textContent = 'Обновляем…';
|
|
2955
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
|
|
2418
2956
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
2419
2957
|
toast('Портфель обновлён');
|
|
2420
2958
|
} else if (act === 'hist') {
|
|
2421
|
-
b.textContent = 'Загружаем…';
|
|
2422
|
-
|
|
2959
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Загружаем…';
|
|
2960
|
+
setBrokerSubTab(id, 'history');
|
|
2423
2961
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
|
|
2424
2962
|
toast('История операций загружена');
|
|
2425
2963
|
} else if (act === 'freeze') {
|
|
2426
|
-
b.textContent = 'Фиксируем…';
|
|
2427
2964
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
|
|
2428
|
-
|
|
2965
|
+
const updated = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2966
|
+
const isIso = updated?.legacyMode === 'isolate';
|
|
2967
|
+
toast(isIso
|
|
2968
|
+
? '🔒 Защита бумаг включена (режим «Изолированный карман» — старые бумаги не продаются)'
|
|
2969
|
+
: '🔓 Включён режим «Плавный переход» (разрешено плавное замещение старых бумаг под сигналы)');
|
|
2429
2970
|
} else if (act === 'prep') {
|
|
2430
|
-
b.textContent = 'Считаем лоты…';
|
|
2431
|
-
|
|
2971
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Считаем лоты…';
|
|
2972
|
+
setBrokerSubTab(id, 'trade');
|
|
2432
2973
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
|
|
2433
2974
|
toast('Пакет заявок по прогнозу рассчитан');
|
|
2434
2975
|
} else if (act === 'exec') {
|
|
@@ -2611,7 +3152,42 @@ function connectWs() {
|
|
|
2611
3152
|
};
|
|
2612
3153
|
}
|
|
2613
3154
|
|
|
2614
|
-
// ----------
|
|
3155
|
+
// ---------- окно подписания Договора-оферты в терминале ----------
|
|
3156
|
+
let termOfferCallback = null;
|
|
3157
|
+
const termOfferModal = $('#offer-modal');
|
|
3158
|
+
const termChk1 = $('#term-offer-chk1');
|
|
3159
|
+
const termChk2 = $('#term-offer-chk2');
|
|
3160
|
+
const termOfferAccept = $('#term-offer-accept');
|
|
3161
|
+
|
|
3162
|
+
function syncTermOfferBtn() {
|
|
3163
|
+
if (termOfferAccept) termOfferAccept.disabled = !(termChk1?.checked && termChk2?.checked);
|
|
3164
|
+
}
|
|
3165
|
+
termChk1?.addEventListener('change', syncTermOfferBtn);
|
|
3166
|
+
termChk2?.addEventListener('change', syncTermOfferBtn);
|
|
3167
|
+
$('#offer-close')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
|
|
3168
|
+
$('#term-offer-decline')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
|
|
3169
|
+
termOfferAccept?.addEventListener('click', async () => {
|
|
3170
|
+
termOfferAccept.disabled = true;
|
|
3171
|
+
try {
|
|
3172
|
+
await post('/api/auth/accept-offer').catch(() => {});
|
|
3173
|
+
termOfferModal.hidden = true;
|
|
3174
|
+
if (termOfferCallback) await termOfferCallback();
|
|
3175
|
+
} finally {
|
|
3176
|
+
termOfferCallback = null;
|
|
3177
|
+
syncTermOfferBtn();
|
|
3178
|
+
}
|
|
3179
|
+
});
|
|
3180
|
+
|
|
3181
|
+
function requireTermOffer(fn) {
|
|
3182
|
+
termOfferCallback = fn;
|
|
3183
|
+
if (termChk1) termChk1.checked = false;
|
|
3184
|
+
if (termChk2) termChk2.checked = false;
|
|
3185
|
+
syncTermOfferBtn();
|
|
3186
|
+
if (termOfferModal) termOfferModal.hidden = false;
|
|
3187
|
+
else fn();
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// ---------- профиль, тарифы ЮKassa, VIP-токен и боты Telegram/MAX ----------
|
|
2615
3191
|
$('#btn-user')?.addEventListener('click', () => {
|
|
2616
3192
|
switchTab('settings');
|
|
2617
3193
|
showSettingsSec('cloud');
|
|
@@ -2652,7 +3228,7 @@ async function renderCloudPane() {
|
|
|
2652
3228
|
adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
|
|
2653
3229
|
<div class="stxt">
|
|
2654
3230
|
<div class="slbl">Консоль администратора платформы</div>
|
|
2655
|
-
<div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, OAuth-провайдерами
|
|
3231
|
+
<div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
|
|
2656
3232
|
</div>
|
|
2657
3233
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2658
3234
|
<a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
|
|
@@ -2660,9 +3236,9 @@ async function renderCloudPane() {
|
|
|
2660
3236
|
</div>`;
|
|
2661
3237
|
} else {
|
|
2662
3238
|
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
|
|
3239
|
+
<summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
|
|
2664
3240
|
<form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
|
|
2665
|
-
<div class="muted small">Если вы
|
|
3241
|
+
<div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
|
|
2666
3242
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2667
3243
|
<input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
|
|
2668
3244
|
<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">
|
|
@@ -2672,63 +3248,51 @@ async function renderCloudPane() {
|
|
|
2672
3248
|
<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">
|
|
2673
3249
|
<button type="submit" class="primary">Сохранить ботов</button>
|
|
2674
3250
|
</div>
|
|
2675
|
-
<div class="muted small" style="margin-top:4px">Администрирование
|
|
3251
|
+
<div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
|
|
2676
3252
|
</form>
|
|
2677
3253
|
</details>`;
|
|
2678
3254
|
}
|
|
2679
3255
|
}
|
|
2680
3256
|
|
|
2681
|
-
const
|
|
2682
|
-
|
|
3257
|
+
const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
|
|
3258
|
+
? 'без ограничений суммы'
|
|
3259
|
+
: u.limits?.maxCapitalPerAccountRub > 0
|
|
3260
|
+
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
|
|
3261
|
+
: 'только демо-торговля';
|
|
3262
|
+
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
3263
|
+
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
3264
|
+
: 'без ограничений (полная мощность VIP)';
|
|
3265
|
+
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
2683
3266
|
|
|
2684
3267
|
box.innerHTML = `
|
|
2685
3268
|
<div class="srow sblock">
|
|
2686
3269
|
<div class="stxt">
|
|
2687
3270
|
<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>
|
|
2688
|
-
<div class="hint"
|
|
3271
|
+
<div class="hint">
|
|
3272
|
+
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Предел доходности стратегии: <b>${retLimStr}</b> · Виртуальных счетов: до <b>${u.limits?.maxVirtualAccounts ?? 1}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
|
|
3273
|
+
</div>
|
|
2689
3274
|
</div>
|
|
2690
3275
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
2691
3276
|
<button type="button" id="btn-cloud-verify">Проверить оплату</button>
|
|
2692
3277
|
<button type="button" id="btn-cloud-logout">Выйти</button>
|
|
2693
3278
|
</div>` : ''}
|
|
2694
3279
|
</div>
|
|
2695
|
-
|
|
2696
|
-
<div class="stxt">
|
|
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">
|
|
3280
|
+
<div class="srow sblock">
|
|
2709
3281
|
<div class="stxt">
|
|
2710
|
-
<div class="slbl"
|
|
3282
|
+
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
2711
3283
|
<div class="hint">
|
|
2712
|
-
|
|
2713
|
-
|
|
3284
|
+
• <b>«Старт» (0 ₽)</b> — тестирование стратегий любой сложности без реальной торговли;<br>
|
|
3285
|
+
• <b>«Инвестор» (3 900 ₽/мес)</b> — до 2 брокерских счетов (до 1 млн ₽ на каждом), торговля «Только в рост (без шорта)», до 50% годовых;<br>
|
|
3286
|
+
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов (до 10 млн ₽ на каждом), «В рост и в падение (с шортом)», до 100% годовых;<br>
|
|
3287
|
+
• <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
|
|
2714
3288
|
</div>
|
|
2715
3289
|
</div>
|
|
2716
3290
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2717
|
-
<
|
|
2718
|
-
<button type="button" id="btn-
|
|
2719
|
-
<button type="button" id="btn-regen-apikey" title="Перевыпустить API-ключ (старый перестанет работать)">Перевыпустить</button>
|
|
2720
|
-
</div>
|
|
2721
|
-
</div>`}
|
|
2722
|
-
<div class="srow sblock">
|
|
2723
|
-
<div class="stxt">
|
|
2724
|
-
<div class="slbl">Тариф и промокод</div>
|
|
2725
|
-
<div class="hint">Подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram / MAX и расширенных лимитов ИИ.</div>
|
|
2726
|
-
</div>
|
|
2727
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
2728
|
-
<input id="promo-code-in" placeholder="Промокод" style="width:150px">
|
|
2729
|
-
<button type="button" id="btn-promo-redeem">Применить код</button>
|
|
3291
|
+
<input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
|
|
3292
|
+
<button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
|
|
2730
3293
|
<button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
|
|
2731
3294
|
<button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
|
|
3295
|
+
<a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Запросить VIP →</a>
|
|
2732
3296
|
</div>
|
|
2733
3297
|
</div>
|
|
2734
3298
|
<div class="srow sblock">
|
|
@@ -2743,39 +3307,13 @@ async function renderCloudPane() {
|
|
|
2743
3307
|
${tgUrl ? `<a class="btn" href="${esc(tgUrl)}" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px">Открыть Telegram</a>` : ''}
|
|
2744
3308
|
${maxUrl ? `<a class="btn" href="${esc(maxUrl)}" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px">Открыть MAX</a>` : ''}
|
|
2745
3309
|
<button type="button" id="btn-notify-test">Тест уведомления</button>
|
|
3310
|
+
<a class="btn" href="/legal" target="_blank" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Договор-оферта →</a>
|
|
2746
3311
|
</div>
|
|
2747
3312
|
</div>
|
|
2748
3313
|
${adminLinkHtml}
|
|
2749
3314
|
${customBotsHtml}
|
|
2750
3315
|
`;
|
|
2751
3316
|
|
|
2752
|
-
box.querySelector('#btn-copy-apikey')?.addEventListener('click', () => {
|
|
2753
|
-
if (!apiKey) return;
|
|
2754
|
-
navigator.clipboard?.writeText(apiKey);
|
|
2755
|
-
toast('API-ключ скопирован в буфер обмена');
|
|
2756
|
-
});
|
|
2757
|
-
box.querySelector('#btn-copy-npx')?.addEventListener('click', () => {
|
|
2758
|
-
navigator.clipboard?.writeText(npxCmd);
|
|
2759
|
-
toast(`Команда «${npxCmd}» скопирована`);
|
|
2760
|
-
});
|
|
2761
|
-
box.querySelector('#btn-regen-apikey')?.addEventListener('click', async () => {
|
|
2762
|
-
if (!confirm('Перевыпустить API-ключ? Прежний ключ перестанет работать.')) return;
|
|
2763
|
-
try {
|
|
2764
|
-
await post('/api/auth/api-key/regenerate');
|
|
2765
|
-
toast('Новый API-ключ выпущен');
|
|
2766
|
-
renderCloudPane();
|
|
2767
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
2768
|
-
});
|
|
2769
|
-
box.querySelector('#btn-lic-activate')?.addEventListener('click', async () => {
|
|
2770
|
-
const k = box.querySelector('#lic-key-in')?.value.trim();
|
|
2771
|
-
if (!k) return toast('Введите API-ключ (na_live_…)', 'error');
|
|
2772
|
-
try {
|
|
2773
|
-
const r = await post('/api/license/activate', { apiKey: k });
|
|
2774
|
-
toast(`Ключ активирован! Тариф: ${r.license?.planName}`);
|
|
2775
|
-
renderCloudPane();
|
|
2776
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
2777
|
-
});
|
|
2778
|
-
|
|
2779
3317
|
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
2780
3318
|
await post('/api/auth/logout');
|
|
2781
3319
|
location.href = '/landing';
|
|
@@ -2787,11 +3325,14 @@ async function renderCloudPane() {
|
|
|
2787
3325
|
renderCloudPane();
|
|
2788
3326
|
} catch (e) { toast(e.message, 'error'); }
|
|
2789
3327
|
});
|
|
2790
|
-
box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click',
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
3328
|
+
box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', () => {
|
|
3329
|
+
const plan = b.dataset.checkout;
|
|
3330
|
+
requireTermOffer(async () => {
|
|
3331
|
+
try {
|
|
3332
|
+
const r = await post('/api/billing/checkout', { plan, months: 1, returnOrigin: location.origin });
|
|
3333
|
+
if (r.confirmationUrl) location.href = r.confirmationUrl;
|
|
3334
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
3335
|
+
});
|
|
2795
3336
|
}));
|
|
2796
3337
|
box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
|
|
2797
3338
|
try {
|
|
@@ -2799,14 +3340,16 @@ async function renderCloudPane() {
|
|
|
2799
3340
|
toast('Тестовое уведомление отправлено в привязанные мессенджеры');
|
|
2800
3341
|
} catch (e) { toast(e.message, 'error'); }
|
|
2801
3342
|
});
|
|
2802
|
-
box.querySelector('#btn-promo-redeem')?.addEventListener('click',
|
|
3343
|
+
box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
|
|
2803
3344
|
const code = box.querySelector('#promo-code-in')?.value.trim();
|
|
2804
|
-
if (!code) return;
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
3345
|
+
if (!code) return toast('Введите промокод или персональный VIP-токен', 'error');
|
|
3346
|
+
requireTermOffer(async () => {
|
|
3347
|
+
try {
|
|
3348
|
+
const r = await post('/api/auth/redeem-promo', { code });
|
|
3349
|
+
toast(`Код активирован! Ваш тариф: ${r.user?.planName}`);
|
|
3350
|
+
renderCloudPane();
|
|
3351
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
3352
|
+
});
|
|
2810
3353
|
});
|
|
2811
3354
|
box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
|
|
2812
3355
|
e.preventDefault();
|
|
@@ -2876,7 +3419,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2876
3419
|
}
|
|
2877
3420
|
});
|
|
2878
3421
|
|
|
2879
|
-
// ---------- старт ----------
|
|
3422
|
+
// ---------- старт и восстановление сохранённого места в интерфейсе ----------
|
|
2880
3423
|
(async function init() {
|
|
2881
3424
|
state.config = await api('/api/config');
|
|
2882
3425
|
buildForm();
|
|
@@ -2895,6 +3438,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
2895
3438
|
renderCloudPane();
|
|
2896
3439
|
}).catch(() => {});
|
|
2897
3440
|
}
|
|
2898
|
-
const
|
|
3441
|
+
const savedNav = loadSavedNav();
|
|
3442
|
+
const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
|
|
2899
3443
|
await loadRuns(initRunId);
|
|
3444
|
+
|
|
3445
|
+
// Восстанавливаем открытые подразделы и главную вкладку, где находился пользователь
|
|
3446
|
+
if (savedNav.nowSec) showNowSec(savedNav.nowSec);
|
|
3447
|
+
if (savedNav.settingsSec) showSettingsSec(savedNav.settingsSec);
|
|
3448
|
+
if (savedNav.mapSec) showMapSec(savedNav.mapSec).catch(() => {});
|
|
3449
|
+
if (savedNav.tab && ['overview', 'now', 'map', 'settings'].includes(savedNav.tab) && !urlParams.get('run')) {
|
|
3450
|
+
switchTab(savedNav.tab);
|
|
3451
|
+
}
|
|
3452
|
+
// В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
|
|
3453
|
+
setTimeout(() => refreshForecastIfStale(), 600);
|
|
2900
3454
|
})().catch((e) => toast(e.message, 'error'));
|