neuroalpha 0.2.0 → 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 +17 -0
- package/README.md +9 -3
- package/bin/cli.js +69 -69
- package/config/seed-run.json +1 -0
- package/config/strategy.json +4 -10
- package/deploy/install.sh +128 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +171 -0
- package/package.json +15 -5
- package/public/admin.html +449 -0
- package/public/app.js +1182 -379
- package/public/index.html +87 -22
- package/public/landing.html +1217 -870
- package/public/legal.html +51 -8
- package/public/styles.css +71 -16
- package/server/api/routes.js +220 -35
- package/server/auth/index.js +633 -340
- package/server/billing/yookassa.js +1 -0
- package/server/brokers/index.js +1369 -645
- package/server/config.js +6 -4
- package/server/data/moex.js +1 -0
- package/server/db.js +69 -4
- package/server/hub.js +1 -0
- package/server/index.js +10 -2
- package/server/llm/client.js +1 -0
- package/server/llm/providers.js +50 -3
- package/server/model/dataset.js +362 -352
- package/server/model/forecast.js +202 -37
- package/server/model/linalg.js +1 -0
- package/server/model/live.js +476 -486
- package/server/model/metrics.js +1 -0
- package/server/model/models.js +31 -21
- package/server/model/profiles.js +1 -0
- package/server/model/research.js +128 -205
- package/server/model/vocab.js +1 -0
- package/server/news/index.js +1 -0
- package/server/notify/index.js +304 -168
- package/server/params-meta.js +22 -26
- package/server/settings.js +1 -0
- package/server/strategist/analytics.js +2 -1
- package/server/strategist/index.js +7 -7
- package/server/strategist/tools.js +2 -1
- package/server/util.js +1 -0
- package/server/model/gpu-worker.html +0 -11
- package/server/model/gpu-worker.js +0 -60
- package/server/model/tf-node.js +0 -26
- package/server/model/transformer-core.js +0 -330
- package/server/model/transformer-gpu.js +0 -221
- package/server/model/transformer-pool.js +0 -200
- package/server/model/transformer-worker.js +0 -28
- package/server/model/transformer.js +0 -57
package/public/app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
1
2
|
const $ = (s, root = document) => root.querySelector(s);
|
|
2
3
|
const MSK = 3 * 3600000;
|
|
3
4
|
|
|
@@ -12,7 +13,34 @@ const post = (url, body = {}) => api(url, { method: 'POST', body });
|
|
|
12
13
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
13
14
|
const fmtNum = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v) ? '—' : Number(v).toLocaleString('ru-RU', { maximumFractionDigits: d }));
|
|
14
15
|
const fmtPct = (v, d = 1) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${fmtNum(v, d)}%`);
|
|
15
|
-
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
|
+
}
|
|
16
44
|
const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
|
|
17
45
|
const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
|
|
18
46
|
const md = (s) => {
|
|
@@ -38,25 +66,59 @@ function toast(text, type = 'info', action = null) {
|
|
|
38
66
|
}
|
|
39
67
|
|
|
40
68
|
const state = { config: null, settings: null, runs: [], run: null, active: null, forecast: null, live: null, chat: { state: null, msgs: new Map(), stream: new Map() }, mapSel: null, mapMode: 'behav', profiles: null, profilesBusy: false };
|
|
41
|
-
const MODELS = ['inertia', 'leadlag', 'attention'
|
|
42
|
-
const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту'
|
|
69
|
+
const MODELS = ['inertia', 'leadlag', 'attention'];
|
|
70
|
+
const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту' };
|
|
43
71
|
const MODEL_HINT = {
|
|
44
72
|
inertia: 'Простейшая планка: акция предсказывается только по своему недавнему движению.',
|
|
45
73
|
leadlag: 'Каждая акция предсказывается по недавним движениям всех остальных, без карты.',
|
|
46
74
|
attention: 'Влияние соседей по карте рынка через 12 «голов внимания».',
|
|
47
|
-
transformer: 'Нейросеть с вниманием между акциями; учится заново в каждом окне.',
|
|
48
75
|
};
|
|
49
|
-
const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7'
|
|
76
|
+
const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7' };
|
|
50
77
|
const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '#0891b2', '#db2777', '#64748b'];
|
|
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 = {
|
|
@@ -166,40 +239,48 @@ const eqLines = {
|
|
|
166
239
|
inertia: eqChart.addLineSeries({ color: COLORS.inertia, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
167
240
|
leadlag: eqChart.addLineSeries({ color: COLORS.leadlag, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
168
241
|
attention: eqChart.addLineSeries({ color: COLORS.attention, lineWidth: 2, priceLineVisible: false, lastValueVisible: false }),
|
|
169
|
-
transformer: eqChart.addLineSeries({ color: COLORS.transformer, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
170
242
|
};
|
|
171
243
|
const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
|
|
172
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
|
+
}
|
|
173
252
|
|
|
174
253
|
const RUN_PARTS = [
|
|
175
|
-
['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' :
|
|
176
|
-
['modelHorizon', (v) => `Прогноз: ${fmtNum(v, 3)}ч`],
|
|
177
|
-
['modelMinEdge', (v) => `Порог: ${fmtNum(v, 3)}`],
|
|
178
|
-
['modelExitRatio', (v) => (
|
|
179
|
-
['modelMinHoldHours', (v) => (v > 0 ? `Удерж.: ${fmtNum(v, 2)}ч` : null)],
|
|
254
|
+
['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : 'Авто (мульти-горизонт 1,5–3,5ч)')],
|
|
255
|
+
['modelHorizon', (v, p) => (p.modelAutoAdapt === false ? `Прогноз: ${fmtNum(v, 3)}ч` : null)],
|
|
256
|
+
['modelMinEdge', (v, p) => (p.modelAutoAdapt === false ? `Порог: ${fmtNum(v, 3)}` : null)],
|
|
257
|
+
['modelExitRatio', (v, p) => (p.modelAutoAdapt === false ? `Выход: ${fmtNum(v, 2)}` : null)],
|
|
180
258
|
['modelTopK', (v) => `Акции: ${v}`],
|
|
181
|
-
['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' :
|
|
259
|
+
['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : 'Доли поровну')],
|
|
260
|
+
['modelDirection', (v) => (v === 'longshort' ? 'В рост и в падение (с шортом)' : 'Только в рост (без шорта)')],
|
|
261
|
+
['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
|
|
182
262
|
['modelTrainDays', (v) => `Окно: ${v}дн`],
|
|
263
|
+
['modelHistoryDays', (v) => `История: ${v}дн`],
|
|
183
264
|
['modelEmbedDim', (v) => `Эмбд.: ${v}`],
|
|
184
|
-
['modelUseMacro', (v) => (v ? 'Макро' : null)],
|
|
185
265
|
['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
|
|
186
|
-
['
|
|
187
|
-
['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : null)],
|
|
188
|
-
['modelUseTransformer', (v, p) => (v || p.modelType === 'transformer' ? 'Трансформер' : null)],
|
|
189
|
-
['transformerEpochs', (v, p) => (usesTf(p) && v !== state.config?.defaults?.transformerEpochs ? `Эпох: ${v}` : null)],
|
|
190
|
-
['transformerGpu', (v, p) => (usesTf(p) && v ? 'Видеокарта' : null)],
|
|
266
|
+
['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
|
|
191
267
|
];
|
|
192
|
-
function usesTf(p) { return p.modelUseTransformer || p.modelType === 'transformer'; }
|
|
193
268
|
function runTitle(r, html = false) {
|
|
194
269
|
const p = r.params || {}, d = state.config?.defaults || {};
|
|
195
|
-
const
|
|
196
|
-
|
|
270
|
+
const m = r.metrics?.full || {};
|
|
271
|
+
const ps = r.metrics?.period ? calcPeriodStats(r.metrics.period, m.returnPct, 0) : null;
|
|
272
|
+
const status = r.status === 'done'
|
|
273
|
+
? `${fmtPct(m.returnPct)}${ps ? ` (~${fmtPct(ps.cagr, 0)}/год)` : ''}${m.maxDrawdownPct ? `, просадка ${fmtNum(m.maxDrawdownPct, 1)}%` : ''}`
|
|
274
|
+
: r.status === 'running' ? 'идёт…' : 'ошибка';
|
|
275
|
+
const diffs = RUN_PARTS.map(([k, f]) => {
|
|
276
|
+
if (p[k] == null || JSON.stringify(p[k]) === JSON.stringify(d[k])) return null;
|
|
197
277
|
const t = f(p[k], p);
|
|
198
|
-
|
|
278
|
+
if (!t) return null;
|
|
279
|
+
return html ? `<span style="color:var(--yellow)">${t}</span>` : t;
|
|
199
280
|
}).filter(Boolean);
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
return [head, ...
|
|
281
|
+
const head = p._title ? `№${r.id} (${html ? esc(p._title) : p._title})` : `№${r.id}`;
|
|
282
|
+
const diffPart = diffs.length ? diffs : ['базовые настройки'];
|
|
283
|
+
return [head, ...diffPart, ...(p._source === 'ai' ? ['Стратег'] : []), status].join(' — ');
|
|
203
284
|
}
|
|
204
285
|
const runTip = (r) => {
|
|
205
286
|
const p = r.params || {};
|
|
@@ -222,31 +303,31 @@ async function loadRuns(selectId) {
|
|
|
222
303
|
? state.runs.map((r) => `<option value="${r.id}" title="${runTip(r)}">${runTitle(r)}</option>`).join('')
|
|
223
304
|
: '<option value="">Исследований пока нет</option>';
|
|
224
305
|
sel.innerHTML = optHtml;
|
|
306
|
+
const brkSel = $('#broker-run-select');
|
|
307
|
+
if (brkSel) {
|
|
308
|
+
const curVal = brkSel.value;
|
|
309
|
+
brkSel.innerHTML = '<option value="">Текущие настройки</option>' + state.runs.filter((r) => r.status === 'done').map((r) => `<option value="${r.id}">${runTitle(r)}</option>`).join('');
|
|
310
|
+
brkSel.value = curVal;
|
|
311
|
+
}
|
|
225
312
|
for (const m of state.chat.msgs.values()) if (m.kind === 'event' && m.data?.eventType === 'model') upsertMsg(m);
|
|
226
|
-
if ($('#btn-clean')) $('#btn-clean').disabled = state.runs.length < 1;
|
|
227
|
-
$('#run-bar').hidden = !state.run;
|
|
228
|
-
const doneRun = state.run?.status === 'done';
|
|
229
|
-
$('#btn-stability').hidden = !doneRun;
|
|
230
|
-
$('#btn-to-live').hidden = !doneRun;
|
|
231
|
-
$('#btn-cmp-run').hidden = state.runs.length < 2;
|
|
232
|
-
syncApplyBtn();
|
|
233
313
|
const id = selectId ?? state.run?.id ?? state.runs.find((r) => r.status === 'done')?.id;
|
|
234
314
|
updateTabBadges();
|
|
235
315
|
if (id && state.runs.some((r) => r.id === id)) {
|
|
236
316
|
sel.value = String(id);
|
|
237
317
|
if (state.run?.id !== id || selectId) await openRun(id);
|
|
238
|
-
|
|
318
|
+
else syncUiAvailability();
|
|
319
|
+
} else {
|
|
320
|
+
state.run = null;
|
|
321
|
+
syncUiAvailability();
|
|
322
|
+
renderOverview();
|
|
323
|
+
}
|
|
239
324
|
}
|
|
240
325
|
|
|
241
326
|
async function openRun(id) {
|
|
242
327
|
state.run = await api(`/api/runs/${id}`);
|
|
243
328
|
$('#run-select').value = String(id);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
$('#btn-stability').hidden = !doneRun;
|
|
247
|
-
$('#btn-to-live').hidden = !doneRun;
|
|
248
|
-
$('#btn-cmp-run').hidden = state.runs.length < 2;
|
|
249
|
-
syncApplyBtn();
|
|
329
|
+
saveNavPatch({ runId: Number(id) });
|
|
330
|
+
syncUiAvailability();
|
|
250
331
|
renderOverview();
|
|
251
332
|
setupMapSlider();
|
|
252
333
|
if (!$('#tab-map').hidden) drawMap();
|
|
@@ -283,29 +364,88 @@ $('#btn-del').addEventListener('click', async () => {
|
|
|
283
364
|
toast(`Эксперимент №${r.id} удалён`);
|
|
284
365
|
} catch (e) { toast(e.message, 'error'); }
|
|
285
366
|
});
|
|
286
|
-
// Проверить устойчивость открытого прогона (5 сдвигов периода −8…+8 дней)
|
|
287
367
|
function stabilityUi(runningId) {
|
|
288
368
|
state.stabilityActive = runningId || null;
|
|
289
369
|
const busy = Boolean(runningId);
|
|
290
|
-
$('#btn-stability').disabled = busy;
|
|
291
|
-
$('#btn-stability').textContent = busy ? 'Идёт проверка устойчивости…' : 'Проверить устойчивость';
|
|
292
|
-
$('#btn-cancel').hidden = !busy && !state.active;
|
|
293
370
|
if (!busy && !state.active) setProgress(null);
|
|
294
371
|
updateTabBadges();
|
|
372
|
+
syncUiAvailability();
|
|
295
373
|
}
|
|
296
|
-
|
|
374
|
+
// Единая синхронизация видимости и доступности элементов управления от текущего состояния системы
|
|
375
|
+
function syncUiAvailability() {
|
|
297
376
|
const r = state.run;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
377
|
+
const isRunning = r?.status === 'running' || (r && r.id === state.active);
|
|
378
|
+
const isDone = r?.status === 'done' && !isRunning;
|
|
379
|
+
const aiReady = Boolean(state.chat.state?.llm && !state.chat.state?.busy);
|
|
380
|
+
const aiBusy = Boolean(state.chat.state?.busy);
|
|
381
|
+
|
|
382
|
+
// 1. Панель действий эксперимента (#run-bar): скрыта во время расчёта
|
|
383
|
+
if ($('#run-bar')) $('#run-bar').hidden = !r || isRunning;
|
|
384
|
+
if ($('#btn-to-live')) $('#btn-to-live').hidden = !isDone;
|
|
385
|
+
if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
|
|
386
|
+
if ($('#btn-ask-run')) {
|
|
387
|
+
$('#btn-ask-run').hidden = !isDone;
|
|
388
|
+
$('#btn-ask-run').disabled = !aiReady;
|
|
389
|
+
$('#btn-ask-run').title = !state.chat.state?.llm
|
|
390
|
+
? 'ИИ не подключён (Настройки → Модели ИИ)'
|
|
391
|
+
: aiBusy
|
|
392
|
+
? 'Дождитесь завершения текущего ответа Стратега'
|
|
393
|
+
: 'Попросить Стратега разобрать этот эксперимент';
|
|
306
394
|
}
|
|
307
|
-
|
|
308
|
-
|
|
395
|
+
if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
|
|
396
|
+
syncApplyBtn();
|
|
397
|
+
|
|
398
|
+
// 2. Выпадающий список экспериментов и кнопка «Исследовать»
|
|
399
|
+
const anyJobRunning = Boolean(state.active || state.stabilityActive);
|
|
400
|
+
if ($('#btn-model')) $('#btn-model').hidden = anyJobRunning;
|
|
401
|
+
if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
|
|
402
|
+
if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
|
|
403
|
+
|
|
404
|
+
// 3. Кнопки обращения к Стратегу в прогнозе и виртуальных счетах
|
|
405
|
+
if ($('#btn-ask-forecast')) {
|
|
406
|
+
$('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
|
|
407
|
+
$('#btn-ask-forecast').title = !state.forecast
|
|
408
|
+
? 'Сначала дождитесь расчёта прогноза'
|
|
409
|
+
: !aiReady
|
|
410
|
+
? 'Стратег сейчас занят или ИИ не подключён'
|
|
411
|
+
: 'Попросить Стратега разобрать текущий прогноз';
|
|
412
|
+
}
|
|
413
|
+
document.querySelectorAll('[data-live-ask]').forEach((b) => {
|
|
414
|
+
b.disabled = !aiReady;
|
|
415
|
+
b.title = !aiReady ? 'Стратег сейчас занят или ИИ не подключён' : 'Попросить Стратега разобрать этот виртуальный счёт';
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// 4. Зависимые настройки в форме (при включённом Авто-режиме или выключенном Трансформере)
|
|
419
|
+
syncSettingsDependencies();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function syncSettingsDependencies() {
|
|
423
|
+
const form = $('#params');
|
|
424
|
+
if (!form || !state.settings) return;
|
|
425
|
+
const s = state.settings;
|
|
426
|
+
const autoOn = s.modelAutoAdapt !== false;
|
|
427
|
+
const dir = s.modelDirection || 'long';
|
|
428
|
+
|
|
429
|
+
const setRowEnabled = (key, enabled, whyDisabled = '') => {
|
|
430
|
+
const row = form.querySelector(`.srow[data-key="${key}"]`);
|
|
431
|
+
if (!row) return;
|
|
432
|
+
row.style.opacity = enabled ? '1' : '0.45';
|
|
433
|
+
const ctrl = form.elements[key];
|
|
434
|
+
if (ctrl) {
|
|
435
|
+
ctrl.disabled = !enabled;
|
|
436
|
+
ctrl.title = enabled ? '' : whyDisabled;
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
|
|
441
|
+
setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
442
|
+
setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
443
|
+
setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
|
|
444
|
+
setRowEnabled('modelMarketFilter', true, '');
|
|
445
|
+
|
|
446
|
+
const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
|
|
447
|
+
if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
|
|
448
|
+
}
|
|
309
449
|
$('#btn-to-live').addEventListener('click', async () => {
|
|
310
450
|
const r = state.run;
|
|
311
451
|
if (!r || r.status !== 'done') return;
|
|
@@ -322,7 +462,7 @@ $('#btn-to-live').addEventListener('click', async () => {
|
|
|
322
462
|
} catch (e) { toast(e.message, 'error'); }
|
|
323
463
|
});
|
|
324
464
|
// «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
|
|
325
|
-
const APPLY_KEYS = ['modelAutoAdapt', '
|
|
465
|
+
const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
|
|
326
466
|
function runSettingsDiff(r = state.run) {
|
|
327
467
|
if (!r?.params || !state.settings) return { entries: [], lines: [] };
|
|
328
468
|
const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
|
|
@@ -359,8 +499,9 @@ function syncApplyBtn() {
|
|
|
359
499
|
syncShowcaseBtn();
|
|
360
500
|
const btn = $('#btn-apply');
|
|
361
501
|
if (!btn) return;
|
|
502
|
+
const isDone = state.run?.status === 'done' && state.run.id !== state.active;
|
|
362
503
|
const { lines } = runSettingsDiff();
|
|
363
|
-
btn.hidden = !
|
|
504
|
+
btn.hidden = !isDone || !lines.length;
|
|
364
505
|
if (lines.length) {
|
|
365
506
|
btn.textContent = `✓ Применить эти настройки (${lines.length})`;
|
|
366
507
|
btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
|
|
@@ -378,26 +519,44 @@ $('#btn-apply').addEventListener('click', async () => {
|
|
|
378
519
|
toast(`Настройки из эксперимента №${r.id} применены`);
|
|
379
520
|
});
|
|
380
521
|
|
|
381
|
-
|
|
522
|
+
// Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
|
|
523
|
+
function calcPeriodStats(period, returnPct, marketPct) {
|
|
524
|
+
const from = period?.from || 0, till = period?.till || 0;
|
|
525
|
+
const calDays = Math.max(1, Math.round((till - from) / 86400000));
|
|
526
|
+
const years = calDays / 365.25;
|
|
527
|
+
const months = Math.max(1, Math.round(calDays / 30.44));
|
|
528
|
+
const yrsLabel = years >= 1 ? `${fmtNum(years, 1)} г. (${months} мес.)` : `${months} мес.`;
|
|
529
|
+
const dateRange = from && till ? `${fmtDate(from)} — ${fmtDate(till)}` : '';
|
|
530
|
+
const r = Number(returnPct ?? 0);
|
|
531
|
+
const m = Number(marketPct ?? 0);
|
|
532
|
+
// CAGR (среднегодовой темп роста с реинвестированием) и простое среднее в год
|
|
533
|
+
const cagr = years >= 0.25 && r > -99 ? ((Math.pow(1 + r / 100, 1 / years) - 1) * 100) : (r / Math.max(years, 0.1));
|
|
534
|
+
const avgSimple = r / Math.max(years, 0.1);
|
|
535
|
+
const mktAnnual = years >= 0.25 && m > -99 ? ((Math.pow(1 + m / 100, 1 / years) - 1) * 100) : (m / Math.max(years, 0.1));
|
|
536
|
+
return { calDays, years, months, yrsLabel, dateRange, cagr, avgSimple, mktAnnual };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function verdictHtml(c, oosA) {
|
|
382
540
|
const a = c.attention, base = c.inertia;
|
|
383
541
|
if (!a) return '';
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
: '';
|
|
386
546
|
let vs = '';
|
|
387
547
|
if (base) {
|
|
388
548
|
const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
|
|
389
|
-
if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs =
|
|
390
|
-
else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs =
|
|
391
|
-
else vs =
|
|
392
|
-
|
|
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}`;
|
|
393
555
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (!neutral && a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий, рынок за то же время ${fmtPct(a.marketPct)}. ${vs}${unreliable}`;
|
|
399
|
-
if ((a.grossReturnPct ?? 0) > 0) return `<b style="color:var(--yellow)">Сигнал есть, но комиссии его съедают:</b> без комиссий ${fmtPct(a.grossReturnPct)}, с комиссиями ${fmtPct(a.returnPct)}. Попробуйте увеличить «Порог выгоды». ${vs}${unreliable}`;
|
|
400
|
-
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}`;
|
|
401
560
|
}
|
|
402
561
|
|
|
403
562
|
const MONTHS = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
|
|
@@ -456,40 +615,214 @@ function renderStability(st) {
|
|
|
456
615
|
box.innerHTML = `<div class="stab-head"><span class="tag ${vCls}">${vLbl}</span><span><b>Медиана ${fmtPct(st.medianPct)}</b> · разброс от <span class="${cls(st.minPct)}">${fmtPct(st.minPct)}</span> до <span class="${cls(st.maxPct)}">${fmtPct(st.maxPct)}</span> · в плюсе ${st.positive} из ${st.count} · худшая просадка ${fmtNum(st.maxDrawdownPct, 1)}%</span><span class="muted small">(${kindLbl})</span></div><div class="stab-chips">${chips}</div>`;
|
|
457
616
|
}
|
|
458
617
|
|
|
618
|
+
// Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
|
|
619
|
+
const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
|
|
620
|
+
|
|
621
|
+
function resetLiveTrain(runId) {
|
|
622
|
+
liveTrainState.runId = runId;
|
|
623
|
+
liveTrainState.stage = 'data';
|
|
624
|
+
liveTrainState.loadedSymbols = [];
|
|
625
|
+
liveTrainState.foldIcs = [];
|
|
626
|
+
liveTrainState.lastViz = null;
|
|
627
|
+
liveTrainState.text = '';
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function drawLiveTrainMap(viz) {
|
|
631
|
+
const cv = $('#ld-map-cv');
|
|
632
|
+
if (!cv || !viz?.pts?.length) return;
|
|
633
|
+
const W = cv.clientWidth || 440, H = 250;
|
|
634
|
+
const dpr = window.devicePixelRatio || 1;
|
|
635
|
+
cv.width = W * dpr; cv.height = H * dpr;
|
|
636
|
+
const ctx = cv.getContext('2d');
|
|
637
|
+
ctx.scale(dpr, dpr);
|
|
638
|
+
const syms = liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
|
|
639
|
+
const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
|
|
640
|
+
const xy = layout(f, W, H);
|
|
641
|
+
const light = document.body.classList.contains('light');
|
|
642
|
+
// лёгкие соединительные линии внутри одного кластера к ближайшему соседу
|
|
643
|
+
ctx.lineWidth = 1;
|
|
644
|
+
for (let i = 0; i < xy.length; i++) {
|
|
645
|
+
let bestJ = -1, bestD = 68;
|
|
646
|
+
for (let j = i + 1; j < xy.length; j++) {
|
|
647
|
+
if (f.cl[i] !== f.cl[j]) continue;
|
|
648
|
+
const d = Math.hypot(xy[i][0] - xy[j][0], xy[i][1] - xy[j][1]);
|
|
649
|
+
if (d < bestD) { bestD = d; bestJ = j; }
|
|
650
|
+
}
|
|
651
|
+
if (bestJ >= 0) {
|
|
652
|
+
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
653
|
+
ctx.strokeStyle = `${col}33`;
|
|
654
|
+
ctx.beginPath(); ctx.moveTo(...xy[i]); ctx.lineTo(...xy[bestJ]); ctx.stroke();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
f.symbols.forEach((s, i) => {
|
|
658
|
+
const [x, y] = xy[i];
|
|
659
|
+
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
660
|
+
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2);
|
|
661
|
+
ctx.fillStyle = col;
|
|
662
|
+
ctx.fill();
|
|
663
|
+
ctx.fillStyle = light ? '#57606a' : '#8b949e';
|
|
664
|
+
ctx.font = '10px system-ui';
|
|
665
|
+
ctx.fillText(s, x + 6, y + 3);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function renderLiveTraining() {
|
|
670
|
+
const empty = $('#overview-empty');
|
|
671
|
+
if (!empty || empty.hidden) return;
|
|
672
|
+
const st = liveTrainState;
|
|
673
|
+
empty.classList.add('live-dash');
|
|
674
|
+
const v = st.lastViz;
|
|
675
|
+
if (!v) {
|
|
676
|
+
// Этап 1: загрузка котировок и дивидендов с Мосбиржи
|
|
677
|
+
const chips = st.loadedSymbols.slice(-36).map((s) => `<span class="ld-sym ok">✓ ${esc(s)}</span>`).join('');
|
|
678
|
+
empty.innerHTML = `<div class="ld-card">
|
|
679
|
+
<div class="ld-title"><span>Этап 1 из 2 · Загрузка и сверка исторических свечей и дивидендов МосБиржи</span><span class="muted small">${esc(st.text || 'Подготовка датасета…')}</span></div>
|
|
680
|
+
<div class="muted small">Для каждой из ~100 акций индекса широкого рынка загружаются 10-минутные свечи, сверяются дивидендные отсечки по фактическим утренним разрывам цены и рассчитывается почасовой суточный профиль объёма.</div>
|
|
681
|
+
${chips ? `<div class="ld-syms">${chips}</div>` : ''}
|
|
682
|
+
</div>`;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Этап 2: пошаговое скользящее обучение (Walk-Forward)
|
|
687
|
+
const span = Math.max(1, (v.periodTill || 1) - (v.periodFrom || 0));
|
|
688
|
+
const pctPos = (ts) => Math.max(0, Math.min(100, ((ts - v.periodFrom) / span) * 100));
|
|
689
|
+
const trLeft = pctPos(v.trainFrom), trRight = pctPos(v.testFrom), teRight = pctPos(v.testTill);
|
|
690
|
+
const firstTestLeft = st.firstTestLeft ?? trRight;
|
|
691
|
+
if (st.firstTestLeft === undefined) st.firstTestLeft = trRight;
|
|
692
|
+
|
|
693
|
+
const ics = st.foldIcs;
|
|
694
|
+
const posFolds = ics.filter((x) => x > 0).length;
|
|
695
|
+
const meanIc = ics.length ? ics.reduce((a, b) => a + b, 0) / ics.length : 0;
|
|
696
|
+
const maxIc = Math.max(0.05, ...ics.map(Math.abs));
|
|
697
|
+
const icBars = Array.from({ length: v.folds }, (_, idx) => {
|
|
698
|
+
const val = ics[idx];
|
|
699
|
+
if (val === undefined) return `<div class="ld-ic-bar" style="height:3px;background:var(--line)" title="Шаг ${idx + 1}: ещё впереди"></div>`;
|
|
700
|
+
const h = Math.max(5, Math.round((Math.abs(val) / maxIc) * 54));
|
|
701
|
+
const col = val >= 0 ? 'var(--green)' : 'var(--red)';
|
|
702
|
+
return `<div class="ld-ic-bar" style="height:${h}px;background:${col}" title="Шаг ${idx + 1}: совпадение прогноза ${fmtNum(val, 3)}"></div>`;
|
|
703
|
+
}).join('');
|
|
704
|
+
|
|
705
|
+
let headsHtml = '';
|
|
706
|
+
if (v.heads?.length) {
|
|
707
|
+
const maxC = Math.max(...v.heads.map((h) => Math.abs(h.coef)), ...(v.self || []).map(Math.abs), 1e-6);
|
|
708
|
+
const bar = (label, val) => {
|
|
709
|
+
const wd = (Math.abs(val) / maxC) * 50;
|
|
710
|
+
return `<div class="headbar"><span>${esc(label)}</span><div class="b"><div style="left:${val >= 0 ? 50 : 50 - wd}%;width:${wd}%;background:${val >= 0 ? 'var(--green)' : 'var(--red)'}"></div></div><span class="num ${cls(val)}">${val > 0 ? '+' : ''}${fmtNum(val, 3)}</span></div>`;
|
|
711
|
+
};
|
|
712
|
+
headsHtml = [
|
|
713
|
+
...[...v.heads].sort((x, y) => Math.abs(y.coef) - Math.abs(x.coef)).slice(0, 7).map((h) => bar(h.label, h.coef)),
|
|
714
|
+
...(v.self?.length ? [bar('своё движение за последний час', v.self[0])] : []),
|
|
715
|
+
].join('');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
empty.innerHTML = `
|
|
719
|
+
<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
|
720
|
+
<div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
|
|
721
|
+
<div class="kpi"><div class="v ${cls(meanIc)}">${meanIc > 0 ? '+' : ''}${fmtNum(meanIc, 3)}</div><div class="l">среднее совпадение (IC)</div><div class="sub">шагов в плюсе: ${posFolds} из ${ics.length} (${ics.length ? Math.round((posFolds / ics.length) * 100) : 0}%)</div></div>
|
|
722
|
+
<div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
|
|
723
|
+
<div class="kpi"><div class="v ${cls(v.foldIc ?? 0)}">${v.foldIc !== null ? `${v.foldIc > 0 ? '+' : ''}${fmtNum(v.foldIc, 3)}` : '—'}</div><div class="l">точность текущего месяца</div><div class="sub">окно обучения до ${fmtDate(v.testFrom)}</div></div>
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<div class="ld-card">
|
|
727
|
+
<div class="ld-title">
|
|
728
|
+
<span>Движение скользящего окна по истории (без заглядывания в будущее)</span>
|
|
729
|
+
<span class="muted small">Обучение: ${fmtDate(v.trainFrom)} — ${fmtDate(v.testFrom)} → Прогноз: до ${fmtDate(v.testTill)}</span>
|
|
730
|
+
</div>
|
|
731
|
+
<div class="ld-tl">
|
|
732
|
+
<div class="ld-tl-done" style="left:${firstTestLeft.toFixed(1)}%;width:${Math.max(0, trRight - firstTestLeft).toFixed(1)}%"></div>
|
|
733
|
+
<div class="ld-tl-train" style="left:${trLeft.toFixed(1)}%;width:${Math.max(3, trRight - trLeft).toFixed(1)}%">Обучение</div>
|
|
734
|
+
<div class="ld-tl-test" style="left:${trRight.toFixed(1)}%;width:${Math.max(1.5, teRight - trRight).toFixed(1)}%" title="Прогнозируемый месяц"></div>
|
|
735
|
+
</div>
|
|
736
|
+
<div class="ld-title" style="margin-top:10px;margin-bottom:2px"><span class="muted small">Точность угадывания порядка акций (IC) по каждому пройденному месяцу</span><span class="muted small">выше нуля — лучше случайного</span></div>
|
|
737
|
+
<div class="ld-ics">${icBars}</div>
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
<div class="ld-grid">
|
|
741
|
+
<div class="ld-card">
|
|
742
|
+
<div class="ld-title"><span>Живая карта рынка на ${fmtDate(v.testFrom)}</span><span class="muted small">кластеры совместного движения</span></div>
|
|
743
|
+
<canvas id="ld-map-cv" style="width:100%;height:250px;display:block"></canvas>
|
|
744
|
+
</div>
|
|
745
|
+
<div class="ld-card">
|
|
746
|
+
<div class="ld-title"><span>Веса голов внимания (текущее окно)</span><span class="muted small"><span class="pos">переток</span> / <span class="neg">возврат</span></span></div>
|
|
747
|
+
${headsHtml || '<div class="muted small">Расчёт весов…</div>'}
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
`;
|
|
751
|
+
drawLiveTrainMap(v);
|
|
752
|
+
}
|
|
753
|
+
|
|
459
754
|
function renderOverview() {
|
|
460
755
|
const run = state.run;
|
|
461
756
|
const empty = $('#overview-empty');
|
|
462
757
|
if (!run?.extra || run.status !== 'done') {
|
|
463
758
|
$('#overview').hidden = true;
|
|
464
759
|
empty.hidden = false;
|
|
760
|
+
if (run?.status === 'running' || state.active) {
|
|
761
|
+
renderLiveTraining();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
empty.classList.remove('live-dash');
|
|
465
765
|
empty.innerHTML = run && run.status !== 'done'
|
|
466
|
-
?
|
|
766
|
+
? `Исследование не завершилось: ${esc(run.error || run.status)}`
|
|
467
767
|
: `<b>Идея.</b> Каждая акция — как слово со своим местом на карте рынка. Недавние движения всех акций — контекст.
|
|
468
768
|
Стратегия, как языковая модель, предсказывает «следующее слово» — какие акции сдвинутся в ближайшие часы.<br><br>
|
|
469
769
|
Нажмите <b>«Исследовать»</b>: стратегия пошагово проверится на двух годах истории (около минуты; первый раз — несколько минут на загрузку цен).
|
|
470
770
|
Или попросите Стратега слева.`;
|
|
471
771
|
return;
|
|
472
772
|
}
|
|
773
|
+
empty.classList.remove('live-dash');
|
|
473
774
|
empty.hidden = true;
|
|
474
775
|
$('#overview').hidden = false;
|
|
475
776
|
const ex = run.extra;
|
|
476
777
|
const c = Object.fromEntries(MODELS.map((n) => [n, ex.comparison[n]?.full]));
|
|
477
778
|
const chosen = run.metrics?.model?.chosen || 'attention';
|
|
478
779
|
const a = c[chosen] || {};
|
|
780
|
+
const fullM = run.metrics?.full || {};
|
|
781
|
+
const oosA = ex.comparison?.[chosen]?.outOfSample || run.metrics?.outOfSample || {};
|
|
782
|
+
const pStats = calcPeriodStats(run.metrics?.period, a.returnPct, a.marketPct);
|
|
479
783
|
const dir = run.params?.modelDirection || 'long';
|
|
480
|
-
const
|
|
481
|
-
?
|
|
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>`
|
|
482
786
|
: '';
|
|
483
|
-
|
|
484
|
-
|
|
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;
|
|
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)}`;
|
|
485
793
|
$('#kpis').innerHTML = [
|
|
486
|
-
kpi(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
794
|
+
kpi(
|
|
795
|
+
`<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`,
|
|
796
|
+
`итог за ${pStats.yrsLabel}`,
|
|
797
|
+
sideSub,
|
|
798
|
+
`Общий чистый доход портфеля за ${pStats.dateRange || 'период'} после вычета комиссий (без комиссий: ${fmtPct(a.grossReturnPct)})`,
|
|
799
|
+
),
|
|
800
|
+
kpi(
|
|
801
|
+
`<span class="${cls(pStats.cagr)}">${fmtPct(pStats.cagr, 1)}</span>`,
|
|
802
|
+
'средний годовой (CAGR)',
|
|
803
|
+
`простое среднее: ${fmtPct(pStats.avgSimple, 1)}/год`,
|
|
804
|
+
'Среднегодовая доходность с учётом реинвестирования прибыли (CAGR) и простое арифметическое среднее в год',
|
|
805
|
+
),
|
|
806
|
+
kpi(
|
|
807
|
+
`<span class="${cls(a.marketPct)}">${fmtPct(a.marketPct)}</span>`,
|
|
808
|
+
'индекс МосБиржи',
|
|
809
|
+
`${fmtPct(pStats.mktAnnual, 1)}/год · альфа ${fmtPct((a.returnPct ?? 0) - (a.marketPct ?? 0))}`,
|
|
810
|
+
'Изменение индекса МосБиржи (IMOEX) за точно такой же период и преимущество стратегии над рынком',
|
|
811
|
+
),
|
|
812
|
+
kpi(
|
|
813
|
+
fmtNum(a.trades, 0),
|
|
814
|
+
'сделок за период',
|
|
815
|
+
`плюсовых ${fmtNum(a.winRate, 1)}% · на 1 ₽ уб.: ${fmtNum(a.profitFactor)} ₽`,
|
|
816
|
+
'Общее количество сделок, доля прибыльных сделок (Win Rate) и отношение общей прибыли к общему убытку (Profit Factor)',
|
|
817
|
+
),
|
|
818
|
+
kpi(
|
|
819
|
+
`${fmtNum(a.maxDrawdownPct, 1)}%`,
|
|
820
|
+
'худшее падение (просадка)',
|
|
821
|
+
`коэф. Шарпа: ${fmtNum(fullM.sharpe, 2)}`,
|
|
822
|
+
'Максимальная просадка капитала от пика и годовой коэффициент Шарпа (стабильность кривой доходности)',
|
|
823
|
+
),
|
|
490
824
|
].join('');
|
|
491
|
-
$('#sides').hidden =
|
|
492
|
-
$('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
|
|
825
|
+
$('#sides').hidden = true;
|
|
493
826
|
renderStability(ex.stability);
|
|
494
827
|
|
|
495
828
|
const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
|
|
@@ -497,6 +830,7 @@ function renderOverview() {
|
|
|
497
830
|
// трансформер — только если он был в прогоне
|
|
498
831
|
for (const n of MODELS) eqLines[n].applyOptions({ visible: !!ex.curves[n] && (n === chosen || $('#more').open) });
|
|
499
832
|
eqChart.timeScale().fitContent();
|
|
833
|
+
requestAnimationFrame(() => eqChart.timeScale().fitContent());
|
|
500
834
|
$('#legend').innerHTML = [chosen, 'market', ...($('#more').open ? MODELS.filter((n) => n !== chosen && ex.curves[n]) : [])]
|
|
501
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>`;
|
|
502
836
|
|
|
@@ -545,16 +879,6 @@ $('#run-bar').addEventListener('click', async (e) => {
|
|
|
545
879
|
}
|
|
546
880
|
if (e.target.closest('#btn-ask-run')) {
|
|
547
881
|
sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
if (e.target.closest('#btn-cmp-run')) {
|
|
551
|
-
const others = state.runs.filter((x) => x.id !== r.id && x.status === 'done');
|
|
552
|
-
const defId = others[0]?.id || '';
|
|
553
|
-
const raw = prompt(`С каким номером эксперимента сравнить №${r.id}? (доступны: ${others.slice(0, 8).map((x) => `№${x.id}`).join(', ')})`, String(defId));
|
|
554
|
-
if (!raw) return;
|
|
555
|
-
const otherId = Number(String(raw).replace(/\D/g, ''));
|
|
556
|
-
if (!otherId) return;
|
|
557
|
-
sendChat(`Сравни исследования №${r.id} и №${otherId} через compare_runs и объясни простыми словами, почему отличается результат и какой вариант надёжнее.`);
|
|
558
882
|
}
|
|
559
883
|
});
|
|
560
884
|
$('#btn-ask-forecast').addEventListener('click', () => {
|
|
@@ -568,10 +892,9 @@ function setProgress(pct, text) {
|
|
|
568
892
|
$('#progress span').textContent = text || '';
|
|
569
893
|
}
|
|
570
894
|
function researchUi(running) {
|
|
571
|
-
$('#btn-model').disabled = running;
|
|
572
|
-
$('#btn-cancel').hidden = !running && !state.stabilityActive;
|
|
573
895
|
if (!running && !state.stabilityActive) setProgress(null);
|
|
574
896
|
updateTabBadges();
|
|
897
|
+
syncUiAvailability();
|
|
575
898
|
}
|
|
576
899
|
async function startResearch(force = false) {
|
|
577
900
|
try {
|
|
@@ -611,45 +934,92 @@ $('#btn-cancel').addEventListener('click', async () => {
|
|
|
611
934
|
});
|
|
612
935
|
|
|
613
936
|
// ======================================================================
|
|
614
|
-
// СЕЙЧАС:
|
|
937
|
+
// СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
|
|
615
938
|
// ======================================================================
|
|
939
|
+
let forecastBusy = false;
|
|
940
|
+
const prevClientExp = new Map();
|
|
941
|
+
const prevClientRank = new Map();
|
|
942
|
+
|
|
616
943
|
async function loadForecast(force, silent = false) {
|
|
944
|
+
if (forecastBusy) return;
|
|
945
|
+
forecastBusy = true;
|
|
617
946
|
const box = $('#forecast');
|
|
618
|
-
if (!silent) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
|
|
619
|
-
$('#btn-forecast').disabled = true;
|
|
947
|
+
if (!silent && !state.forecast) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
|
|
620
948
|
try {
|
|
621
|
-
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);
|
|
622
964
|
$('#fc-title').textContent = `Прогноз на ${f.horizon} ч · свеча ${f.bar.slice(8, 10)}.${f.bar.slice(5, 7)} ${f.bar.slice(11)}`;
|
|
623
|
-
|
|
965
|
+
const badge = $('#fc-live-badge');
|
|
966
|
+
if (badge) badge.textContent = `● живой радар · ${liveTimeStr}`;
|
|
967
|
+
|
|
624
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>` : '');
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
<
|
|
629
|
-
<
|
|
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>`;
|
|
630
992
|
setupMapSlider();
|
|
993
|
+
if (state.brokers?.accounts?.length) renderBrokers();
|
|
631
994
|
} catch (e) {
|
|
632
995
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
633
996
|
} finally {
|
|
634
|
-
|
|
997
|
+
forecastBusy = false;
|
|
998
|
+
syncUiAvailability();
|
|
635
999
|
}
|
|
636
1000
|
}
|
|
637
|
-
|
|
638
|
-
const FORECAST_MAX_AGE = 3600000;
|
|
1001
|
+
const FORECAST_TICK_MS = 45000; // живое обновление каждые 45 секунд
|
|
639
1002
|
function refreshForecastIfStale() {
|
|
640
|
-
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
|
+
}
|
|
641
1009
|
}
|
|
642
|
-
setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); },
|
|
1010
|
+
setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
|
|
643
1011
|
|
|
644
1012
|
function showNowSec(sec) {
|
|
645
1013
|
document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
|
|
646
1014
|
document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
|
|
1015
|
+
saveNavPatch({ nowSec: sec });
|
|
647
1016
|
}
|
|
648
1017
|
$('#now-nav').addEventListener('click', (e) => {
|
|
649
1018
|
const b = e.target.closest('[data-nsec]');
|
|
650
1019
|
if (b) showNowSec(b.dataset.nsec);
|
|
651
1020
|
});
|
|
652
1021
|
|
|
1022
|
+
let openLiveId = loadSavedNav().openLiveId ?? null;
|
|
653
1023
|
function renderLive(st) {
|
|
654
1024
|
state.live = st;
|
|
655
1025
|
const accs = st?.accounts || [];
|
|
@@ -659,13 +1029,17 @@ function renderLive(st) {
|
|
|
659
1029
|
$('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
|
|
660
1030
|
return;
|
|
661
1031
|
}
|
|
1032
|
+
if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
|
|
1033
|
+
openLiveId = accs[0].runId;
|
|
1034
|
+
}
|
|
662
1035
|
$('#live').innerHTML = accs.map((a) => {
|
|
1036
|
+
const isOpen = String(a.runId) === String(openLiveId);
|
|
663
1037
|
const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
|
|
664
1038
|
const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
|
|
665
1039
|
const retPct = a.returnPct ?? 0;
|
|
666
1040
|
const step = a.recent?.[0];
|
|
667
1041
|
const p = a.params || {};
|
|
668
|
-
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' ? ' · в рост и в падение' : ' · только в рост'}`;
|
|
669
1043
|
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
670
1044
|
if (a.corridor) {
|
|
671
1045
|
const c = a.corridor;
|
|
@@ -678,31 +1052,36 @@ function renderLive(st) {
|
|
|
678
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>`;
|
|
679
1053
|
}
|
|
680
1054
|
const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
|
|
681
|
-
return `<div class="live-card${a.running ? '' : ' stopped'}" data-acc="${a.runId}">
|
|
682
|
-
<div class="
|
|
683
|
-
<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>
|
|
684
1059
|
<b>${esc(dispName)}</b>
|
|
685
|
-
<span class="tag ${a.running ? 'ok' : ''}"
|
|
1060
|
+
<span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
|
|
686
1061
|
${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
|
|
687
|
-
<div class="muted small">Замороженные настройки: ${esc(frozen)} · старт ${fmtNum(p.capital, 0)} ₽</div>
|
|
688
1062
|
</div>
|
|
689
|
-
<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>
|
|
690
1065
|
<button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
|
|
691
1066
|
${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
|
|
692
1067
|
<button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
|
|
693
1068
|
</div>
|
|
694
1069
|
</div>
|
|
695
|
-
<div
|
|
696
|
-
<div class="
|
|
697
|
-
<div class="
|
|
698
|
-
|
|
699
|
-
|
|
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>` : ''}
|
|
700
1081
|
</div>
|
|
701
|
-
<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>
|
|
702
|
-
${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>`}
|
|
703
|
-
${step ? `<p class="muted small">Последнее решение (${fmtTs(step.ts)}): ${step.decisions.length ? step.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}</p>` : ''}
|
|
704
1082
|
</div>`;
|
|
705
1083
|
}).join('');
|
|
1084
|
+
syncUiAvailability();
|
|
706
1085
|
}
|
|
707
1086
|
$('#btn-live').addEventListener('click', async () => {
|
|
708
1087
|
const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
|
|
@@ -741,6 +1120,14 @@ $('#live').addEventListener('click', async (e) => {
|
|
|
741
1120
|
const id = Number(askBtn.dataset.liveAsk);
|
|
742
1121
|
const acc = (state.live?.accounts || []).find((x) => x.runId === id);
|
|
743
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);
|
|
744
1131
|
}
|
|
745
1132
|
});
|
|
746
1133
|
|
|
@@ -898,6 +1285,7 @@ function drawProfilesMap() {
|
|
|
898
1285
|
}
|
|
899
1286
|
async function showMapSec(sec) {
|
|
900
1287
|
document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
|
|
1288
|
+
saveNavPatch({ mapSec: sec });
|
|
901
1289
|
if (sec === 'journal') {
|
|
902
1290
|
$('#map-sec').hidden = true;
|
|
903
1291
|
$('#journal-box').hidden = false;
|
|
@@ -945,12 +1333,12 @@ async function loadJournal() {
|
|
|
945
1333
|
}));
|
|
946
1334
|
}
|
|
947
1335
|
|
|
948
|
-
//
|
|
1336
|
+
// Логические группы настроек по смыслу:
|
|
1337
|
+
// g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
|
|
949
1338
|
const GROUPS = [
|
|
950
|
-
['
|
|
951
|
-
['
|
|
952
|
-
['
|
|
953
|
-
['Дополнительно', []],
|
|
1339
|
+
['Стратегия и сигналы', ['modelAutoAdapt', 'modelTopK', 'modelDirection', 'modelHorizon', 'modelMinEdge', 'modelExitRatio']],
|
|
1340
|
+
['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
|
|
1341
|
+
['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
|
|
954
1342
|
];
|
|
955
1343
|
function buildForm() {
|
|
956
1344
|
const field = (p) => {
|
|
@@ -965,7 +1353,7 @@ function buildForm() {
|
|
|
965
1353
|
const cleanRow = `<div class="srow" data-key="_cleanup"><div class="stxt"><div class="slbl">Очистка истории экспериментов</div><div class="hint">Удалить старые эксперименты, оставив ровно указанное число лучших по итогу (0 — удалить все завершённые эксперименты).</div></div><div class="row-gap" style="align-items:center"><span class="muted small">оставить</span><input id="keep-n" type="number" value="0" min="0" max="50" class="sctrl" style="width:68px"><button id="btn-clean" type="button">Очистить историю</button></div></div>`;
|
|
966
1354
|
$('#params').innerHTML = GROUPS.map(([name, keys], i) => {
|
|
967
1355
|
const items = keys.length ? keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean) : shown.filter((p) => !listed.has(p.key));
|
|
968
|
-
const extra = i ===
|
|
1356
|
+
const extra = i === 2 ? cleanRow : '';
|
|
969
1357
|
return items.length || extra ? `<section class="ssec" data-sec="g${i}"${i ? ' hidden' : ''}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}${extra}</div></section>` : '';
|
|
970
1358
|
}).join('');
|
|
971
1359
|
$('#btn-clean')?.addEventListener('click', cleanRunsHistory);
|
|
@@ -975,6 +1363,8 @@ function showSettingsSec(sec) {
|
|
|
975
1363
|
document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
|
|
976
1364
|
document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
977
1365
|
document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
1366
|
+
saveNavPatch({ settingsSec: sec });
|
|
1367
|
+
if (sec === 'cloud') renderCloudPane().catch(() => {});
|
|
978
1368
|
}
|
|
979
1369
|
document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
|
|
980
1370
|
const b = e.target.closest('[data-sec]');
|
|
@@ -1013,6 +1403,7 @@ function markDefaults(s) {
|
|
|
1013
1403
|
b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
|
|
1014
1404
|
});
|
|
1015
1405
|
syncApplyBtn();
|
|
1406
|
+
syncSettingsDependencies();
|
|
1016
1407
|
updateTabBadges();
|
|
1017
1408
|
}
|
|
1018
1409
|
const saveSettings = async (body) => {
|
|
@@ -1222,27 +1613,21 @@ function syncActionBtn() {
|
|
|
1222
1613
|
btn.hidden = Boolean(s?.busy);
|
|
1223
1614
|
if (speechActive) {
|
|
1224
1615
|
// 1. Микрофон включён -> в любом случае Стоп
|
|
1225
|
-
|
|
1226
|
-
$('#ico-send').hidden = true;
|
|
1227
|
-
$('#ico-stop').hidden = false;
|
|
1616
|
+
btn.dataset.icon = 'stop';
|
|
1228
1617
|
btn.classList.remove('send-mode');
|
|
1229
1618
|
btn.classList.add('rec');
|
|
1230
1619
|
btn.disabled = false;
|
|
1231
1620
|
btn.title = 'Остановить запись голоса';
|
|
1232
1621
|
} else if (hasText) {
|
|
1233
1622
|
// 2. Микрофон выключен, есть текст -> Стрелка
|
|
1234
|
-
|
|
1235
|
-
$('#ico-send').hidden = false;
|
|
1236
|
-
$('#ico-stop').hidden = true;
|
|
1623
|
+
btn.dataset.icon = 'send';
|
|
1237
1624
|
btn.classList.add('send-mode');
|
|
1238
1625
|
btn.classList.remove('rec');
|
|
1239
1626
|
btn.disabled = !s?.llm;
|
|
1240
1627
|
btn.title = 'Отправить (Enter)';
|
|
1241
1628
|
} else {
|
|
1242
1629
|
// 3. Микрофон выключен, нет текста -> Микрофон
|
|
1243
|
-
|
|
1244
|
-
$('#ico-send').hidden = true;
|
|
1245
|
-
$('#ico-stop').hidden = true;
|
|
1630
|
+
btn.dataset.icon = 'mic';
|
|
1246
1631
|
btn.classList.remove('send-mode', 'rec');
|
|
1247
1632
|
btn.disabled = false;
|
|
1248
1633
|
btn.title = 'Голосовой ввод (русский язык)';
|
|
@@ -1277,6 +1662,7 @@ function renderChatState() {
|
|
|
1277
1662
|
$('#chat-status').textContent = st;
|
|
1278
1663
|
for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
|
|
1279
1664
|
renderSuggest();
|
|
1665
|
+
syncUiAvailability();
|
|
1280
1666
|
}
|
|
1281
1667
|
|
|
1282
1668
|
const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
|
|
@@ -1711,6 +2097,27 @@ addForm.addEventListener('submit', async (e) => {
|
|
|
1711
2097
|
// ======================================================================
|
|
1712
2098
|
const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
|
|
1713
2099
|
const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
|
|
2100
|
+
const LEGACY_MODES = {
|
|
2101
|
+
isolate: 'Изолированный карман (старые бумаги не трогаются)',
|
|
2102
|
+
smooth: 'Плавный переход (продажа старых только под новые сигналы)',
|
|
2103
|
+
takeover: 'Полное управление (все акции под стратегией)',
|
|
2104
|
+
};
|
|
2105
|
+
const INST_KIND_LABEL = {
|
|
2106
|
+
share: ['Акция', 'ok'],
|
|
2107
|
+
metal: ['Драгметалл', 'mid'],
|
|
2108
|
+
currency: ['Валюта', 'mid'],
|
|
2109
|
+
bond: ['Облигация', ''],
|
|
2110
|
+
etf: ['Фонд', ''],
|
|
2111
|
+
futures: ['Фьючерс', 'mid'],
|
|
2112
|
+
};
|
|
2113
|
+
const ALLOC_PALETTE = ['#3b82f6', '#06b6d4', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899', '#64748b'];
|
|
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;
|
|
1714
2121
|
let editingBrokerId = null;
|
|
1715
2122
|
|
|
1716
2123
|
async function loadBrokers() {
|
|
@@ -1725,83 +2132,326 @@ function renderBrokers() {
|
|
|
1725
2132
|
if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
1726
2133
|
updateTabBadges();
|
|
1727
2134
|
|
|
1728
|
-
// 1) список в Настройках
|
|
2135
|
+
// 1) список в Настройках (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
1729
2136
|
const listEl = $('#brokers-list');
|
|
1730
2137
|
if (listEl) {
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
<
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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>
|
|
1743
2159
|
</div>
|
|
1744
|
-
<div class="
|
|
1745
|
-
|
|
1746
|
-
${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>` : ''}
|
|
1747
2169
|
</div>
|
|
1748
|
-
</div
|
|
2170
|
+
</div>`;
|
|
2171
|
+
}).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
|
|
1749
2172
|
}
|
|
1750
2173
|
|
|
1751
|
-
// 2) карточки во вкладке
|
|
2174
|
+
// 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
1752
2175
|
const tradeEl = $('#brokers-trade');
|
|
1753
2176
|
if (!tradeEl) return;
|
|
1754
2177
|
if (!accs.length) {
|
|
1755
2178
|
tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
|
|
1756
2179
|
return;
|
|
1757
2180
|
}
|
|
2181
|
+
if (!accs.some((a) => a.id === openBrokerId)) {
|
|
2182
|
+
openBrokerId = accs[0].id;
|
|
2183
|
+
}
|
|
2184
|
+
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
1758
2185
|
tradeEl.innerHTML = accs.map((a) => {
|
|
2186
|
+
const isOpen = a.id === openBrokerId;
|
|
1759
2187
|
const ls = a.lastSync;
|
|
1760
2188
|
const pb = a.pendingBatch;
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
|
|
2189
|
+
const lh = a.lastHistory;
|
|
2190
|
+
const lMode = a.legacyMode || 'isolate';
|
|
2191
|
+
const baseMap = a.baselineQty || {};
|
|
2192
|
+
const curSubTab = brokerTabById.get(a.id) || 'assets';
|
|
2193
|
+
|
|
2194
|
+
const algoQtyOf = (p) => {
|
|
2195
|
+
if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
|
|
2196
|
+
if (lMode === 'takeover') return p.qty;
|
|
2197
|
+
const bq = Number(baseMap[p.symbol] || 0);
|
|
2198
|
+
if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
|
|
2199
|
+
if (p.qty < 0 && bq < 0) return Math.min(0, p.qty - bq);
|
|
2200
|
+
return p.qty;
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
const posStatus = (p) => {
|
|
2204
|
+
if (p.instrumentType && p.instrumentType !== 'share') {
|
|
2205
|
+
const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
|
|
2206
|
+
return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
|
|
2207
|
+
}
|
|
2208
|
+
if (fcRowMap && !fcRowMap.has(p.symbol)) return '<span class="tag" title="Вне индекса широкого рынка — стратегия не трогает">вне словаря (защищена)</span>';
|
|
2209
|
+
if (lMode === 'takeover') return '<span class="tag ok">под управлением стратегии</span>';
|
|
2210
|
+
const bq = Number(baseMap[p.symbol] || 0);
|
|
2211
|
+
const aq = algoQtyOf(p);
|
|
2212
|
+
if (lMode === 'isolate') {
|
|
2213
|
+
if (aq === 0) return `<span class="tag" title="Начальная позиция счёта — робот её не продаёт">защищена (старый портфель: ${fmtNum(bq, 0)} шт.)</span>`;
|
|
2214
|
+
if (bq !== 0) return `<span class="tag ok">старый: ${fmtNum(bq, 0)} шт. · стратегия: ${fmtNum(aq, 0)} шт.</span>`;
|
|
2215
|
+
return '<span class="tag ok">открыта стратегией</span>';
|
|
2216
|
+
}
|
|
2217
|
+
if (aq === 0) return `<span class="tag mid" title="Будет плавно замещена при появлении новых сигналов покупки">плавный переход (${fmtNum(bq, 0)} шт.)</span>`;
|
|
2218
|
+
return '<span class="tag ok">под управлением стратегии</span>';
|
|
2219
|
+
};
|
|
2220
|
+
|
|
2221
|
+
// Полоса структуры активов (Allocation Bar)
|
|
2222
|
+
const totalVal = Math.max(1, Number(ls?.totalRub || 0));
|
|
2223
|
+
const allocSegments = [];
|
|
2224
|
+
(ls?.positions || []).forEach((p, idx) => {
|
|
2225
|
+
const pct = Math.max(0, Math.min(100, (Math.abs(p.valueRub || 0) / totalVal) * 100));
|
|
2226
|
+
if (pct >= 0.3) allocSegments.push({ label: `${p.name || p.symbol} (${p.symbol})`, valRub: p.valueRub, pct: Number(pct.toFixed(1)), color: ALLOC_PALETTE[idx % ALLOC_PALETTE.length] });
|
|
2227
|
+
});
|
|
2228
|
+
if ((ls?.cashRub || 0) > 10) {
|
|
2229
|
+
const cPct = Math.max(0, Math.min(100, (ls.cashRub / totalVal) * 100));
|
|
2230
|
+
allocSegments.push({ label: 'Свободные рубли (RUB)', valRub: ls.cashRub, pct: Number(cPct.toFixed(1)), color: '#16a34a' });
|
|
2231
|
+
}
|
|
2232
|
+
const allocBarHtml = allocSegments.length ? `<div class="brk-alloc-wrap">
|
|
2233
|
+
<div class="brk-alloc-bar">${allocSegments.map((s) => `<div style="width:${s.pct}%;background:${s.color}" title="${esc(s.label)}: ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)"></div>`).join('')}</div>
|
|
2234
|
+
<div class="brk-alloc-legend">${allocSegments.map((s) => `<span><i style="background:${s.color}"></i><b>${esc(s.label)}</b> — ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)</span>`).join('')}</div>
|
|
2235
|
+
</div>` : '';
|
|
2236
|
+
|
|
2237
|
+
// Считаем сколько ₽ в стратегии и сколько ₽ защищено
|
|
2238
|
+
let strategyRub = 0, protectedRub = 0;
|
|
2239
|
+
for (const p of ls?.positions || []) {
|
|
2240
|
+
const aq = algoQtyOf(p);
|
|
2241
|
+
const aVal = Math.abs(aq * (p.price || 0));
|
|
2242
|
+
strategyRub += aVal;
|
|
2243
|
+
protectedRub += Math.max(0, Math.abs(p.valueRub || 0) - aVal);
|
|
2244
|
+
}
|
|
2245
|
+
const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
|
|
1764
2246
|
|
|
2247
|
+
// Вкладка 1: Активы портфеля
|
|
2248
|
+
const posHtml = ls?.positions?.length
|
|
2249
|
+
? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
|
|
2250
|
+
const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
|
|
2251
|
+
const fcRow = fcRowMap?.get(p.symbol);
|
|
2252
|
+
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
2253
|
+
return `<tr>
|
|
2254
|
+
<td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
|
|
2255
|
+
<td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
|
|
2256
|
+
<td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
|
|
2257
|
+
<td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
|
|
2258
|
+
<td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
|
|
2259
|
+
<td class="num ${cls(p.pnlRub)}"><b>${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</b>${p.pnlPct ? ` <span class="small">(${p.pnlPct >= 0 ? '+' : ''}${fmtNum(p.pnlPct, 1)}%)</span>` : ''}</td>
|
|
2260
|
+
<td>${posStatus(p)}${fcBadge}</td>
|
|
2261
|
+
</tr>`;
|
|
2262
|
+
}).join('')}</tbody></table>`
|
|
2263
|
+
: `<p class="muted small">${ls?.ok ? 'Открытых позиций на счёте сейчас нет (все средства в рублях).' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и активы с брокера.'}</p>`;
|
|
2264
|
+
|
|
2265
|
+
// Вкладка 2: Торговля и заявки стратегии
|
|
2266
|
+
const activeOrdHtml = ls?.activeOrders?.length
|
|
2267
|
+
? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b></div><table><thead><tr><th>Бумага</th><th>Направление</th><th class="num">Лотов (исп./всего)</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${ls.activeOrders.map((o) => `<tr><td><b>${esc(o.symbol)}</b> ${o.name ? `<span class="muted small">${esc(o.name)}</span>` : ''}</td><td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td><td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td><td class="num">${fmtNum(o.price, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td></tr>`).join('')}</tbody></table></div>`
|
|
2268
|
+
: '';
|
|
1765
2269
|
let batchHtml = '';
|
|
1766
2270
|
if (pb) {
|
|
1767
2271
|
const ordRows = pb.orders?.length
|
|
1768
|
-
? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
|
|
1769
|
-
:
|
|
2272
|
+
? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
|
|
2273
|
+
: `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
|
|
1770
2274
|
const resHtml = pb.results?.length
|
|
1771
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>`
|
|
1772
2276
|
: '';
|
|
1773
|
-
|
|
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
|
+
: '';
|
|
2280
|
+
batchHtml = `<div class="stab-box">
|
|
1774
2281
|
<div class="stab-head">
|
|
1775
2282
|
<b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
|
|
1776
2283
|
${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
|
|
1777
2284
|
<span class="grow"></span>
|
|
1778
|
-
${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>` :
|
|
1779
|
-
<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>`}
|
|
1780
2286
|
</div>
|
|
1781
2287
|
${ordRows}
|
|
1782
2288
|
${resHtml}
|
|
2289
|
+
${throttleHtml}
|
|
1783
2290
|
</div>`;
|
|
2291
|
+
} else {
|
|
2292
|
+
batchHtml = `<p class="muted small">Нажмите <b>«Рассчитать заявки (в лотах)»</b> сверху (или дождитесь закрытия свечи при включённом авто-расписании), чтобы сформировать пакет лимитных заявок по текущему прогнозу.</p>`;
|
|
1784
2293
|
}
|
|
1785
2294
|
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
2295
|
+
// Вкладка 3: История операций и динамика счёта
|
|
2296
|
+
const eqHist = Array.isArray(a.equityHistory) ? a.equityHistory : [];
|
|
2297
|
+
const eqHistHtml = eqHist.length > 1
|
|
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>`
|
|
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
|
+
|
|
2398
|
+
const histHtml = lh?.operations?.length
|
|
2399
|
+
? `${eqHistHtml}<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:10px">
|
|
2400
|
+
<div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount})</div></div>
|
|
2401
|
+
<div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
|
|
2402
|
+
<div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
|
|
2403
|
+
<div class="kpi"><div class="v muted">−${fmtNum(lh.summary?.commissionsRub || 0, 0)} ₽</div><div class="l">комиссии и налоги</div></div>
|
|
2404
|
+
</div>
|
|
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>` : ''}
|
|
1791
2413
|
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
1792
2414
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
1793
2415
|
${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
|
|
1794
|
-
<div class="muted small">Предохранители: лимит ${fmtNum(a.maxCapitalRub, 0)} ₽ · дневной стоп −${fmtNum(a.maxDailyDrawdownPct, 1)}% · отступ лимитки ${fmtNum(a.limitOffsetPct, 2)}%${a.skipEdgeMinutes ? ' · пропуск краёв сессии' : ''}${a.baseRunLabel ? ` · настройки ${esc(a.baseRunLabel)}` : ''}</div>
|
|
1795
2416
|
</div>
|
|
1796
|
-
<div class="row-gap">
|
|
1797
|
-
|
|
1798
|
-
|
|
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>` : ''}
|
|
2419
|
+
</div>
|
|
2420
|
+
</div>
|
|
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>
|
|
1799
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>
|
|
1800
2454
|
</div>
|
|
1801
|
-
${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(3,1fr)"><div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте</div></div><div class="kpi"><div class="v">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли</div></div><div class="kpi"><div class="v ${cls(-(ls.dayDrawdownPct || 0))}">${fmtNum(-(ls.dayDrawdownPct || 0), 2)}%</div><div class="l">изменение за день (стоп −${fmtNum(a.maxDailyDrawdownPct, 1)}%)</div></div></div>` : ''}
|
|
1802
|
-
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
1803
|
-
${posHtml}
|
|
1804
|
-
${batchHtml}
|
|
1805
2455
|
</div>`;
|
|
1806
2456
|
}).join('');
|
|
1807
2457
|
}
|
|
@@ -1812,15 +2462,141 @@ $('#btn-brokers-cfg').addEventListener('click', () => {
|
|
|
1812
2462
|
});
|
|
1813
2463
|
|
|
1814
2464
|
const brokerForm = $('#broker-form');
|
|
2465
|
+
let brokerInitialSnap = '';
|
|
2466
|
+
|
|
2467
|
+
function getBrokerFormSnap() {
|
|
2468
|
+
const f = brokerForm.elements;
|
|
2469
|
+
return JSON.stringify({
|
|
2470
|
+
name: f.name.value.trim(),
|
|
2471
|
+
broker: f.broker.value,
|
|
2472
|
+
accountId: f.accountId.value.trim(),
|
|
2473
|
+
token: f.token.value.trim(),
|
|
2474
|
+
mode: f.mode.value,
|
|
2475
|
+
legacyMode: f.legacyMode?.value || 'isolate',
|
|
2476
|
+
baseRunId: f.baseRunId.value || '',
|
|
2477
|
+
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
2478
|
+
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
2479
|
+
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2480
|
+
autoSchedule: Boolean(f.autoSchedule.checked),
|
|
2481
|
+
skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
|
|
2482
|
+
sandbox: Boolean(f.sandbox.checked),
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
function updateBrokerSaveBtnState() {
|
|
2487
|
+
const btn = $('#broker-save-btn');
|
|
2488
|
+
if (!btn) return;
|
|
2489
|
+
if (!editingBrokerId) {
|
|
2490
|
+
const f = brokerForm.elements;
|
|
2491
|
+
const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim());
|
|
2492
|
+
btn.disabled = !hasNewInput;
|
|
2493
|
+
btn.classList.toggle('primary', hasNewInput);
|
|
2494
|
+
btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const changed = getBrokerFormSnap() !== brokerInitialSnap;
|
|
2498
|
+
btn.disabled = !changed;
|
|
2499
|
+
btn.classList.toggle('primary', changed);
|
|
2500
|
+
btn.title = changed ? 'Сохранить внесённые изменения' : 'Нет изменений для сохранения';
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
brokerForm.addEventListener('input', updateBrokerSaveBtnState);
|
|
2504
|
+
brokerForm.addEventListener('change', updateBrokerSaveBtnState);
|
|
2505
|
+
|
|
2506
|
+
function startBrokerEdit(acc) {
|
|
2507
|
+
editingBrokerId = acc.id;
|
|
2508
|
+
const f = brokerForm.elements;
|
|
2509
|
+
f.id.value = acc.id;
|
|
2510
|
+
f.name.value = acc.name;
|
|
2511
|
+
f.broker.value = acc.broker;
|
|
2512
|
+
f.accountId.value = acc.accountId || '';
|
|
2513
|
+
f.token.value = '';
|
|
2514
|
+
f.mode.value = acc.mode || 'confirm';
|
|
2515
|
+
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2516
|
+
if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
|
|
2517
|
+
const opt = document.createElement('option');
|
|
2518
|
+
opt.value = String(acc.baseRunId);
|
|
2519
|
+
opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
|
|
2520
|
+
f.baseRunId.appendChild(opt);
|
|
2521
|
+
}
|
|
2522
|
+
f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
|
|
2523
|
+
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
2524
|
+
f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
|
|
2525
|
+
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2526
|
+
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2527
|
+
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
2528
|
+
f.sandbox.checked = !!acc.sandbox;
|
|
2529
|
+
$('#broker-add-box').open = true;
|
|
2530
|
+
$('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
|
|
2531
|
+
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
2532
|
+
$('#broker-cancel-btn').hidden = false;
|
|
2533
|
+
brokerInitialSnap = getBrokerFormSnap();
|
|
2534
|
+
updateBrokerSaveBtnState();
|
|
2535
|
+
}
|
|
2536
|
+
|
|
1815
2537
|
function stopBrokerEdit() {
|
|
1816
2538
|
editingBrokerId = null;
|
|
1817
2539
|
brokerForm.reset();
|
|
1818
2540
|
brokerForm.elements.id.value = '';
|
|
2541
|
+
if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
|
|
1819
2542
|
$('#broker-add-sum').textContent = 'Добавить брокерский счёт';
|
|
1820
2543
|
$('#broker-save-btn').textContent = 'Сохранить счёт';
|
|
1821
2544
|
$('#broker-cancel-btn').hidden = true;
|
|
2545
|
+
brokerInitialSnap = getBrokerFormSnap();
|
|
2546
|
+
updateBrokerSaveBtnState();
|
|
1822
2547
|
}
|
|
1823
2548
|
$('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
|
|
2549
|
+
updateBrokerSaveBtnState();
|
|
2550
|
+
|
|
2551
|
+
// Авто-поиск всех субсчетов в Т-Инвестициях по токену
|
|
2552
|
+
$('#btn-broker-discover')?.addEventListener('click', async () => {
|
|
2553
|
+
const btn = $('#btn-broker-discover');
|
|
2554
|
+
const box = $('#broker-discovered');
|
|
2555
|
+
const f = brokerForm.elements;
|
|
2556
|
+
btn.disabled = true;
|
|
2557
|
+
const old = btn.textContent;
|
|
2558
|
+
btn.textContent = 'Ищем счета…';
|
|
2559
|
+
try {
|
|
2560
|
+
const res = await post('/api/brokers/discover', {
|
|
2561
|
+
id: editingBrokerId || undefined,
|
|
2562
|
+
broker: f.broker.value,
|
|
2563
|
+
token: f.token.value,
|
|
2564
|
+
sandbox: f.sandbox.checked,
|
|
2565
|
+
});
|
|
2566
|
+
const list = res.accounts || [];
|
|
2567
|
+
if (!list.length) {
|
|
2568
|
+
box.hidden = false;
|
|
2569
|
+
box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
|
|
2570
|
+
} else {
|
|
2571
|
+
box.hidden = false;
|
|
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)">
|
|
2573
|
+
<div>
|
|
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>` : ''}
|
|
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>
|
|
2576
|
+
</div>
|
|
2577
|
+
<button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
|
|
2578
|
+
</div>`).join('');
|
|
2579
|
+
}
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
toast(err.message, 'error');
|
|
2582
|
+
} finally {
|
|
2583
|
+
btn.disabled = false;
|
|
2584
|
+
btn.textContent = old;
|
|
2585
|
+
}
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
$('#broker-discovered')?.addEventListener('click', (e) => {
|
|
2589
|
+
const b = e.target.closest('button[data-pick-acc]');
|
|
2590
|
+
if (!b) return;
|
|
2591
|
+
const f = brokerForm.elements;
|
|
2592
|
+
f.accountId.value = b.dataset.pickAcc;
|
|
2593
|
+
if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
|
|
2594
|
+
f.name.value = `Т-Банк · ${b.dataset.pickName}`;
|
|
2595
|
+
}
|
|
2596
|
+
updateBrokerSaveBtnState();
|
|
2597
|
+
toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
|
|
2598
|
+
});
|
|
2599
|
+
|
|
1824
2600
|
brokerForm.addEventListener('submit', async (e) => {
|
|
1825
2601
|
e.preventDefault();
|
|
1826
2602
|
const f = brokerForm.elements;
|
|
@@ -1831,6 +2607,7 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
1831
2607
|
accountId: f.accountId.value,
|
|
1832
2608
|
token: f.token.value,
|
|
1833
2609
|
mode: f.mode.value,
|
|
2610
|
+
legacyMode: f.legacyMode?.value || 'isolate',
|
|
1834
2611
|
baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
|
|
1835
2612
|
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
1836
2613
|
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
@@ -1852,30 +2629,22 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
1852
2629
|
|
|
1853
2630
|
$('#brokers-list').addEventListener('click', async (e) => {
|
|
1854
2631
|
const b = e.target.closest('button[data-bact]');
|
|
1855
|
-
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
|
+
}
|
|
1856
2642
|
const id = b.closest('[data-bid]').dataset.bid;
|
|
1857
2643
|
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
1858
2644
|
if (!acc) return;
|
|
1859
2645
|
if (b.dataset.bact === 'edit') {
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
f.id.value = acc.id;
|
|
1863
|
-
f.name.value = acc.name;
|
|
1864
|
-
f.broker.value = acc.broker;
|
|
1865
|
-
f.accountId.value = acc.accountId || '';
|
|
1866
|
-
f.token.value = '';
|
|
1867
|
-
f.mode.value = acc.mode || 'confirm';
|
|
1868
|
-
f.baseRunId.value = acc.baseRunId || '';
|
|
1869
|
-
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
1870
|
-
f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
|
|
1871
|
-
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
1872
|
-
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
1873
|
-
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
1874
|
-
f.sandbox.checked = !!acc.sandbox;
|
|
1875
|
-
$('#broker-add-box').open = true;
|
|
1876
|
-
$('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
|
|
1877
|
-
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
1878
|
-
$('#broker-cancel-btn').hidden = false;
|
|
2646
|
+
startBrokerEdit(acc);
|
|
2647
|
+
$('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
1879
2648
|
return;
|
|
1880
2649
|
}
|
|
1881
2650
|
if (b.dataset.bact === 'del') {
|
|
@@ -1901,19 +2670,65 @@ $('#brokers-list').addEventListener('click', async (e) => {
|
|
|
1901
2670
|
});
|
|
1902
2671
|
|
|
1903
2672
|
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
2673
|
+
const tabBtn = e.target.closest('button[data-btab]');
|
|
2674
|
+
if (tabBtn) {
|
|
2675
|
+
const id = tabBtn.dataset.bid;
|
|
2676
|
+
const t = tabBtn.dataset.btab;
|
|
2677
|
+
setBrokerSubTab(id, t);
|
|
2678
|
+
renderBrokers();
|
|
2679
|
+
if (t === 'history') {
|
|
2680
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2681
|
+
if (acc && !acc.lastHistory) {
|
|
2682
|
+
post(`/api/brokers/${encodeURIComponent(id)}/history`).then((st) => { state.brokers = st; renderBrokers(); }).catch((err) => toast(err.message, 'error'));
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
1904
2687
|
const b = e.target.closest('button[data-btrade]');
|
|
1905
|
-
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
|
+
}
|
|
1906
2698
|
const id = b.dataset.bid;
|
|
1907
2699
|
const act = b.dataset.btrade;
|
|
2700
|
+
if (act === 'cfg') {
|
|
2701
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2702
|
+
switchTab('settings');
|
|
2703
|
+
showSettingsSec('brokers');
|
|
2704
|
+
if (acc) {
|
|
2705
|
+
startBrokerEdit(acc);
|
|
2706
|
+
setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
|
2707
|
+
}
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
1908
2710
|
b.disabled = true;
|
|
1909
2711
|
const old = b.textContent;
|
|
1910
2712
|
try {
|
|
1911
2713
|
if (act === 'sync') {
|
|
1912
|
-
b.textContent = 'Обновляем…';
|
|
2714
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
|
|
1913
2715
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
1914
2716
|
toast('Портфель обновлён');
|
|
2717
|
+
} else if (act === 'hist') {
|
|
2718
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Загружаем…';
|
|
2719
|
+
setBrokerSubTab(id, 'history');
|
|
2720
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/history`);
|
|
2721
|
+
toast('История операций загружена');
|
|
2722
|
+
} else if (act === 'freeze') {
|
|
2723
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/freeze-baseline`);
|
|
2724
|
+
const updated = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2725
|
+
const isIso = updated?.legacyMode === 'isolate';
|
|
2726
|
+
toast(isIso
|
|
2727
|
+
? '🔒 Защита бумаг включена (режим «Изолированный карман» — старые бумаги не продаются)'
|
|
2728
|
+
: '🔓 Включён режим «Плавный переход» (разрешено плавное замещение старых бумаг под сигналы)');
|
|
1915
2729
|
} else if (act === 'prep') {
|
|
1916
|
-
b.textContent = 'Считаем лоты…';
|
|
2730
|
+
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Считаем лоты…';
|
|
2731
|
+
setBrokerSubTab(id, 'trade');
|
|
1917
2732
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
|
|
1918
2733
|
toast('Пакет заявок по прогнозу рассчитан');
|
|
1919
2734
|
} else if (act === 'exec') {
|
|
@@ -2004,6 +2819,8 @@ function connectWs() {
|
|
|
2004
2819
|
case 'journal:update': loadJournal().catch(() => {}); break;
|
|
2005
2820
|
case 'research:start':
|
|
2006
2821
|
state.active = m.runId;
|
|
2822
|
+
resetLiveTrain(m.runId);
|
|
2823
|
+
delete liveTrainState.firstTestLeft;
|
|
2007
2824
|
researchUi(true);
|
|
2008
2825
|
setProgress(1, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}: начинаем…`);
|
|
2009
2826
|
liveLine(`r-${m.runId}`, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}${m.note ? ` — ${esc(m.note)}` : ''}: <span class="pg">начинаем…</span><div class="bar-s"><div></div></div>`);
|
|
@@ -2015,7 +2832,19 @@ function connectWs() {
|
|
|
2015
2832
|
$('.pg', el).textContent = m.text;
|
|
2016
2833
|
$('.bar-s div', el).style.width = `${pct}%`;
|
|
2017
2834
|
if (!state.active) { state.active = m.runId; researchUi(true); }
|
|
2018
|
-
if (m.runId === state.active)
|
|
2835
|
+
if (m.runId === state.active) {
|
|
2836
|
+
setProgress(pct, m.text);
|
|
2837
|
+
if (liveTrainState.runId !== m.runId) { resetLiveTrain(m.runId); delete liveTrainState.firstTestLeft; }
|
|
2838
|
+
liveTrainState.stage = m.stage;
|
|
2839
|
+
liveTrainState.text = m.text || '';
|
|
2840
|
+
if (m.symbol && !liveTrainState.loadedSymbols.includes(m.symbol)) liveTrainState.loadedSymbols.push(m.symbol);
|
|
2841
|
+
if (m.viz) {
|
|
2842
|
+
if (m.viz.symbols) liveTrainState.symbols = m.viz.symbols;
|
|
2843
|
+
if (m.viz.foldIc !== undefined && m.viz.fold) liveTrainState.foldIcs[m.viz.fold - 1] = m.viz.foldIc;
|
|
2844
|
+
liveTrainState.lastViz = m.viz;
|
|
2845
|
+
}
|
|
2846
|
+
if (state.run?.id === m.runId && state.run?.status !== 'done') renderLiveTraining();
|
|
2847
|
+
}
|
|
2019
2848
|
break;
|
|
2020
2849
|
}
|
|
2021
2850
|
case 'research:done':
|
|
@@ -2061,7 +2890,6 @@ function connectWs() {
|
|
|
2061
2890
|
case 'stability:progress':
|
|
2062
2891
|
stabilityUi(m.runId);
|
|
2063
2892
|
setProgress(m.pct, m.text);
|
|
2064
|
-
$('#btn-stability').textContent = `${m.pct}% · проверяем…`;
|
|
2065
2893
|
break;
|
|
2066
2894
|
case 'stability:done':
|
|
2067
2895
|
stabilityUi(null);
|
|
@@ -2083,7 +2911,42 @@ function connectWs() {
|
|
|
2083
2911
|
};
|
|
2084
2912
|
}
|
|
2085
2913
|
|
|
2086
|
-
// ----------
|
|
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 ----------
|
|
2087
2950
|
$('#btn-user')?.addEventListener('click', () => {
|
|
2088
2951
|
switchTab('settings');
|
|
2089
2952
|
showSettingsSec('cloud');
|
|
@@ -2096,11 +2959,19 @@ async function renderCloudPane() {
|
|
|
2096
2959
|
const me = await api('/api/auth/me').catch(() => state.config || {});
|
|
2097
2960
|
state.config = { ...(state.config || {}), ...me };
|
|
2098
2961
|
const u = me.user;
|
|
2962
|
+
const lic = u?.localLicense;
|
|
2963
|
+
const isDefaultLocal = !me.cloudMode && u?.id === 1 && u?.email === 'owner@neuroalpha.local';
|
|
2964
|
+
const dispName = (lic?.name && lic.name !== 'Владелец платформы' && lic.name !== 'Пользователь')
|
|
2965
|
+
? lic.name
|
|
2966
|
+
: (isDefaultLocal ? 'Пользователь' : (u?.name || 'Пользователь'));
|
|
2967
|
+
const dispEmail = (lic?.email && lic.email !== 'owner@neuroalpha.local')
|
|
2968
|
+
? lic.email
|
|
2969
|
+
: (isDefaultLocal ? '' : (u?.email || ''));
|
|
2099
2970
|
const userBtn = $('#btn-user');
|
|
2100
|
-
if (userBtn) userBtn.textContent = u ? `${
|
|
2971
|
+
if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Войти';
|
|
2101
2972
|
|
|
2102
2973
|
if (!u) {
|
|
2103
|
-
box.innerHTML = `<div class="srow"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><a class="btn primary" href="/landing">Войти / Регистрация</a></div>`;
|
|
2974
|
+
box.innerHTML = `<div class="srow sblock"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><div class="row-gap"><a class="btn primary" href="/landing">Войти / Регистрация</a></div></div>`;
|
|
2104
2975
|
return;
|
|
2105
2976
|
}
|
|
2106
2977
|
|
|
@@ -2109,129 +2980,83 @@ async function renderCloudPane() {
|
|
|
2109
2980
|
const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2110
2981
|
const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2111
2982
|
|
|
2112
|
-
let
|
|
2983
|
+
let customBotsHtml = '';
|
|
2984
|
+
let adminLinkHtml = '';
|
|
2113
2985
|
if (u.role === 'admin') {
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
<
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
<div class="muted small"><b>Боты уведомлений и подтверждения заявок (Telegram и MAX)</b></div>
|
|
2986
|
+
if (me.cloudMode) {
|
|
2987
|
+
adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
|
|
2988
|
+
<div class="stxt">
|
|
2989
|
+
<div class="slbl">Консоль администратора платформы</div>
|
|
2990
|
+
<div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
|
|
2991
|
+
</div>
|
|
2992
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2993
|
+
<a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
|
|
2994
|
+
</div>
|
|
2995
|
+
</div>`;
|
|
2996
|
+
} else {
|
|
2997
|
+
customBotsHtml = `<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
|
|
2998
|
+
<summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
|
|
2999
|
+
<form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
|
|
3000
|
+
<div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
|
|
2130
3001
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2131
|
-
<input name="tgBotName" value="${esc(
|
|
2132
|
-
<input name="tgToken" type="
|
|
3002
|
+
<input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
|
|
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">
|
|
2133
3004
|
</div>
|
|
2134
3005
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2135
|
-
<input name="maxBotName" value="${esc(
|
|
2136
|
-
<input name="maxToken" type="
|
|
3006
|
+
<input name="maxBotName" value="${esc(nt.maxBotName || '')}" placeholder="Имя бота MAX" style="width:200px">
|
|
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">
|
|
2137
3008
|
<button type="submit" class="primary">Сохранить ботов</button>
|
|
2138
3009
|
</div>
|
|
3010
|
+
<div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
|
|
2139
3011
|
</form>
|
|
2140
|
-
|
|
2141
|
-
<div class="muted small"><b>Промокоды (выдача пробного доступа)</b></div>
|
|
2142
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
2143
|
-
<input name="code" placeholder="Код (напр. ALPHA14)" style="width:150px" required>
|
|
2144
|
-
<select name="plan" style="width:150px"><option value="investor">Инвестор</option><option value="pro">Профи</option></select>
|
|
2145
|
-
<input name="days" type="number" value="14" min="1" max="365" style="width:90px" title="На сколько дней">
|
|
2146
|
-
<input name="maxUses" type="number" value="50" min="1" max="10000" style="width:90px" title="Макс. активаций">
|
|
2147
|
-
<button type="submit">Создать промокод</button>
|
|
2148
|
-
</div>
|
|
2149
|
-
${adm.promos?.length ? `<div class="muted small">Активные коды: ${adm.promos.map((p) => `<code>${esc(p.code)}</code> (${esc(p.plan)}, ${p.days} дн., исп. ${p.used_count}/${p.max_uses})`).join(' · ')}</div>` : ''}
|
|
2150
|
-
</form>
|
|
2151
|
-
<div class="muted small" style="margin-bottom:6px"><b>Пользователи платформы (${adm.users?.length || 0})</b></div>
|
|
2152
|
-
<table><thead><tr><th>ID</th><th>Имя / Email</th><th>Тариф</th><th>Мессенджеры</th><th>Сменить тариф</th></tr></thead><tbody>
|
|
2153
|
-
${(adm.users || []).map((usr) => `<tr>
|
|
2154
|
-
<td>${usr.id}</td>
|
|
2155
|
-
<td><b>${esc(usr.name)}</b> <span class="muted small">${esc(usr.email)}</span></td>
|
|
2156
|
-
<td><span class="tag ok">${esc(usr.planName)}</span></td>
|
|
2157
|
-
<td class="small">${usr.tgConnected ? '<span class="pos">TG ✓</span> ' : ''}${usr.maxConnected ? '<span class="pos">MAX ✓</span>' : (!usr.tgConnected ? '—' : '')}</td>
|
|
2158
|
-
<td>
|
|
2159
|
-
<select data-adm-plan="${usr.id}" class="sctrl" style="padding:2px 6px">
|
|
2160
|
-
<option value="free"${usr.plan === 'free' ? ' selected' : ''}>Старт (Free)</option>
|
|
2161
|
-
<option value="investor"${usr.plan === 'investor' ? ' selected' : ''}>Инвестор</option>
|
|
2162
|
-
<option value="pro"${usr.plan === 'pro' ? ' selected' : ''}>Профи</option>
|
|
2163
|
-
</select>
|
|
2164
|
-
</td>
|
|
2165
|
-
</tr>`).join('')}
|
|
2166
|
-
</tbody></table>
|
|
2167
|
-
</div>`;
|
|
3012
|
+
</details>`;
|
|
2168
3013
|
}
|
|
2169
3014
|
}
|
|
2170
3015
|
|
|
2171
|
-
const
|
|
2172
|
-
|
|
2173
|
-
|
|
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 ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
2174
3025
|
|
|
2175
3026
|
box.innerHTML = `
|
|
2176
|
-
<div class="srow">
|
|
3027
|
+
<div class="srow sblock">
|
|
2177
3028
|
<div class="stxt">
|
|
2178
|
-
<div class="slbl">${esc(
|
|
2179
|
-
<div class="hint"
|
|
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>
|
|
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>
|
|
2180
3033
|
</div>
|
|
2181
|
-
|
|
3034
|
+
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
2182
3035
|
<button type="button" id="btn-cloud-verify">Проверить оплату</button>
|
|
2183
3036
|
<button type="button" id="btn-cloud-logout">Выйти</button>
|
|
2184
|
-
</div
|
|
3037
|
+
</div>` : ''}
|
|
2185
3038
|
</div>
|
|
2186
|
-
<div class="srow">
|
|
3039
|
+
<div class="srow sblock">
|
|
2187
3040
|
<div class="stxt">
|
|
2188
|
-
<div class="slbl"
|
|
3041
|
+
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
2189
3042
|
<div class="hint">
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
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>).
|
|
2193
3047
|
</div>
|
|
2194
3048
|
</div>
|
|
2195
3049
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
2196
|
-
<
|
|
2197
|
-
<button type="button" id="btn-
|
|
2198
|
-
<button type="button"
|
|
3050
|
+
<input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
|
|
3051
|
+
<button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
|
|
3052
|
+
<button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
|
|
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>
|
|
2199
3055
|
</div>
|
|
2200
3056
|
</div>
|
|
2201
|
-
|
|
3057
|
+
<div class="srow sblock">
|
|
2202
3058
|
<div class="stxt">
|
|
2203
|
-
<div class="slbl"
|
|
2204
|
-
<div class="hint">Если вы зарегистрировались и оплатили тариф на сайте, вставьте полученный <code>na_live_…</code> ключ сюда для разблокировки тарифа на этом компьютере.</div>
|
|
2205
|
-
</div>
|
|
2206
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
2207
|
-
<input id="lic-key-in" value="${esc(lic?.apiKey || '')}" placeholder="na_live_…" style="width:210px">
|
|
2208
|
-
<input id="lic-url-in" value="${esc(lic?.cloudUrl && lic.cloudUrl !== 'local' ? lic.cloudUrl : '')}" placeholder="URL сайта (если внешний)" style="width:170px">
|
|
2209
|
-
<button type="button" id="btn-lic-activate">Активировать ключ</button>
|
|
2210
|
-
</div>
|
|
2211
|
-
</div>` : ''}
|
|
2212
|
-
<div class="srow">
|
|
2213
|
-
<div class="stxt">
|
|
2214
|
-
<div class="slbl">Тариф и подписка (карты МИР и СБП)</div>
|
|
2215
|
-
<div class="hint">Мгновенное подключение брокерских счетов (Т-Банк, БКС, Финам, Алор), подтверждения заявок в Telegram и MAX и расширенных лимитов.</div>
|
|
2216
|
-
</div>
|
|
2217
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
2218
|
-
<button type="button" class="primary" data-checkout="investor">Оформить «Инвестор» · 3 900 ₽/мес</button>
|
|
2219
|
-
<button type="button" class="primary" data-checkout="pro">Оформить «Профи» · 9 900 ₽/мес</button>
|
|
2220
|
-
</div>
|
|
2221
|
-
</div>
|
|
2222
|
-
<div class="srow">
|
|
2223
|
-
<div class="stxt">
|
|
2224
|
-
<div class="slbl">Активировать промокод</div>
|
|
2225
|
-
<div class="hint">Введите промокод партнёра или пробного периода для подключения тарифа «Инвестор» или «Профи».</div>
|
|
2226
|
-
</div>
|
|
2227
|
-
<div class="row-gap">
|
|
2228
|
-
<input id="promo-code-in" placeholder="Промокод" style="width:140px">
|
|
2229
|
-
<button type="button" id="btn-promo-redeem">Активировать</button>
|
|
2230
|
-
</div>
|
|
2231
|
-
</div>
|
|
2232
|
-
<div class="srow">
|
|
2233
|
-
<div class="stxt">
|
|
2234
|
-
<div class="slbl">Боты Telegram и MAX (уведомления и исполнение заявок в 1 клик)</div>
|
|
3059
|
+
<div class="slbl">Уведомления в Telegram и MAX (подтверждение заявок в 1 клик)</div>
|
|
2235
3060
|
<div class="hint">
|
|
2236
3061
|
Ваш код привязки: <b><code>${esc(code)}</code></b> — отправьте этот код боту в Telegram или в мессенджер MAX.<br>
|
|
2237
3062
|
Статус: Telegram — ${u.tgConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'} · MAX — ${u.maxConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'}
|
|
@@ -2241,39 +3066,13 @@ async function renderCloudPane() {
|
|
|
2241
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>` : ''}
|
|
2242
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>` : ''}
|
|
2243
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>
|
|
2244
3070
|
</div>
|
|
2245
3071
|
</div>
|
|
2246
|
-
${
|
|
3072
|
+
${adminLinkHtml}
|
|
3073
|
+
${customBotsHtml}
|
|
2247
3074
|
`;
|
|
2248
3075
|
|
|
2249
|
-
box.querySelector('#btn-copy-apikey')?.addEventListener('click', () => {
|
|
2250
|
-
if (!apiKey) return;
|
|
2251
|
-
navigator.clipboard?.writeText(apiKey);
|
|
2252
|
-
toast('API-ключ скопирован в буфер обмена');
|
|
2253
|
-
});
|
|
2254
|
-
box.querySelector('#btn-copy-npx')?.addEventListener('click', () => {
|
|
2255
|
-
navigator.clipboard?.writeText(npxCmd);
|
|
2256
|
-
toast(`Команда «${npxCmd}» скопирована`);
|
|
2257
|
-
});
|
|
2258
|
-
box.querySelector('#btn-regen-apikey')?.addEventListener('click', async () => {
|
|
2259
|
-
if (!confirm('Перевыпустить API-ключ? Прежний ключ перестанет работать.')) return;
|
|
2260
|
-
try {
|
|
2261
|
-
await post('/api/auth/api-key/regenerate');
|
|
2262
|
-
toast('Новый API-ключ выпущен');
|
|
2263
|
-
renderCloudPane();
|
|
2264
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
2265
|
-
});
|
|
2266
|
-
box.querySelector('#btn-lic-activate')?.addEventListener('click', async () => {
|
|
2267
|
-
const k = box.querySelector('#lic-key-in')?.value.trim();
|
|
2268
|
-
const uUrl = box.querySelector('#lic-url-in')?.value.trim();
|
|
2269
|
-
if (!k) return toast('Введите API-ключ (na_live_…)', 'error');
|
|
2270
|
-
try {
|
|
2271
|
-
const r = await post('/api/license/activate', { apiKey: k, cloudUrl: uUrl });
|
|
2272
|
-
toast(`Ключ активирован! Тариф: ${r.license?.planName}`);
|
|
2273
|
-
renderCloudPane();
|
|
2274
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
2275
|
-
});
|
|
2276
|
-
|
|
2277
3076
|
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
2278
3077
|
await post('/api/auth/logout');
|
|
2279
3078
|
location.href = '/landing';
|
|
@@ -2285,11 +3084,14 @@ async function renderCloudPane() {
|
|
|
2285
3084
|
renderCloudPane();
|
|
2286
3085
|
} catch (e) { toast(e.message, 'error'); }
|
|
2287
3086
|
});
|
|
2288
|
-
box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click',
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
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
|
+
});
|
|
2293
3095
|
}));
|
|
2294
3096
|
box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
|
|
2295
3097
|
try {
|
|
@@ -2297,32 +3099,16 @@ async function renderCloudPane() {
|
|
|
2297
3099
|
toast('Тестовое уведомление отправлено в привязанные мессенджеры');
|
|
2298
3100
|
} catch (e) { toast(e.message, 'error'); }
|
|
2299
3101
|
});
|
|
2300
|
-
box.querySelector('#btn-promo-redeem')?.addEventListener('click',
|
|
3102
|
+
box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
|
|
2301
3103
|
const code = box.querySelector('#promo-code-in')?.value.trim();
|
|
2302
|
-
if (!code) return;
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
e.preventDefault();
|
|
2311
|
-
const f = e.target.elements;
|
|
2312
|
-
try {
|
|
2313
|
-
await post('/api/admin/promo', { code: f.code.value, plan: f.plan.value, days: f.days.value, maxUses: f.maxUses.value });
|
|
2314
|
-
toast('Промокод создан');
|
|
2315
|
-
renderCloudPane();
|
|
2316
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
2317
|
-
});
|
|
2318
|
-
box.querySelector('#adm-billing-form')?.addEventListener('submit', async (e) => {
|
|
2319
|
-
e.preventDefault();
|
|
2320
|
-
const f = e.target.elements;
|
|
2321
|
-
try {
|
|
2322
|
-
await post('/api/admin/billing', { shopId: f.shopId.value, secretKey: f.secretKey.value, sendReceipt: f.sendReceipt.checked });
|
|
2323
|
-
toast('Настройки ЮKassa сохранены');
|
|
2324
|
-
renderCloudPane();
|
|
2325
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
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
|
+
});
|
|
2326
3112
|
});
|
|
2327
3113
|
box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
|
|
2328
3114
|
e.preventDefault();
|
|
@@ -2333,13 +3119,6 @@ async function renderCloudPane() {
|
|
|
2333
3119
|
renderCloudPane();
|
|
2334
3120
|
} catch (err) { toast(err.message, 'error'); }
|
|
2335
3121
|
});
|
|
2336
|
-
box.querySelectorAll('[data-adm-plan]').forEach((sel) => sel.addEventListener('change', async () => {
|
|
2337
|
-
try {
|
|
2338
|
-
await post('/api/admin/user-plan', { userId: Number(sel.dataset.admPlan), plan: sel.value, days: 30 });
|
|
2339
|
-
toast('Тариф пользователя обновлён');
|
|
2340
|
-
renderCloudPane();
|
|
2341
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
2342
|
-
}));
|
|
2343
3122
|
}
|
|
2344
3123
|
|
|
2345
3124
|
// ---------- тема (тёмная / светлая) ----------
|
|
@@ -2366,15 +3145,28 @@ $('#btn-theme')?.addEventListener('click', () => {
|
|
|
2366
3145
|
if (localStorage.getItem('theme') === 'light') applyTheme(true);
|
|
2367
3146
|
|
|
2368
3147
|
// ---------- справка ----------
|
|
2369
|
-
|
|
2370
|
-
|
|
3148
|
+
const helpCache = new Map();
|
|
3149
|
+
async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
|
|
2371
3150
|
$('#help').hidden = false;
|
|
2372
|
-
|
|
3151
|
+
document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
|
|
3152
|
+
if (helpCache.has(docName)) {
|
|
3153
|
+
$('#help-body').innerHTML = helpCache.get(docName);
|
|
3154
|
+
$('#help-body').scrollTop = 0;
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
$('#help-body').textContent = 'Загрузка…';
|
|
2373
3158
|
try {
|
|
2374
|
-
const res = await fetch(`/docs/${encodeURIComponent(
|
|
2375
|
-
|
|
2376
|
-
|
|
3159
|
+
const res = await fetch(`/docs/${encodeURIComponent(docName)}`);
|
|
3160
|
+
const html = md(await res.text());
|
|
3161
|
+
helpCache.set(docName, html);
|
|
3162
|
+
$('#help-body').innerHTML = html;
|
|
3163
|
+
$('#help-body').scrollTop = 0;
|
|
2377
3164
|
} catch (e) { $('#help-body').textContent = e.message; }
|
|
3165
|
+
}
|
|
3166
|
+
$('#btn-help').addEventListener('click', () => openHelpDoc('ИНСТРУКЦИЯ.md'));
|
|
3167
|
+
$('#help-tabs')?.addEventListener('click', (e) => {
|
|
3168
|
+
const b = e.target.closest('button[data-hdoc]');
|
|
3169
|
+
if (b) openHelpDoc(b.dataset.hdoc);
|
|
2378
3170
|
});
|
|
2379
3171
|
$('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
|
|
2380
3172
|
$('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
|
|
@@ -2386,7 +3178,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2386
3178
|
}
|
|
2387
3179
|
});
|
|
2388
3180
|
|
|
2389
|
-
// ---------- старт ----------
|
|
3181
|
+
// ---------- старт и восстановление сохранённого места в интерфейсе ----------
|
|
2390
3182
|
(async function init() {
|
|
2391
3183
|
state.config = await api('/api/config');
|
|
2392
3184
|
buildForm();
|
|
@@ -2405,6 +3197,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
2405
3197
|
renderCloudPane();
|
|
2406
3198
|
}).catch(() => {});
|
|
2407
3199
|
}
|
|
2408
|
-
const
|
|
3200
|
+
const savedNav = loadSavedNav();
|
|
3201
|
+
const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
|
|
2409
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);
|
|
2410
3213
|
})().catch((e) => toast(e.message, 'error'));
|