neuroalpha 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -3
- package/bin/cli.js +69 -0
- package/config/strategy.json +33 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
- package/package.json +43 -6
- package/public/app.js +2410 -0
- package/public/index.html +337 -0
- package/public/landing.html +870 -0
- package/public/legal.html +90 -0
- package/public/styles.css +343 -0
- package/server/api/routes.js +333 -0
- package/server/auth/index.js +340 -0
- package/server/billing/yookassa.js +132 -0
- package/server/brokers/index.js +645 -0
- package/server/config.js +44 -0
- package/server/data/moex.js +165 -0
- package/server/db.js +420 -0
- package/server/hub.js +14 -0
- package/server/index.js +92 -0
- package/server/llm/client.js +187 -0
- package/server/llm/providers.js +355 -0
- package/server/model/dataset.js +352 -0
- package/server/model/forecast.js +121 -0
- package/server/model/gpu-worker.html +11 -0
- package/server/model/gpu-worker.js +60 -0
- package/server/model/linalg.js +142 -0
- package/server/model/live.js +486 -0
- package/server/model/metrics.js +134 -0
- package/server/model/models.js +459 -0
- package/server/model/profiles.js +166 -0
- package/server/model/research.js +880 -0
- package/server/model/tf-node.js +26 -0
- package/server/model/transformer-core.js +330 -0
- package/server/model/transformer-gpu.js +221 -0
- package/server/model/transformer-pool.js +200 -0
- package/server/model/transformer-worker.js +28 -0
- package/server/model/transformer.js +57 -0
- package/server/model/vocab.js +28 -0
- package/server/news/index.js +309 -0
- package/server/notify/index.js +168 -0
- package/server/params-meta.js +63 -0
- package/server/settings.js +79 -0
- package/server/strategist/analytics.js +150 -0
- package/server/strategist/index.js +484 -0
- package/server/strategist/tools.js +302 -0
- package/server/util.js +9 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,2410 @@
|
|
|
1
|
+
const $ = (s, root = document) => root.querySelector(s);
|
|
2
|
+
const MSK = 3 * 3600000;
|
|
3
|
+
|
|
4
|
+
// ---------- утилиты ----------
|
|
5
|
+
const api = async (url, opts = {}) => {
|
|
6
|
+
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined });
|
|
7
|
+
const j = await res.json().catch(() => ({}));
|
|
8
|
+
if (!res.ok) throw new Error(j.error || res.statusText);
|
|
9
|
+
return j;
|
|
10
|
+
};
|
|
11
|
+
const post = (url, body = {}) => api(url, { method: 'POST', body });
|
|
12
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
13
|
+
const fmtNum = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v) ? '—' : Number(v).toLocaleString('ru-RU', { maximumFractionDigits: d }));
|
|
14
|
+
const fmtPct = (v, d = 1) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${fmtNum(v, d)}%`);
|
|
15
|
+
const fmtTs = (ts) => { if (!ts) return '—'; const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)} ${d.slice(11, 16)}`; };
|
|
16
|
+
const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
|
|
17
|
+
const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
|
|
18
|
+
const md = (s) => {
|
|
19
|
+
const raw = window.marked ? marked.parse(String(s || ''), { breaks: true }) : `<p>${esc(s)}</p>`;
|
|
20
|
+
return raw.replace(/№(\d{1,6})\b/g, '<span class="link" data-run="$1" title="Открыть эксперимент №$1">№$1</span>');
|
|
21
|
+
};
|
|
22
|
+
const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
|
|
23
|
+
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
|
24
|
+
// action: { label, fn } — кнопка в сообщении (тогда оно висит дольше)
|
|
25
|
+
function toast(text, type = 'info', action = null) {
|
|
26
|
+
const el = document.createElement('div');
|
|
27
|
+
el.className = type;
|
|
28
|
+
el.textContent = text;
|
|
29
|
+
if (action) {
|
|
30
|
+
const b = document.createElement('button');
|
|
31
|
+
b.textContent = action.label;
|
|
32
|
+
b.style.marginLeft = '10px';
|
|
33
|
+
b.addEventListener('click', () => { el.remove(); action.fn(); });
|
|
34
|
+
el.append(b);
|
|
35
|
+
}
|
|
36
|
+
$('#toast').append(el);
|
|
37
|
+
setTimeout(() => el.remove(), action ? 12000 : type === 'error' ? 8000 : 4000);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
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', 'transformer'];
|
|
42
|
+
const MODEL_LABEL = { inertia: 'Своя инерция', leadlag: 'Кто за кем ходит', attention: 'Внимание через карту', transformer: 'Трансформер' };
|
|
43
|
+
const MODEL_HINT = {
|
|
44
|
+
inertia: 'Простейшая планка: акция предсказывается только по своему недавнему движению.',
|
|
45
|
+
leadlag: 'Каждая акция предсказывается по недавним движениям всех остальных, без карты.',
|
|
46
|
+
attention: 'Влияние соседей по карте рынка через 12 «голов внимания».',
|
|
47
|
+
transformer: 'Нейросеть с вниманием между акциями; учится заново в каждом окне.',
|
|
48
|
+
};
|
|
49
|
+
const COLORS = { attention: '#4f8cff', market: '#8b949e', inertia: '#d29922', leadlag: '#a371f7', transformer: '#39c5cf' };
|
|
50
|
+
const CLUSTER_COLORS = ['#4f8cff', '#3fb950', '#d29922', '#f85149', '#a371f7', '#0891b2', '#db2777', '#64748b'];
|
|
51
|
+
const labelOf = (k) => state.config?.params.find((p) => p.key === k)?.label || k;
|
|
52
|
+
|
|
53
|
+
// ======================================================================
|
|
54
|
+
// СПЛИТТЕР
|
|
55
|
+
// ======================================================================
|
|
56
|
+
(function splitter() {
|
|
57
|
+
const sp = $('#splitter');
|
|
58
|
+
const saved = localStorage.getItem('chatWidth');
|
|
59
|
+
if (saved) document.documentElement.style.setProperty('--chat-w', saved);
|
|
60
|
+
let dragging = false;
|
|
61
|
+
sp.addEventListener('mousedown', (e) => { dragging = true; sp.classList.add('drag'); document.body.classList.add('dragging'); e.preventDefault(); });
|
|
62
|
+
window.addEventListener('mousemove', (e) => {
|
|
63
|
+
if (!dragging) return;
|
|
64
|
+
const w = Math.max(300, Math.min(window.innerWidth - 420, e.clientX));
|
|
65
|
+
document.documentElement.style.setProperty('--chat-w', `${w}px`);
|
|
66
|
+
});
|
|
67
|
+
window.addEventListener('mouseup', () => {
|
|
68
|
+
if (!dragging) return;
|
|
69
|
+
dragging = false;
|
|
70
|
+
sp.classList.remove('drag');
|
|
71
|
+
document.body.classList.remove('dragging');
|
|
72
|
+
localStorage.setItem('chatWidth', getComputedStyle(document.documentElement).getPropertyValue('--chat-w').trim());
|
|
73
|
+
if (!$('#tab-map').hidden) drawMap();
|
|
74
|
+
});
|
|
75
|
+
sp.addEventListener('dblclick', () => {
|
|
76
|
+
document.documentElement.style.removeProperty('--chat-w');
|
|
77
|
+
localStorage.removeItem('chatWidth');
|
|
78
|
+
if (!$('#tab-map').hidden) drawMap();
|
|
79
|
+
});
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
// ======================================================================
|
|
83
|
+
// ВКЛАДКИ
|
|
84
|
+
// ======================================================================
|
|
85
|
+
function updateTabBadges() {
|
|
86
|
+
// 1. Эксперименты: общее количество + иконка, если прямо сейчас идёт расчёт или стресс-тест
|
|
87
|
+
const ovEl = $('#tstat-overview');
|
|
88
|
+
if (ovEl) {
|
|
89
|
+
const total = state.runs?.length || 0;
|
|
90
|
+
const isRunning = Boolean(state.active || state.stabilityActive || state.runs?.some((r) => r.status === 'running'));
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (total > 0) parts.push(`<span class="tbadge" title="Всего экспериментов в истории: ${total}">${total}</span>`);
|
|
93
|
+
if (isRunning) {
|
|
94
|
+
const rId = state.active || state.stabilityActive || state.runs?.find((r) => r.status === 'running')?.id;
|
|
95
|
+
parts.push(`<span class="tbadge run" title="Сейчас идёт расчёт${rId ? ` №${rId}` : ''}"><i class="tspin"></i></span>`);
|
|
96
|
+
}
|
|
97
|
+
ovEl.innerHTML = parts.join('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. Торговля: сколько виртуальных и реальных брокерских счетов
|
|
101
|
+
const nowEl = $('#tstat-now');
|
|
102
|
+
if (nowEl) {
|
|
103
|
+
const vAccs = state.live?.accounts || [];
|
|
104
|
+
const vRun = vAccs.filter((a) => a.running).length;
|
|
105
|
+
const bAccs = state.brokers?.accounts || [];
|
|
106
|
+
const bActive = bAccs.filter((a) => !a.haltedReason && a.mode !== 'readonly').length;
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (vAccs.length > 0) {
|
|
109
|
+
parts.push(`<span class="tbadge${vRun > 0 ? ' ok' : ''}" title="Виртуальные счета: запущено ${vRun}, всего ${vAccs.length}">${vRun > 0 ? `▶ ${vRun}` : vAccs.length} вирт.</span>`);
|
|
110
|
+
}
|
|
111
|
+
if (bAccs.length > 0) {
|
|
112
|
+
parts.push(`<span class="tbadge${bActive > 0 ? ' ok' : ''}" title="Брокерские счета: подключено ${bAccs.length}">${bAccs.length} реал.</span>`);
|
|
113
|
+
}
|
|
114
|
+
nowEl.innerHTML = parts.join('');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Карта и журнал: сколько записей в журнале гипотез (+ индикатор обновления профилей)
|
|
118
|
+
const mapEl = $('#tstat-map');
|
|
119
|
+
if (mapEl) {
|
|
120
|
+
const jCount = state.journalCount ?? 0;
|
|
121
|
+
const parts = [];
|
|
122
|
+
if (jCount > 0) parts.push(`<span class="tbadge" title="Записей в журнале гипотез: ${jCount}">${jCount}</span>`);
|
|
123
|
+
if (state.profilesBusy) parts.push(`<span class="tbadge run" title="Составляются описания компаний"><i class="tspin"></i></span>`);
|
|
124
|
+
mapEl.innerHTML = parts.join('');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 4. Настройки: сколько параметров изменено относительно умолчаний
|
|
128
|
+
const setEl = $('#tstat-settings');
|
|
129
|
+
if (setEl && state.settings && state.config?.defaults) {
|
|
130
|
+
const d = state.config.defaults;
|
|
131
|
+
const shownKeys = (state.config.params || []).filter((p) => !p.hidden).map((p) => p.key);
|
|
132
|
+
const diff = shownKeys.filter((k) => state.settings[k] !== undefined && JSON.stringify(state.settings[k]) !== JSON.stringify(d[k])).length;
|
|
133
|
+
setEl.innerHTML = diff > 0 ? `<span class="tbadge" title="Изменено настроек от значений по умолчанию: ${diff}">${diff}</span>` : '';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function switchTab(t) {
|
|
138
|
+
if (t === 'journal') t = 'map';
|
|
139
|
+
$('#tabs').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.tab === t));
|
|
140
|
+
document.querySelectorAll('.right > .tab').forEach((el) => { el.hidden = el.id !== `tab-${t}`; });
|
|
141
|
+
if (t === 'settings') loadHistory().catch(() => {});
|
|
142
|
+
if (t === 'now') { refreshForecastIfStale(); loadNews(); }
|
|
143
|
+
if (t === 'map') {
|
|
144
|
+
setupMapSlider();
|
|
145
|
+
drawMap();
|
|
146
|
+
loadJournal();
|
|
147
|
+
if (state.mapMode === 'text' && !state.profiles) loadProfilesData().then(drawMap);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
$('#tabs').addEventListener('click', (e) => {
|
|
151
|
+
const b = e.target.closest('button[data-tab]');
|
|
152
|
+
if (b) switchTab(b.dataset.tab);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ======================================================================
|
|
156
|
+
// ОБЗОР: исследования
|
|
157
|
+
// ======================================================================
|
|
158
|
+
const opts = () => ({
|
|
159
|
+
layout: { background: { color: 'transparent' }, textColor: '#8b949e' },
|
|
160
|
+
grid: { vertLines: { visible: false }, horzLines: { color: '#1b2029' } },
|
|
161
|
+
timeScale: { borderVisible: false }, rightPriceScale: { borderVisible: false }, crosshair: { mode: 0 }, autoSize: true, attributionLogo: false,
|
|
162
|
+
});
|
|
163
|
+
const eqChart = LightweightCharts.createChart($('#eq-chart'), opts());
|
|
164
|
+
const eqLines = {
|
|
165
|
+
market: eqChart.addLineSeries({ color: COLORS.market, lineWidth: 1, priceLineVisible: false, lastValueVisible: false }),
|
|
166
|
+
inertia: eqChart.addLineSeries({ color: COLORS.inertia, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
167
|
+
leadlag: eqChart.addLineSeries({ color: COLORS.leadlag, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
168
|
+
attention: eqChart.addLineSeries({ color: COLORS.attention, lineWidth: 2, priceLineVisible: false, lastValueVisible: false }),
|
|
169
|
+
transformer: eqChart.addLineSeries({ color: COLORS.transformer, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false }),
|
|
170
|
+
};
|
|
171
|
+
const icChart = LightweightCharts.createChart($('#ic-chart'), opts());
|
|
172
|
+
const icSeries = icChart.addHistogramSeries({ priceFormat: { type: 'price', precision: 3, minMove: 0.001 }, priceLineVisible: false, lastValueVisible: false });
|
|
173
|
+
|
|
174
|
+
const RUN_PARTS = [
|
|
175
|
+
['modelAutoAdapt', (v) => (v === false ? 'Ручной режим' : null)],
|
|
176
|
+
['modelHorizon', (v) => `Прогноз: ${fmtNum(v, 3)}ч`],
|
|
177
|
+
['modelMinEdge', (v) => `Порог: ${fmtNum(v, 3)}`],
|
|
178
|
+
['modelExitRatio', (v) => (v !== 0.5 ? `Выход: ${fmtNum(v, 2)}` : null)],
|
|
179
|
+
['modelMinHoldHours', (v) => (v > 0 ? `Удерж.: ${fmtNum(v, 2)}ч` : null)],
|
|
180
|
+
['modelTopK', (v) => `Акции: ${v}`],
|
|
181
|
+
['modelAllocMode', (v) => (v === 'invvol' ? 'Доли 1/размах' : null)],
|
|
182
|
+
['modelTrainDays', (v) => `Окно: ${v}дн`],
|
|
183
|
+
['modelEmbedDim', (v) => `Эмбд.: ${v}`],
|
|
184
|
+
['modelUseMacro', (v) => (v ? 'Макро' : null)],
|
|
185
|
+
['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
|
|
186
|
+
['modelDirection', (v) => (v === 'longshort' ? 'Продажи' : v === 'neutral' ? 'Нейтрально' : null)],
|
|
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)],
|
|
191
|
+
];
|
|
192
|
+
function usesTf(p) { return p.modelUseTransformer || p.modelType === 'transformer'; }
|
|
193
|
+
function runTitle(r, html = false) {
|
|
194
|
+
const p = r.params || {}, d = state.config?.defaults || {};
|
|
195
|
+
const parts = RUN_PARTS.map(([k, f]) => {
|
|
196
|
+
if (p[k] == null) return null;
|
|
197
|
+
const t = f(p[k], p);
|
|
198
|
+
return html && JSON.stringify(p[k]) !== JSON.stringify(d[k]) ? `<span style="color:var(--yellow)">${t}</span>` : t;
|
|
199
|
+
}).filter(Boolean);
|
|
200
|
+
const status = r.status === 'done' ? fmtPct(r.metrics?.full?.returnPct) : r.status === 'running' ? 'идёт…' : 'ошибка';
|
|
201
|
+
const head = p._title ? `№${r.id} (${p._title})` : `№${r.id}`;
|
|
202
|
+
return [head, ...parts, ...(p._source === 'ai' ? ['Стратег'] : []), status].join(' — ');
|
|
203
|
+
}
|
|
204
|
+
const runTip = (r) => {
|
|
205
|
+
const p = r.params || {};
|
|
206
|
+
return esc([
|
|
207
|
+
`Исследование №${r.id} от ${fmtDate(r.created_at)}`,
|
|
208
|
+
...RUN_PARTS.map(([k]) => `${labelOf(k)}: ${p[k] ?? '—'}`),
|
|
209
|
+
p._source === 'ai' ? 'Запустил Стратег' : '',
|
|
210
|
+
p._note ? `Гипотеза: ${p._note}` : '',
|
|
211
|
+
].filter(Boolean).join('\n'));
|
|
212
|
+
};
|
|
213
|
+
const runLabel = (id) => {
|
|
214
|
+
const r = state.runs.find((x) => x.id === id);
|
|
215
|
+
return r ? runTitle(r) : `№${id}`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
async function loadRuns(selectId) {
|
|
219
|
+
state.runs = (await api('/api/runs')).filter((r) => r.mode === 'model');
|
|
220
|
+
const sel = $('#run-select');
|
|
221
|
+
const optHtml = state.runs.length
|
|
222
|
+
? state.runs.map((r) => `<option value="${r.id}" title="${runTip(r)}">${runTitle(r)}</option>`).join('')
|
|
223
|
+
: '<option value="">Исследований пока нет</option>';
|
|
224
|
+
sel.innerHTML = optHtml;
|
|
225
|
+
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
|
+
const id = selectId ?? state.run?.id ?? state.runs.find((r) => r.status === 'done')?.id;
|
|
234
|
+
updateTabBadges();
|
|
235
|
+
if (id && state.runs.some((r) => r.id === id)) {
|
|
236
|
+
sel.value = String(id);
|
|
237
|
+
if (state.run?.id !== id || selectId) await openRun(id);
|
|
238
|
+
} else renderOverview();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function openRun(id) {
|
|
242
|
+
state.run = await api(`/api/runs/${id}`);
|
|
243
|
+
$('#run-select').value = String(id);
|
|
244
|
+
$('#run-bar').hidden = false;
|
|
245
|
+
const doneRun = state.run?.status === 'done';
|
|
246
|
+
$('#btn-stability').hidden = !doneRun;
|
|
247
|
+
$('#btn-to-live').hidden = !doneRun;
|
|
248
|
+
$('#btn-cmp-run').hidden = state.runs.length < 2;
|
|
249
|
+
syncApplyBtn();
|
|
250
|
+
renderOverview();
|
|
251
|
+
setupMapSlider();
|
|
252
|
+
if (!$('#tab-map').hidden) drawMap();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
$('#run-select').addEventListener('change', (e) => e.target.value && openRun(Number(e.target.value)));
|
|
256
|
+
// очистка истории: оставить ровно keep лучших по итогу (0 — удалить все завершённые), в Настройки → Дополнительно
|
|
257
|
+
async function cleanRunsHistory() {
|
|
258
|
+
const raw = Number($('#keep-n')?.value ?? 0);
|
|
259
|
+
const keep = Number.isFinite(raw) ? Math.max(0, Math.min(50, Math.floor(raw))) : 0;
|
|
260
|
+
const total = state.runs.filter((r) => r.status !== 'running').length;
|
|
261
|
+
if (total <= keep) return toast(`Экспериментов всего ${total} — удалять нечего`);
|
|
262
|
+
const msg = keep === 0
|
|
263
|
+
? `Удалить все завершённые эксперименты (${total})?`
|
|
264
|
+
: `Оставить ровно ${keep} лучших по итогу и удалить остальные ${total - keep}?`;
|
|
265
|
+
if (!confirm(msg)) return;
|
|
266
|
+
try {
|
|
267
|
+
const r = await post('/api/runs/cleanup', { keep });
|
|
268
|
+
state.run = null;
|
|
269
|
+
await loadRuns();
|
|
270
|
+
toast(`История очищена: удалено ${r.deleted}, осталось ${r.kept.length}`);
|
|
271
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
272
|
+
}
|
|
273
|
+
// удалить один эксперимент (вместе с его сделками и событиями в ленте)
|
|
274
|
+
$('#btn-del').addEventListener('click', async () => {
|
|
275
|
+
const r = state.run;
|
|
276
|
+
if (!r) return;
|
|
277
|
+
if (r.status === 'running') return toast('Эксперимент ещё идёт — сначала отмените его');
|
|
278
|
+
if (!confirm(`Удалить ${runLabel(r.id)}?`)) return;
|
|
279
|
+
try {
|
|
280
|
+
await api(`/api/runs/${r.id}`, { method: 'DELETE' });
|
|
281
|
+
state.run = null;
|
|
282
|
+
await loadRuns();
|
|
283
|
+
toast(`Эксперимент №${r.id} удалён`);
|
|
284
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
285
|
+
});
|
|
286
|
+
// Проверить устойчивость открытого прогона (5 сдвигов периода −8…+8 дней)
|
|
287
|
+
function stabilityUi(runningId) {
|
|
288
|
+
state.stabilityActive = runningId || null;
|
|
289
|
+
const busy = Boolean(runningId);
|
|
290
|
+
$('#btn-stability').disabled = busy;
|
|
291
|
+
$('#btn-stability').textContent = busy ? 'Идёт проверка устойчивости…' : 'Проверить устойчивость';
|
|
292
|
+
$('#btn-cancel').hidden = !busy && !state.active;
|
|
293
|
+
if (!busy && !state.active) setProgress(null);
|
|
294
|
+
updateTabBadges();
|
|
295
|
+
}
|
|
296
|
+
$('#btn-stability').addEventListener('click', async () => {
|
|
297
|
+
const r = state.run;
|
|
298
|
+
if (!r || r.status !== 'done') return;
|
|
299
|
+
try {
|
|
300
|
+
stabilityUi(r.id);
|
|
301
|
+
setProgress(1, 'Запускаем проверку устойчивости по 5 сдвигам периода…');
|
|
302
|
+
await post(`/api/runs/${r.id}/stability`);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
stabilityUi(null);
|
|
305
|
+
toast(e.message, 'error');
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// Запустить виртуальный счёт прямо из открытого эксперимента (с заморозкой его настроек и ориентиром истории)
|
|
309
|
+
$('#btn-to-live').addEventListener('click', async () => {
|
|
310
|
+
const r = state.run;
|
|
311
|
+
if (!r || r.status !== 'done') return;
|
|
312
|
+
const rawCap = prompt(`Стартовая сумма виртуального счёта (по настройкам эксперимента №${r.id}), ₽:`, String(r.params?.capital || 1000000));
|
|
313
|
+
if (rawCap === null) return;
|
|
314
|
+
const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
|
|
315
|
+
try {
|
|
316
|
+
const res = await post('/api/live/start', { baseRunId: r.id, capital });
|
|
317
|
+
renderLive(res.status);
|
|
318
|
+
toast(`Виртуальный счёт №${res.runId} запущен по эксперименту №${r.id}`, 'info', {
|
|
319
|
+
label: 'Открыть',
|
|
320
|
+
fn: () => { switchTab('now'); showNowSec('virtual'); },
|
|
321
|
+
});
|
|
322
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
323
|
+
});
|
|
324
|
+
// «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
|
|
325
|
+
const APPLY_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelMinHoldHours', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelTextWeight', 'modelUseTransformer', 'transformerEpochs', 'transformerGpu', 'commissionPct', 'slippagePct'];
|
|
326
|
+
function runSettingsDiff(r = state.run) {
|
|
327
|
+
if (!r?.params || !state.settings) return { entries: [], lines: [] };
|
|
328
|
+
const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
|
|
329
|
+
const diffKeys = APPLY_KEYS.filter((k) => pv(k) !== undefined && JSON.stringify(pv(k)) !== JSON.stringify(state.settings[k]));
|
|
330
|
+
return {
|
|
331
|
+
entries: diffKeys.map((k) => [k, pv(k)]),
|
|
332
|
+
lines: diffKeys.map((k) => `${labelOf(k)}: ${state.settings[k] ?? '—'} → ${pv(k) ?? '—'}`),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function syncShowcaseBtn() {
|
|
336
|
+
const btn = $('#btn-showcase');
|
|
337
|
+
if (!btn) return;
|
|
338
|
+
const isAdmin = state.config?.user?.role === 'admin';
|
|
339
|
+
const r = state.run;
|
|
340
|
+
btn.hidden = !isAdmin || !r || r.status !== 'done';
|
|
341
|
+
if (!btn.hidden) {
|
|
342
|
+
const on = Boolean(r.showcase);
|
|
343
|
+
btn.textContent = on ? '★ На витрине' : '☆ На витрину';
|
|
344
|
+
btn.style.color = on ? 'var(--yellow)' : '';
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
$('#btn-showcase')?.addEventListener('click', async () => {
|
|
348
|
+
const r = state.run;
|
|
349
|
+
if (!r) return;
|
|
350
|
+
try {
|
|
351
|
+
const updated = await api(`/api/runs/${r.id}/showcase`, { method: 'PUT', body: { showcase: !r.showcase } });
|
|
352
|
+
state.run.showcase = updated.showcase;
|
|
353
|
+
syncShowcaseBtn();
|
|
354
|
+
toast(updated.showcase ? `Эксперимент №${r.id} добавлен на публичную витрину` : `Эксперимент №${r.id} убран с витрины`);
|
|
355
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
function syncApplyBtn() {
|
|
359
|
+
syncShowcaseBtn();
|
|
360
|
+
const btn = $('#btn-apply');
|
|
361
|
+
if (!btn) return;
|
|
362
|
+
const { lines } = runSettingsDiff();
|
|
363
|
+
btn.hidden = !state.run || !lines.length;
|
|
364
|
+
if (lines.length) {
|
|
365
|
+
btn.textContent = `✓ Применить эти настройки (${lines.length})`;
|
|
366
|
+
btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
$('#btn-apply').addEventListener('click', async () => {
|
|
370
|
+
const r = state.run;
|
|
371
|
+
if (!r) return;
|
|
372
|
+
const { entries, lines } = runSettingsDiff(r);
|
|
373
|
+
if (!lines.length) { syncApplyBtn(); return; }
|
|
374
|
+
if (!confirm(`Применить настройки из эксперимента №${r.id}?\n\n${lines.join('\n')}`)) return;
|
|
375
|
+
await saveSettings(Object.fromEntries(entries));
|
|
376
|
+
fillForm(state.settings);
|
|
377
|
+
syncApplyBtn();
|
|
378
|
+
toast(`Настройки из эксперимента №${r.id} применены`);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
function verdictHtml(c, dir) {
|
|
382
|
+
const a = c.attention, base = c.inertia;
|
|
383
|
+
if (!a) return '';
|
|
384
|
+
const neutral = dir === 'neutral';
|
|
385
|
+
// помогает ли карта рынка — по торговому результату против простейшей модели
|
|
386
|
+
let vs = '';
|
|
387
|
+
if (base) {
|
|
388
|
+
const pf = a.profitFactor ?? 0, bpf = base.profitFactor ?? 0;
|
|
389
|
+
if (a.returnPct - base.returnPct > 5 && pf >= bpf) vs = `<b class="pos">Карта рынка даёт преимущество:</b> ${fmtPct(a.returnPct)} против ${fmtPct(base.returnPct)} у простейшей стратегии; на 1 ₽ убытка — ${fmtNum(pf)} ₽ против ${fmtNum(bpf)} ₽.`;
|
|
390
|
+
else if (Math.abs(a.returnPct - base.returnPct) <= 5) vs = `Карта рынка и простейшая стратегия заработали примерно одинаково (${fmtPct(a.returnPct)} против ${fmtPct(base.returnPct)}).`;
|
|
391
|
+
else vs = `Простейшая стратегия заработала больше (${fmtPct(base.returnPct)} против ${fmtPct(a.returnPct)}) — карта рынка пока не помогает.`;
|
|
392
|
+
vs += ` <span class="muted small">Средняя точность по всем акциям: ${fmtNum(a.ic, 3)} и ${fmtNum(base.ic, 3)} — преимущество проявляется на лучших акциях, которые и покупаются.</span>`;
|
|
393
|
+
}
|
|
394
|
+
let unreliable = a.trades < 30 || (base && base.trades < 30) ? ' <span class="muted small">Сделок мало, сравнение ненадёжно.</span>' : '';
|
|
395
|
+
if (c.transformer) unreliable += `<div class="muted small">Трансформер: итог ${fmtPct(c.transformer.returnPct)} против ${fmtPct(a.returnPct)} у внимания через карту.</div>`;
|
|
396
|
+
if (a.trades < 30) return `<b>Слишком мало сделок для выводов (${a.trades}).</b> <span class="muted">${vs}</span>${unreliable}`;
|
|
397
|
+
if (neutral && a.returnPct > 0 && (a.profitFactor ?? 0) >= 1.1) return `<b class="pos">Стратегия зарабатывает:</b> ${fmtPct(a.returnPct)} после комиссий; нейтрально к рынку: рынок почти не влияет. ${vs}${unreliable}`;
|
|
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}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const MONTHS = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
|
|
404
|
+
$('#pmode').addEventListener('click', (e) => {
|
|
405
|
+
if (!e.target.dataset.k) return;
|
|
406
|
+
state.pmode = e.target.dataset.k;
|
|
407
|
+
renderPeriods();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
function renderPeriods() {
|
|
411
|
+
const pr = state.run?.extra?.periods;
|
|
412
|
+
const box = $('#periods-box');
|
|
413
|
+
box.hidden = !pr?.months?.length;
|
|
414
|
+
if (box.hidden) return;
|
|
415
|
+
const mode = state.pmode || 'model';
|
|
416
|
+
$('#pmode').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.k === mode));
|
|
417
|
+
const val = (x) => (mode === 'market' ? x.market : mode === 'diff' ? (x.model === null || x.market === null ? null : x.model - x.market) : x.model);
|
|
418
|
+
const tip = (x, extra = '') => esc(`Стратегия ${fmtPct(x.model, 2)} · без комиссий ${fmtPct(x.gross, 2)} · рынок ${fmtPct(x.market, 2)}${extra}`);
|
|
419
|
+
const cell = (x, scale, extra = '', klass = '') => {
|
|
420
|
+
const v = val(x);
|
|
421
|
+
if (v === null || v === undefined) return `<td class="${klass}"></td>`;
|
|
422
|
+
const k = Math.min(Math.abs(v) / scale, 1) * 0.5;
|
|
423
|
+
const bg = v === 0 ? '' : `background:rgba(${v > 0 ? '63,185,80' : '248,81,73'},${k.toFixed(2)})`;
|
|
424
|
+
return `<td class="cell num ${klass}" style="${bg}" title="${tip(x, extra)}">${fmtNum(v, Math.abs(v) < 10 ? 1 : 0)}</td>`;
|
|
425
|
+
};
|
|
426
|
+
const byYm = new Map(pr.months.map((m) => [m.ym, m]));
|
|
427
|
+
const years = pr.years.map((y) => y.y);
|
|
428
|
+
const rows = years.map((y) => {
|
|
429
|
+
const yr = pr.years.find((z) => z.y === y);
|
|
430
|
+
const cells = MONTHS.map((_, i) => {
|
|
431
|
+
const m = byYm.get(`${y}-${String(i + 1).padStart(2, '0')}`);
|
|
432
|
+
return m ? cell(m, 10, m.oos ? ' · контрольный период' : '', m.oos ? 'oos' : '') : '<td></td>';
|
|
433
|
+
}).join('');
|
|
434
|
+
return `<tr><td class="y">${y}${yr.partial ? ` <span class="small" title="Неполный год: ${yr.months} мес.">·${yr.months}м</span>` : ''}</td>${cells}${cell(yr, 30, yr.partial ? ` · неполный год, ${yr.months} мес.` : '', 'tot')}</tr>`;
|
|
435
|
+
}).join('');
|
|
436
|
+
const what = { model: 'стратегия (с комиссиями), %', market: 'рынок, %', diff: 'стратегия минус рынок, п. п.' }[mode];
|
|
437
|
+
const pair = (x) => `стратегия ${fmtPct(x.model)} / рынок ${fmtPct(x.market)}`;
|
|
438
|
+
$('#periods').innerHTML = `<table><thead><tr><th class="y"></th>${MONTHS.map((m) => `<th>${m}</th>`).join('')}<th>Год</th></tr></thead><tbody>${rows}</tbody></table>
|
|
439
|
+
<p class="muted small note">В ячейках: ${what}. Подсказка при наведении — все три значения. Пунктир снизу — месяцы контрольного периода (больше половины месяца после границы настройки и контроля).</p>
|
|
440
|
+
<p class="muted small note">Годовые: ${pr.years.map((y) => `${y.y}${y.partial ? ` (${y.months} мес.)` : ''} ${pair(y)}`).join('; ')}${pr.last12 ? ` · Последние 12 мес.: ${pair(pr.last12)}` : ''}</p>`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function renderStability(st) {
|
|
444
|
+
const box = $('#stability');
|
|
445
|
+
if (!box) return;
|
|
446
|
+
if (!st || !st.runs?.length) { box.hidden = true; return; }
|
|
447
|
+
box.hidden = false;
|
|
448
|
+
const vMap = {
|
|
449
|
+
ok: ['Устойчиво', 'ok'],
|
|
450
|
+
mid: ['Средняя устойчивость', 'mid'],
|
|
451
|
+
no: ['Сильно зависит от случая', 'no'],
|
|
452
|
+
};
|
|
453
|
+
const [vLbl, vCls] = vMap[st.verdict] || ['Проверка', ''];
|
|
454
|
+
const kindLbl = st.mode === 'shifts' ? '5 сдвигов периода (−8…+8 дн.)' : 'быстрая проверка по сдвигам входа';
|
|
455
|
+
const chips = st.runs.map((r) => `<span class="stab-chip" title="Просадка ${fmtNum(r.maxDrawdownPct, 1)}%, сделок ${r.trades}">${esc(r.label)}: <b class="${cls(r.returnPct)}">${fmtPct(r.returnPct)}</b></span>`).join('');
|
|
456
|
+
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
|
+
}
|
|
458
|
+
|
|
459
|
+
function renderOverview() {
|
|
460
|
+
const run = state.run;
|
|
461
|
+
const empty = $('#overview-empty');
|
|
462
|
+
if (!run?.extra || run.status !== 'done') {
|
|
463
|
+
$('#overview').hidden = true;
|
|
464
|
+
empty.hidden = false;
|
|
465
|
+
empty.innerHTML = run && run.status !== 'done'
|
|
466
|
+
? (run.status === 'running' ? 'Исследование идёт…' : `Исследование не завершилось: ${esc(run.error || run.status)}`)
|
|
467
|
+
: `<b>Идея.</b> Каждая акция — как слово со своим местом на карте рынка. Недавние движения всех акций — контекст.
|
|
468
|
+
Стратегия, как языковая модель, предсказывает «следующее слово» — какие акции сдвинутся в ближайшие часы.<br><br>
|
|
469
|
+
Нажмите <b>«Исследовать»</b>: стратегия пошагово проверится на двух годах истории (около минуты; первый раз — несколько минут на загрузку цен).
|
|
470
|
+
Или попросите Стратега слева.`;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
empty.hidden = true;
|
|
474
|
+
$('#overview').hidden = false;
|
|
475
|
+
const ex = run.extra;
|
|
476
|
+
const c = Object.fromEntries(MODELS.map((n) => [n, ex.comparison[n]?.full]));
|
|
477
|
+
const chosen = run.metrics?.model?.chosen || 'attention';
|
|
478
|
+
const a = c[chosen] || {};
|
|
479
|
+
const dir = run.params?.modelDirection || 'long';
|
|
480
|
+
const adaptNote = ex.adaptation
|
|
481
|
+
? `<div class="muted small">Авто-адаптация: порог выхода ${fmtNum(ex.adaptation.exitRatio, 2)}${ex.adaptation.quarantined?.length ? ` · отсеяны убыточные бумаги (${ex.adaptation.quarantined.length}): ${esc(ex.adaptation.quarantined.slice(0, 10).join(', '))}${ex.adaptation.quarantined.length > 10 ? '…' : ''}` : ''}</div>`
|
|
482
|
+
: '';
|
|
483
|
+
$('#verdict').innerHTML = verdictHtml(c, dir) + adaptNote + (run.params?._note ? `<div class="muted small">Гипотеза: ${esc(run.params._note)}</div>` : '');
|
|
484
|
+
const kpi = (v, l, h) => `<div class="kpi" title="${esc(h)}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
|
|
485
|
+
$('#kpis').innerHTML = [
|
|
486
|
+
kpi(`<span class="${cls(a.returnPct)}">${fmtPct(a.returnPct)}</span>`, 'итог стратегии', 'Доход портфеля из лучших прогнозов после комиссий'),
|
|
487
|
+
kpi(`<span class="${cls(a.marketPct)}">${fmtPct(a.marketPct)}</span>`, 'рынок', 'Индекс МосБиржи за то же время'),
|
|
488
|
+
kpi(fmtNum(a.trades, 0), 'сделок', 'Чем больше сделок, тем надёжнее вывод'),
|
|
489
|
+
kpi(`${fmtNum(a.maxDrawdownPct, 1)}%`, 'худшее падение', 'Насколько счёт проседал от своего максимума'),
|
|
490
|
+
].join('');
|
|
491
|
+
$('#sides').hidden = dir === 'long' || a.shortPct === undefined;
|
|
492
|
+
$('#sides').textContent = `Покупки: ${fmtPct(a.longPct)} · Продажи: ${fmtPct(a.shortPct)}`;
|
|
493
|
+
renderStability(ex.stability);
|
|
494
|
+
|
|
495
|
+
const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
|
|
496
|
+
for (const n of ['market', ...MODELS]) eqLines[n].setData(uniq(ex.curves[n]).map((p) => ({ time: chartTime(p.ts), value: p.equity })));
|
|
497
|
+
// трансформер — только если он был в прогоне
|
|
498
|
+
for (const n of MODELS) eqLines[n].applyOptions({ visible: !!ex.curves[n] && (n === chosen || $('#more').open) });
|
|
499
|
+
eqChart.timeScale().fitContent();
|
|
500
|
+
$('#legend').innerHTML = [chosen, 'market', ...($('#more').open ? MODELS.filter((n) => n !== chosen && ex.curves[n]) : [])]
|
|
501
|
+
.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
|
+
|
|
503
|
+
renderPeriods();
|
|
504
|
+
|
|
505
|
+
const best = MODELS.reduce((b, n) => (c[n] && (!b || c[n].returnPct > c[b].returnPct) ? n : b), null);
|
|
506
|
+
const th = (t, h, num = true) => `<th class="${num ? 'num' : ''}" title="${esc(h)}">${t}</th>`;
|
|
507
|
+
$('#cmp').innerHTML = `<table><thead><tr>${th('Стратегия', '', false)}${th('Совпадение', 'Насколько порядок акций «кто сильнее рынка» по прогнозу совпадает с фактом (−1…1). На рынке 0,03+ уже ценно.')}${th('Недель с плюсом', 'В какой доле недель прогноз угадывал лучше случайного')}${th('Итог', 'После комиссий')}${th('Без комиссий', 'Если бы торговля была бесплатной — есть ли сигнал вообще')}${th('Сделок', '')}${th('На 1 ₽ убытка', 'Сколько заработано на каждый потерянный рубль')}</tr></thead><tbody>${
|
|
508
|
+
MODELS.map((n) => c[n] && `<tr class="${n === best ? 'best' : ''}"><td title="${esc(MODEL_HINT[n])}">${MODEL_LABEL[n]}${n === chosen ? ' <span class="tag">торгует</span>' : ''}</td>
|
|
509
|
+
<td class="num">${fmtNum(c[n].ic, 3)}</td><td class="num">${fmtNum(c[n].weeksPositivePct, 0)}%</td><td class="num ${cls(c[n].returnPct)}">${fmtPct(c[n].returnPct)}</td>
|
|
510
|
+
<td class="num ${cls(c[n].grossReturnPct)}">${fmtPct(c[n].grossReturnPct)}</td><td class="num">${c[n].trades}</td><td class="num">${fmtNum(c[n].profitFactor)}</td></tr>`).join('')}</tbody></table>`;
|
|
511
|
+
|
|
512
|
+
const w = uniq(ex.weeklyIc?.[chosen]);
|
|
513
|
+
icSeries.setData(w.map((x) => ({ time: chartTime(x.ts), value: x.ic, color: x.ic >= 0 ? '#2b7a3b' : '#a33b36' })));
|
|
514
|
+
icChart.timeScale().fitContent();
|
|
515
|
+
|
|
516
|
+
const max = Math.max(...ex.heads.map((h) => Math.abs(h.coef)), ...ex.self.map(Math.abs), ...(ex.macroHeads || []).map((h) => Math.abs(h.coef)), 1e-6);
|
|
517
|
+
const bar = (label, v) => { const wd = (Math.abs(v) / max) * 50; return `<div class="headbar"><span>${esc(label)}</span><div class="b"><div style="left:${v >= 0 ? 50 : 50 - wd}%;width:${wd}%;background:${v >= 0 ? 'var(--green)' : 'var(--red)'}"></div></div><span class="num ${cls(v)}">${v > 0 ? '+' : ''}${fmtNum(v, 3)}</span></div>`; };
|
|
518
|
+
$('#heads').innerHTML = `<p class="muted small"><span class="pos">Зелёный</span> — соседи по карте «тянут» акцию за собой (переток). <span class="neg">Красный</span> — акция склонна возвращаться после перекоса. «Узкое» внимание смотрит на ближайших соседей, «широкое» — на многих.</p>
|
|
519
|
+
${[...ex.heads].sort((x, y) => Math.abs(y.coef) - Math.abs(x.coef)).slice(0, 8).map((h) => bar(h.label, h.coef)).join('')}
|
|
520
|
+
${bar('своё движение за последний час', ex.self[0])}
|
|
521
|
+
${ex.macroHeads?.length ? `<h4 class="muted" style="margin:10px 0 4px">Нефть, валюта, облигации</h4>${[...ex.macroHeads].sort((x, y) => Math.abs(y.coef) - Math.abs(x.coef)).map((h) => bar(h.label, h.coef)).join('')}` : ''}`;
|
|
522
|
+
|
|
523
|
+
const trades = run.trades || [];
|
|
524
|
+
$('#trades-sum').textContent = `Сделки торгующей стратегии (${trades.length})`;
|
|
525
|
+
const shorts = trades.some((t) => t.side === 'short');
|
|
526
|
+
$('#trades').innerHTML = trades.length ? `<table><thead><tr><th>Акция</th>${shorts ? '<th></th>' : ''}<th>${shorts ? 'Открыли' : 'Купили'}</th><th>${shorts ? 'Закрыли' : 'Продали'}</th><th class="num">Результат</th><th class="num">%</th></tr></thead><tbody>${
|
|
527
|
+
trades.slice(-300).reverse().map((t) => `<tr><td>${t.symbol}</td>${shorts ? `<td class="muted small">${t.side === 'short' ? 'продажа' : 'покупка'}</td>` : ''}<td>${fmtTs(t.entry_ts)}</td><td>${fmtTs(t.exit_ts)}</td><td class="num ${cls(t.pnl)}">${fmtNum(t.pnl, 0)} ₽</td><td class="num ${cls(t.pnl_pct)}">${fmtPct(t.pnl_pct, 2)}</td></tr>`).join('')}</tbody></table>${trades.length > 300 ? '<p class="muted small">Показаны последние 300.</p>' : ''}` : '<p class="muted">Сделок нет.</p>';
|
|
528
|
+
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
$('#more').addEventListener('toggle', () => { renderOverview(); icChart.timeScale().fitContent(); });
|
|
532
|
+
$('#run-bar').addEventListener('click', async (e) => {
|
|
533
|
+
const r = state.run;
|
|
534
|
+
if (!r) return;
|
|
535
|
+
if (e.target.closest('#btn-rename-run')) {
|
|
536
|
+
const next = prompt(`Название для эксперимента №${r.id} (оставьте пустым, чтобы сбросить):`, r.params?._title || '');
|
|
537
|
+
if (next === null) return;
|
|
538
|
+
try {
|
|
539
|
+
const updated = await api(`/api/runs/${r.id}/title`, { method: 'PUT', body: { title: next } });
|
|
540
|
+
state.run.params = updated.params;
|
|
541
|
+
await loadRuns(r.id);
|
|
542
|
+
renderOverview();
|
|
543
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (e.target.closest('#btn-ask-run')) {
|
|
547
|
+
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
|
+
}
|
|
559
|
+
});
|
|
560
|
+
$('#btn-ask-forecast').addEventListener('click', () => {
|
|
561
|
+
sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
function setProgress(pct, text) {
|
|
565
|
+
$('#progress').hidden = pct === null;
|
|
566
|
+
if (pct === null) return;
|
|
567
|
+
$('#progress .bar').style.width = `${pct}%`;
|
|
568
|
+
$('#progress span').textContent = text || '';
|
|
569
|
+
}
|
|
570
|
+
function researchUi(running) {
|
|
571
|
+
$('#btn-model').disabled = running;
|
|
572
|
+
$('#btn-cancel').hidden = !running && !state.stabilityActive;
|
|
573
|
+
if (!running && !state.stabilityActive) setProgress(null);
|
|
574
|
+
updateTabBadges();
|
|
575
|
+
}
|
|
576
|
+
async function startResearch(force = false) {
|
|
577
|
+
try {
|
|
578
|
+
researchUi(true);
|
|
579
|
+
const { runId, reused } = await post('/api/model/research', force ? { force: true } : {});
|
|
580
|
+
if (reused) {
|
|
581
|
+
researchUi(false);
|
|
582
|
+
toast(`Такое исследование уже есть — открыто ${runLabel(runId)}`, 'info', { label: 'Пересчитать', fn: () => startResearch(true) });
|
|
583
|
+
openRun(runId);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
state.active = runId;
|
|
587
|
+
setProgress(1, `Исследование №${runId}: начинаем…`);
|
|
588
|
+
loadRuns(runId);
|
|
589
|
+
} catch (e) { researchUi(false); toast(e.message, 'error'); }
|
|
590
|
+
}
|
|
591
|
+
$('#btn-model').addEventListener('click', () => startResearch(false));
|
|
592
|
+
$('#btn-cancel').addEventListener('click', async () => {
|
|
593
|
+
const stabId = state.stabilityActive;
|
|
594
|
+
const resId = state.active;
|
|
595
|
+
if (!resId && !stabId) return;
|
|
596
|
+
const b = $('#btn-cancel');
|
|
597
|
+
b.disabled = true;
|
|
598
|
+
b.textContent = 'Останавливаем…';
|
|
599
|
+
try {
|
|
600
|
+
if (stabId) {
|
|
601
|
+
await post(`/api/runs/${stabId}/stability/cancel`);
|
|
602
|
+
stabilityUi(null);
|
|
603
|
+
}
|
|
604
|
+
if (resId) await post(`/api/model/${resId}/cancel`);
|
|
605
|
+
} catch (e) {
|
|
606
|
+
toast(e.message, 'error');
|
|
607
|
+
} finally {
|
|
608
|
+
b.disabled = false;
|
|
609
|
+
b.textContent = 'Отменить';
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// ======================================================================
|
|
614
|
+
// СЕЙЧАС: прогноз и живая проверка
|
|
615
|
+
// ======================================================================
|
|
616
|
+
async function loadForecast(force, silent = false) {
|
|
617
|
+
const box = $('#forecast');
|
|
618
|
+
if (!silent) box.innerHTML = '<span class="muted">Обучаем стратегию на последних месяцах… (до минуты)</span>';
|
|
619
|
+
$('#btn-forecast').disabled = true;
|
|
620
|
+
try {
|
|
621
|
+
const f = state.forecast = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
|
|
622
|
+
$('#fc-title').textContent = `Прогноз на ${f.horizon} ч · свеча ${f.bar.slice(8, 10)}.${f.bar.slice(5, 7)} ${f.bar.slice(11)}`;
|
|
623
|
+
// ● у акции с новостями: зелёный/красный/серый по тону, подсказка — суть, источник и время
|
|
624
|
+
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 row = (r) => `<tr><td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${dot(r.news)}</td><td class="num ${cls(r.expectedPct)}">${fmtPct(r.expectedPct, 2)}</td>
|
|
626
|
+
<td>${r.signal ? '<span class="tag ok">купить</span>' : r.shortSignal ? '<span class="tag no">продать</span>' : ''}</td><td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td></tr>`;
|
|
627
|
+
box.innerHTML = `<table><thead><tr><th>Сильнее рынка</th><th class="num">Ожидание</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
|
|
628
|
+
<table style="margin-top:10px"><thead><tr><th>Слабее рынка</th><th class="num">Ожидание</th><th></th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(-4).reverse().map(row).join('')}</tbody></table>
|
|
629
|
+
<p class="muted small">Ожидание — движение относительно рынка в целом. «Купить» — если ожидание выше порога ${fmtPct(f.thresholdPct, 2)} (окупает комиссии с запасом).${f.direction && f.direction !== 'long' ? ' «Продать» — ожидание ниже −порога: продажа без покупки.' : ''}</p>`;
|
|
630
|
+
setupMapSlider();
|
|
631
|
+
} catch (e) {
|
|
632
|
+
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
633
|
+
} finally {
|
|
634
|
+
$('#btn-forecast').disabled = false;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
$('#btn-forecast').addEventListener('click', () => loadForecast(true));
|
|
638
|
+
const FORECAST_MAX_AGE = 3600000;
|
|
639
|
+
function refreshForecastIfStale() {
|
|
640
|
+
if (state.forecast?.at && Date.now() - state.forecast.at > FORECAST_MAX_AGE && !$('#btn-forecast').disabled) loadForecast(true, true);
|
|
641
|
+
}
|
|
642
|
+
setInterval(() => { if (!$('#tab-now').hidden) refreshForecastIfStale(); }, 5 * 60000);
|
|
643
|
+
|
|
644
|
+
function showNowSec(sec) {
|
|
645
|
+
document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
|
|
646
|
+
document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
|
|
647
|
+
}
|
|
648
|
+
$('#now-nav').addEventListener('click', (e) => {
|
|
649
|
+
const b = e.target.closest('[data-nsec]');
|
|
650
|
+
if (b) showNowSec(b.dataset.nsec);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
function renderLive(st) {
|
|
654
|
+
state.live = st;
|
|
655
|
+
const accs = st?.accounts || [];
|
|
656
|
+
if ($('#nnav-virtual')) $('#nnav-virtual').textContent = accs.length ? `Виртуальные счета (${accs.length})` : 'Виртуальные счета';
|
|
657
|
+
updateTabBadges();
|
|
658
|
+
if (!accs.length) {
|
|
659
|
+
$('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
$('#live').innerHTML = accs.map((a) => {
|
|
663
|
+
const s = a.snapshot || { equity: a.params?.capital || 0, positions: [], pending: [] };
|
|
664
|
+
const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
|
|
665
|
+
const retPct = a.returnPct ?? 0;
|
|
666
|
+
const step = a.recent?.[0];
|
|
667
|
+
const p = a.params || {};
|
|
668
|
+
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ` · ${p.modelDirection === 'neutral' ? 'нейтрально' : 'покупки+продажи'}` : ''}`;
|
|
669
|
+
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
670
|
+
if (a.corridor) {
|
|
671
|
+
const c = a.corridor;
|
|
672
|
+
const statusMap = {
|
|
673
|
+
inside: ['в коридоре истории', 'ok'],
|
|
674
|
+
above: ['опережает историю', 'ok'],
|
|
675
|
+
below: ['ниже коридора истории — проверьте', 'no'],
|
|
676
|
+
};
|
|
677
|
+
const [sLbl, sCls] = statusMap[c.status] || ['', ''];
|
|
678
|
+
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
|
+
}
|
|
680
|
+
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="live-head">
|
|
683
|
+
<div>
|
|
684
|
+
<b>${esc(dispName)}</b>
|
|
685
|
+
<span class="tag ${a.running ? 'ok' : ''}" style="margin-left:6px">${a.running ? 'работает' : 'остановлен'}</span>
|
|
686
|
+
${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
|
+
</div>
|
|
689
|
+
<div class="row-gap">
|
|
690
|
+
<button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
|
|
691
|
+
${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
|
|
692
|
+
<button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
696
|
+
<div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">на счёте</div></div>
|
|
697
|
+
<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>
|
|
698
|
+
${corrHtml}
|
|
699
|
+
<div class="kpi"><div class="v">${a.metrics?.full?.trades ?? 0}</div><div class="l">закрыто сделок</div></div>
|
|
700
|
+
</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
|
+
</div>`;
|
|
705
|
+
}).join('');
|
|
706
|
+
}
|
|
707
|
+
$('#btn-live').addEventListener('click', async () => {
|
|
708
|
+
const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
|
|
709
|
+
if (rawCap === null) return;
|
|
710
|
+
const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
|
|
711
|
+
try {
|
|
712
|
+
const res = await post('/api/live/start', { capital });
|
|
713
|
+
renderLive(res.status);
|
|
714
|
+
toast(`Виртуальный счёт №${res.runId} запущен`);
|
|
715
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
716
|
+
});
|
|
717
|
+
$('#live').addEventListener('click', async (e) => {
|
|
718
|
+
const stopBtn = e.target.closest('[data-live-stop]');
|
|
719
|
+
if (stopBtn) {
|
|
720
|
+
const id = Number(stopBtn.dataset.liveStop);
|
|
721
|
+
if (!confirm(`Остановить виртуальный счёт №${id}?`)) return;
|
|
722
|
+
try { const r = await post('/api/live/stop', { runId: id }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const delBtn = e.target.closest('[data-live-del]');
|
|
726
|
+
if (delBtn) {
|
|
727
|
+
const id = Number(delBtn.dataset.liveDel);
|
|
728
|
+
if (!confirm(`Удалить виртуальный счёт №${id} и его сделки?`)) return;
|
|
729
|
+
try { const r = await api(`/api/live/${id}`, { method: 'DELETE' }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const openBtn = e.target.closest('[data-open-run]');
|
|
733
|
+
if (openBtn) {
|
|
734
|
+
const id = Number(openBtn.dataset.openRun);
|
|
735
|
+
document.querySelector('[data-tab=overview]')?.click();
|
|
736
|
+
openRun(id);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const askBtn = e.target.closest('[data-live-ask]');
|
|
740
|
+
if (askBtn) {
|
|
741
|
+
const id = Number(askBtn.dataset.liveAsk);
|
|
742
|
+
const acc = (state.live?.accounts || []).find((x) => x.runId === id);
|
|
743
|
+
sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// ---------- новости: только для прогноза «сейчас» и живой проверки ----------
|
|
748
|
+
async function loadNews() {
|
|
749
|
+
try {
|
|
750
|
+
const { items } = await api('/api/news?limit=20');
|
|
751
|
+
if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
|
|
752
|
+
$('#news-list').innerHTML = items.length
|
|
753
|
+
? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}">${s.symbol}</b>`).join(', ')} · ` : ''}<a href="${esc(n.link)}" target="_blank" rel="noopener">${esc(n.gist || n.title)}</a> <span class="muted small">${esc(n.src)} · ${fmtTs(n.ts)}</span></div>`).join('')
|
|
754
|
+
: '<span class="muted">Свежих размеченных новостей пока нет.</span>';
|
|
755
|
+
} catch { /* ignore */ }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ======================================================================
|
|
759
|
+
// КАРТА
|
|
760
|
+
// ======================================================================
|
|
761
|
+
function mapFrames() {
|
|
762
|
+
const ex = state.run?.extra;
|
|
763
|
+
const fr = (ex?.map?.snapshots || []).map((s) => ({ ts: s.ts, pts: s.pts, cl: s.cl, symbols: ex.symbols, names: ex.names }));
|
|
764
|
+
if (state.forecast?.map) fr.push({ ts: state.forecast.barTs, now: true, ...state.forecast.map });
|
|
765
|
+
return fr;
|
|
766
|
+
}
|
|
767
|
+
function setupMapSlider() {
|
|
768
|
+
const s = $('#map-slider');
|
|
769
|
+
const atEnd = Number(s.value) >= Number(s.max);
|
|
770
|
+
s.max = Math.max(0, mapFrames().length - 1);
|
|
771
|
+
if (atEnd) s.value = s.max;
|
|
772
|
+
}
|
|
773
|
+
function layout(f, W, H) {
|
|
774
|
+
const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
|
|
775
|
+
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
776
|
+
const pad = 36;
|
|
777
|
+
return f.pts.map(([x, y]) => [pad + ((x - x0) / (x1 - x0 || 1)) * (W - 2 * pad), H - pad - ((y - y0) / (y1 - y0 || 1)) * (H - 2 * pad)]);
|
|
778
|
+
}
|
|
779
|
+
// ближайшая к клику точка карты (общая для карты по поведению и по описанию)
|
|
780
|
+
function pickPoint(f, e) {
|
|
781
|
+
const cv = $('#map-canvas');
|
|
782
|
+
const rect = cv.getBoundingClientRect();
|
|
783
|
+
const xy = layout(f, cv.clientWidth, cv.clientHeight);
|
|
784
|
+
let best = -1, bd = 16;
|
|
785
|
+
xy.forEach(([x, y], i) => { const d = Math.hypot(x - (e.clientX - rect.left), y - (e.clientY - rect.top)); if (d < bd) { bd = d; best = i; } });
|
|
786
|
+
return best;
|
|
787
|
+
}
|
|
788
|
+
function drawMap() {
|
|
789
|
+
if (state.mapMode === 'text') return drawProfilesMap();
|
|
790
|
+
const cv = $('#map-canvas');
|
|
791
|
+
const W = cv.clientWidth, H = cv.clientHeight;
|
|
792
|
+
if (!W) return;
|
|
793
|
+
$('#map-slider').hidden = false;
|
|
794
|
+
$('#map-text-box').hidden = true;
|
|
795
|
+
const dpr = window.devicePixelRatio || 1;
|
|
796
|
+
cv.width = W * dpr; cv.height = H * dpr;
|
|
797
|
+
const ctx = cv.getContext('2d');
|
|
798
|
+
ctx.scale(dpr, dpr);
|
|
799
|
+
const frames = mapFrames();
|
|
800
|
+
if (!frames.length) {
|
|
801
|
+
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
|
802
|
+
ctx.fillText('Карта появится после исследования или прогноза.', 20, 30);
|
|
803
|
+
$('#map-date').textContent = '';
|
|
804
|
+
$('#map-info').textContent = '';
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
|
|
808
|
+
$('#map-date').textContent = f.now ? `Сейчас · ${fmtDate(f.ts)} · заливка — прогноз` : `Месяц с ${fmtDate(f.ts)}`;
|
|
809
|
+
const xy = layout(f, W, H);
|
|
810
|
+
const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
|
|
811
|
+
const fc = f.now && state.forecast ? Object.fromEntries(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
812
|
+
if (sel >= 0) {
|
|
813
|
+
const near = xy.map((p, i) => [i, Math.hypot(p[0] - xy[sel][0], p[1] - xy[sel][1])]).filter(([i]) => i !== sel).sort((a, b) => a[1] - b[1]).slice(0, 5);
|
|
814
|
+
ctx.strokeStyle = document.body.classList.contains('light') ? 'rgba(36, 87, 214, 0.38)' : '#ffffff40';
|
|
815
|
+
for (const [i] of near) { ctx.beginPath(); ctx.moveTo(...xy[sel]); ctx.lineTo(...xy[i]); ctx.stroke(); }
|
|
816
|
+
}
|
|
817
|
+
f.symbols.forEach((s, i) => {
|
|
818
|
+
const [x, y] = xy[i];
|
|
819
|
+
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
820
|
+
const r = fc?.[s] ? 3 + Math.min(7, Math.abs(fc[s].expectedPct) * 15) : 4;
|
|
821
|
+
ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
|
|
822
|
+
ctx.beginPath(); ctx.arc(x, y, i === sel ? r + 2 : r, 0, Math.PI * 2);
|
|
823
|
+
ctx.fillStyle = fc?.[s] ? (fc[s].expectedPct >= 0 ? '#3fb950' : '#f85149') : col;
|
|
824
|
+
ctx.fill();
|
|
825
|
+
ctx.globalAlpha = 1;
|
|
826
|
+
const light = document.body.classList.contains('light');
|
|
827
|
+
ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
|
|
828
|
+
ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
|
|
829
|
+
ctx.fillText(s, x + r + 3, y + 3);
|
|
830
|
+
});
|
|
831
|
+
if (sel < 0) { $('#map-info').textContent = 'Близко — значит акции обычно движутся вместе; цвет — найденная группа. Нажмите на акцию, чтобы увидеть соседей.'; return; }
|
|
832
|
+
const near = xy.map((p, i) => [i, Math.hypot(p[0] - xy[sel][0], p[1] - xy[sel][1])]).filter(([i]) => i !== sel).sort((a, b) => a[1] - b[1]).slice(0, 5).map(([i]) => f.symbols[i]);
|
|
833
|
+
const r = state.forecast?.rows.find((x) => x.symbol === f.symbols[sel]);
|
|
834
|
+
$('#map-info').innerHTML = `<b style="color:var(--text)">${f.symbols[sel]}</b> ${esc(f.names?.[sel] || '')} · рядом: ${near.join(', ')} · группа: ${f.symbols.filter((_, i) => f.cl[i] === f.cl[sel]).join(', ')}${r ? ` · прогноз ${fmtPct(r.expectedPct, 2)}` : ''}`;
|
|
835
|
+
}
|
|
836
|
+
$('#map-slider').addEventListener('input', drawMap);
|
|
837
|
+
$('#map-canvas').addEventListener('click', (e) => {
|
|
838
|
+
if (state.mapMode === 'text') {
|
|
839
|
+
const d = state.profiles;
|
|
840
|
+
if (!d?.map) return;
|
|
841
|
+
const best = pickPoint(d.map, e);
|
|
842
|
+
state.mapSel = best >= 0 ? d.map.symbols[best] : null;
|
|
843
|
+
drawMap();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const frames = mapFrames();
|
|
847
|
+
if (!frames.length) return;
|
|
848
|
+
const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
|
|
849
|
+
const best = pickPoint(f, e);
|
|
850
|
+
state.mapSel = best >= 0 ? f.symbols[best] : null;
|
|
851
|
+
drawMap();
|
|
852
|
+
});
|
|
853
|
+
window.addEventListener('resize', debounce(() => { if (!$('#tab-map').hidden) drawMap(); }, 150));
|
|
854
|
+
|
|
855
|
+
// ---------- карта «по описанию»: 2D-проекция устойчивых признаков бизнеса ----------
|
|
856
|
+
async function loadProfilesData() {
|
|
857
|
+
try { state.profiles = await api('/api/profiles'); } catch (e) { toast(e.message, 'error'); }
|
|
858
|
+
}
|
|
859
|
+
function drawProfilesMap() {
|
|
860
|
+
const cv = $('#map-canvas');
|
|
861
|
+
const W = cv.clientWidth, H = cv.clientHeight;
|
|
862
|
+
if (!W) return;
|
|
863
|
+
const dpr = window.devicePixelRatio || 1;
|
|
864
|
+
cv.width = W * dpr; cv.height = H * dpr;
|
|
865
|
+
const ctx = cv.getContext('2d');
|
|
866
|
+
ctx.scale(dpr, dpr);
|
|
867
|
+
$('#map-slider').hidden = true;
|
|
868
|
+
const d = state.profiles;
|
|
869
|
+
$('#map-date').textContent = d?.map ? `Описания бизнеса · ${d.count} из ${d.total} компаний` : 'Описания компаний';
|
|
870
|
+
if (!d?.map) {
|
|
871
|
+
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
|
872
|
+
ctx.fillText(d?.count ? 'Описаний пока мало — карту по описаниям построить не из чего.' : 'Карты по описаниям пока нет.', 20, 30);
|
|
873
|
+
$('#map-text-box').hidden = false;
|
|
874
|
+
$('#btn-profiles').disabled = state.profilesBusy;
|
|
875
|
+
$('#map-info').textContent = 'Компании рядом, если похож их бизнес: экспортёр, банк, дивиденды и другие устойчивые свойства.';
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
$('#map-text-box').hidden = true;
|
|
879
|
+
const f = d.map;
|
|
880
|
+
const xy = layout(f, W, H);
|
|
881
|
+
const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
|
|
882
|
+
f.symbols.forEach((s, i) => {
|
|
883
|
+
const [x, y] = xy[i];
|
|
884
|
+
ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
|
|
885
|
+
ctx.beginPath(); ctx.arc(x, y, i === sel ? 6 : 4, 0, Math.PI * 2);
|
|
886
|
+
ctx.fillStyle = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
887
|
+
ctx.fill();
|
|
888
|
+
ctx.globalAlpha = 1;
|
|
889
|
+
const light = document.body.classList.contains('light');
|
|
890
|
+
ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
|
|
891
|
+
ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
|
|
892
|
+
ctx.fillText(s, x + 7, y + 3);
|
|
893
|
+
});
|
|
894
|
+
if (sel < 0) { $('#map-info').textContent = 'Рядом — похожий бизнес. Цвет — найденная группа. Нажмите на акцию, чтобы увидеть её самые выраженные свойства.'; return; }
|
|
895
|
+
const prof = d.profiles[f.symbols[sel]];
|
|
896
|
+
const top = (prof || []).map((v, k) => [v, k]).sort((a, b) => b[0] - a[0]).filter(([v]) => v >= 0.3).slice(0, 5);
|
|
897
|
+
$('#map-info').innerHTML = `<b style="color:var(--text)">${f.symbols[sel]}</b> ${esc(f.names[sel] || '')}${top.length ? ` · свойства: ${top.map(([v, k]) => `${esc(d.features[k].label)} ${Math.round(v * 100)}%`).join(', ')}` : ' · нет ярко выраженных свойств'}`;
|
|
898
|
+
}
|
|
899
|
+
async function showMapSec(sec) {
|
|
900
|
+
document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
|
|
901
|
+
if (sec === 'journal') {
|
|
902
|
+
$('#map-sec').hidden = true;
|
|
903
|
+
$('#journal-box').hidden = false;
|
|
904
|
+
await loadJournal();
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
$('#map-sec').hidden = false;
|
|
908
|
+
$('#journal-box').hidden = true;
|
|
909
|
+
state.mapMode = sec;
|
|
910
|
+
$('#map-title-lbl').textContent = sec === 'text' ? 'Карта рынка · по описаниям бизнеса' : 'Карта рынка · по совместному поведению';
|
|
911
|
+
if (sec === 'text' && !state.profiles) await loadProfilesData();
|
|
912
|
+
drawMap();
|
|
913
|
+
}
|
|
914
|
+
$('#map-nav').addEventListener('click', (e) => {
|
|
915
|
+
const b = e.target.closest('[data-msec]');
|
|
916
|
+
if (b) showMapSec(b.dataset.msec);
|
|
917
|
+
});
|
|
918
|
+
$('#btn-profiles').addEventListener('click', async () => {
|
|
919
|
+
try {
|
|
920
|
+
const r = await post('/api/profiles/refresh');
|
|
921
|
+
if (r.started === false) return toast('Описания уже составляются — подождите');
|
|
922
|
+
state.profilesBusy = true;
|
|
923
|
+
$('#btn-profiles').disabled = true;
|
|
924
|
+
$('#profiles-msg').textContent = 'Составляем описания компаний…';
|
|
925
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// ======================================================================
|
|
929
|
+
// ЖУРНАЛ И НАСТРОЙКИ
|
|
930
|
+
// ======================================================================
|
|
931
|
+
const VERDICT = { confirmed: ['подтвердилась', 'ok'], rejected: ['не подтвердилась', 'no'], unclear: ['неясно', 'mid'] };
|
|
932
|
+
async function loadJournal() {
|
|
933
|
+
const list = await api('/api/journal');
|
|
934
|
+
state.journalCount = list.length;
|
|
935
|
+
updateTabBadges();
|
|
936
|
+
if ($('#mnav-journal')) $('#mnav-journal').textContent = list.length ? `Журнал гипотез (${list.length})` : 'Журнал гипотез';
|
|
937
|
+
$('#journal').innerHTML = list.length
|
|
938
|
+
? `<p class="muted small">Память Стратега: он читает журнал в начале каждого разговора. Неверную запись можно удалить.</p><table><thead><tr><th>Гипотеза</th><th>Результат</th><th>Вывод</th><th></th></tr></thead><tbody>${list.map((j) => {
|
|
939
|
+
const [vl, vc] = VERDICT[j.verdict] || [j.verdict, ''];
|
|
940
|
+
return `<tr><td>${esc(j.hypothesis)}<div class="muted small">${fmtTs(j.ts)}${j.change ? ` · ${esc(j.change)}` : ''}</div></td><td class="muted">${esc(j.result)}</td><td><span class="tag ${vc}">${vl}</span></td><td><button class="icon" data-jdel="${j.id}">✕</button></td></tr>`;
|
|
941
|
+
}).join('')}</tbody></table>`
|
|
942
|
+
: '<p class="muted">Журнал пуст. Попросите Стратега провести эксперимент — он запишет вывод сюда.</p>';
|
|
943
|
+
$('#journal').querySelectorAll('[data-jdel]').forEach((b) => b.addEventListener('click', async () => {
|
|
944
|
+
if (confirm('Удалить запись? Стратег её «забудет».')) await api(`/api/journal/${b.dataset.jdel}`, { method: 'DELETE' });
|
|
945
|
+
}));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Группы настроек в форме; не перечисленные сюда попадают в «Дополнительно»
|
|
949
|
+
const GROUPS = [
|
|
950
|
+
['Общие', ['modelAutoAdapt', 'modelHorizon', 'modelTopK', 'modelDirection', 'modelType', 'commissionPct', 'autopilotBudget']],
|
|
951
|
+
['Для карты', ['modelEmbedDim', 'modelTextWeight', 'modelUseMacro']],
|
|
952
|
+
['Для Трансформера', ['modelUseTransformer', 'transformerEpochs', 'transformerGpu']],
|
|
953
|
+
['Дополнительно', []],
|
|
954
|
+
];
|
|
955
|
+
function buildForm() {
|
|
956
|
+
const field = (p) => {
|
|
957
|
+
let input;
|
|
958
|
+
if (p.type === 'select') input = `<select name="${p.key}" class="sctrl">${p.options.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select>`;
|
|
959
|
+
else if (p.type === 'checkbox') input = `<input type="checkbox" name="${p.key}" class="switch">`;
|
|
960
|
+
else input = `<input type="number" step="${p.step ?? 'any'}" name="${p.key}" min="${p.min}" max="${p.max}" class="sctrl">`;
|
|
961
|
+
return `<div class="srow" data-key="${p.key}"><div class="stxt"><div class="slbl">${esc(p.label)}<button type="button" class="mark" data-def="${p.key}" hidden title="Отличается от умолчания — нажмите, чтобы вернуть">изменено</button></div><div class="hint">${esc(p.hint)}</div></div>${input}</div>`;
|
|
962
|
+
};
|
|
963
|
+
const shown = state.config.params.filter((p) => !p.hidden);
|
|
964
|
+
const listed = new Set(GROUPS.flatMap(([, keys]) => keys));
|
|
965
|
+
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
|
+
$('#params').innerHTML = GROUPS.map(([name, keys], i) => {
|
|
967
|
+
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 === 3 ? cleanRow : '';
|
|
969
|
+
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
|
+
}).join('');
|
|
971
|
+
$('#btn-clean')?.addEventListener('click', cleanRunsHistory);
|
|
972
|
+
}
|
|
973
|
+
function showSettingsSec(sec) {
|
|
974
|
+
$('#s-search').value = '';
|
|
975
|
+
document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
|
|
976
|
+
document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
977
|
+
document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
978
|
+
}
|
|
979
|
+
document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
|
|
980
|
+
const b = e.target.closest('[data-sec]');
|
|
981
|
+
if (b) showSettingsSec(b.dataset.sec);
|
|
982
|
+
});
|
|
983
|
+
$('#s-search').addEventListener('input', (e) => {
|
|
984
|
+
const q = e.target.value.trim().toLowerCase();
|
|
985
|
+
if (!q) {
|
|
986
|
+
const cur = document.querySelector('#tab-settings .snav [data-sec].active')?.dataset.sec || 'g0';
|
|
987
|
+
showSettingsSec(cur);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
document.querySelectorAll('#tab-settings .ssec').forEach((sec) => {
|
|
991
|
+
if (!sec.dataset.sec.startsWith('g')) { sec.hidden = true; return; }
|
|
992
|
+
let vis = 0;
|
|
993
|
+
sec.querySelectorAll('.srow').forEach((r) => {
|
|
994
|
+
const ok = r.textContent.toLowerCase().includes(q);
|
|
995
|
+
r.hidden = !ok;
|
|
996
|
+
if (ok) vis++;
|
|
997
|
+
});
|
|
998
|
+
sec.hidden = !vis;
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
function fillForm(s) {
|
|
1002
|
+
for (const el of $('#params').elements) {
|
|
1003
|
+
if (!el.name || el === document.activeElement) continue;
|
|
1004
|
+
if (el.type === 'checkbox') el.checked = !!s[el.name];
|
|
1005
|
+
else el.value = s[el.name];
|
|
1006
|
+
}
|
|
1007
|
+
markDefaults(s);
|
|
1008
|
+
}
|
|
1009
|
+
// Метка «изменено» у параметров, отличающихся от умолчания
|
|
1010
|
+
function markDefaults(s) {
|
|
1011
|
+
const d = state.config?.defaults || {};
|
|
1012
|
+
$('#params').querySelectorAll('[data-def]').forEach((b) => {
|
|
1013
|
+
b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
|
|
1014
|
+
});
|
|
1015
|
+
syncApplyBtn();
|
|
1016
|
+
updateTabBadges();
|
|
1017
|
+
}
|
|
1018
|
+
const saveSettings = async (body) => {
|
|
1019
|
+
try {
|
|
1020
|
+
const r = await api('/api/settings', { method: 'PUT', body });
|
|
1021
|
+
if (r.errors?.length) toast(r.errors.join('; '), 'error');
|
|
1022
|
+
state.settings = r.settings;
|
|
1023
|
+
markDefaults(state.settings);
|
|
1024
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1025
|
+
};
|
|
1026
|
+
const saveSetting = (k, v) => saveSettings({ [k]: v });
|
|
1027
|
+
$('#params').addEventListener('change', (e) => e.target.name && saveSetting(e.target.name, e.target.type === 'checkbox' ? e.target.checked : e.target.value));
|
|
1028
|
+
// клик по метке «изменено» — вернуть параметр к умолчанию
|
|
1029
|
+
$('#params').addEventListener('click', async (e) => {
|
|
1030
|
+
const b = e.target.closest('[data-def]');
|
|
1031
|
+
if (!b) return;
|
|
1032
|
+
await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
|
|
1033
|
+
fillForm(state.settings);
|
|
1034
|
+
});
|
|
1035
|
+
$('#btn-reset').addEventListener('click', async () => {
|
|
1036
|
+
if (!confirm('Вернуть все настройки по умолчанию?')) return;
|
|
1037
|
+
state.settings = await post('/api/settings/reset');
|
|
1038
|
+
fillForm(state.settings);
|
|
1039
|
+
loadHistory().catch(() => {});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
// ---------- история изменений настроек ----------
|
|
1043
|
+
async function loadHistory() {
|
|
1044
|
+
const list = await api('/api/settings/history');
|
|
1045
|
+
$('#snav-hist').textContent = list.length ? `История изменений (${list.length})` : 'История изменений';
|
|
1046
|
+
$('#history').innerHTML = list.length
|
|
1047
|
+
? `<table><thead><tr><th>Дата</th><th>Кто</th><th>Что изменилось</th><th></th></tr></thead><tbody>${list.map((h) => `<tr>
|
|
1048
|
+
<td class="muted small">${fmtTs(h.ts)}</td>
|
|
1049
|
+
<td>${h.source === 'ai' ? 'Стратег' : 'вы'}</td>
|
|
1050
|
+
<td>${Object.entries(h.changes).map(([k, v]) => `${esc(labelOf(k))}: ${esc(v.было)} → ${esc(v.стало)}`).join('; ')}${h.reason ? `<div class="muted small">${esc(h.reason)}</div>` : ''}</td>
|
|
1051
|
+
<td><button class="icon" data-revert="${h.id}" title="Вернуть значения «было»">Вернуть</button></td>
|
|
1052
|
+
</tr>`).join('')}</tbody></table>`
|
|
1053
|
+
: '<p class="muted">Изменений ещё не было.</p>';
|
|
1054
|
+
}
|
|
1055
|
+
$('#history').addEventListener('click', async (e) => {
|
|
1056
|
+
const b = e.target.closest('[data-revert]');
|
|
1057
|
+
if (!b) return;
|
|
1058
|
+
try {
|
|
1059
|
+
const r = await post(`/api/settings/history/${b.dataset.revert}/revert`);
|
|
1060
|
+
if (r.errors?.length) toast(r.errors.join('; '), 'error');
|
|
1061
|
+
else if (!Object.keys(r.changed).length) toast('Настройки уже в этом состоянии — откатывать нечего');
|
|
1062
|
+
state.settings = r.settings;
|
|
1063
|
+
fillForm(state.settings);
|
|
1064
|
+
await loadHistory();
|
|
1065
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// ======================================================================
|
|
1069
|
+
// ЧАТ СО СТРАТЕГОМ
|
|
1070
|
+
// ======================================================================
|
|
1071
|
+
const TOOL_LABELS = {
|
|
1072
|
+
get_settings: 'Смотрит настройки', list_runs: 'Смотрит список исследований', analyze_run: 'Разбирает исследование', compare_runs: 'Сравнивает исследования',
|
|
1073
|
+
run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
|
|
1074
|
+
journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
|
|
1075
|
+
set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
|
|
1076
|
+
};
|
|
1077
|
+
const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
|
|
1078
|
+
const feed = $('#feed');
|
|
1079
|
+
const nearBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
|
|
1080
|
+
const scrollDown = (force) => { if (force || nearBottom()) requestAnimationFrame(() => { feed.scrollTop = feed.scrollHeight; }); };
|
|
1081
|
+
const feedDown = $('#feed-down');
|
|
1082
|
+
let unseen = 0;
|
|
1083
|
+
const updateDown = () => {
|
|
1084
|
+
feedDown.hidden = !unseen || nearBottom();
|
|
1085
|
+
feedDown.textContent = unseen ? `↓ ${unseen}` : '↓';
|
|
1086
|
+
};
|
|
1087
|
+
const toBottom = () => { unseen = 0; scrollDown(true); updateDown(); };
|
|
1088
|
+
feed.addEventListener('scroll', () => { if (nearBottom()) unseen = 0; updateDown(); });
|
|
1089
|
+
feedDown.addEventListener('click', toBottom);
|
|
1090
|
+
const msgEls = new Map();
|
|
1091
|
+
const liveEls = new Map();
|
|
1092
|
+
|
|
1093
|
+
function argsPreview(name, a) {
|
|
1094
|
+
if (!a) return '';
|
|
1095
|
+
const ch = Object.entries(a.changes || {}).map(([k, v]) => `${labelOf(k)}: ${v}`).join('; ');
|
|
1096
|
+
if (name === 'run_model_research') return `${a.note || ''}${ch ? `\n${ch}` : ''}`;
|
|
1097
|
+
if (name === 'set_settings') return `${a.reason || ''}${ch ? `\n${ch}` : ''}`;
|
|
1098
|
+
if (name === 'analyze_run' || name === 'final_check') return `№${a.run_id}`;
|
|
1099
|
+
if (name === 'compare_runs') return `№${a.a} и №${a.b}`;
|
|
1100
|
+
if (name === 'journal_add') return a.hypothesis || '';
|
|
1101
|
+
if (name === 'market_neighbors') return a.symbol || '';
|
|
1102
|
+
return '';
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function actHtml(m, call) {
|
|
1106
|
+
const t = m.data?.tools?.[call.id] || {};
|
|
1107
|
+
const status = t.status || 'done';
|
|
1108
|
+
let args = null;
|
|
1109
|
+
try { args = JSON.parse(call.function.arguments || '{}'); } catch { /* ignore */ }
|
|
1110
|
+
const w = state.chat.state?.waiting;
|
|
1111
|
+
const waiting = status === 'pending' && w && w.msgId === m.id && w.callId === call.id;
|
|
1112
|
+
const prev = t.preview || argsPreview(call.function.name, args);
|
|
1113
|
+
const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop'].includes(call.function.name);
|
|
1114
|
+
if (!big && !waiting) {
|
|
1115
|
+
return `<div class="ev">${esc(TOOL_LABELS[call.function.name] || call.function.name)}${prev ? `: ${esc(prev.split('\n')[0])}` : ''} <span class="st ${status}">· ${STATUS[status] || status}</span></div>`;
|
|
1116
|
+
}
|
|
1117
|
+
const chips = args?.changes && Object.keys(args.changes).length
|
|
1118
|
+
? `<div class="act-chips">${Object.entries(args.changes).map(([k, v]) => {
|
|
1119
|
+
const cur = state.settings?.[k];
|
|
1120
|
+
return `<span class="stab-chip">${esc(labelOf(k))}: ${cur !== undefined && String(cur) !== String(v) ? `<span class="muted">${esc(cur)} →</span> ` : ''}<b>${esc(v)}</b></span>`;
|
|
1121
|
+
}).join('')}</div>`
|
|
1122
|
+
: '';
|
|
1123
|
+
return `<div class="act ${status}" data-call="${esc(call.id)}">
|
|
1124
|
+
<div class="h"><b>${esc(TOOL_LABELS[call.function.name] || call.function.name)}</b><span class="st ${status}">${STATUS[status] || status}</span></div>
|
|
1125
|
+
${prev ? `<div class="p">${esc(prev.split('\n')[0])}</div>` : ''}
|
|
1126
|
+
${chips}
|
|
1127
|
+
${t.summary ? `<div>${esc(t.summary)}${t.runId ? ` · <span class="link" data-run="${t.runId}">открыть №${t.runId}</span>` : ''}</div>` : ''}
|
|
1128
|
+
${waiting ? '<div class="btns"><button class="primary" data-act="approve">Применить</button><button data-act="reject">Отклонить</button></div>' : ''}
|
|
1129
|
+
</div>`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function renderMsg(m) {
|
|
1133
|
+
if (m.kind === 'tool' || m.data?.hidden) return null;
|
|
1134
|
+
const el = document.createElement('div');
|
|
1135
|
+
el.dataset.id = m.id;
|
|
1136
|
+
if (m.kind === 'user') { el.className = 'msg user'; el.textContent = m.content; return el; }
|
|
1137
|
+
if (m.kind === 'event') {
|
|
1138
|
+
el.className = `ev ${m.data?.ok === true ? 'ok' : m.data?.ok === false ? 'bad' : 'info'}`;
|
|
1139
|
+
const run = m.data?.eventType === 'model' && state.runs.find((r) => r.id === m.data.runId);
|
|
1140
|
+
if (run) el.title = runTip(run);
|
|
1141
|
+
const text = run ? `Исследование завершено: ${runTitle(run, true)}` : esc(m.data?.display || m.content);
|
|
1142
|
+
el.innerHTML = `${text}${m.data?.runId && m.data?.eventType === 'model' ? ' · <span class="link" data-run="' + m.data.runId + '">открыть</span>' : ''}`;
|
|
1143
|
+
return el;
|
|
1144
|
+
}
|
|
1145
|
+
const s = state.chat.stream.get(m.id);
|
|
1146
|
+
const reasoning = s ? s.reasoning : m.reasoning;
|
|
1147
|
+
const content = s ? s.content : m.content;
|
|
1148
|
+
if (!s && !content && !reasoning && !m.tool_calls?.length) return null;
|
|
1149
|
+
el.className = 'msg ai';
|
|
1150
|
+
const wasOpen = msgEls.get(m.id)?.querySelector('details.think')?.open;
|
|
1151
|
+
el.innerHTML = `${reasoning ? `<details class="think"${wasOpen ? ' open' : ''}><summary>${s && !s.content ? `Думает… (${reasoning.length} симв.)` : 'Размышления'}</summary><div class="think-body">${esc(reasoning)}</div></details>` : (s && !content ? '<div class="muted small typing">Думает</div>' : '')}
|
|
1152
|
+
<div class="md-c${s && content ? ' typing' : ''}">${content ? md(content) : ''}</div>
|
|
1153
|
+
${(m.tool_calls || []).map((c) => actHtml(m, c)).join('')}`;
|
|
1154
|
+
return el;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function upsertMsg(m) {
|
|
1158
|
+
state.chat.msgs.set(m.id, m);
|
|
1159
|
+
const old = msgEls.get(m.id);
|
|
1160
|
+
const stick = nearBottom();
|
|
1161
|
+
const el = renderMsg(m);
|
|
1162
|
+
if (!el) { old?.remove(); msgEls.delete(m.id); return; }
|
|
1163
|
+
if (old) {
|
|
1164
|
+
const tb = old.querySelector('.think-body');
|
|
1165
|
+
const sc = tb?.scrollTop;
|
|
1166
|
+
old.replaceWith(el);
|
|
1167
|
+
const nb = el.querySelector('.think-body');
|
|
1168
|
+
if (nb) nb.scrollTop = state.chat.stream.has(m.id) ? nb.scrollHeight : sc;
|
|
1169
|
+
} else {
|
|
1170
|
+
feed.insertBefore(el, [...feed.children].find((c) => c.classList.contains('live')) || null);
|
|
1171
|
+
if (!stick) unseen++;
|
|
1172
|
+
}
|
|
1173
|
+
msgEls.set(m.id, el);
|
|
1174
|
+
if (stick) scrollDown(true);
|
|
1175
|
+
updateDown();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
let streamTimer = null;
|
|
1179
|
+
function onDelta(d) {
|
|
1180
|
+
let s = state.chat.stream.get(d.id);
|
|
1181
|
+
if (!s) { s = { reasoning: '', content: '' }; state.chat.stream.set(d.id, s); }
|
|
1182
|
+
if (d.reasoning) s.reasoning += d.reasoning;
|
|
1183
|
+
if (d.content) s.content += d.content;
|
|
1184
|
+
if (streamTimer) return;
|
|
1185
|
+
streamTimer = setTimeout(() => {
|
|
1186
|
+
streamTimer = null;
|
|
1187
|
+
for (const id of state.chat.stream.keys()) { const m = state.chat.msgs.get(id); if (m) upsertMsg(m); }
|
|
1188
|
+
}, 120);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function liveLine(key, html) {
|
|
1192
|
+
let el = liveEls.get(key);
|
|
1193
|
+
const stick = nearBottom();
|
|
1194
|
+
if (!el) { el = document.createElement('div'); el.className = 'live'; liveEls.set(key, el); feed.append(el); }
|
|
1195
|
+
el.innerHTML = html;
|
|
1196
|
+
if (stick) scrollDown(true);
|
|
1197
|
+
return el;
|
|
1198
|
+
}
|
|
1199
|
+
const dropLive = (key) => { liveEls.get(key)?.remove(); liveEls.delete(key); };
|
|
1200
|
+
|
|
1201
|
+
const MODE_META = {
|
|
1202
|
+
autopilot: {
|
|
1203
|
+
label: 'Авто',
|
|
1204
|
+
svg: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><polygon points="9 1.5 3.5 9 8 9 7 14.5 12.5 7 8 7 9 1.5"/></svg>',
|
|
1205
|
+
},
|
|
1206
|
+
advisor: {
|
|
1207
|
+
label: 'С согласия',
|
|
1208
|
+
svg: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v6M5.5 3.5v4.5M10.5 3.5v4.5M3.5 8v2.5a4.5 4.5 0 0 0 9 0V6.5"/></svg>',
|
|
1209
|
+
},
|
|
1210
|
+
discuss: {
|
|
1211
|
+
label: 'Обсуждение',
|
|
1212
|
+
svg: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 3.5h11v8h-7l-4 3v-11z"/></svg>',
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
const MODE_TITLE = 'Режим работы Стратега';
|
|
1216
|
+
|
|
1217
|
+
function syncActionBtn() {
|
|
1218
|
+
const btn = $('#chat-action');
|
|
1219
|
+
if (!btn) return;
|
|
1220
|
+
const s = state.chat.state;
|
|
1221
|
+
const hasText = Boolean($('#chat-input')?.value.trim());
|
|
1222
|
+
btn.hidden = Boolean(s?.busy);
|
|
1223
|
+
if (speechActive) {
|
|
1224
|
+
// 1. Микрофон включён -> в любом случае Стоп
|
|
1225
|
+
$('#ico-mic').hidden = true;
|
|
1226
|
+
$('#ico-send').hidden = true;
|
|
1227
|
+
$('#ico-stop').hidden = false;
|
|
1228
|
+
btn.classList.remove('send-mode');
|
|
1229
|
+
btn.classList.add('rec');
|
|
1230
|
+
btn.disabled = false;
|
|
1231
|
+
btn.title = 'Остановить запись голоса';
|
|
1232
|
+
} else if (hasText) {
|
|
1233
|
+
// 2. Микрофон выключен, есть текст -> Стрелка
|
|
1234
|
+
$('#ico-mic').hidden = true;
|
|
1235
|
+
$('#ico-send').hidden = false;
|
|
1236
|
+
$('#ico-stop').hidden = true;
|
|
1237
|
+
btn.classList.add('send-mode');
|
|
1238
|
+
btn.classList.remove('rec');
|
|
1239
|
+
btn.disabled = !s?.llm;
|
|
1240
|
+
btn.title = 'Отправить (Enter)';
|
|
1241
|
+
} else {
|
|
1242
|
+
// 3. Микрофон выключен, нет текста -> Микрофон
|
|
1243
|
+
$('#ico-mic').hidden = false;
|
|
1244
|
+
$('#ico-send').hidden = true;
|
|
1245
|
+
$('#ico-stop').hidden = true;
|
|
1246
|
+
btn.classList.remove('send-mode', 'rec');
|
|
1247
|
+
btn.disabled = false;
|
|
1248
|
+
btn.title = 'Голосовой ввод (русский язык)';
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function renderChatState() {
|
|
1253
|
+
const s = state.chat.state;
|
|
1254
|
+
if (!s) return;
|
|
1255
|
+
const noTools = s.tools === false;
|
|
1256
|
+
const curMode = noTools ? 'discuss' : (s.mode in MODE_META ? s.mode : 'advisor');
|
|
1257
|
+
const mm = MODE_META[curMode] || MODE_META.advisor;
|
|
1258
|
+
if ($('#chat-mode-ico')) $('#chat-mode-ico').innerHTML = mm.svg;
|
|
1259
|
+
if ($('#chat-mode-lbl')) $('#chat-mode-lbl').textContent = mm.label;
|
|
1260
|
+
if ($('#chat-mode-btn')) $('#chat-mode-btn').title = noTools ? 'Выбранная модель не умеет пользоваться инструментами — Стратег только беседует' : MODE_TITLE;
|
|
1261
|
+
document.querySelectorAll('#mode-menu [data-mode-val]').forEach((b) => {
|
|
1262
|
+
const v = b.dataset.modeVal;
|
|
1263
|
+
b.disabled = noTools && v !== 'discuss';
|
|
1264
|
+
b.classList.toggle('active', v === curMode);
|
|
1265
|
+
});
|
|
1266
|
+
$('#chat-stop').hidden = !s.busy && !s.waiting;
|
|
1267
|
+
syncActionBtn();
|
|
1268
|
+
let st = '';
|
|
1269
|
+
if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Настройки → Модели ИИ)';
|
|
1270
|
+
else if (s.waiting) st = 'Стратег ждёт вашего решения (или просто напишите ответ)';
|
|
1271
|
+
else if (s.busy) {
|
|
1272
|
+
const m = [...state.chat.msgs.values()].reverse().find((x) => x.tool_calls && Object.values(x.data?.tools || {}).some((t) => t.status === 'running'));
|
|
1273
|
+
const c = m?.tool_calls.find((x) => m.data.tools[x.id]?.status === 'running');
|
|
1274
|
+
st = c ? `${TOOL_LABELS[c.function.name] || 'Работает'}…` : 'Думает…';
|
|
1275
|
+
if (s.mode === 'autopilot') st += ` · осталось исследований: ${s.budgetLeft}`;
|
|
1276
|
+
} else if (noTools) st = 'У этой модели нет инструментов: Стратег только беседует';
|
|
1277
|
+
$('#chat-status').textContent = st;
|
|
1278
|
+
for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
|
|
1279
|
+
renderSuggest();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
|
|
1283
|
+
function renderSuggest() {
|
|
1284
|
+
const has = [...state.chat.msgs.values()].some((m) => m.kind === 'user');
|
|
1285
|
+
$('#suggest').innerHTML = has || state.chat.state?.busy ? '' : SUGGEST.map((s) => `<button>${esc(s)}</button>`).join('');
|
|
1286
|
+
$('#suggest').querySelectorAll('button').forEach((b) => b.addEventListener('click', () => sendChat(b.textContent)));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function loadChat() {
|
|
1290
|
+
const h = await api('/api/chat');
|
|
1291
|
+
feed.querySelectorAll('.msg, .ev').forEach((e) => { if (!e.classList.contains('live')) e.remove(); });
|
|
1292
|
+
msgEls.clear();
|
|
1293
|
+
state.chat.msgs.clear();
|
|
1294
|
+
state.chat.stream.clear();
|
|
1295
|
+
state.chat.state = h.state;
|
|
1296
|
+
for (const m of h.messages) upsertMsg(m);
|
|
1297
|
+
renderChatState();
|
|
1298
|
+
toBottom();
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function resizeChatInput() {
|
|
1302
|
+
const el = $('#chat-input');
|
|
1303
|
+
if (!el) return;
|
|
1304
|
+
el.style.height = 'auto';
|
|
1305
|
+
el.style.height = `${Math.min(150, Math.max(36, el.scrollHeight))}px`;
|
|
1306
|
+
syncActionBtn();
|
|
1307
|
+
}
|
|
1308
|
+
$('#chat-input').addEventListener('input', resizeChatInput);
|
|
1309
|
+
|
|
1310
|
+
function closeModeMenu() {
|
|
1311
|
+
const m = $('#mode-menu');
|
|
1312
|
+
if (m) m.hidden = true;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
$('#chat-mode-btn')?.addEventListener('click', (e) => {
|
|
1316
|
+
e.stopPropagation();
|
|
1317
|
+
closeModelMenu();
|
|
1318
|
+
const m = $('#mode-menu');
|
|
1319
|
+
if (m) m.hidden = !m.hidden;
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
$('#mode-menu')?.addEventListener('click', async (e) => {
|
|
1323
|
+
const b = e.target.closest('button[data-mode-val]');
|
|
1324
|
+
if (!b || b.disabled) return;
|
|
1325
|
+
closeModeMenu();
|
|
1326
|
+
try {
|
|
1327
|
+
state.chat.state = await post('/api/chat/mode', { mode: b.dataset.modeVal });
|
|
1328
|
+
renderChatState();
|
|
1329
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
async function sendChat(text) {
|
|
1333
|
+
if (speechRec && speechActive) stopVoiceInput(false);
|
|
1334
|
+
text = (text ?? $('#chat-input').value).trim();
|
|
1335
|
+
if (!text) return;
|
|
1336
|
+
try {
|
|
1337
|
+
const s = state.chat.state;
|
|
1338
|
+
const mode = s?.tools === false ? 'discuss' : (s?.mode || 'advisor');
|
|
1339
|
+
await post('/api/chat/send', { text, mode });
|
|
1340
|
+
$('#chat-input').value = '';
|
|
1341
|
+
resizeChatInput();
|
|
1342
|
+
toBottom();
|
|
1343
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
$('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!state.chat.state?.busy) sendChat(); } });
|
|
1347
|
+
$('#chat-stop').addEventListener('click', () => post('/api/chat/stop'));
|
|
1348
|
+
$('#chat-new').addEventListener('click', async () => { if (confirm('Начать новый разговор? Журнал гипотез сохранится.')) await post('/api/chat/new'); });
|
|
1349
|
+
|
|
1350
|
+
// ---------- голосовой ввод (Web Speech API) + звуковой сигнал БИП ----------
|
|
1351
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1352
|
+
let speechRec = null;
|
|
1353
|
+
let speechActive = false;
|
|
1354
|
+
let speechBaseText = '';
|
|
1355
|
+
let audioCtx = null;
|
|
1356
|
+
|
|
1357
|
+
function playMicBeep(kind = 'start') {
|
|
1358
|
+
try {
|
|
1359
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
1360
|
+
if (!Ctx) return;
|
|
1361
|
+
if (!audioCtx) audioCtx = new Ctx();
|
|
1362
|
+
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
1363
|
+
const now = audioCtx.currentTime;
|
|
1364
|
+
const osc = audioCtx.createOscillator();
|
|
1365
|
+
const gain = audioCtx.createGain();
|
|
1366
|
+
osc.type = 'sine';
|
|
1367
|
+
if (kind === 'start') {
|
|
1368
|
+
// Двойной восходящий тон: микрофон готов к записи
|
|
1369
|
+
osc.frequency.setValueAtTime(660, now);
|
|
1370
|
+
osc.frequency.setValueAtTime(880, now + 0.07);
|
|
1371
|
+
gain.gain.setValueAtTime(0.001, now);
|
|
1372
|
+
gain.gain.exponentialRampToValueAtTime(0.14, now + 0.015);
|
|
1373
|
+
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
|
|
1374
|
+
osc.connect(gain);
|
|
1375
|
+
gain.connect(audioCtx.destination);
|
|
1376
|
+
osc.start(now);
|
|
1377
|
+
osc.stop(now + 0.17);
|
|
1378
|
+
} else {
|
|
1379
|
+
// Нисходящий мягкий тон: окончание записи
|
|
1380
|
+
osc.frequency.setValueAtTime(740, now);
|
|
1381
|
+
osc.frequency.setValueAtTime(520, now + 0.07);
|
|
1382
|
+
gain.gain.setValueAtTime(0.001, now);
|
|
1383
|
+
gain.gain.exponentialRampToValueAtTime(0.14, now + 0.015);
|
|
1384
|
+
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
|
|
1385
|
+
osc.connect(gain);
|
|
1386
|
+
gain.connect(audioCtx.destination);
|
|
1387
|
+
osc.start(now);
|
|
1388
|
+
osc.stop(now + 0.17);
|
|
1389
|
+
}
|
|
1390
|
+
} catch { /* ignore audio errors */ }
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function stopVoiceInput(beep = true) {
|
|
1394
|
+
const wasActive = speechActive;
|
|
1395
|
+
speechActive = false;
|
|
1396
|
+
try { speechRec?.stop(); } catch { /* ignore */ }
|
|
1397
|
+
if (wasActive && beep) playMicBeep('stop');
|
|
1398
|
+
syncActionBtn();
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function toggleVoiceInput() {
|
|
1402
|
+
if (!SpeechRecognition) {
|
|
1403
|
+
toast('Ваш браузер не поддерживает встроенное распознавание речи (откройте в Chrome или Edge)', 'error');
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (speechActive) {
|
|
1407
|
+
stopVoiceInput(true);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (!speechRec) {
|
|
1411
|
+
speechRec = new SpeechRecognition();
|
|
1412
|
+
speechRec.lang = 'ru-RU';
|
|
1413
|
+
speechRec.continuous = true;
|
|
1414
|
+
speechRec.interimResults = true;
|
|
1415
|
+
speechRec.onstart = () => {
|
|
1416
|
+
// БИП строго в момент фактической готовности микрофона
|
|
1417
|
+
playMicBeep('start');
|
|
1418
|
+
syncActionBtn();
|
|
1419
|
+
};
|
|
1420
|
+
speechRec.onresult = (ev) => {
|
|
1421
|
+
let finalPart = '', interimPart = '';
|
|
1422
|
+
for (let i = 0; i < ev.results.length; i++) {
|
|
1423
|
+
const tr = ev.results[i][0].transcript;
|
|
1424
|
+
if (ev.results[i].isFinal) finalPart += tr + ' ';
|
|
1425
|
+
else interimPart += tr;
|
|
1426
|
+
}
|
|
1427
|
+
const sep = speechBaseText && !speechBaseText.endsWith(' ') ? ' ' : '';
|
|
1428
|
+
$('#chat-input').value = `${speechBaseText}${sep}${finalPart}${interimPart}`.trimStart();
|
|
1429
|
+
resizeChatInput();
|
|
1430
|
+
};
|
|
1431
|
+
speechRec.onerror = (ev) => {
|
|
1432
|
+
if (ev.error !== 'aborted' && ev.error !== 'no-speech') toast(`Голосовой ввод: ${ev.error}`, 'error');
|
|
1433
|
+
stopVoiceInput(false);
|
|
1434
|
+
};
|
|
1435
|
+
speechRec.onend = () => {
|
|
1436
|
+
if (speechActive) stopVoiceInput(true);
|
|
1437
|
+
else syncActionBtn();
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
speechBaseText = $('#chat-input').value.trim();
|
|
1441
|
+
speechActive = true;
|
|
1442
|
+
syncActionBtn();
|
|
1443
|
+
try {
|
|
1444
|
+
speechRec.start();
|
|
1445
|
+
$('#chat-input').focus();
|
|
1446
|
+
} catch {
|
|
1447
|
+
stopVoiceInput(false);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
$('#chat-action')?.addEventListener('click', () => {
|
|
1452
|
+
if (speechActive) {
|
|
1453
|
+
stopVoiceInput(true);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const hasText = Boolean($('#chat-input')?.value.trim());
|
|
1457
|
+
if (hasText) sendChat();
|
|
1458
|
+
else toggleVoiceInput();
|
|
1459
|
+
});
|
|
1460
|
+
feed.addEventListener('click', async (e) => {
|
|
1461
|
+
const run = e.target.closest('[data-run]');
|
|
1462
|
+
if (run) { switchTab('overview'); openRun(Number(run.dataset.run)); return; }
|
|
1463
|
+
const act = e.target.closest('[data-act]');
|
|
1464
|
+
if (!act) return;
|
|
1465
|
+
const msgId = Number(act.closest('[data-id]').dataset.id);
|
|
1466
|
+
const callId = act.closest('[data-call]').dataset.call;
|
|
1467
|
+
let comment = '';
|
|
1468
|
+
if (act.dataset.act === 'reject') { comment = prompt('Почему отклоняете? (Стратег учтёт; можно пусто)'); if (comment === null) return; }
|
|
1469
|
+
try { await post('/api/chat/action', { msgId, callId, decision: act.dataset.act, comment }); } catch (err) { toast(err.message, 'error'); }
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
// ======================================================================
|
|
1473
|
+
// МОДЕЛИ ИИ
|
|
1474
|
+
// ======================================================================
|
|
1475
|
+
const PROTO_LABEL = { openai: 'OpenAI-совместимый', anthropic: 'Anthropic', gigachat: 'GigaChat', ollama: 'Ollama' };
|
|
1476
|
+
const URL_HINT = {
|
|
1477
|
+
openai: 'https://…/v1/chat/completions',
|
|
1478
|
+
gigachat: 'https://gigachat.devices.sberbank.ru/api/v1/chat/completions',
|
|
1479
|
+
ollama: 'http://localhost:11434/v1/chat/completions',
|
|
1480
|
+
anthropic: 'https://api.anthropic.com/v1/messages',
|
|
1481
|
+
};
|
|
1482
|
+
const llmRemote = new Map();
|
|
1483
|
+
const modelKey = (p, m) => `${p}::${m}`;
|
|
1484
|
+
|
|
1485
|
+
async function loadLlm() {
|
|
1486
|
+
state.llm = await api('/api/llm/providers');
|
|
1487
|
+
renderLlm();
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function renderModelMenu(filter = '') {
|
|
1491
|
+
if (!state.llm) return;
|
|
1492
|
+
const { providers, active } = state.llm;
|
|
1493
|
+
const usable = (p) => p.hasKey || !p.needsKey;
|
|
1494
|
+
const q = filter.trim().toLowerCase();
|
|
1495
|
+
const listEl = $('#model-menu-list');
|
|
1496
|
+
if (!listEl) return;
|
|
1497
|
+
const groups = providers.filter((p) => p.models.length).map((p) => {
|
|
1498
|
+
const ms = p.models.filter((m) => !q || m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q));
|
|
1499
|
+
if (!ms.length) return '';
|
|
1500
|
+
const items = ms.map((m) => {
|
|
1501
|
+
const on = active?.providerId === p.id && active.model === m.id;
|
|
1502
|
+
const ok = usable(p);
|
|
1503
|
+
return `<button type="button" class="model-opt${on ? ' active' : ''}" data-mval="${esc(modelKey(p.id, m.id))}"${ok ? '' : ' disabled'}>
|
|
1504
|
+
<span class="m-name">${esc(m.label)}</span>
|
|
1505
|
+
<span class="row-gap" style="align-items:center;gap:6px">
|
|
1506
|
+
${!m.tools ? '<span class="m-badge">беседа</span>' : ''}
|
|
1507
|
+
${!ok ? '<span class="m-badge">нет ключа</span>' : ''}
|
|
1508
|
+
${on ? '<span class="m-check">✓</span>' : ''}
|
|
1509
|
+
</span>
|
|
1510
|
+
</button>`;
|
|
1511
|
+
}).join('');
|
|
1512
|
+
return `<div class="model-group"><div class="model-group-lbl">${esc(p.name)}</div>${items}</div>`;
|
|
1513
|
+
}).filter(Boolean).join('');
|
|
1514
|
+
listEl.innerHTML = groups || '<div class="muted small" style="padding:8px 10px">Модели не найдены</div>';
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function renderLlm() {
|
|
1518
|
+
const { providers, active } = state.llm;
|
|
1519
|
+
const btn = $('#chat-model-btn');
|
|
1520
|
+
const lbl = $('#chat-model-lbl');
|
|
1521
|
+
if (lbl) lbl.textContent = active?.model ? (active.label || active.model) : 'Модель не выбрана';
|
|
1522
|
+
if (btn) btn.title = active?.enabled ? `Отвечает: ${active.provider} · ${active.label}` : 'Модель не выбрана или нет ключа — настройте в «Настройки → Модели ИИ»';
|
|
1523
|
+
renderModelMenu($('#model-search')?.value || '');
|
|
1524
|
+
$('#llm-sum').textContent = `Модели ИИ для Стратега: ${active?.enabled ? `${active.provider} · ${active.label}` : 'не выбрана'}`;
|
|
1525
|
+
|
|
1526
|
+
$('#llm-list').innerHTML = providers.map((p) => `<div class="llm-prov" data-p="${esc(p.id)}">
|
|
1527
|
+
<div class="llm-head"><b>${esc(p.name)}</b><span class="grow"></span>
|
|
1528
|
+
<button type="button" data-act="test">Проверить</button>
|
|
1529
|
+
<button type="button" data-act="remote">Модели с сервера</button>
|
|
1530
|
+
<button type="button" data-act="edit">Изменить</button>
|
|
1531
|
+
<button type="button" data-act="del" class="icon" title="Удалить подключение">Удалить</button>
|
|
1532
|
+
</div>
|
|
1533
|
+
<div class="llm-meta">${esc(PROTO_LABEL[p.protocol] || p.protocol)} · ${esc(p.baseUrl)} · ${p.needsKey ? (p.hasKey ? `ключ есть (${esc(p.keySource)})` : '<span class="nokey">ключа нет</span>') : 'ключ не нужен'}${p.protocol === 'gigachat' ? (p.caFile ? ` · сертификат: ${esc(p.caFile)}` : ' · <span class="nokey">сертификат НУЦ не указан</span>') : ''}</div>
|
|
1534
|
+
<div class="llm-models">${p.models.map((m) => {
|
|
1535
|
+
const on = active?.providerId === p.id && active.model === m.id;
|
|
1536
|
+
return `<div class="m${on ? ' on' : ''}"><b>${esc(m.label)}</b><span class="muted small">${m.label === m.id ? '' : esc(m.id)}</span><span class="grow"></span>${p.protocol === 'anthropic' ? '<span class="muted small">только беседа</span>' : `<label><input type="checkbox" data-act="tools" data-m="${esc(m.id)}"${m.tools ? ' checked' : ''}>инструменты</label>`}<button type="button" class="icon" data-act="rm" data-m="${esc(m.id)}" title="${on ? 'Эта модель сейчас выбрана' : 'Убрать модель из списка'}"${on ? ' disabled' : ''}>✕</button></div>`;
|
|
1537
|
+
}).join('') || '<div class="muted small">Моделей нет — нажмите «Модели с сервера» или добавьте вручную.</div>'}</div>
|
|
1538
|
+
${llmRemote.has(p.id) ? `<div class="llm-remote">${llmRemote.get(p.id).length ? llmRemote.get(p.id).map((m) => `<button type="button" data-act="add" data-m="${esc(m.id)}"${m.added ? ' disabled' : ''}>${m.added ? '✓' : '+'} ${esc(m.id)}</button>`).join('') : '<span class="muted small">Сервер не вернул моделей</span>'}</div>` : ''}
|
|
1539
|
+
</div>`).join('') || '<p class="muted">Подключений нет — добавьте своё ниже.</p>';
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
async function llmModels(pid, fn) {
|
|
1543
|
+
const p = state.llm.providers.find((x) => x.id === pid);
|
|
1544
|
+
await api(`/api/llm/providers/${encodeURIComponent(pid)}`, { method: 'PUT', body: { models: fn(p.models.map((m) => ({ ...m }))) } });
|
|
1545
|
+
await loadLlm();
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function closeModelMenu() {
|
|
1549
|
+
const menu = $('#model-menu');
|
|
1550
|
+
if (menu) menu.hidden = true;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
$('#chat-model-btn')?.addEventListener('click', (e) => {
|
|
1554
|
+
e.stopPropagation();
|
|
1555
|
+
closeModeMenu();
|
|
1556
|
+
const menu = $('#model-menu');
|
|
1557
|
+
if (!menu) return;
|
|
1558
|
+
const open = menu.hidden;
|
|
1559
|
+
menu.hidden = !open;
|
|
1560
|
+
if (open) {
|
|
1561
|
+
const s = $('#model-search');
|
|
1562
|
+
if (s) { s.value = ''; renderModelMenu(''); setTimeout(() => s.focus(), 10); }
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
$('#model-search')?.addEventListener('input', (e) => renderModelMenu(e.target.value));
|
|
1567
|
+
|
|
1568
|
+
$('#model-menu-list')?.addEventListener('click', async (e) => {
|
|
1569
|
+
const b = e.target.closest('button[data-mval]');
|
|
1570
|
+
if (!b || b.disabled) return;
|
|
1571
|
+
const [pid, ...rest] = b.dataset.mval.split('::');
|
|
1572
|
+
closeModelMenu();
|
|
1573
|
+
try {
|
|
1574
|
+
state.llm = await post('/api/llm/select', { providerId: pid, model: rest.join('::') });
|
|
1575
|
+
state.config.llm = state.llm.active;
|
|
1576
|
+
showLlmStatus();
|
|
1577
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
1578
|
+
renderLlm();
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
$('#model-manage-btn')?.addEventListener('click', () => {
|
|
1582
|
+
closeModelMenu();
|
|
1583
|
+
switchTab('settings');
|
|
1584
|
+
showSettingsSec('llm');
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
document.addEventListener('click', (e) => {
|
|
1588
|
+
if (!e.target.closest('#model-picker')) closeModelMenu();
|
|
1589
|
+
if (!e.target.closest('#mode-picker')) closeModeMenu();
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
$('#llm-list').addEventListener('click', async (e) => {
|
|
1593
|
+
const b = e.target.closest('button[data-act]');
|
|
1594
|
+
if (!b) return;
|
|
1595
|
+
const pid = b.closest('[data-p]').dataset.p;
|
|
1596
|
+
const p = state.llm.providers.find((x) => x.id === pid);
|
|
1597
|
+
const old = b.textContent;
|
|
1598
|
+
try {
|
|
1599
|
+
switch (b.dataset.act) {
|
|
1600
|
+
case 'test': {
|
|
1601
|
+
const model = state.llm.active?.providerId === pid ? state.llm.active.model : p.models[0]?.id;
|
|
1602
|
+
if (!model) { toast('Сначала добавьте модель', 'error'); break; }
|
|
1603
|
+
b.disabled = true;
|
|
1604
|
+
b.textContent = 'Проверяю…';
|
|
1605
|
+
const r = await post('/api/llm/test', { providerId: pid, model });
|
|
1606
|
+
if (r.ok) toast(`${p.name} · ${model}: «${r.text}» за ${fmtNum(r.ms / 1000, 1)} с`);
|
|
1607
|
+
else toast(`${p.name}: ${r.error}`, 'error');
|
|
1608
|
+
break;
|
|
1609
|
+
}
|
|
1610
|
+
case 'remote': {
|
|
1611
|
+
b.disabled = true;
|
|
1612
|
+
b.textContent = 'Запрашиваю…';
|
|
1613
|
+
llmRemote.set(pid, (await api(`/api/llm/providers/${encodeURIComponent(pid)}/remote-models`)).models);
|
|
1614
|
+
renderLlm();
|
|
1615
|
+
break;
|
|
1616
|
+
}
|
|
1617
|
+
case 'add': {
|
|
1618
|
+
await llmModels(pid, (ms) => [...ms, { id: b.dataset.m, label: b.dataset.m, tools: p.protocol !== 'anthropic' }]);
|
|
1619
|
+
const list = llmRemote.get(pid);
|
|
1620
|
+
if (list) list.forEach((m) => { if (m.id === b.dataset.m) m.added = true; });
|
|
1621
|
+
renderLlm();
|
|
1622
|
+
break;
|
|
1623
|
+
}
|
|
1624
|
+
case 'rm': await llmModels(pid, (ms) => ms.filter((m) => m.id !== b.dataset.m)); break;
|
|
1625
|
+
case 'edit': startEdit(p); break;
|
|
1626
|
+
case 'del':
|
|
1627
|
+
if (confirm(`Удалить подключение «${p.name}»?`)) { llmRemote.delete(pid); state.llm = await api(`/api/llm/providers/${encodeURIComponent(pid)}`, { method: 'DELETE' }); renderLlm(); }
|
|
1628
|
+
break;
|
|
1629
|
+
}
|
|
1630
|
+
} catch (err) { toast(err.message, 'error'); } finally {
|
|
1631
|
+
if (b.isConnected) { b.disabled = false; b.textContent = old; }
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
$('#llm-list').addEventListener('change', async (e) => {
|
|
1635
|
+
const c = e.target.closest('input[data-act="tools"]');
|
|
1636
|
+
if (!c) return;
|
|
1637
|
+
const pid = c.closest('[data-p]').dataset.p;
|
|
1638
|
+
try { await llmModels(pid, (ms) => ms.map((m) => (m.id === c.dataset.m ? { ...m, tools: c.checked } : m))); } catch (err) { toast(err.message, 'error'); }
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
const addForm = $('#llm-add');
|
|
1642
|
+
let editingPid = null;
|
|
1643
|
+
function syncAddForm() {
|
|
1644
|
+
const proto = addForm.elements.protocol.value;
|
|
1645
|
+
addForm.elements.baseUrl.placeholder = URL_HINT[proto];
|
|
1646
|
+
addForm.elements.baseUrl.required = proto === 'openai' && !addForm.elements.baseUrl.value;
|
|
1647
|
+
$('#llm-key-label').textContent = proto === 'gigachat' ? 'Ключ авторизации (Authorization key)' : 'Ключ (API key)';
|
|
1648
|
+
addForm.elements.apiKey.placeholder = editingPid ? 'не меняется — оставьте пустым' : '';
|
|
1649
|
+
addForm.querySelector('[data-for=key]').hidden = proto === 'ollama';
|
|
1650
|
+
addForm.querySelector('[data-for=scope]').hidden = proto !== 'gigachat';
|
|
1651
|
+
addForm.querySelector('[data-for=cert]').hidden = proto !== 'gigachat';
|
|
1652
|
+
addForm.querySelector('[data-for=clearkey]').hidden = proto === 'ollama' || !editingPid;
|
|
1653
|
+
addForm.elements.tools.disabled = proto === 'anthropic';
|
|
1654
|
+
}
|
|
1655
|
+
addForm.elements.protocol.addEventListener('change', syncAddForm);
|
|
1656
|
+
syncAddForm();
|
|
1657
|
+
function startEdit(p) {
|
|
1658
|
+
editingPid = p.id;
|
|
1659
|
+
const f = addForm.elements;
|
|
1660
|
+
f.pid.value = p.id;
|
|
1661
|
+
f.name.value = p.name;
|
|
1662
|
+
f.protocol.value = p.protocol;
|
|
1663
|
+
f.baseUrl.value = p.baseUrl;
|
|
1664
|
+
f.apiKey.value = '';
|
|
1665
|
+
f.scope.value = p.scope || '';
|
|
1666
|
+
f.caFile.value = p.caFile || '';
|
|
1667
|
+
f.model.value = '';
|
|
1668
|
+
f.tools.checked = p.models[0]?.tools !== false && p.protocol !== 'anthropic';
|
|
1669
|
+
f.clearKey.checked = false;
|
|
1670
|
+
$('#llm-add-box').open = true;
|
|
1671
|
+
$('#llm-add-sum').textContent = `Изменить подключение «${p.name}»`;
|
|
1672
|
+
$('#llm-add-btn').textContent = 'Сохранить';
|
|
1673
|
+
$('#llm-cancel').hidden = false;
|
|
1674
|
+
addForm.scrollIntoView({ block: 'nearest' });
|
|
1675
|
+
syncAddForm();
|
|
1676
|
+
}
|
|
1677
|
+
function stopEdit() {
|
|
1678
|
+
editingPid = null;
|
|
1679
|
+
addForm.reset();
|
|
1680
|
+
addForm.elements.pid.value = '';
|
|
1681
|
+
$('#llm-add-sum').textContent = 'Добавить подключение';
|
|
1682
|
+
$('#llm-add-btn').textContent = 'Добавить';
|
|
1683
|
+
$('#llm-cancel').hidden = true;
|
|
1684
|
+
syncAddForm();
|
|
1685
|
+
}
|
|
1686
|
+
$('#llm-cancel').addEventListener('click', stopEdit);
|
|
1687
|
+
addForm.addEventListener('submit', async (e) => {
|
|
1688
|
+
e.preventDefault();
|
|
1689
|
+
const f = addForm.elements;
|
|
1690
|
+
const model = f.model.value.trim();
|
|
1691
|
+
const body = {
|
|
1692
|
+
name: f.name.value, protocol: f.protocol.value, baseUrl: f.baseUrl.value, scope: f.scope.value, caFile: f.caFile.value,
|
|
1693
|
+
...(f.apiKey.value.trim() ? { apiKey: f.apiKey.value } : {}),
|
|
1694
|
+
...(f.clearKey.checked ? { clearKey: true } : {}),
|
|
1695
|
+
// при редактировании пустое поле модели — не трогать список моделей
|
|
1696
|
+
...(model || !editingPid ? { models: model ? [{ id: model, label: model, tools: f.tools.checked && f.protocol.value !== 'anthropic' }] : [] } : {}),
|
|
1697
|
+
};
|
|
1698
|
+
const wasEdit = !!editingPid;
|
|
1699
|
+
try {
|
|
1700
|
+
state.llm = wasEdit
|
|
1701
|
+
? await api(`/api/llm/providers/${encodeURIComponent(editingPid)}`, { method: 'PUT', body })
|
|
1702
|
+
: await post('/api/llm/providers', body);
|
|
1703
|
+
stopEdit();
|
|
1704
|
+
renderLlm();
|
|
1705
|
+
toast(wasEdit ? 'Подключение сохранено' : 'Подключение добавлено. Нажмите «Проверить», чтобы убедиться, что оно работает.');
|
|
1706
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
// ======================================================================
|
|
1710
|
+
// БРОКЕРСКИЕ СЧЕТА (Т-БАНК, ФИНАМ, АЛОР, БКС)
|
|
1711
|
+
// ======================================================================
|
|
1712
|
+
const BROKER_LABELS = { tbank: 'Т-Банк', finam: 'Финам', alor: 'Алор', bcs: 'БКС' };
|
|
1713
|
+
const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
|
|
1714
|
+
let editingBrokerId = null;
|
|
1715
|
+
|
|
1716
|
+
async function loadBrokers() {
|
|
1717
|
+
state.brokers = await api('/api/brokers');
|
|
1718
|
+
renderBrokers();
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function renderBrokers() {
|
|
1722
|
+
const accs = state.brokers?.accounts || [];
|
|
1723
|
+
const navBtn = $('#snav-brokers');
|
|
1724
|
+
if (navBtn) navBtn.textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
1725
|
+
if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
1726
|
+
updateTabBadges();
|
|
1727
|
+
|
|
1728
|
+
// 1) список в Настройках
|
|
1729
|
+
const listEl = $('#brokers-list');
|
|
1730
|
+
if (listEl) {
|
|
1731
|
+
listEl.innerHTML = accs.length ? accs.map((a) => `<div class="llm-prov" data-bid="${esc(a.id)}">
|
|
1732
|
+
<div class="llm-head">
|
|
1733
|
+
<b>${esc(a.name)}</b>
|
|
1734
|
+
<span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
|
|
1735
|
+
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
1736
|
+
${a.autoSchedule !== false ? '<span class="tag ok" title="Заявки рассчитываются автоматически раз в горизонт прогноза и приходят в Telegram / MAX">авто-расписание</span>' : ''}
|
|
1737
|
+
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
1738
|
+
${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
|
|
1739
|
+
<span class="grow"></span>
|
|
1740
|
+
<button type="button" data-bact="sync">Проверить связь</button>
|
|
1741
|
+
<button type="button" data-bact="edit" class="icon">Изменить</button>
|
|
1742
|
+
<button type="button" data-bact="del" class="icon">Удалить</button>
|
|
1743
|
+
</div>
|
|
1744
|
+
<div class="llm-meta">
|
|
1745
|
+
Счёт: ${esc(a.accountId || 'авто')} · Токен: ${a.hasToken ? '<span class="pos">задан</span>' : '<span class="nokey">нет токена</span>'} · Лимит стратегии: ${fmtNum(a.maxCapitalRub, 0)} ₽ · Дневной стоп: −${fmtNum(a.maxDailyDrawdownPct, 1)}% · Отступ лимитки: ${fmtNum(a.limitOffsetPct, 2)}%${a.baseRunLabel ? ` · Настройки из ${esc(a.baseRunLabel)}` : ''}
|
|
1746
|
+
${a.lastSync ? `<br>Синхронизация ${fmtTs(a.lastSync.at)}: ${a.lastSync.ok ? `<span class="pos">на счёте ${fmtNum(a.lastSync.totalRub, 0)} ₽ (свободно ${fmtNum(a.lastSync.cashRub, 0)} ₽, бумаг ${a.lastSync.positions?.length || 0})</span>` : `<span class="neg">${esc(a.lastSync.error)}</span>`}` : ''}
|
|
1747
|
+
</div>
|
|
1748
|
+
</div>`).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// 2) карточки во вкладке «Торговля»
|
|
1752
|
+
const tradeEl = $('#brokers-trade');
|
|
1753
|
+
if (!tradeEl) return;
|
|
1754
|
+
if (!accs.length) {
|
|
1755
|
+
tradeEl.innerHTML = 'Подключите брокерский счёт в «Настройки → Брокерские счета». Счёт может работать в режиме «Только чтение», «Подтверждение заявок» или «Автомат» с жёсткими предохранителями.';
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
tradeEl.innerHTML = accs.map((a) => {
|
|
1759
|
+
const ls = a.lastSync;
|
|
1760
|
+
const pb = a.pendingBatch;
|
|
1761
|
+
const posHtml = ls?.positions?.length
|
|
1762
|
+
? `<table><thead><tr><th>Бумага</th><th class="num">Штук</th><th class="num">Цена</th><th class="num">Стоимость</th><th class="num">Финрезультат</th></tr></thead><tbody>${ls.positions.map((p) => `<tr><td><b>${esc(p.symbol)}</b></td><td class="num">${fmtNum(p.qty, 0)}</td><td class="num">${fmtNum(p.price, 3)}</td><td class="num">${fmtNum(p.valueRub, 0)} ₽</td><td class="num ${cls(p.pnlRub)}">${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</td></tr>`).join('')}</tbody></table>`
|
|
1763
|
+
: `<p class="muted small">${ls?.ok ? 'Открытых позиций по акциям нет.' : 'Нажмите «Обновить портфель», чтобы загрузить баланс и позиции с брокера.'}</p>`;
|
|
1764
|
+
|
|
1765
|
+
let batchHtml = '';
|
|
1766
|
+
if (pb) {
|
|
1767
|
+
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
|
+
: '<p class="muted small">По текущему прогнозу новых сделок не требуется.</p>';
|
|
1770
|
+
const resHtml = pb.results?.length
|
|
1771
|
+
? `<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
|
+
: '';
|
|
1773
|
+
batchHtml = `<div class="stab-box" style="margin-top:10px">
|
|
1774
|
+
<div class="stab-head">
|
|
1775
|
+
<b>Пакет лимитных заявок (${fmtTs(pb.createdAt)}, свеча ${esc(pb.bar || '—')})</b>
|
|
1776
|
+
${pb.edgeWarning ? `<span class="tag mid">${esc(pb.edgeWarning)}</span>` : ''}
|
|
1777
|
+
<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>
|
|
1780
|
+
</div>
|
|
1781
|
+
${ordRows}
|
|
1782
|
+
${resHtml}
|
|
1783
|
+
</div>`;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
return `<div class="live-card" data-bid="${esc(a.id)}">
|
|
1787
|
+
<div class="live-head">
|
|
1788
|
+
<div>
|
|
1789
|
+
<b>${esc(a.name)}</b>
|
|
1790
|
+
<span class="tag">${esc(BROKER_LABELS[a.broker] || a.broker)}</span>
|
|
1791
|
+
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
1792
|
+
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
1793
|
+
${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
|
+
</div>
|
|
1796
|
+
<div class="row-gap">
|
|
1797
|
+
<button type="button" data-btrade="sync" data-bid="${esc(a.id)}">Обновить портфель</button>
|
|
1798
|
+
<button type="button" class="primary" data-btrade="prep" data-bid="${esc(a.id)}">Рассчитать заявки (в лотах)</button>
|
|
1799
|
+
</div>
|
|
1800
|
+
</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
|
+
</div>`;
|
|
1806
|
+
}).join('');
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
$('#btn-brokers-cfg').addEventListener('click', () => {
|
|
1810
|
+
switchTab('settings');
|
|
1811
|
+
showSettingsSec('brokers');
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
const brokerForm = $('#broker-form');
|
|
1815
|
+
function stopBrokerEdit() {
|
|
1816
|
+
editingBrokerId = null;
|
|
1817
|
+
brokerForm.reset();
|
|
1818
|
+
brokerForm.elements.id.value = '';
|
|
1819
|
+
$('#broker-add-sum').textContent = 'Добавить брокерский счёт';
|
|
1820
|
+
$('#broker-save-btn').textContent = 'Сохранить счёт';
|
|
1821
|
+
$('#broker-cancel-btn').hidden = true;
|
|
1822
|
+
}
|
|
1823
|
+
$('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
|
|
1824
|
+
brokerForm.addEventListener('submit', async (e) => {
|
|
1825
|
+
e.preventDefault();
|
|
1826
|
+
const f = brokerForm.elements;
|
|
1827
|
+
const body = {
|
|
1828
|
+
id: f.id.value || undefined,
|
|
1829
|
+
name: f.name.value,
|
|
1830
|
+
broker: f.broker.value,
|
|
1831
|
+
accountId: f.accountId.value,
|
|
1832
|
+
token: f.token.value,
|
|
1833
|
+
mode: f.mode.value,
|
|
1834
|
+
baseRunId: f.baseRunId.value ? Number(f.baseRunId.value) : null,
|
|
1835
|
+
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
1836
|
+
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
1837
|
+
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
1838
|
+
autoSchedule: f.autoSchedule.checked,
|
|
1839
|
+
skipEdgeMinutes: f.skipEdgeMinutes.checked,
|
|
1840
|
+
sandbox: f.sandbox.checked,
|
|
1841
|
+
resetHalt: true,
|
|
1842
|
+
};
|
|
1843
|
+
try {
|
|
1844
|
+
state.brokers = editingBrokerId
|
|
1845
|
+
? await api(`/api/brokers/${encodeURIComponent(editingBrokerId)}`, { method: 'PUT', body })
|
|
1846
|
+
: await post('/api/brokers', body);
|
|
1847
|
+
stopBrokerEdit();
|
|
1848
|
+
renderBrokers();
|
|
1849
|
+
toast('Брокерский счёт сохранён');
|
|
1850
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
$('#brokers-list').addEventListener('click', async (e) => {
|
|
1854
|
+
const b = e.target.closest('button[data-bact]');
|
|
1855
|
+
if (!b) return;
|
|
1856
|
+
const id = b.closest('[data-bid]').dataset.bid;
|
|
1857
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
1858
|
+
if (!acc) return;
|
|
1859
|
+
if (b.dataset.bact === 'edit') {
|
|
1860
|
+
editingBrokerId = acc.id;
|
|
1861
|
+
const f = brokerForm.elements;
|
|
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;
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
if (b.dataset.bact === 'del') {
|
|
1882
|
+
if (!confirm(`Удалить брокерский счёт «${acc.name}»?`)) return;
|
|
1883
|
+
try { state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, { method: 'DELETE' }); renderBrokers(); } catch (err) { toast(err.message, 'error'); }
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (b.dataset.bact === 'sync') {
|
|
1887
|
+
b.disabled = true;
|
|
1888
|
+
const old = b.textContent;
|
|
1889
|
+
b.textContent = 'Проверяем…';
|
|
1890
|
+
try {
|
|
1891
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
1892
|
+
renderBrokers();
|
|
1893
|
+
toast(`Связь с «${acc.name}» проверена`);
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
await loadBrokers().catch(() => {});
|
|
1896
|
+
toast(err.message, 'error');
|
|
1897
|
+
} finally {
|
|
1898
|
+
if (b.isConnected) { b.disabled = false; b.textContent = old; }
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
1904
|
+
const b = e.target.closest('button[data-btrade]');
|
|
1905
|
+
if (!b) return;
|
|
1906
|
+
const id = b.dataset.bid;
|
|
1907
|
+
const act = b.dataset.btrade;
|
|
1908
|
+
b.disabled = true;
|
|
1909
|
+
const old = b.textContent;
|
|
1910
|
+
try {
|
|
1911
|
+
if (act === 'sync') {
|
|
1912
|
+
b.textContent = 'Обновляем…';
|
|
1913
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
1914
|
+
toast('Портфель обновлён');
|
|
1915
|
+
} else if (act === 'prep') {
|
|
1916
|
+
b.textContent = 'Считаем лоты…';
|
|
1917
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/prepare`);
|
|
1918
|
+
toast('Пакет заявок по прогнозу рассчитан');
|
|
1919
|
+
} else if (act === 'exec') {
|
|
1920
|
+
if (!confirm('Отправить лимитные заявки брокеру?')) return;
|
|
1921
|
+
b.textContent = 'Отправляем…';
|
|
1922
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/execute`);
|
|
1923
|
+
toast('Заявки отправлены брокеру');
|
|
1924
|
+
} else if (act === 'rej') {
|
|
1925
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reject`);
|
|
1926
|
+
}
|
|
1927
|
+
renderBrokers();
|
|
1928
|
+
} catch (err) {
|
|
1929
|
+
await loadBrokers().catch(() => {});
|
|
1930
|
+
toast(err.message, 'error');
|
|
1931
|
+
} finally {
|
|
1932
|
+
if (b.isConnected) { b.disabled = false; b.textContent = old; }
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
// ======================================================================
|
|
1937
|
+
// СОБЫТИЯ СЕРВЕРА
|
|
1938
|
+
// ======================================================================
|
|
1939
|
+
function setStatus(ok, title) {
|
|
1940
|
+
const dot = $('#st');
|
|
1941
|
+
dot.className = `dot ${ok}`;
|
|
1942
|
+
dot.title = title;
|
|
1943
|
+
}
|
|
1944
|
+
let connected = false;
|
|
1945
|
+
function showLlmStatus() {
|
|
1946
|
+
if (!connected) return;
|
|
1947
|
+
const llm = state.config?.llm;
|
|
1948
|
+
setStatus(llm?.enabled ? 'ok' : 'warn', llm?.enabled ? `Сервер на связи · ИИ: ${llm.provider} · ${llm.label || llm.model}${llm.tools ? '' : ' (без инструментов)'}` : 'Сервер на связи · ИИ не подключён (Настройки → Модели ИИ)');
|
|
1949
|
+
}
|
|
1950
|
+
function connectWs() {
|
|
1951
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
|
|
1952
|
+
ws.onopen = () => {
|
|
1953
|
+
connected = true;
|
|
1954
|
+
showLlmStatus();
|
|
1955
|
+
loadChat().catch(() => {});
|
|
1956
|
+
loadLlm().catch(() => {});
|
|
1957
|
+
};
|
|
1958
|
+
ws.onclose = () => { connected = false; setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)'); setTimeout(connectWs, 2000); };
|
|
1959
|
+
ws.onmessage = (ev) => {
|
|
1960
|
+
const m = JSON.parse(ev.data);
|
|
1961
|
+
switch (m.type) {
|
|
1962
|
+
case 'hello':
|
|
1963
|
+
renderLive(m.live);
|
|
1964
|
+
refreshForecastIfStale();
|
|
1965
|
+
// если исследование идёт на сервере (запущено раньше или Стратегом) — показать кнопку отмены
|
|
1966
|
+
if (!state.active && m.running?.length) {
|
|
1967
|
+
state.active = m.running[0];
|
|
1968
|
+
researchUi(true);
|
|
1969
|
+
setProgress(0, `Исследование №${m.running[0]} идёт (запущено ранее)…`);
|
|
1970
|
+
}
|
|
1971
|
+
if (!state.stabilityActive && m.stabilityRunning) {
|
|
1972
|
+
stabilityUi(m.stabilityRunning);
|
|
1973
|
+
setProgress(10, `Проверка устойчивости эксперимента №${m.stabilityRunning} идёт…`);
|
|
1974
|
+
}
|
|
1975
|
+
break;
|
|
1976
|
+
case 'chat:message':
|
|
1977
|
+
case 'chat:update':
|
|
1978
|
+
if (m.type === 'chat:update' && !m.message.data?.streaming) state.chat.stream.delete(m.message.id);
|
|
1979
|
+
if (m.message.data?.streaming && (!state.chat.stream.has(m.message.id) || m.message.data?.retry)) state.chat.stream.set(m.message.id, { reasoning: '', content: '' });
|
|
1980
|
+
upsertMsg(m.message);
|
|
1981
|
+
renderSuggest();
|
|
1982
|
+
break;
|
|
1983
|
+
case 'chat:delta': onDelta(m); break;
|
|
1984
|
+
case 'chat:state':
|
|
1985
|
+
state.chat.state = m.state;
|
|
1986
|
+
if (!m.state.busy) state.chat.stream.clear();
|
|
1987
|
+
renderChatState();
|
|
1988
|
+
break;
|
|
1989
|
+
case 'chat:reset': loadChat(); break;
|
|
1990
|
+
case 'llm:changed':
|
|
1991
|
+
state.config.llm = m.llm;
|
|
1992
|
+
showLlmStatus();
|
|
1993
|
+
loadLlm().catch(() => {});
|
|
1994
|
+
break;
|
|
1995
|
+
case 'settings:update':
|
|
1996
|
+
state.settings = m.settings;
|
|
1997
|
+
fillForm(m.settings);
|
|
1998
|
+
loadHistory().catch(() => {});
|
|
1999
|
+
if (m.source === 'ai') {
|
|
2000
|
+
for (const k of Object.keys(m.changed || {})) { const l = $('#params').elements[k]?.closest('.srow, label'); if (l) { l.classList.remove('flash'); void l.offsetWidth; l.classList.add('flash'); } }
|
|
2001
|
+
toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
|
|
2002
|
+
}
|
|
2003
|
+
break;
|
|
2004
|
+
case 'journal:update': loadJournal().catch(() => {}); break;
|
|
2005
|
+
case 'research:start':
|
|
2006
|
+
state.active = m.runId;
|
|
2007
|
+
researchUi(true);
|
|
2008
|
+
setProgress(1, `Исследование №${m.runId}${m.source === 'ai' ? ' (Стратег)' : ''}: начинаем…`);
|
|
2009
|
+
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>`);
|
|
2010
|
+
loadRuns(m.runId);
|
|
2011
|
+
break;
|
|
2012
|
+
case 'research:progress': {
|
|
2013
|
+
const pct = m.stage === 'data' ? m.pct * 0.3 : 30 + m.pct * 0.7;
|
|
2014
|
+
const el = liveEls.get(`r-${m.runId}`) || liveLine(`r-${m.runId}`, `Исследование №${m.runId}: <span class="pg"></span><div class="bar-s"><div></div></div>`);
|
|
2015
|
+
$('.pg', el).textContent = m.text;
|
|
2016
|
+
$('.bar-s div', el).style.width = `${pct}%`;
|
|
2017
|
+
if (!state.active) { state.active = m.runId; researchUi(true); }
|
|
2018
|
+
if (m.runId === state.active) setProgress(pct, m.text);
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
2021
|
+
case 'research:done':
|
|
2022
|
+
case 'research:error': {
|
|
2023
|
+
dropLive(`r-${m.runId}`);
|
|
2024
|
+
const mine = m.runId === state.active;
|
|
2025
|
+
if (mine) { state.active = null; researchUi(false); }
|
|
2026
|
+
loadRuns(m.type === 'research:done' ? m.runId : undefined).then(() => {
|
|
2027
|
+
if (mine && m.type === 'research:error') toast(`Исследование ${runLabel(m.runId)}: ${m.error}`, 'error');
|
|
2028
|
+
});
|
|
2029
|
+
break;
|
|
2030
|
+
}
|
|
2031
|
+
case 'research:reused':
|
|
2032
|
+
loadRuns(m.runId);
|
|
2033
|
+
break;
|
|
2034
|
+
case 'profiles:progress':
|
|
2035
|
+
state.profilesBusy = true;
|
|
2036
|
+
$('#btn-profiles').disabled = true;
|
|
2037
|
+
$('#profiles-msg').textContent = `Составляем описания компаний: ${m.done} из ${m.total}`;
|
|
2038
|
+
updateTabBadges();
|
|
2039
|
+
break;
|
|
2040
|
+
case 'profiles:done':
|
|
2041
|
+
state.profilesBusy = false;
|
|
2042
|
+
$('#btn-profiles').disabled = false;
|
|
2043
|
+
state.profiles = null; // перечитаем с сервера
|
|
2044
|
+
updateTabBadges();
|
|
2045
|
+
loadProfilesData().then(() => { if (!$('#tab-map').hidden) drawMap(); });
|
|
2046
|
+
toast(m.error ? `Описания компаний: ошибка — ${m.error}` : `Описания компаний готовы (${m.count})`, m.error ? 'error' : 'info');
|
|
2047
|
+
break;
|
|
2048
|
+
case 'paper:step': {
|
|
2049
|
+
const s = m.step;
|
|
2050
|
+
liveLine(`p-${s.ts}`, `Живая проверка · ${fmtTs(s.ts)}: ${s.decisions.length ? s.decisions.map((d) => esc(d.text)).join('; ') : 'без изменений'}`);
|
|
2051
|
+
const keys = [...liveEls.keys()].filter((k) => k.startsWith('p-'));
|
|
2052
|
+
for (const k of keys.slice(0, -5)) dropLive(k);
|
|
2053
|
+
break;
|
|
2054
|
+
}
|
|
2055
|
+
case 'news:update': if (!$('#tab-now').hidden) loadNews(); break;
|
|
2056
|
+
case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
|
|
2057
|
+
case 'stability:start':
|
|
2058
|
+
stabilityUi(m.runId);
|
|
2059
|
+
setProgress(2, `Проверка устойчивости эксперимента №${m.runId}…`);
|
|
2060
|
+
break;
|
|
2061
|
+
case 'stability:progress':
|
|
2062
|
+
stabilityUi(m.runId);
|
|
2063
|
+
setProgress(m.pct, m.text);
|
|
2064
|
+
$('#btn-stability').textContent = `${m.pct}% · проверяем…`;
|
|
2065
|
+
break;
|
|
2066
|
+
case 'stability:done':
|
|
2067
|
+
stabilityUi(null);
|
|
2068
|
+
if (state.run?.id === m.runId && state.run.extra) {
|
|
2069
|
+
state.run.extra.stability = m.stability;
|
|
2070
|
+
renderOverview();
|
|
2071
|
+
}
|
|
2072
|
+
toast(`Устойчивость эксперимента №${m.runId}: медиана ${fmtPct(m.stability.medianPct)}, разброс ${fmtPct(m.stability.minPct)} … ${fmtPct(m.stability.maxPct)}`);
|
|
2073
|
+
break;
|
|
2074
|
+
case 'stability:error':
|
|
2075
|
+
stabilityUi(null);
|
|
2076
|
+
toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
|
|
2077
|
+
break;
|
|
2078
|
+
case 'paper:status': renderLive(m.status); break;
|
|
2079
|
+
case 'paper:started': case 'paper:stopped': api('/api/live/status').then(renderLive); break;
|
|
2080
|
+
case 'paper:fill': toast(`Живая проверка: куплено ${m.position.symbol}`); break;
|
|
2081
|
+
case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// ---------- профиль, тарифы ЮKassa, боты Telegram/MAX и админ-панель ----------
|
|
2087
|
+
$('#btn-user')?.addEventListener('click', () => {
|
|
2088
|
+
switchTab('settings');
|
|
2089
|
+
showSettingsSec('cloud');
|
|
2090
|
+
renderCloudPane();
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
async function renderCloudPane() {
|
|
2094
|
+
const box = $('#cloud-pane');
|
|
2095
|
+
if (!box) return;
|
|
2096
|
+
const me = await api('/api/auth/me').catch(() => state.config || {});
|
|
2097
|
+
state.config = { ...(state.config || {}), ...me };
|
|
2098
|
+
const u = me.user;
|
|
2099
|
+
const userBtn = $('#btn-user');
|
|
2100
|
+
if (userBtn) userBtn.textContent = u ? `${u.name} · ${u.planName}` : 'Войти';
|
|
2101
|
+
|
|
2102
|
+
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>`;
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
const nt = me.notify || {};
|
|
2108
|
+
const code = u.notifyCode || 'NA-OWNER1';
|
|
2109
|
+
const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2110
|
+
const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2111
|
+
|
|
2112
|
+
let adminHtml = '';
|
|
2113
|
+
if (u.role === 'admin') {
|
|
2114
|
+
const adm = await api('/api/admin/state').catch(() => null);
|
|
2115
|
+
if (adm) {
|
|
2116
|
+
const b = adm.billing || {}, n = adm.notify || {};
|
|
2117
|
+
adminHtml = `<div style="margin-top:22px;border-top:1px solid var(--line);padding-top:16px">
|
|
2118
|
+
<div class="slbl" style="margin-bottom:10px">Панель администратора облака (ЮKassa, боты Telegram / MAX, пользователи)</div>
|
|
2119
|
+
<form id="adm-billing-form" style="display:grid;gap:10px;margin-bottom:16px">
|
|
2120
|
+
<div class="muted small"><b>Приём оплаты ЮKassa (API v3)</b> · Webhook URL: <code>${location.origin}/api/billing/yookassa/webhook</code></div>
|
|
2121
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2122
|
+
<input name="shopId" value="${esc(b.shopId || '')}" placeholder="Shop ID ЮKassa" style="width:180px">
|
|
2123
|
+
<input name="secretKey" type="password" placeholder="${b.hasSecret ? 'Секретный ключ задан (оставьте пустым)' : 'Секретный ключ ЮKassa'}" style="flex:1;min-width:220px">
|
|
2124
|
+
<label class="row-gap small muted" style="align-items:center"><input type="checkbox" name="sendReceipt" class="switch"${b.sendReceipt ? ' checked' : ''}>Чеки 54-ФЗ</label>
|
|
2125
|
+
<button type="submit" class="primary">Сохранить ЮKassa</button>
|
|
2126
|
+
</div>
|
|
2127
|
+
</form>
|
|
2128
|
+
<form id="adm-notify-form" style="display:grid;gap:10px;margin-bottom:16px">
|
|
2129
|
+
<div class="muted small"><b>Боты уведомлений и подтверждения заявок (Telegram и MAX)</b></div>
|
|
2130
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2131
|
+
<input name="tgBotName" value="${esc(n.tgBotName || '')}" placeholder="Имя @бота Telegram" style="width:180px">
|
|
2132
|
+
<input name="tgToken" type="password" placeholder="${n.hasTgToken ? 'Токен Telegram задан' : 'Токен Telegram Bot API'}" style="flex:1;min-width:220px">
|
|
2133
|
+
</div>
|
|
2134
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2135
|
+
<input name="maxBotName" value="${esc(n.maxBotName || '')}" placeholder="Имя бота MAX" style="width:180px">
|
|
2136
|
+
<input name="maxToken" type="password" placeholder="${n.hasMaxToken ? 'Токен MAX задан' : 'Токен MAX Bot API'}" style="flex:1;min-width:220px">
|
|
2137
|
+
<button type="submit" class="primary">Сохранить ботов</button>
|
|
2138
|
+
</div>
|
|
2139
|
+
</form>
|
|
2140
|
+
<form id="adm-promo-form" style="display:grid;gap:8px;margin-bottom:16px">
|
|
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>`;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const apiKey = u.apiKey || '';
|
|
2172
|
+
const npxCmd = apiKey ? `npx neuroalpha --key ${apiKey}` : 'npx neuroalpha';
|
|
2173
|
+
const lic = u.localLicense;
|
|
2174
|
+
|
|
2175
|
+
box.innerHTML = `
|
|
2176
|
+
<div class="srow">
|
|
2177
|
+
<div class="stxt">
|
|
2178
|
+
<div class="slbl">${esc(u.name)} · <span class="muted">${esc(u.email)}</span> <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
|
|
2179
|
+
<div class="hint">Лимиты тарифа: до ${u.limits?.maxRunsPerDay ?? 2} экспериментов в сутки · виртуальных счетов: до ${u.limits?.maxVirtualAccounts ?? 1} · брокерских счетов: до ${u.limits?.maxBrokerAccounts ?? 0}${u.planUntil ? ` · оплачен до ${fmtDate(u.planUntil)}` : ''}.</div>
|
|
2180
|
+
</div>
|
|
2181
|
+
<div class="row-gap">
|
|
2182
|
+
<button type="button" id="btn-cloud-verify">Проверить оплату</button>
|
|
2183
|
+
<button type="button" id="btn-cloud-logout">Выйти</button>
|
|
2184
|
+
</div>
|
|
2185
|
+
</div>
|
|
2186
|
+
<div class="srow">
|
|
2187
|
+
<div class="stxt">
|
|
2188
|
+
<div class="slbl">Ваш персональный API-ключ (для локального запуска через <code>npx</code> и API)</div>
|
|
2189
|
+
<div class="hint">
|
|
2190
|
+
Ключ: <b><code id="user-api-key">${esc(apiKey || 'не создан')}</code></b><br>
|
|
2191
|
+
Команда для запуска на вашем ПК (ключи брокера остаются локально): <code>${esc(npxCmd)}</code>
|
|
2192
|
+
${lic ? `<br><span class="pos">✓ Активирована лицензия: ${esc(lic.planName)} (${esc(lic.email || '')})</span>` : ''}
|
|
2193
|
+
</div>
|
|
2194
|
+
</div>
|
|
2195
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2196
|
+
<button type="button" id="btn-copy-apikey" class="primary">Скопировать ключ</button>
|
|
2197
|
+
<button type="button" id="btn-copy-npx">Скопировать npx-команду</button>
|
|
2198
|
+
<button type="button" id="btn-regen-apikey" title="Перевыпустить API-ключ (старый перестанет работать)">Перевыпустить</button>
|
|
2199
|
+
</div>
|
|
2200
|
+
</div>
|
|
2201
|
+
${!me.cloudMode ? `<div class="srow">
|
|
2202
|
+
<div class="stxt">
|
|
2203
|
+
<div class="slbl">Активировать API-ключ подписки с сайта</div>
|
|
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>
|
|
2235
|
+
<div class="hint">
|
|
2236
|
+
Ваш код привязки: <b><code>${esc(code)}</code></b> — отправьте этот код боту в Telegram или в мессенджер MAX.<br>
|
|
2237
|
+
Статус: Telegram — ${u.tgConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'} · MAX — ${u.maxConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'}
|
|
2238
|
+
</div>
|
|
2239
|
+
</div>
|
|
2240
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
2241
|
+
${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
|
+
${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
|
+
<button type="button" id="btn-notify-test">Тест уведомления</button>
|
|
2244
|
+
</div>
|
|
2245
|
+
</div>
|
|
2246
|
+
${adminHtml}
|
|
2247
|
+
`;
|
|
2248
|
+
|
|
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
|
+
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
2278
|
+
await post('/api/auth/logout');
|
|
2279
|
+
location.href = '/landing';
|
|
2280
|
+
});
|
|
2281
|
+
box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
|
|
2282
|
+
try {
|
|
2283
|
+
const r = await post('/api/billing/verify');
|
|
2284
|
+
toast(r.status === 'succeeded' ? `Оплата подтверждена! Тариф: ${r.user?.planName}` : `Статус платежа: ${r.status}`);
|
|
2285
|
+
renderCloudPane();
|
|
2286
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
2287
|
+
});
|
|
2288
|
+
box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', async () => {
|
|
2289
|
+
try {
|
|
2290
|
+
const r = await post('/api/billing/checkout', { plan: b.dataset.checkout, months: 1, returnOrigin: location.origin });
|
|
2291
|
+
if (r.confirmationUrl) location.href = r.confirmationUrl;
|
|
2292
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
2293
|
+
}));
|
|
2294
|
+
box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
|
|
2295
|
+
try {
|
|
2296
|
+
await post('/api/auth/notify-test');
|
|
2297
|
+
toast('Тестовое уведомление отправлено в привязанные мессенджеры');
|
|
2298
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
2299
|
+
});
|
|
2300
|
+
box.querySelector('#btn-promo-redeem')?.addEventListener('click', async () => {
|
|
2301
|
+
const code = box.querySelector('#promo-code-in')?.value.trim();
|
|
2302
|
+
if (!code) return;
|
|
2303
|
+
try {
|
|
2304
|
+
const r = await post('/api/auth/redeem-promo', { code });
|
|
2305
|
+
toast(`Промокод активирован! Ваш тариф: ${r.user?.planName}`);
|
|
2306
|
+
renderCloudPane();
|
|
2307
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
2308
|
+
});
|
|
2309
|
+
box.querySelector('#adm-promo-form')?.addEventListener('submit', async (e) => {
|
|
2310
|
+
e.preventDefault();
|
|
2311
|
+
const f = e.target.elements;
|
|
2312
|
+
try {
|
|
2313
|
+
await post('/api/admin/promo', { code: f.code.value, plan: f.plan.value, days: f.days.value, maxUses: f.maxUses.value });
|
|
2314
|
+
toast('Промокод создан');
|
|
2315
|
+
renderCloudPane();
|
|
2316
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2317
|
+
});
|
|
2318
|
+
box.querySelector('#adm-billing-form')?.addEventListener('submit', async (e) => {
|
|
2319
|
+
e.preventDefault();
|
|
2320
|
+
const f = e.target.elements;
|
|
2321
|
+
try {
|
|
2322
|
+
await post('/api/admin/billing', { shopId: f.shopId.value, secretKey: f.secretKey.value, sendReceipt: f.sendReceipt.checked });
|
|
2323
|
+
toast('Настройки ЮKassa сохранены');
|
|
2324
|
+
renderCloudPane();
|
|
2325
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2326
|
+
});
|
|
2327
|
+
box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
|
|
2328
|
+
e.preventDefault();
|
|
2329
|
+
const f = e.target.elements;
|
|
2330
|
+
try {
|
|
2331
|
+
await post('/api/admin/notify', { tgBotName: f.tgBotName.value, tgToken: f.tgToken.value, maxBotName: f.maxBotName.value, maxToken: f.maxToken.value });
|
|
2332
|
+
toast('Настройки ботов Telegram и MAX сохранены');
|
|
2333
|
+
renderCloudPane();
|
|
2334
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2335
|
+
});
|
|
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
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// ---------- тема (тёмная / светлая) ----------
|
|
2346
|
+
function applyTheme(light) {
|
|
2347
|
+
document.body.classList.toggle('light', light);
|
|
2348
|
+
const btn = $('#btn-theme');
|
|
2349
|
+
if (btn) {
|
|
2350
|
+
btn.textContent = light ? '☾' : '☀';
|
|
2351
|
+
btn.title = light ? 'Переключить на тёмную тему' : 'Переключить на светлую тему';
|
|
2352
|
+
}
|
|
2353
|
+
const chartOpts = {
|
|
2354
|
+
layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e' },
|
|
2355
|
+
grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
|
|
2356
|
+
};
|
|
2357
|
+
eqChart.applyOptions(chartOpts);
|
|
2358
|
+
icChart.applyOptions(chartOpts);
|
|
2359
|
+
if (!$('#tab-map').hidden) drawMap();
|
|
2360
|
+
}
|
|
2361
|
+
$('#btn-theme')?.addEventListener('click', () => {
|
|
2362
|
+
const next = !document.body.classList.contains('light');
|
|
2363
|
+
localStorage.setItem('theme', next ? 'light' : 'dark');
|
|
2364
|
+
applyTheme(next);
|
|
2365
|
+
});
|
|
2366
|
+
if (localStorage.getItem('theme') === 'light') applyTheme(true);
|
|
2367
|
+
|
|
2368
|
+
// ---------- справка ----------
|
|
2369
|
+
let helpLoaded = false;
|
|
2370
|
+
$('#btn-help').addEventListener('click', async () => {
|
|
2371
|
+
$('#help').hidden = false;
|
|
2372
|
+
if (helpLoaded) return;
|
|
2373
|
+
try {
|
|
2374
|
+
const res = await fetch(`/docs/${encodeURIComponent('ИНСТРУКЦИЯ.md')}`);
|
|
2375
|
+
$('#help-body').innerHTML = md(await res.text());
|
|
2376
|
+
helpLoaded = true;
|
|
2377
|
+
} catch (e) { $('#help-body').textContent = e.message; }
|
|
2378
|
+
});
|
|
2379
|
+
$('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
|
|
2380
|
+
$('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
|
|
2381
|
+
document.addEventListener('keydown', (e) => {
|
|
2382
|
+
if (e.key === 'Escape') {
|
|
2383
|
+
$('#help').hidden = true;
|
|
2384
|
+
closeModelMenu();
|
|
2385
|
+
closeModeMenu();
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2388
|
+
|
|
2389
|
+
// ---------- старт ----------
|
|
2390
|
+
(async function init() {
|
|
2391
|
+
state.config = await api('/api/config');
|
|
2392
|
+
buildForm();
|
|
2393
|
+
state.settings = await api('/api/settings');
|
|
2394
|
+
fillForm(state.settings);
|
|
2395
|
+
loadHistory().catch(() => {});
|
|
2396
|
+
connectWs();
|
|
2397
|
+
renderLive(await api('/api/live/status'));
|
|
2398
|
+
loadBrokers().catch(() => {});
|
|
2399
|
+
loadJournal().catch(() => {});
|
|
2400
|
+
renderCloudPane().catch(() => {});
|
|
2401
|
+
const urlParams = new URLSearchParams(location.search);
|
|
2402
|
+
if (urlParams.get('payment_check') === '1') {
|
|
2403
|
+
post('/api/billing/verify').then((r) => {
|
|
2404
|
+
if (r.status === 'succeeded') toast(`Оплата прошла успешно! Включён тариф «${r.user?.planName}»`);
|
|
2405
|
+
renderCloudPane();
|
|
2406
|
+
}).catch(() => {});
|
|
2407
|
+
}
|
|
2408
|
+
const initRunId = Number(urlParams.get('run')) || undefined;
|
|
2409
|
+
await loadRuns(initRunId);
|
|
2410
|
+
})().catch((e) => toast(e.message, 'error'));
|