neuroalpha 0.3.12 → 0.3.13
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 +524 -211
- 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 +61 -29
- package/server/auth/index.js +109 -20
- package/server/billing/yookassa.js +1 -1
- package/server/brokers/index.js +179 -34
- 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 +188 -17
- package/server/model/linalg.js +1 -1
- package/server/model/live.js +6 -19
- 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 +12 -36
- 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,92 @@ $('#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;
|
|
892
1023
|
function renderLive(st) {
|
|
893
1024
|
state.live = st;
|
|
894
1025
|
const accs = st?.accounts || [];
|
|
@@ -898,13 +1029,17 @@ function renderLive(st) {
|
|
|
898
1029
|
$('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
|
|
899
1030
|
return;
|
|
900
1031
|
}
|
|
1032
|
+
if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
|
|
1033
|
+
openLiveId = accs[0].runId;
|
|
1034
|
+
}
|
|
901
1035
|
$('#live').innerHTML = accs.map((a) => {
|
|
1036
|
+
const isOpen = String(a.runId) === String(openLiveId);
|
|
902
1037
|
const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
|
|
903
1038
|
const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
|
|
904
1039
|
const retPct = a.returnPct ?? 0;
|
|
905
1040
|
const step = a.recent?.[0];
|
|
906
1041
|
const p = a.params || {};
|
|
907
|
-
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ?
|
|
1042
|
+
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
|
|
908
1043
|
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
909
1044
|
if (a.corridor) {
|
|
910
1045
|
const c = a.corridor;
|
|
@@ -917,29 +1052,33 @@ function renderLive(st) {
|
|
|
917
1052
|
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
1053
|
}
|
|
919
1054
|
const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
|
|
920
|
-
return `<div class="live-card${a.running ? '' : ' stopped'}" data-acc="${a.runId}">
|
|
921
|
-
<div class="
|
|
922
|
-
<div>
|
|
1055
|
+
return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
|
|
1056
|
+
<div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
1057
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1058
|
+
<span class="acc-caret">▶</span>
|
|
923
1059
|
<b>${esc(dispName)}</b>
|
|
924
|
-
<span class="tag ${a.running ? 'ok' : ''}"
|
|
1060
|
+
<span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
|
|
925
1061
|
${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
1062
|
</div>
|
|
928
|
-
<div class="row-gap">
|
|
1063
|
+
<div class="row-gap" style="align-items:center">
|
|
1064
|
+
<span class="small muted">На счёте: <b>${fmtNum(s.equity, 0)} ₽</b> (<span class="${cls(pnl)}">${fmtPct(retPct, 2)}</span>) · поз.: ${s.positions.length}</span>
|
|
929
1065
|
<button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
|
|
930
1066
|
${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
|
|
931
1067
|
<button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
|
|
932
1068
|
</div>
|
|
933
1069
|
</div>
|
|
934
|
-
<div
|
|
935
|
-
<div class="
|
|
936
|
-
<div class="
|
|
937
|
-
|
|
938
|
-
|
|
1070
|
+
<div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
|
|
1071
|
+
<div class="muted small" style="margin-bottom:8px">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
|
|
1072
|
+
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
1073
|
+
<div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">на счёте</div></div>
|
|
1074
|
+
<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>
|
|
1075
|
+
${corrHtml}
|
|
1076
|
+
<div class="kpi"><div class="v">${a.metrics?.full?.trades ?? 0}</div><div class="l">закрыто сделок</div></div>
|
|
1077
|
+
</div>
|
|
1078
|
+
<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>
|
|
1079
|
+
${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>`}
|
|
1080
|
+
${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
|
|
939
1081
|
</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
1082
|
</div>`;
|
|
944
1083
|
}).join('');
|
|
945
1084
|
syncUiAvailability();
|
|
@@ -981,6 +1120,14 @@ $('#live').addEventListener('click', async (e) => {
|
|
|
981
1120
|
const id = Number(askBtn.dataset.liveAsk);
|
|
982
1121
|
const acc = (state.live?.accounts || []).find((x) => x.runId === id);
|
|
983
1122
|
sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const foldHead = e.target.closest('[data-live-fold]');
|
|
1126
|
+
if (foldHead) {
|
|
1127
|
+
const id = foldHead.dataset.liveFold;
|
|
1128
|
+
openLiveId = String(openLiveId) === String(id) ? null : id;
|
|
1129
|
+
saveNavPatch({ openLiveId });
|
|
1130
|
+
renderLive(state.live);
|
|
984
1131
|
}
|
|
985
1132
|
});
|
|
986
1133
|
|
|
@@ -1138,6 +1285,7 @@ function drawProfilesMap() {
|
|
|
1138
1285
|
}
|
|
1139
1286
|
async function showMapSec(sec) {
|
|
1140
1287
|
document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
|
|
1288
|
+
saveNavPatch({ mapSec: sec });
|
|
1141
1289
|
if (sec === 'journal') {
|
|
1142
1290
|
$('#map-sec').hidden = true;
|
|
1143
1291
|
$('#journal-box').hidden = false;
|
|
@@ -1215,6 +1363,8 @@ function showSettingsSec(sec) {
|
|
|
1215
1363
|
document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
|
|
1216
1364
|
document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
1217
1365
|
document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
1366
|
+
saveNavPatch({ settingsSec: sec });
|
|
1367
|
+
if (sec === 'cloud') renderCloudPane().catch(() => {});
|
|
1218
1368
|
}
|
|
1219
1369
|
document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
|
|
1220
1370
|
const b = e.target.closest('[data-sec]');
|
|
@@ -1961,7 +2111,13 @@ const INST_KIND_LABEL = {
|
|
|
1961
2111
|
futures: ['Фьючерс', 'mid'],
|
|
1962
2112
|
};
|
|
1963
2113
|
const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
|
|
1964
|
-
const brokerTabById = new Map(); // accId -> 'assets' | 'trade' | 'history'
|
|
2114
|
+
const brokerTabById = new Map(Object.entries(loadSavedNav().brokerTabs || {})); // accId -> 'assets' | 'trade' | 'history'
|
|
2115
|
+
function setBrokerSubTab(id, tab) {
|
|
2116
|
+
brokerTabById.set(id, tab);
|
|
2117
|
+
saveNavPatch({ brokerTabs: Object.fromEntries(brokerTabById) });
|
|
2118
|
+
}
|
|
2119
|
+
let openBrokerId = loadSavedNav().openBrokerId ?? null;
|
|
2120
|
+
let openBrokerCfgId = loadSavedNav().openBrokerCfgId ?? null;
|
|
1965
2121
|
let editingBrokerId = null;
|
|
1966
2122
|
|
|
1967
2123
|
async function loadBrokers() {
|
|
@@ -1976,40 +2132,58 @@ function renderBrokers() {
|
|
|
1976
2132
|
if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
1977
2133
|
updateTabBadges();
|
|
1978
2134
|
|
|
1979
|
-
// 1) список в Настройках
|
|
2135
|
+
// 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
1980
2136
|
const listEl = $('#brokers-list');
|
|
1981
2137
|
if (listEl) {
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2138
|
+
if (accs.length && !accs.some((a) => a.id === openBrokerCfgId)) {
|
|
2139
|
+
openBrokerCfgId = accs[0].id;
|
|
2140
|
+
}
|
|
2141
|
+
listEl.innerHTML = accs.length ? accs.map((a) => {
|
|
2142
|
+
const ls = a.lastSync;
|
|
2143
|
+
const isCfgOpen = a.id === openBrokerCfgId;
|
|
2144
|
+
return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2145
|
+
<div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
|
|
2146
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2147
|
+
<span class="acc-caret">▶</span>
|
|
2148
|
+
<b>${esc(a.name)}</b>
|
|
2149
|
+
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2150
|
+
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2151
|
+
${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
|
|
2152
|
+
</div>
|
|
2153
|
+
<div class="row-gap" style="align-items:center">
|
|
2154
|
+
${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
|
|
2155
|
+
<button type="button" data-bact="sync">Проверить связь</button>
|
|
2156
|
+
<button type="button" data-bact="edit" class="icon">Изменить</button>
|
|
2157
|
+
<button type="button" data-bact="del" class="icon">Удалить</button>
|
|
2158
|
+
</div>
|
|
1996
2159
|
</div>
|
|
1997
|
-
<div class="
|
|
1998
|
-
|
|
1999
|
-
${a.
|
|
2160
|
+
<div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
|
|
2161
|
+
<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>
|
|
2162
|
+
<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>
|
|
2163
|
+
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
|
|
2164
|
+
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2165
|
+
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2166
|
+
<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>
|
|
2167
|
+
${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
|
|
2168
|
+
${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
2169
|
</div>
|
|
2001
|
-
</div
|
|
2170
|
+
</div>`;
|
|
2171
|
+
}).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
|
|
2002
2172
|
}
|
|
2003
2173
|
|
|
2004
|
-
// 2) карточки во вкладке «Торговля → Брокерские счета» (
|
|
2174
|
+
// 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
2005
2175
|
const tradeEl = $('#brokers-trade');
|
|
2006
2176
|
if (!tradeEl) return;
|
|
2007
2177
|
if (!accs.length) {
|
|
2008
2178
|
tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
|
|
2009
2179
|
return;
|
|
2010
2180
|
}
|
|
2181
|
+
if (!accs.some((a) => a.id === openBrokerId)) {
|
|
2182
|
+
openBrokerId = accs[0].id;
|
|
2183
|
+
}
|
|
2011
2184
|
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
2012
2185
|
tradeEl.innerHTML = accs.map((a) => {
|
|
2186
|
+
const isOpen = a.id === openBrokerId;
|
|
2013
2187
|
const ls = a.lastSync;
|
|
2014
2188
|
const pb = a.pendingBatch;
|
|
2015
2189
|
const lh = a.lastHistory;
|
|
@@ -2100,16 +2274,19 @@ function renderBrokers() {
|
|
|
2100
2274
|
const resHtml = pb.results?.length
|
|
2101
2275
|
? `<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
2276
|
: '';
|
|
2277
|
+
const throttleHtml = pb.planThrottleNote
|
|
2278
|
+
? `<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>`
|
|
2279
|
+
: '';
|
|
2103
2280
|
batchHtml = `<div class="stab-box">
|
|
2104
2281
|
<div class="stab-head">
|
|
2105
2282
|
<b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
|
|
2106
2283
|
${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
|
|
2107
2284
|
<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>
|
|
2285
|
+
${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
2286
|
</div>
|
|
2111
2287
|
${ordRows}
|
|
2112
2288
|
${resHtml}
|
|
2289
|
+
${throttleHtml}
|
|
2113
2290
|
</div>`;
|
|
2114
2291
|
} else {
|
|
2115
2292
|
batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
|
|
@@ -2120,6 +2297,104 @@ function renderBrokers() {
|
|
|
2120
2297
|
const eqHistHtml = eqHist.length > 1
|
|
2121
2298
|
? `<div class="muted small" style="margin-bottom:8px">Точки синхронизации баланса (переживают перезапуск сервера): ${eqHist.slice(-6).map((pt) => `${fmtTs(pt.ts)} — <b>${fmtNum(pt.totalRub, 0)} ₽</b>`).join(' · ')}</div>`
|
|
2122
2299
|
: '';
|
|
2300
|
+
const renderOpsTable = (opsList) => {
|
|
2301
|
+
const byDay = new Map();
|
|
2302
|
+
for (const op of opsList) {
|
|
2303
|
+
const dayKey = op.date ? new Date(op.date + MSK).toISOString().slice(0, 10) : 'none';
|
|
2304
|
+
if (!byDay.has(dayKey)) byDay.set(dayKey, []);
|
|
2305
|
+
byDay.get(dayKey).push(op);
|
|
2306
|
+
}
|
|
2307
|
+
const rows = [];
|
|
2308
|
+
for (const [, dayOps] of byDay) {
|
|
2309
|
+
let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
|
|
2310
|
+
const symQty = new Map(), symCash = new Map();
|
|
2311
|
+
for (const op of dayOps) {
|
|
2312
|
+
const pay = Number(op.paymentRub) || 0;
|
|
2313
|
+
if (op.kind === 'buy') {
|
|
2314
|
+
tradesCnt++;
|
|
2315
|
+
buyRub += Math.abs(pay);
|
|
2316
|
+
if (op.symbol) {
|
|
2317
|
+
symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2318
|
+
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2319
|
+
}
|
|
2320
|
+
} else if (op.kind === 'sell') {
|
|
2321
|
+
tradesCnt++;
|
|
2322
|
+
sellRub += Math.abs(pay);
|
|
2323
|
+
if (op.symbol) {
|
|
2324
|
+
symQty.set(op.symbol, (symQty.get(op.symbol) || 0) - (Number(op.qty) || 0));
|
|
2325
|
+
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2326
|
+
}
|
|
2327
|
+
} else if (op.kind === 'fee') {
|
|
2328
|
+
if (/дивиденд|купон/i.test(op.title || '')) divTaxRub += pay;
|
|
2329
|
+
else feeRub += pay;
|
|
2330
|
+
} else if (op.kind === 'div') {
|
|
2331
|
+
divTaxRub += pay;
|
|
2332
|
+
} else if (op.kind === 'in' || op.kind === 'out') {
|
|
2333
|
+
inOutRub += pay;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
|
|
2337
|
+
let closedTradePnl = 0, closedSyms = 0;
|
|
2338
|
+
for (const [sym, netQ] of symQty) {
|
|
2339
|
+
if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
|
|
2340
|
+
closedTradePnl += symCash.get(sym);
|
|
2341
|
+
closedSyms++;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (closedSyms > 0) closedTradePnl += feeRub;
|
|
2345
|
+
|
|
2346
|
+
const summaryParts = [];
|
|
2347
|
+
if (closedSyms > 0) {
|
|
2348
|
+
summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽</b>`);
|
|
2349
|
+
} else if (tradesCnt > 0) {
|
|
2350
|
+
const tParts = [];
|
|
2351
|
+
if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
|
|
2352
|
+
if (sellRub > 0) tParts.push(`продано на ${fmtNum(sellRub, 0)} ₽`);
|
|
2353
|
+
if (feeRub !== 0) tParts.push(`комиссия ${fmtNum(feeRub, 2)} ₽`);
|
|
2354
|
+
summaryParts.push(tParts.join(' · '));
|
|
2355
|
+
} else if (feeRub !== 0) {
|
|
2356
|
+
summaryParts.push(`комиссии и налоги: <span class="neg">${fmtNum(feeRub, 2)} ₽</span>`);
|
|
2357
|
+
}
|
|
2358
|
+
if (divTaxRub !== 0) {
|
|
2359
|
+
summaryParts.push(`дивиденды/купоны (чистыми): <b class="${cls(divTaxRub)}">${divTaxRub > 0 ? '+' : ''}${fmtNum(divTaxRub, 2)} ₽</b>`);
|
|
2360
|
+
}
|
|
2361
|
+
if (inOutRub !== 0) {
|
|
2362
|
+
summaryParts.push(`${inOutRub > 0 ? 'пополнение' : 'вывод'}: <b class="${cls(inOutRub)}">${inOutRub > 0 ? '+' : ''}${fmtNum(inOutRub, 0)} ₽</b>`);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
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>`);
|
|
2366
|
+
for (const op of dayOps) {
|
|
2367
|
+
rows.push(`<tr>
|
|
2368
|
+
<td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
|
|
2369
|
+
<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>
|
|
2370
|
+
<td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
|
|
2371
|
+
<td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2372
|
+
<td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
|
|
2373
|
+
<td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
2374
|
+
</tr>`);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
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>`;
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2380
|
+
const byYear = new Map();
|
|
2381
|
+
for (const op of lh?.operations || []) {
|
|
2382
|
+
const y = op.date ? new Date(op.date + MSK).toISOString().slice(0, 4) : 'Прочее';
|
|
2383
|
+
if (!byYear.has(y)) byYear.set(y, []);
|
|
2384
|
+
byYear.get(y).push(op);
|
|
2385
|
+
}
|
|
2386
|
+
const years = [...byYear.keys()];
|
|
2387
|
+
const yearsHtml = years.map((y, idx) => {
|
|
2388
|
+
const yOps = byYear.get(y);
|
|
2389
|
+
const yTrades = yOps.filter((o) => o.kind === 'buy' || o.kind === 'sell').length;
|
|
2390
|
+
const yDivs = yOps.filter((o) => o.kind === 'div').reduce((s, o) => s + (o.paymentRub || 0), 0);
|
|
2391
|
+
const yMeta = `операций: ${yOps.length}${yTrades ? ` · сделок: ${yTrades}` : ''}${yDivs > 0 ? ` · дивиденды и купоны: +${fmtNum(yDivs, 0)} ₽` : ''}`;
|
|
2392
|
+
return `<details class="hist-year-box"${idx === 0 ? ' open' : ''}>
|
|
2393
|
+
<summary><span>📁 ${esc(y)} год</span><span class="muted small">${yMeta}</span></summary>
|
|
2394
|
+
${renderOpsTable(yOps)}
|
|
2395
|
+
</details>`;
|
|
2396
|
+
}).join('');
|
|
2397
|
+
|
|
2123
2398
|
const histHtml = lh?.operations?.length
|
|
2124
2399
|
? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
|
|
2125
2400
|
<div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
|
|
@@ -2127,52 +2402,56 @@ function renderBrokers() {
|
|
|
2127
2402
|
<div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
|
|
2128
2403
|
<div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
|
|
2129
2404
|
</div>
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
<
|
|
2137
|
-
|
|
2138
|
-
: `${eqHistHtml}<p class="muted small">Нажмите <b>«Загрузить историю операций»</b>, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
|
|
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>
|
|
2405
|
+
${yearsHtml}`
|
|
2406
|
+
: `${eqHistHtml}<p class="muted small">Нажмите <b>«↻»</b> или переключитесь сюда, чтобы получить из брокера выписку всех покупок, продаж, комиссий и дивидендов по счёту.</p>`;
|
|
2407
|
+
|
|
2408
|
+
return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2409
|
+
<div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
2410
|
+
<div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2411
|
+
<span class="acc-caret">▶</span>
|
|
2412
|
+
<b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
|
|
2145
2413
|
<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
2414
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2150
2415
|
${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
2416
|
</div>
|
|
2153
|
-
<div class="row-gap" style="
|
|
2154
|
-
|
|
2155
|
-
<button type="button" data-btrade="hist" data-bid="${esc(a.id)}">История операций</button>
|
|
2156
|
-
${lMode !== 'takeover' && ls?.positions?.length ? `<button type="button" data-btrade="freeze" data-bid="${esc(a.id)}" title="Запомнить все текущие бумаги на счёте как ваш личный старый портфель, который стратегия не будет продавать в режиме «Изолированный карман»">Защитить текущие бумаги</button>` : ''}
|
|
2157
|
-
<button type="button" data-btrade="cfg" data-bid="${esc(a.id)}" title="Открыть настройки и предохранители этого брокерского счёта">⚙ Настроить счёт</button>
|
|
2158
|
-
<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}">Рассчитать заявки (в лотах)</button>
|
|
2417
|
+
<div class="row-gap" style="align-items:center">
|
|
2418
|
+
${ls?.ok ? `<span class="small muted">Всего: <b>${fmtNum(ls.totalRub, 0)} ₽</b> · свободно: <b class="pos">${fmtNum(ls.cashRub, 0)} ₽</b> · поз.: ${ls.positions?.length || 0}</span>` : ''}
|
|
2159
2419
|
</div>
|
|
2160
2420
|
</div>
|
|
2161
|
-
${
|
|
2162
|
-
<div class="
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
<
|
|
2171
|
-
|
|
2421
|
+
<div ${isOpen ? '' : 'hidden'}>
|
|
2422
|
+
<div class="brk-specs">
|
|
2423
|
+
<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>
|
|
2424
|
+
<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>
|
|
2425
|
+
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
|
|
2426
|
+
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2427
|
+
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2428
|
+
<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>
|
|
2429
|
+
</div>
|
|
2430
|
+
<div class="brk-toolbar">
|
|
2431
|
+
${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>` : ''}
|
|
2432
|
+
${a.autoSchedule === false ? `<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}" title="Авто-торговля по расписанию выключена — нажмите для ручного расчёта заявок">⚡ Рассчитать заявки</button>` : ''}
|
|
2433
|
+
<span class="grow"></span>
|
|
2434
|
+
${a.autoSchedule !== false ? `<button type="button" class="brk-icon-btn" data-btrade="prep" data-bid="${esc(a.id)}" title="Внеочередной пересчёт пакета заявок прямо сейчас (помимо авто-расписания)">⚡</button>` : ''}
|
|
2435
|
+
<button type="button" class="brk-icon-btn" data-btrade="sync" data-bid="${esc(a.id)}" title="Обновить портфель и баланс с брокера">↻</button>
|
|
2436
|
+
<button type="button" class="brk-icon-btn" data-btrade="hist" data-bid="${esc(a.id)}" title="Обновить историю операций по счёту">📜</button>
|
|
2437
|
+
<button type="button" class="brk-icon-btn" data-btrade="cfg" data-bid="${esc(a.id)}" title="Настроить счёт и предохранители">⚙</button>
|
|
2438
|
+
</div>
|
|
2439
|
+
${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
2440
|
+
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2441
|
+
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2442
|
+
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
|
|
2443
|
+
<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>
|
|
2444
|
+
</div>` : ''}
|
|
2445
|
+
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2446
|
+
<div class="brk-subtabs">
|
|
2447
|
+
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
|
|
2448
|
+
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
|
2449
|
+
<button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
|
|
2450
|
+
</div>
|
|
2451
|
+
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2452
|
+
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
2453
|
+
<div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
|
|
2172
2454
|
</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
2455
|
</div>`;
|
|
2177
2456
|
}).join('');
|
|
2178
2457
|
}
|
|
@@ -2292,7 +2571,7 @@ $('#btn-broker-discover')?.addEventListener('click', async () => {
|
|
|
2292
2571
|
box.hidden = false;
|
|
2293
2572
|
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
2573
|
<div>
|
|
2295
|
-
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok"
|
|
2574
|
+
<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
2575
|
<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
2576
|
</div>
|
|
2298
2577
|
<button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
|
|
@@ -2350,7 +2629,16 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2350
2629
|
|
|
2351
2630
|
$('#brokers-list').addEventListener('click', async (e) => {
|
|
2352
2631
|
const b = e.target.closest('button[data-bact]');
|
|
2353
|
-
if (!b)
|
|
2632
|
+
if (!b) {
|
|
2633
|
+
const fold = e.target.closest('[data-bcfg-fold]');
|
|
2634
|
+
if (fold) {
|
|
2635
|
+
const fid = fold.dataset.bcfgFold;
|
|
2636
|
+
openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
|
|
2637
|
+
saveNavPatch({ openBrokerCfgId });
|
|
2638
|
+
renderBrokers();
|
|
2639
|
+
}
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2354
2642
|
const id = b.closest('[data-bid]').dataset.bid;
|
|
2355
2643
|
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2356
2644
|
if (!acc) return;
|
|
@@ -2386,7 +2674,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2386
2674
|
if (tabBtn) {
|
|
2387
2675
|
const id = tabBtn.dataset.bid;
|
|
2388
2676
|
const t = tabBtn.dataset.btab;
|
|
2389
|
-
|
|
2677
|
+
setBrokerSubTab(id, t);
|
|
2390
2678
|
renderBrokers();
|
|
2391
2679
|
if (t === 'history') {
|
|
2392
2680
|
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
@@ -2397,7 +2685,16 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2397
2685
|
return;
|
|
2398
2686
|
}
|
|
2399
2687
|
const b = e.target.closest('button[data-btrade]');
|
|
2400
|
-
if (!b)
|
|
2688
|
+
if (!b) {
|
|
2689
|
+
const fold = e.target.closest('[data-brk-fold]');
|
|
2690
|
+
if (fold) {
|
|
2691
|
+
const fid = fold.dataset.brkFold;
|
|
2692
|
+
openBrokerId = openBrokerId === fid ? null : fid;
|
|
2693
|
+
saveNavPatch({ openBrokerId });
|
|
2694
|
+
renderBrokers();
|
|
2695
|
+
}
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2401
2698
|
const id = b.dataset.bid;
|
|
2402
2699
|
const act = b.dataset.btrade;
|
|
2403
2700
|
if (act === 'cfg') {
|
|
@@ -2414,21 +2711,24 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2414
2711
|
const old = b.textContent;
|
|
2415
2712
|
try {
|
|
2416
2713
|
if (act === 'sync') {
|
|
2417
|
-
b.textContent = 'Обновляем…';
|
|
2714
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
|
|
2418
2715
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
2419
2716
|
toast('Портфель обновлён');
|
|
2420
2717
|
} else if (act === 'hist') {
|
|
2421
|
-
b.textContent = 'Загружаем…';
|
|
2422
|
-
|
|
2718
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Загружаем…';
|
|
2719
|
+
setBrokerSubTab(id, 'history');
|
|
2423
2720
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
|
|
2424
2721
|
toast('История операций загружена');
|
|
2425
2722
|
} else if (act === 'freeze') {
|
|
2426
|
-
b.textContent = 'Фиксируем…';
|
|
2427
2723
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
|
|
2428
|
-
|
|
2724
|
+
const updated = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2725
|
+
const isIso = updated?.legacyMode === 'isolate';
|
|
2726
|
+
toast(isIso
|
|
2727
|
+
? '🔒 Защита бумаг включена (режим «Изолированный карман» — старые бумаги не продаются)'
|
|
2728
|
+
: '🔓 Включён режим «Плавный переход» (разрешено плавное замещение старых бумаг под сигналы)');
|
|
2429
2729
|
} else if (act === 'prep') {
|
|
2430
|
-
b.textContent = 'Считаем лоты…';
|
|
2431
|
-
|
|
2730
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Считаем лоты…';
|
|
2731
|
+
setBrokerSubTab(id, 'trade');
|
|
2432
2732
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
|
|
2433
2733
|
toast('Пакет заявок по прогнозу рассчитан');
|
|
2434
2734
|
} else if (act === 'exec') {
|
|
@@ -2611,7 +2911,42 @@ function connectWs() {
|
|
|
2611
2911
|
};
|
|
2612
2912
|
}
|
|
2613
2913
|
|
|
2614
|
-
// ----------
|
|
2914
|
+
// ---------- окно подписания Договора-оферты в терминале ----------
|
|
2915
|
+
let termOfferCallback = null;
|
|
2916
|
+
const termOfferModal = $('#offer-modal');
|
|
2917
|
+
const termChk1 = $('#term-offer-chk1');
|
|
2918
|
+
const termChk2 = $('#term-offer-chk2');
|
|
2919
|
+
const termOfferAccept = $('#term-offer-accept');
|
|
2920
|
+
|
|
2921
|
+
function syncTermOfferBtn() {
|
|
2922
|
+
if (termOfferAccept) termOfferAccept.disabled = !(termChk1?.checked && termChk2?.checked);
|
|
2923
|
+
}
|
|
2924
|
+
termChk1?.addEventListener('change', syncTermOfferBtn);
|
|
2925
|
+
termChk2?.addEventListener('change', syncTermOfferBtn);
|
|
2926
|
+
$('#offer-close')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
|
|
2927
|
+
$('#term-offer-decline')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
|
|
2928
|
+
termOfferAccept?.addEventListener('click', async () => {
|
|
2929
|
+
termOfferAccept.disabled = true;
|
|
2930
|
+
try {
|
|
2931
|
+
await post('/api/auth/accept-offer').catch(() => {});
|
|
2932
|
+
termOfferModal.hidden = true;
|
|
2933
|
+
if (termOfferCallback) await termOfferCallback();
|
|
2934
|
+
} finally {
|
|
2935
|
+
termOfferCallback = null;
|
|
2936
|
+
syncTermOfferBtn();
|
|
2937
|
+
}
|
|
2938
|
+
});
|
|
2939
|
+
|
|
2940
|
+
function requireTermOffer(fn) {
|
|
2941
|
+
termOfferCallback = fn;
|
|
2942
|
+
if (termChk1) termChk1.checked = false;
|
|
2943
|
+
if (termChk2) termChk2.checked = false;
|
|
2944
|
+
syncTermOfferBtn();
|
|
2945
|
+
if (termOfferModal) termOfferModal.hidden = false;
|
|
2946
|
+
else fn();
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
// ---------- профиль, тарифы ЮKassa, VIP-токен и боты Telegram/MAX ----------
|
|
2615
2950
|
$('#btn-user')?.addEventListener('click', () => {
|
|
2616
2951
|
switchTab('settings');
|
|
2617
2952
|
showSettingsSec('cloud');
|
|
@@ -2652,7 +2987,7 @@ async function renderCloudPane() {
|
|
|
2652
2987
|
adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
|
|
2653
2988
|
<div class="stxt">
|
|
2654
2989
|
<div class="slbl">Консоль администратора платформы</div>
|
|
2655
|
-
<div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, OAuth-провайдерами
|
|
2990
|
+
<div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
|
|
2656
2991
|
</div>
|
|
2657
2992
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2658
2993
|
<a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
|
|
@@ -2660,9 +2995,9 @@ async function renderCloudPane() {
|
|
|
2660
2995
|
</div>`;
|
|
2661
2996
|
} else {
|
|
2662
2997
|
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
|
|
2998
|
+
<summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
|
|
2664
2999
|
<form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
|
|
2665
|
-
<div class="muted small">Если вы
|
|
3000
|
+
<div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
|
|
2666
3001
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2667
3002
|
<input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
|
|
2668
3003
|
<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 +3007,51 @@ async function renderCloudPane() {
|
|
|
2672
3007
|
<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
3008
|
<button type="submit" class="primary">Сохранить ботов</button>
|
|
2674
3009
|
</div>
|
|
2675
|
-
<div class="muted small" style="margin-top:4px">Администрирование
|
|
3010
|
+
<div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
|
|
2676
3011
|
</form>
|
|
2677
3012
|
</details>`;
|
|
2678
3013
|
}
|
|
2679
3014
|
}
|
|
2680
3015
|
|
|
2681
|
-
const
|
|
2682
|
-
|
|
3016
|
+
const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
|
|
3017
|
+
? 'без ограничений суммы'
|
|
3018
|
+
: u.limits?.maxCapitalPerAccountRub > 0
|
|
3019
|
+
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
|
|
3020
|
+
: 'только демо-торговля';
|
|
3021
|
+
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
3022
|
+
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
3023
|
+
: 'без ограничений (полная мощность VIP)';
|
|
3024
|
+
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
2683
3025
|
|
|
2684
3026
|
box.innerHTML = `
|
|
2685
3027
|
<div class="srow sblock">
|
|
2686
3028
|
<div class="stxt">
|
|
2687
3029
|
<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"
|
|
3030
|
+
<div class="hint">
|
|
3031
|
+
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Предел доходности стратегии: <b>${retLimStr}</b> · Виртуальных счетов: до <b>${u.limits?.maxVirtualAccounts ?? 1}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
|
|
3032
|
+
</div>
|
|
2689
3033
|
</div>
|
|
2690
3034
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
2691
3035
|
<button type="button" id="btn-cloud-verify">Проверить оплату</button>
|
|
2692
3036
|
<button type="button" id="btn-cloud-logout">Выйти</button>
|
|
2693
3037
|
</div>` : ''}
|
|
2694
3038
|
</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">
|
|
3039
|
+
<div class="srow sblock">
|
|
2709
3040
|
<div class="stxt">
|
|
2710
|
-
<div class="slbl"
|
|
3041
|
+
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
2711
3042
|
<div class="hint">
|
|
2712
|
-
|
|
2713
|
-
|
|
3043
|
+
• <b>«Старт» (0 ₽)</b> — тестирование стратегий любой сложности без реальной торговли;<br>
|
|
3044
|
+
• <b>«Инвестор» (3 900 ₽/мес)</b> — до 2 брокерских счетов (до 1 млн ₽ на каждом), торговля «Только в рост (без шорта)», до 50% годовых;<br>
|
|
3045
|
+
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов (до 10 млн ₽ на каждом), «В рост и в падение (с шортом)», до 100% годовых;<br>
|
|
3046
|
+
• <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
|
|
2714
3047
|
</div>
|
|
2715
3048
|
</div>
|
|
2716
3049
|
<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>
|
|
3050
|
+
<input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
|
|
3051
|
+
<button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
|
|
2730
3052
|
<button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
|
|
2731
3053
|
<button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
|
|
3054
|
+
<a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Запросить VIP →</a>
|
|
2732
3055
|
</div>
|
|
2733
3056
|
</div>
|
|
2734
3057
|
<div class="srow sblock">
|
|
@@ -2743,39 +3066,13 @@ async function renderCloudPane() {
|
|
|
2743
3066
|
${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
3067
|
${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
3068
|
<button type="button" id="btn-notify-test">Тест уведомления</button>
|
|
3069
|
+
<a class="btn" href="/legal" target="_blank" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Договор-оферта →</a>
|
|
2746
3070
|
</div>
|
|
2747
3071
|
</div>
|
|
2748
3072
|
${adminLinkHtml}
|
|
2749
3073
|
${customBotsHtml}
|
|
2750
3074
|
`;
|
|
2751
3075
|
|
|
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
3076
|
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
2780
3077
|
await post('/api/auth/logout');
|
|
2781
3078
|
location.href = '/landing';
|
|
@@ -2787,11 +3084,14 @@ async function renderCloudPane() {
|
|
|
2787
3084
|
renderCloudPane();
|
|
2788
3085
|
} catch (e) { toast(e.message, 'error'); }
|
|
2789
3086
|
});
|
|
2790
|
-
box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click',
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
3087
|
+
box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', () => {
|
|
3088
|
+
const plan = b.dataset.checkout;
|
|
3089
|
+
requireTermOffer(async () => {
|
|
3090
|
+
try {
|
|
3091
|
+
const r = await post('/api/billing/checkout', { plan, months: 1, returnOrigin: location.origin });
|
|
3092
|
+
if (r.confirmationUrl) location.href = r.confirmationUrl;
|
|
3093
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
3094
|
+
});
|
|
2795
3095
|
}));
|
|
2796
3096
|
box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
|
|
2797
3097
|
try {
|
|
@@ -2799,14 +3099,16 @@ async function renderCloudPane() {
|
|
|
2799
3099
|
toast('Тестовое уведомление отправлено в привязанные мессенджеры');
|
|
2800
3100
|
} catch (e) { toast(e.message, 'error'); }
|
|
2801
3101
|
});
|
|
2802
|
-
box.querySelector('#btn-promo-redeem')?.addEventListener('click',
|
|
3102
|
+
box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
|
|
2803
3103
|
const code = box.querySelector('#promo-code-in')?.value.trim();
|
|
2804
|
-
if (!code) return;
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
3104
|
+
if (!code) return toast('Введите промокод или персональный VIP-токен', 'error');
|
|
3105
|
+
requireTermOffer(async () => {
|
|
3106
|
+
try {
|
|
3107
|
+
const r = await post('/api/auth/redeem-promo', { code });
|
|
3108
|
+
toast(`Код активирован! Ваш тариф: ${r.user?.planName}`);
|
|
3109
|
+
renderCloudPane();
|
|
3110
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
3111
|
+
});
|
|
2810
3112
|
});
|
|
2811
3113
|
box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
|
|
2812
3114
|
e.preventDefault();
|
|
@@ -2876,7 +3178,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2876
3178
|
}
|
|
2877
3179
|
});
|
|
2878
3180
|
|
|
2879
|
-
// ---------- старт ----------
|
|
3181
|
+
// ---------- старт и восстановление сохранённого места в интерфейсе ----------
|
|
2880
3182
|
(async function init() {
|
|
2881
3183
|
state.config = await api('/api/config');
|
|
2882
3184
|
buildForm();
|
|
@@ -2895,6 +3197,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
2895
3197
|
renderCloudPane();
|
|
2896
3198
|
}).catch(() => {});
|
|
2897
3199
|
}
|
|
2898
|
-
const
|
|
3200
|
+
const savedNav = loadSavedNav();
|
|
3201
|
+
const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
|
|
2899
3202
|
await loadRuns(initRunId);
|
|
3203
|
+
|
|
3204
|
+
// Восстанавливаем открытые подразделы и главную вкладку, где находился пользователь
|
|
3205
|
+
if (savedNav.nowSec) showNowSec(savedNav.nowSec);
|
|
3206
|
+
if (savedNav.settingsSec) showSettingsSec(savedNav.settingsSec);
|
|
3207
|
+
if (savedNav.mapSec) showMapSec(savedNav.mapSec).catch(() => {});
|
|
3208
|
+
if (savedNav.tab && ['overview', 'now', 'map', 'settings'].includes(savedNav.tab) && !urlParams.get('run')) {
|
|
3209
|
+
switchTab(savedNav.tab);
|
|
3210
|
+
}
|
|
3211
|
+
// В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
|
|
3212
|
+
setTimeout(() => refreshForecastIfStale(), 600);
|
|
2900
3213
|
})().catch((e) => toast(e.message, 'error'));
|