neuroalpha 0.3.15 → 0.3.16
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 +80 -170
- package/config/strategy.json +2 -2
- package/docs//320/221/320/240/320/236/320/232/320/225/320/240/320/253_API.md +4 -3
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +10 -17
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +97 -156
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +29 -45
- package/package.json +1 -1
- package/public/admin.html +9 -8
- package/public/app.js +444 -829
- package/public/index.html +141 -199
- package/public/landing.html +19 -33
- package/public/legal.html +3 -4
- package/public/styles.css +6 -2
- package/server/api/routes.js +10 -3
- package/server/auth/index.js +53 -34
- package/server/brokers/index.js +430 -104
- package/server/data/moex.js +74 -23
- package/server/db.js +3 -2
- package/server/model/dataset.js +10 -2
- package/server/model/forecast.js +56 -19
- package/server/model/live.js +30 -6
- package/server/model/models.js +113 -31
- package/server/model/research.js +100 -26
- package/server/params-meta.js +16 -16
- package/server/strategist/index.js +4 -0
- package/server/strategist/tools.js +229 -0
package/public/app.js
CHANGED
|
@@ -103,7 +103,6 @@ function setChatClosed(closed) {
|
|
|
103
103
|
try {
|
|
104
104
|
eqChart.timeScale().fitContent();
|
|
105
105
|
icChart.timeScale().fitContent();
|
|
106
|
-
if (!$('#tab-map')?.hidden) drawMap();
|
|
107
106
|
} catch { /* ignore */ }
|
|
108
107
|
});
|
|
109
108
|
}
|
|
@@ -132,93 +131,67 @@ $('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
|
|
|
132
131
|
sp.classList.remove('drag');
|
|
133
132
|
document.body.classList.remove('dragging');
|
|
134
133
|
localStorage.setItem('chatWidth', getComputedStyle(document.documentElement).getPropertyValue('--chat-w').trim());
|
|
135
|
-
if (!$('#tab-map').hidden) drawMap();
|
|
136
134
|
});
|
|
137
135
|
sp.addEventListener('dblclick', () => {
|
|
138
136
|
document.documentElement.style.removeProperty('--chat-w');
|
|
139
137
|
localStorage.removeItem('chatWidth');
|
|
140
|
-
if (!$('#tab-map').hidden) drawMap();
|
|
141
138
|
});
|
|
142
139
|
})();
|
|
143
140
|
|
|
144
141
|
// ======================================================================
|
|
145
|
-
//
|
|
142
|
+
// НАВИГАЦИЯ В ЕДИНОМ РАБОЧЕМ ОКНЕ
|
|
146
143
|
// ======================================================================
|
|
144
|
+
function isOwnerUser() {
|
|
145
|
+
const u = state.config?.user;
|
|
146
|
+
return Boolean(u?.isOwner || u?.role === 'admin' || u?.plan === 'owner' || u?.limits?.allowResearch);
|
|
147
|
+
}
|
|
148
|
+
|
|
147
149
|
function updateTabBadges() {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const rId = state.active || state.stabilityActive || state.runs?.find((r) => r.status === 'running')?.id;
|
|
157
|
-
parts.push(`<span class="tbadge run" title="Сейчас идёт расчёт${rId ? ` №${rId}` : ''}"><i class="tspin"></i></span>`);
|
|
150
|
+
const ovNav = $('#nnav-overview');
|
|
151
|
+
if (ovNav) {
|
|
152
|
+
const owner = isOwnerUser();
|
|
153
|
+
ovNav.hidden = !owner;
|
|
154
|
+
if (owner) {
|
|
155
|
+
const total = state.runs?.length || 0;
|
|
156
|
+
const isRunning = Boolean(state.active || state.stabilityActive || state.runs?.some((r) => r.status === 'running'));
|
|
157
|
+
ovNav.textContent = total > 0 ? `Эксперименты (${total})${isRunning ? ' ⟳' : ''}` : 'Эксперименты';
|
|
158
158
|
}
|
|
159
|
-
ovEl.innerHTML = parts.join('');
|
|
160
159
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const nowEl = $('#tstat-now');
|
|
164
|
-
if (nowEl) {
|
|
165
|
-
const vAccs = state.live?.accounts || [];
|
|
166
|
-
const vRun = vAccs.filter((a) => a.running).length;
|
|
160
|
+
const brkNav = $('#nnav-brokers');
|
|
161
|
+
if (brkNav) {
|
|
167
162
|
const bAccs = state.brokers?.accounts || [];
|
|
168
|
-
|
|
169
|
-
const parts = [];
|
|
170
|
-
if (vAccs.length > 0) {
|
|
171
|
-
parts.push(`<span class="tbadge${vRun > 0 ? ' ok' : ''}" title="Виртуальные счета: запущено ${vRun}, всего ${vAccs.length}">${vRun > 0 ? `▶ ${vRun}` : vAccs.length} вирт.</span>`);
|
|
172
|
-
}
|
|
173
|
-
if (bAccs.length > 0) {
|
|
174
|
-
parts.push(`<span class="tbadge${bActive > 0 ? ' ok' : ''}" title="Брокерские счета: подключено ${bAccs.length}">${bAccs.length} реал.</span>`);
|
|
175
|
-
}
|
|
176
|
-
nowEl.innerHTML = parts.join('');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 3. Карта и журнал: сколько записей в журнале гипотез (+ индикатор обновления профилей)
|
|
180
|
-
const mapEl = $('#tstat-map');
|
|
181
|
-
if (mapEl) {
|
|
182
|
-
const jCount = state.journalCount ?? 0;
|
|
183
|
-
const parts = [];
|
|
184
|
-
if (jCount > 0) parts.push(`<span class="tbadge" title="Записей в журнале гипотез: ${jCount}">${jCount}</span>`);
|
|
185
|
-
if (state.profilesBusy) parts.push(`<span class="tbadge run" title="Составляются описания компаний"><i class="tspin"></i></span>`);
|
|
186
|
-
mapEl.innerHTML = parts.join('');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 4. Настройки: сколько параметров изменено относительно умолчаний
|
|
190
|
-
const setEl = $('#tstat-settings');
|
|
191
|
-
if (setEl && state.settings && state.config?.defaults) {
|
|
192
|
-
const d = state.config.defaults;
|
|
193
|
-
const shownKeys = (state.config.params || []).filter((p) => !p.hidden).map((p) => p.key);
|
|
194
|
-
const diff = shownKeys.filter((k) => state.settings[k] !== undefined && JSON.stringify(state.settings[k]) !== JSON.stringify(d[k])).length;
|
|
195
|
-
setEl.innerHTML = diff > 0 ? `<span class="tbadge" title="Изменено настроек от значений по умолчанию: ${diff}">${diff}</span>` : '';
|
|
163
|
+
brkNav.textContent = bAccs.length ? `Брокерские счета (${bAccs.length})` : 'Брокерские счета';
|
|
196
164
|
}
|
|
197
165
|
}
|
|
198
166
|
|
|
199
|
-
function
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
167
|
+
function showMainSec(sec) {
|
|
168
|
+
if (sec === 'now') sec = 'brokers';
|
|
169
|
+
if (sec === 'settings' || sec === 'g1') sec = 'g0';
|
|
170
|
+
if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
|
|
171
|
+
document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
172
|
+
document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
173
|
+
if ($('#main-workspace')) $('#main-workspace').scrollTop = 0;
|
|
174
|
+
saveNavPatch({ mainSec: sec });
|
|
175
|
+
if (sec === 'overview') {
|
|
205
176
|
requestAnimationFrame(() => {
|
|
206
177
|
eqChart.timeScale().fitContent();
|
|
207
178
|
icChart.timeScale().fitContent();
|
|
208
179
|
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
loadJournal();
|
|
216
|
-
if (state.mapMode === 'text' && !state.profiles) loadProfilesData().then(drawMap);
|
|
180
|
+
} else if (sec === 'forecast') {
|
|
181
|
+
refreshForecastIfStale();
|
|
182
|
+
} else if (sec === 'news') {
|
|
183
|
+
loadNews();
|
|
184
|
+
} else if (sec === 'cloud') {
|
|
185
|
+
renderCloudPane().catch(() => {});
|
|
217
186
|
}
|
|
218
187
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
188
|
+
const switchTab = (t) => showMainSec(t);
|
|
189
|
+
const showNowSec = (s) => showMainSec(s);
|
|
190
|
+
const showSettingsSec = (s) => showMainSec(s);
|
|
191
|
+
|
|
192
|
+
$('#main-nav')?.addEventListener('click', (e) => {
|
|
193
|
+
const b = e.target.closest('button[data-sec]');
|
|
194
|
+
if (b) showMainSec(b.dataset.sec);
|
|
222
195
|
});
|
|
223
196
|
|
|
224
197
|
// ======================================================================
|
|
@@ -261,7 +234,7 @@ const RUN_PARTS = [
|
|
|
261
234
|
['modelMarketFilter', (v) => (v === 'ma' ? 'Фильтр индекса' : 'Без фильтра')],
|
|
262
235
|
['modelTrainDays', (v) => `Окно: ${v}дн`],
|
|
263
236
|
['modelHistoryDays', (v) => `История: ${v}дн`],
|
|
264
|
-
['modelEmbedDim', (v) => `Эмбд.: ${v}`],
|
|
237
|
+
['modelEmbedDim', (v) => `Эмбд.: ${Number(v) === 0 ? 'Авто' : v}`],
|
|
265
238
|
['modelTextWeight', (v) => (v > 0 ? `Описания: ${fmtNum(v, 2)}` : null)],
|
|
266
239
|
['commissionPct', (v) => `Комиссия: ${fmtNum(v, 3)}%`],
|
|
267
240
|
];
|
|
@@ -325,12 +298,10 @@ async function loadRuns(selectId) {
|
|
|
325
298
|
|
|
326
299
|
async function openRun(id) {
|
|
327
300
|
state.run = await api(`/api/runs/${id}`);
|
|
328
|
-
$('#run-select').value = String(id);
|
|
301
|
+
if ($('#run-select')) $('#run-select').value = String(id);
|
|
329
302
|
saveNavPatch({ runId: Number(id) });
|
|
330
303
|
syncUiAvailability();
|
|
331
304
|
renderOverview();
|
|
332
|
-
setupMapSlider();
|
|
333
|
-
if (!$('#tab-map').hidden) drawMap();
|
|
334
305
|
}
|
|
335
306
|
|
|
336
307
|
$('#run-select').addEventListener('change', (e) => e.target.value && openRun(Number(e.target.value)));
|
|
@@ -445,37 +416,16 @@ function syncSettingsDependencies() {
|
|
|
445
416
|
|
|
446
417
|
const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
|
|
447
418
|
if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (rawCap === null) return;
|
|
454
|
-
const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
|
|
455
|
-
try {
|
|
456
|
-
const res = await post('/api/live/start', { baseRunId: r.id, capital });
|
|
457
|
-
renderLive(res.status);
|
|
458
|
-
toast(`Виртуальный счёт №${res.runId} запущен по эксперименту №${r.id}`, 'info', {
|
|
459
|
-
label: 'Открыть',
|
|
460
|
-
fn: () => { switchTab('now'); showNowSec('virtual'); },
|
|
461
|
-
});
|
|
462
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
463
|
-
});
|
|
464
|
-
// «Применить эти настройки»: показывается только когда настройки открытого прогона отличаются от текущих
|
|
465
|
-
const APPLY_KEYS = ['modelAutoAdapt', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight', 'commissionPct', 'slippagePct'];
|
|
466
|
-
function runSettingsDiff(r = state.run) {
|
|
467
|
-
if (!r?.params || !state.settings) return { entries: [], lines: [] };
|
|
468
|
-
const pv = (k) => (r.params[k] !== undefined ? r.params[k] : state.config?.defaults?.[k]);
|
|
469
|
-
const diffKeys = APPLY_KEYS.filter((k) => pv(k) !== undefined && JSON.stringify(pv(k)) !== JSON.stringify(state.settings[k]));
|
|
470
|
-
return {
|
|
471
|
-
entries: diffKeys.map((k) => [k, pv(k)]),
|
|
472
|
-
lines: diffKeys.map((k) => `${labelOf(k)}: ${state.settings[k] ?? '—'} → ${pv(k) ?? '—'}`),
|
|
473
|
-
};
|
|
419
|
+
if ($('#btn-emb-auto')) {
|
|
420
|
+
const isAutoEmb = Number(s.modelEmbedDim ?? 0) === 0;
|
|
421
|
+
$('#btn-emb-auto').classList.toggle('primary', isAutoEmb);
|
|
422
|
+
$('#btn-emb-auto').textContent = isAutoEmb ? '✓ Авто (0)' : '⚡ Авто (0)';
|
|
423
|
+
}
|
|
474
424
|
}
|
|
475
425
|
function syncShowcaseBtn() {
|
|
476
426
|
const btn = $('#btn-showcase');
|
|
477
427
|
if (!btn) return;
|
|
478
|
-
const isAdmin =
|
|
428
|
+
const isAdmin = isOwnerUser();
|
|
479
429
|
const r = state.run;
|
|
480
430
|
btn.hidden = !isAdmin || !r || r.status !== 'done';
|
|
481
431
|
if (!btn.hidden) {
|
|
@@ -497,27 +447,7 @@ $('#btn-showcase')?.addEventListener('click', async () => {
|
|
|
497
447
|
|
|
498
448
|
function syncApplyBtn() {
|
|
499
449
|
syncShowcaseBtn();
|
|
500
|
-
const btn = $('#btn-apply');
|
|
501
|
-
if (!btn) return;
|
|
502
|
-
const isDone = state.run?.status === 'done' && state.run.id !== state.active;
|
|
503
|
-
const { lines } = runSettingsDiff();
|
|
504
|
-
btn.hidden = !isDone || !lines.length;
|
|
505
|
-
if (lines.length) {
|
|
506
|
-
btn.textContent = `✓ Применить эти настройки (${lines.length})`;
|
|
507
|
-
btn.title = `Скопировать отличающиеся настройки эксперимента в текущие:\n${lines.join('\n')}`;
|
|
508
|
-
}
|
|
509
450
|
}
|
|
510
|
-
$('#btn-apply').addEventListener('click', async () => {
|
|
511
|
-
const r = state.run;
|
|
512
|
-
if (!r) return;
|
|
513
|
-
const { entries, lines } = runSettingsDiff(r);
|
|
514
|
-
if (!lines.length) { syncApplyBtn(); return; }
|
|
515
|
-
if (!confirm(`Применить настройки из эксперимента №${r.id}?\n\n${lines.join('\n')}`)) return;
|
|
516
|
-
await saveSettings(Object.fromEntries(entries));
|
|
517
|
-
fillForm(state.settings);
|
|
518
|
-
syncApplyBtn();
|
|
519
|
-
toast(`Настройки из эксперимента №${r.id} применены`);
|
|
520
|
-
});
|
|
521
451
|
|
|
522
452
|
// Срок эксперимента, сложный годовой процент (CAGR) и простой средний годовой %
|
|
523
453
|
function calcPeriodStats(period, returnPct, marketPct) {
|
|
@@ -715,7 +645,17 @@ function renderLiveTraining() {
|
|
|
715
645
|
].join('');
|
|
716
646
|
}
|
|
717
647
|
|
|
648
|
+
const isCalcStage = st.stage === 'calc';
|
|
649
|
+
const calcBanner = isCalcStage ? `<div class="ld-calc-banner">
|
|
650
|
+
<div class="ld-calc-spin"></div>
|
|
651
|
+
<div style="min-width:0;flex:1">
|
|
652
|
+
<div style="font-weight:600;font-size:13.5px;color:var(--accent)">Идёт финальный пересчёт сделок, кривых капитала и фазовой устойчивости…</div>
|
|
653
|
+
<div class="small muted" style="margin-top:2px">${esc(st.text || 'Симуляция портфеля и расчёт метрик…')}</div>
|
|
654
|
+
</div>
|
|
655
|
+
</div>` : '';
|
|
656
|
+
|
|
718
657
|
empty.innerHTML = `
|
|
658
|
+
${calcBanner}
|
|
719
659
|
<div class="kpis" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
|
720
660
|
<div class="kpi"><div class="v">${v.fold} <span class="muted small">из ${v.folds}</span></div><div class="l">шаг скользящего окна</div><div class="sub">прогноз: ${fmtDate(v.testFrom)} — ${fmtDate(v.testTill)}</div></div>
|
|
721
661
|
<div class="kpi"><div class="v ${cls(meanIc)}">${meanIc > 0 ? '+' : ''}${fmtNum(meanIc, 3)}</div><div class="l">среднее совпадение (IC)</div><div class="sub">шагов в плюсе: ${posFolds} из ${ics.length} (${ics.length ? Math.round((posFolds / ics.length) * 100) : 0}%)</div></div>
|
|
@@ -937,14 +877,49 @@ $('#btn-cancel').addEventListener('click', async () => {
|
|
|
937
877
|
// СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
|
|
938
878
|
// ======================================================================
|
|
939
879
|
let forecastBusy = false;
|
|
880
|
+
let pulseClearTimer = null;
|
|
940
881
|
const prevClientExp = new Map();
|
|
941
882
|
const prevClientRank = new Map();
|
|
942
883
|
|
|
884
|
+
function triggerTradingPulse(text = 'Обновление котировок и сигналов…', durationMs = 2400) {
|
|
885
|
+
state.tradingPulseActive = true;
|
|
886
|
+
state.tradingPulseText = text;
|
|
887
|
+
updateTabBadges();
|
|
888
|
+
const badge = $('#fc-live-badge');
|
|
889
|
+
if (badge) {
|
|
890
|
+
badge.innerHTML = `<i class="tspin" style="margin-right:5px;vertical-align:-1px"></i>${esc(text)}`;
|
|
891
|
+
badge.style.background = 'rgba(59, 130, 246, 0.18)';
|
|
892
|
+
badge.style.borderColor = 'var(--accent)';
|
|
893
|
+
badge.style.color = 'var(--accent)';
|
|
894
|
+
}
|
|
895
|
+
clearTimeout(pulseClearTimer);
|
|
896
|
+
pulseClearTimer = setTimeout(() => {
|
|
897
|
+
state.tradingPulseActive = false;
|
|
898
|
+
updateTabBadges();
|
|
899
|
+
if (badge && state.forecast) {
|
|
900
|
+
const liveTimeStr = new Date((state.forecast.liveTs || state.forecast.at || Date.now()) + MSK).toISOString().slice(11, 19);
|
|
901
|
+
badge.textContent = `● живой радар · ${liveTimeStr}`;
|
|
902
|
+
badge.style.background = '';
|
|
903
|
+
badge.style.borderColor = '';
|
|
904
|
+
badge.style.color = '';
|
|
905
|
+
}
|
|
906
|
+
}, durationMs);
|
|
907
|
+
}
|
|
908
|
+
|
|
943
909
|
async function loadForecast(force, silent = false) {
|
|
944
910
|
if (forecastBusy) return;
|
|
945
911
|
forecastBusy = true;
|
|
912
|
+
triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
|
|
946
913
|
const box = $('#forecast');
|
|
947
|
-
if (!silent && !state.forecast)
|
|
914
|
+
if (!silent && !state.forecast) {
|
|
915
|
+
box.innerHTML = `<div class="ld-calc-banner" style="margin:4px 0">
|
|
916
|
+
<div class="ld-calc-spin"></div>
|
|
917
|
+
<div>
|
|
918
|
+
<div style="font-weight:600;color:var(--accent)">Синхронизация живых котировок Мосбиржи и расчёт матрицы внимания…</div>
|
|
919
|
+
<div class="small muted">Занимает ~1–2 секунды (чтение локального кэша свечей и живого стакана TQBR)</div>
|
|
920
|
+
</div>
|
|
921
|
+
</div>`;
|
|
922
|
+
}
|
|
948
923
|
try {
|
|
949
924
|
const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`);
|
|
950
925
|
// Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
|
|
@@ -968,8 +943,8 @@ async function loadForecast(force, silent = false) {
|
|
|
968
943
|
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>` : '');
|
|
969
944
|
const arrowOf = (r) => {
|
|
970
945
|
const d = Number(r.deltaPct || 0);
|
|
971
|
-
if (d
|
|
972
|
-
if (d
|
|
946
|
+
if (d >= 0.005) return `<span class="pos small" title="Ожидание усилилось на +${fmtNum(d, 2)}% с прошлого замера">+${fmtNum(d, 2)}% ▲</span>`;
|
|
947
|
+
if (d <= -0.005) return `<span class="neg small" title="Ожидание снизилось на ${fmtNum(d, 2)}% с прошлого замера">${fmtNum(d, 2)}% ▼</span>`;
|
|
973
948
|
return '<span class="muted small" title="Без изменений">—</span>';
|
|
974
949
|
};
|
|
975
950
|
const rankBadge = (r) => {
|
|
@@ -978,19 +953,30 @@ async function loadForecast(force, silent = false) {
|
|
|
978
953
|
if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
|
|
979
954
|
return '';
|
|
980
955
|
};
|
|
981
|
-
const colgroup = `<colgroup><col style="width:
|
|
956
|
+
const colgroup = `<colgroup><col style="width:33%"><col style="width:12%"><col style="width:12%"><col style="width:11%"><col style="width:12%"><col style="width:20%"></colgroup>`;
|
|
957
|
+
const sigBadge = (r) => {
|
|
958
|
+
if (r.signal) return '<span class="tag ok">купить</span>';
|
|
959
|
+
if (r.shortSignal) return '<span class="tag no">шорт</span>';
|
|
960
|
+
if (r.shortBlockedByBroker) return '<span class="tag" title="Ожидание пробило порог шорта, но брокер запрещает короткие продажи по этой бумаге (shortEnabledFlag = false)">без шорта</span>';
|
|
961
|
+
return '<span class="muted small">—</span>';
|
|
962
|
+
};
|
|
982
963
|
const row = (r) => `<tr>
|
|
983
964
|
<td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
|
|
984
965
|
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
|
|
985
966
|
<td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
|
|
986
967
|
<td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
|
|
987
|
-
<td style="text-align:center;white-space:nowrap">${r
|
|
968
|
+
<td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
|
|
988
969
|
<td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
|
|
989
970
|
</tr>`;
|
|
971
|
+
const buyThr = f.buyThresholdPct ?? f.thresholdPct;
|
|
972
|
+
const shortThr = f.shortThresholdPct ?? f.thresholdPct;
|
|
973
|
+
const shortRows = [
|
|
974
|
+
...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
975
|
+
...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
976
|
+
].slice(0, 4);
|
|
990
977
|
box.innerHTML = `<table style="table-layout:fixed">${colgroup}<thead><tr><th>Сильнее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${f.rows.slice(0, 6).map(row).join('')}</tbody></table>
|
|
991
|
-
<table style="margin-top:14px;table-layout:fixed">${colgroup}<thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${
|
|
992
|
-
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога
|
|
993
|
-
setupMapSlider();
|
|
978
|
+
<table style="margin-top:14px;table-layout:fixed">${colgroup}<thead><tr><th>Слабее рынка</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${shortRows.map(row).join('')}</tbody></table>
|
|
979
|
+
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 45 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
|
|
994
980
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
995
981
|
} catch (e) {
|
|
996
982
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
@@ -1008,424 +994,26 @@ function refreshForecastIfStale() {
|
|
|
1008
994
|
loadForecast(false, true);
|
|
1009
995
|
}
|
|
1010
996
|
}
|
|
1011
|
-
setInterval(() => { if (
|
|
1012
|
-
|
|
1013
|
-
function showNowSec(sec) {
|
|
1014
|
-
document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
|
|
1015
|
-
document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
|
|
1016
|
-
saveNavPatch({ nowSec: sec });
|
|
1017
|
-
}
|
|
1018
|
-
$('#now-nav').addEventListener('click', (e) => {
|
|
1019
|
-
const b = e.target.closest('[data-nsec]');
|
|
1020
|
-
if (b) showNowSec(b.dataset.nsec);
|
|
1021
|
-
});
|
|
1022
|
-
|
|
1023
|
-
let openLiveId = loadSavedNav().openLiveId ?? null;
|
|
1024
|
-
const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
|
|
1025
|
-
function setLiveSubTab(id, tab) {
|
|
1026
|
-
liveTabById.set(String(id), tab);
|
|
1027
|
-
saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
function renderLive(st) {
|
|
1031
|
-
state.live = st;
|
|
1032
|
-
const accs = st?.accounts || [];
|
|
1033
|
-
if ($('#nnav-virtual')) $('#nnav-virtual').textContent = accs.length ? `Виртуальные счета (${accs.length})` : 'Виртуальные счета';
|
|
1034
|
-
updateTabBadges();
|
|
1035
|
-
if (!accs.length) {
|
|
1036
|
-
$('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
|
|
1040
|
-
openLiveId = accs[0].runId;
|
|
1041
|
-
}
|
|
1042
|
-
$('#live').innerHTML = accs.map((a) => {
|
|
1043
|
-
const isOpen = String(a.runId) === String(openLiveId);
|
|
1044
|
-
const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
|
|
1045
|
-
const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
|
|
1046
|
-
const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
|
|
1047
|
-
const retPct = a.returnPct ?? 0;
|
|
1048
|
-
const step = a.recent?.[0];
|
|
1049
|
-
const p = a.params || {};
|
|
1050
|
-
const mf = a.metrics?.full || {};
|
|
1051
|
-
const trades = Array.isArray(a.trades) ? a.trades : [];
|
|
1052
|
-
const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
|
|
1053
|
-
const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
|
|
1054
|
-
const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
|
|
1055
|
-
const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
|
|
1056
|
-
const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
|
|
1057
|
-
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
|
|
1058
|
-
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
1059
|
-
if (a.corridor) {
|
|
1060
|
-
const c = a.corridor;
|
|
1061
|
-
const statusMap = {
|
|
1062
|
-
inside: ['в коридоре истории', 'ok'],
|
|
1063
|
-
above: ['опережает историю', 'ok'],
|
|
1064
|
-
below: ['ниже коридора истории — проверьте', 'no'],
|
|
1065
|
-
};
|
|
1066
|
-
const [sLbl, sCls] = statusMap[c.status] || ['', ''];
|
|
1067
|
-
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>`;
|
|
1068
|
-
}
|
|
1069
|
-
const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
|
|
1070
|
-
|
|
1071
|
-
// Вкладка 1: Текущие позиции и ожидающие заявки
|
|
1072
|
-
const posPaneHtml = `
|
|
1073
|
-
${s.pending?.length ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Ожидают исполнения на открытии следующей свечи (${s.pending.length})</b></div><div class="muted small">${s.pending.map((o) => `<span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${{ buy: 'купить', short: 'открыть шорт', cover: 'закрыть шорт' }[o.action] || 'продать'} <b>${esc(o.symbol)}</b></span>`).join(' ')}</div></div>` : ''}
|
|
1074
|
-
${s.positions.length ? `<table><thead><tr><th>Бумага</th><th>Направление</th><th>Открыта</th><th class="num">Кол-во</th><th class="num">Цена входа → Сейчас</th><th class="num">Текущий доход</th></tr></thead><tbody>${s.positions.map((pos) => {
|
|
1075
|
-
const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
|
|
1076
|
-
return `<tr>
|
|
1077
|
-
<td><b>${esc(pos.symbol)}</b></td>
|
|
1078
|
-
<td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
|
|
1079
|
-
<td class="muted small">${fmtTs(pos.entryTs)}</td>
|
|
1080
|
-
<td class="num">${fmtNum(pos.qty, 0)} шт.</td>
|
|
1081
|
-
<td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
|
|
1082
|
-
<td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
|
|
1083
|
-
</tr>`;
|
|
1084
|
-
}).join('')}</tbody></table>` : `<p class="muted small" style="margin:6px 0">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'открытых позиций нет' : 'ничего не куплено'} (все <b>${fmtNum(s.cash ?? s.equity, 0)} ₽</b> в свободном кэше) — стратегия ждёт сильного сигнала.</p>`}
|
|
1085
|
-
${step ? `<p class="muted small" style="margin:8px 0 0">Последняя проверка рынка (${fmtTs(step.ts)}): ${step.decisions?.length ? step.decisions.map((d) => `<b>${esc(d.symbol)}</b>: ${esc(d.text)}`).join(' · ') : 'без изменений'}${step.top?.length ? ` · лидеры прогноза: ${step.top.slice(0, 3).map((x) => `${esc(x.symbol)} (${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%)`).join(', ')}` : ''}</p>` : ''}
|
|
1086
|
-
`;
|
|
1087
|
-
|
|
1088
|
-
// Вкладка 2: История закрытых сделок и по-дневной доход
|
|
1089
|
-
let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
|
|
1090
|
-
if (trades.length) {
|
|
1091
|
-
const byDay = new Map();
|
|
1092
|
-
for (const t of trades) {
|
|
1093
|
-
const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
|
|
1094
|
-
if (!byDay.has(dKey)) byDay.set(dKey, []);
|
|
1095
|
-
byDay.get(dKey).push(t);
|
|
1096
|
-
}
|
|
1097
|
-
const rows = [];
|
|
1098
|
-
for (const [, dayTrades] of byDay) {
|
|
1099
|
-
const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
|
|
1100
|
-
const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
|
|
1101
|
-
const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
|
|
1102
|
-
rows.push(`<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTrades[0]?.exitTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">сделок: <b>${dayTrades.length}</b> (в плюс: ${dayWins}) · комиссия: ${fmtNum(dayFee, 0)} ₽ · доход за день: <b class="${cls(dayPnl)}">${dayPnl >= 0 ? '+' : ''}${fmtNum(dayPnl, 0)} ₽</b></span></div></td></tr>`);
|
|
1103
|
-
for (const t of dayTrades) {
|
|
1104
|
-
rows.push(`<tr>
|
|
1105
|
-
<td><b>${esc(t.symbol)}</b></td>
|
|
1106
|
-
<td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
|
|
1107
|
-
<td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
|
|
1108
|
-
<td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
|
|
1109
|
-
<td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
|
|
1110
|
-
<td class="num ${cls(t.pnl)}" style="white-space:nowrap"><b>${t.pnl >= 0 ? '+' : ''}${fmtNum(t.pnl, 0)} ₽</b> <span class="small">(${fmtPct(t.pnlPct, 2)})</span></td>
|
|
1111
|
-
<td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
|
|
1112
|
-
</tr>`);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
tradesPaneHtml = `<table><thead><tr><th>Бумага</th><th>Тип</th><th>Вход → Выход</th><th class="num">Кол-во</th><th class="num">Цена входа → выхода</th><th class="num">Чистый доход</th><th>Причина</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
// Вкладка 3: Журнал шагов и решений ИИ по свечам
|
|
1119
|
-
const stepsList = Array.isArray(a.recent) ? a.recent : [];
|
|
1120
|
-
const stepsPaneHtml = stepsList.length
|
|
1121
|
-
? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
|
|
1122
|
-
<td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
|
|
1123
|
-
<td>${stItem.decisions?.length ? stItem.decisions.map((d) => `<span class="tag ${d.action === 'buy' ? 'ok' : d.action === 'short' || d.action === 'sell' ? 'mid' : ''}">${esc(d.symbol)}</span> <span class="small">${esc(d.text)}</span>`).join('<br>') : '<span class="muted small">Позиции без изменений</span>'}</td>
|
|
1124
|
-
<td class="small muted">${stItem.top?.length ? stItem.top.slice(0, 3).map((x) => `<b>${esc(x.symbol)}</b> <span class="${cls(x.expectedPct)}">${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%</span>`).join(' · ') : '—'}</td>
|
|
1125
|
-
</tr>`).join('')}</tbody></table>`
|
|
1126
|
-
: '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
|
|
1127
|
-
|
|
1128
|
-
return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
|
|
1129
|
-
<div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
1130
|
-
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1131
|
-
<span class="acc-caret">▶</span>
|
|
1132
|
-
<b>${esc(dispName)}</b>
|
|
1133
|
-
<span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
|
|
1134
|
-
${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
|
|
1135
|
-
</div>
|
|
1136
|
-
<div class="row-gap" style="align-items:center">
|
|
1137
|
-
<button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
|
|
1138
|
-
${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
|
|
1139
|
-
<button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
|
|
1140
|
-
</div>
|
|
1141
|
-
</div>
|
|
1142
|
-
<div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
|
|
1143
|
-
<div class="brk-specs" style="margin-bottom:10px">
|
|
1144
|
-
<span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
|
|
1145
|
-
<span class="k">Статус и время работы:</span><span class="v">запущен <b>${fmtTs(a.startedAt)}</b>${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча <b>${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}</b>${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}</span>
|
|
1146
|
-
<span class="k">Структура дохода:</span><span class="v">зафиксировано по сделкам: <b class="${cls(realizedPnl)}">${realizedPnl >= 0 ? '+' : ''}${fmtNum(realizedPnl, 0)} ₽</b> · в открытых позициях: <b class="${cls(openPnl)}">${openPnl >= 0 ? '+' : ''}${fmtNum(openPnl, 0)} ₽</b> · учтено комиссий: <span class="muted">−${fmtNum(totalFee, 0)} ₽</span> · макс. просадка: <b>${fmtNum(mf.maxDrawdownPct || 0, 2)}%</b></span>
|
|
1147
|
-
</div>
|
|
1148
|
-
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
1149
|
-
<div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
|
|
1150
|
-
<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>
|
|
1151
|
-
${corrHtml}
|
|
1152
|
-
<div class="kpi"><div class="v">${trades.length || (mf.trades ?? 0)} <span class="muted small">(${winRate}% в плюс)</span></div><div class="l">закрыто сделок · профит-фактор ${fmtNum(mf.profitFactor || 0, 2)}</div></div>
|
|
1153
|
-
</div>
|
|
1154
|
-
<div class="brk-subtabs" style="margin-top:10px">
|
|
1155
|
-
<button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
|
|
1156
|
-
<button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
|
|
1157
|
-
<button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
|
|
1158
|
-
</div>
|
|
1159
|
-
<div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
|
|
1160
|
-
<div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
|
|
1161
|
-
<div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
|
|
1162
|
-
</div>
|
|
1163
|
-
</div>`;
|
|
1164
|
-
}).join('');
|
|
1165
|
-
syncUiAvailability();
|
|
1166
|
-
}
|
|
1167
|
-
$('#btn-live').addEventListener('click', async () => {
|
|
1168
|
-
const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
|
|
1169
|
-
if (rawCap === null) return;
|
|
1170
|
-
const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
|
|
1171
|
-
try {
|
|
1172
|
-
const res = await post('/api/live/start', { capital });
|
|
1173
|
-
renderLive(res.status);
|
|
1174
|
-
toast(`Виртуальный счёт №${res.runId} запущен`);
|
|
1175
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1176
|
-
});
|
|
1177
|
-
$('#live').addEventListener('click', async (e) => {
|
|
1178
|
-
const tabBtn = e.target.closest('button[data-ltab]');
|
|
1179
|
-
if (tabBtn) {
|
|
1180
|
-
setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
|
|
1181
|
-
renderLive(state.live);
|
|
1182
|
-
return;
|
|
1183
|
-
}
|
|
1184
|
-
const stopBtn = e.target.closest('[data-live-stop]');
|
|
1185
|
-
if (stopBtn) {
|
|
1186
|
-
const id = Number(stopBtn.dataset.liveStop);
|
|
1187
|
-
if (!confirm(`Остановить виртуальный счёт №${id}?`)) return;
|
|
1188
|
-
try { const r = await post('/api/live/stop', { runId: id }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
const delBtn = e.target.closest('[data-live-del]');
|
|
1192
|
-
if (delBtn) {
|
|
1193
|
-
const id = Number(delBtn.dataset.liveDel);
|
|
1194
|
-
if (!confirm(`Удалить виртуальный счёт №${id} и его сделки?`)) return;
|
|
1195
|
-
try { const r = await api(`/api/live/${id}`, { method: 'DELETE' }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
|
|
1196
|
-
return;
|
|
1197
|
-
}
|
|
1198
|
-
const openBtn = e.target.closest('[data-open-run]');
|
|
1199
|
-
if (openBtn) {
|
|
1200
|
-
const id = Number(openBtn.dataset.openRun);
|
|
1201
|
-
document.querySelector('[data-tab=overview]')?.click();
|
|
1202
|
-
openRun(id);
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
const askBtn = e.target.closest('[data-live-ask]');
|
|
1206
|
-
if (askBtn) {
|
|
1207
|
-
const id = Number(askBtn.dataset.liveAsk);
|
|
1208
|
-
const acc = (state.live?.accounts || []).find((x) => x.runId === id);
|
|
1209
|
-
sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
const foldHead = e.target.closest('[data-live-fold]');
|
|
1213
|
-
if (foldHead) {
|
|
1214
|
-
const id = foldHead.dataset.liveFold;
|
|
1215
|
-
openLiveId = String(openLiveId) === String(id) ? null : id;
|
|
1216
|
-
saveNavPatch({ openLiveId });
|
|
1217
|
-
renderLive(state.live);
|
|
1218
|
-
}
|
|
1219
|
-
});
|
|
997
|
+
setInterval(() => { if (!document.hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
|
|
1220
998
|
|
|
1221
|
-
// ----------
|
|
999
|
+
// ---------- новости ----------
|
|
1222
1000
|
async function loadNews() {
|
|
1223
1001
|
try {
|
|
1224
1002
|
const { items } = await api('/api/news?limit=20');
|
|
1225
1003
|
if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
|
|
1226
|
-
$('#news-list')
|
|
1227
|
-
|
|
1228
|
-
|
|
1004
|
+
if ($('#news-list')) {
|
|
1005
|
+
$('#news-list').innerHTML = items.length
|
|
1006
|
+
? 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('')
|
|
1007
|
+
: '<span class="muted">Свежих размеченных новостей пока нет.</span>';
|
|
1008
|
+
}
|
|
1229
1009
|
} catch { /* ignore */ }
|
|
1230
1010
|
}
|
|
1231
1011
|
|
|
1232
1012
|
// ======================================================================
|
|
1233
|
-
//
|
|
1234
|
-
// ======================================================================
|
|
1235
|
-
function mapFrames() {
|
|
1236
|
-
const ex = state.run?.extra;
|
|
1237
|
-
const fr = (ex?.map?.snapshots || []).map((s) => ({ ts: s.ts, pts: s.pts, cl: s.cl, symbols: ex.symbols, names: ex.names }));
|
|
1238
|
-
if (state.forecast?.map) fr.push({ ts: state.forecast.barTs, now: true, ...state.forecast.map });
|
|
1239
|
-
return fr;
|
|
1240
|
-
}
|
|
1241
|
-
function setupMapSlider() {
|
|
1242
|
-
const s = $('#map-slider');
|
|
1243
|
-
const atEnd = Number(s.value) >= Number(s.max);
|
|
1244
|
-
s.max = Math.max(0, mapFrames().length - 1);
|
|
1245
|
-
if (atEnd) s.value = s.max;
|
|
1246
|
-
}
|
|
1247
|
-
function layout(f, W, H) {
|
|
1248
|
-
const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
|
|
1249
|
-
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
1250
|
-
const pad = 36;
|
|
1251
|
-
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)]);
|
|
1252
|
-
}
|
|
1253
|
-
// ближайшая к клику точка карты (общая для карты по поведению и по описанию)
|
|
1254
|
-
function pickPoint(f, e) {
|
|
1255
|
-
const cv = $('#map-canvas');
|
|
1256
|
-
const rect = cv.getBoundingClientRect();
|
|
1257
|
-
const xy = layout(f, cv.clientWidth, cv.clientHeight);
|
|
1258
|
-
let best = -1, bd = 16;
|
|
1259
|
-
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; } });
|
|
1260
|
-
return best;
|
|
1261
|
-
}
|
|
1262
|
-
function drawMap() {
|
|
1263
|
-
if (state.mapMode === 'text') return drawProfilesMap();
|
|
1264
|
-
const cv = $('#map-canvas');
|
|
1265
|
-
const W = cv.clientWidth, H = cv.clientHeight;
|
|
1266
|
-
if (!W) return;
|
|
1267
|
-
$('#map-slider').hidden = false;
|
|
1268
|
-
$('#map-text-box').hidden = true;
|
|
1269
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1270
|
-
cv.width = W * dpr; cv.height = H * dpr;
|
|
1271
|
-
const ctx = cv.getContext('2d');
|
|
1272
|
-
ctx.scale(dpr, dpr);
|
|
1273
|
-
const frames = mapFrames();
|
|
1274
|
-
if (!frames.length) {
|
|
1275
|
-
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
|
1276
|
-
ctx.fillText('Карта появится после исследования или прогноза.', 20, 30);
|
|
1277
|
-
$('#map-date').textContent = '';
|
|
1278
|
-
$('#map-info').textContent = '';
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1281
|
-
const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
|
|
1282
|
-
$('#map-date').textContent = f.now ? `Сейчас · ${fmtDate(f.ts)} · заливка — прогноз` : `Месяц с ${fmtDate(f.ts)}`;
|
|
1283
|
-
const xy = layout(f, W, H);
|
|
1284
|
-
const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
|
|
1285
|
-
const fc = f.now && state.forecast ? Object.fromEntries(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
1286
|
-
if (sel >= 0) {
|
|
1287
|
-
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);
|
|
1288
|
-
ctx.strokeStyle = document.body.classList.contains('light') ? 'rgba(36, 87, 214, 0.38)' : '#ffffff40';
|
|
1289
|
-
for (const [i] of near) { ctx.beginPath(); ctx.moveTo(...xy[sel]); ctx.lineTo(...xy[i]); ctx.stroke(); }
|
|
1290
|
-
}
|
|
1291
|
-
f.symbols.forEach((s, i) => {
|
|
1292
|
-
const [x, y] = xy[i];
|
|
1293
|
-
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
1294
|
-
const r = fc?.[s] ? 3 + Math.min(7, Math.abs(fc[s].expectedPct) * 15) : 4;
|
|
1295
|
-
ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
|
|
1296
|
-
ctx.beginPath(); ctx.arc(x, y, i === sel ? r + 2 : r, 0, Math.PI * 2);
|
|
1297
|
-
ctx.fillStyle = fc?.[s] ? (fc[s].expectedPct >= 0 ? '#3fb950' : '#f85149') : col;
|
|
1298
|
-
ctx.fill();
|
|
1299
|
-
ctx.globalAlpha = 1;
|
|
1300
|
-
const light = document.body.classList.contains('light');
|
|
1301
|
-
ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
|
|
1302
|
-
ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
|
|
1303
|
-
ctx.fillText(s, x + r + 3, y + 3);
|
|
1304
|
-
});
|
|
1305
|
-
if (sel < 0) { $('#map-info').textContent = 'Близко — значит акции обычно движутся вместе; цвет — найденная группа. Нажмите на акцию, чтобы увидеть соседей.'; return; }
|
|
1306
|
-
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]);
|
|
1307
|
-
const r = state.forecast?.rows.find((x) => x.symbol === f.symbols[sel]);
|
|
1308
|
-
$('#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)}` : ''}`;
|
|
1309
|
-
}
|
|
1310
|
-
$('#map-slider').addEventListener('input', drawMap);
|
|
1311
|
-
$('#map-canvas').addEventListener('click', (e) => {
|
|
1312
|
-
if (state.mapMode === 'text') {
|
|
1313
|
-
const d = state.profiles;
|
|
1314
|
-
if (!d?.map) return;
|
|
1315
|
-
const best = pickPoint(d.map, e);
|
|
1316
|
-
state.mapSel = best >= 0 ? d.map.symbols[best] : null;
|
|
1317
|
-
drawMap();
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
const frames = mapFrames();
|
|
1321
|
-
if (!frames.length) return;
|
|
1322
|
-
const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
|
|
1323
|
-
const best = pickPoint(f, e);
|
|
1324
|
-
state.mapSel = best >= 0 ? f.symbols[best] : null;
|
|
1325
|
-
drawMap();
|
|
1326
|
-
});
|
|
1327
|
-
window.addEventListener('resize', debounce(() => { if (!$('#tab-map').hidden) drawMap(); }, 150));
|
|
1328
|
-
|
|
1329
|
-
// ---------- карта «по описанию»: 2D-проекция устойчивых признаков бизнеса ----------
|
|
1330
|
-
async function loadProfilesData() {
|
|
1331
|
-
try { state.profiles = await api('/api/profiles'); } catch (e) { toast(e.message, 'error'); }
|
|
1332
|
-
}
|
|
1333
|
-
function drawProfilesMap() {
|
|
1334
|
-
const cv = $('#map-canvas');
|
|
1335
|
-
const W = cv.clientWidth, H = cv.clientHeight;
|
|
1336
|
-
if (!W) return;
|
|
1337
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1338
|
-
cv.width = W * dpr; cv.height = H * dpr;
|
|
1339
|
-
const ctx = cv.getContext('2d');
|
|
1340
|
-
ctx.scale(dpr, dpr);
|
|
1341
|
-
$('#map-slider').hidden = true;
|
|
1342
|
-
const d = state.profiles;
|
|
1343
|
-
$('#map-date').textContent = d?.map ? `Описания бизнеса · ${d.count} из ${d.total} компаний` : 'Описания компаний';
|
|
1344
|
-
if (!d?.map) {
|
|
1345
|
-
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
|
1346
|
-
ctx.fillText(d?.count ? 'Описаний пока мало — карту по описаниям построить не из чего.' : 'Карты по описаниям пока нет.', 20, 30);
|
|
1347
|
-
$('#map-text-box').hidden = false;
|
|
1348
|
-
$('#btn-profiles').disabled = state.profilesBusy;
|
|
1349
|
-
$('#map-info').textContent = 'Компании рядом, если похож их бизнес: экспортёр, банк, дивиденды и другие устойчивые свойства.';
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
|
-
$('#map-text-box').hidden = true;
|
|
1353
|
-
const f = d.map;
|
|
1354
|
-
const xy = layout(f, W, H);
|
|
1355
|
-
const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
|
|
1356
|
-
f.symbols.forEach((s, i) => {
|
|
1357
|
-
const [x, y] = xy[i];
|
|
1358
|
-
ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
|
|
1359
|
-
ctx.beginPath(); ctx.arc(x, y, i === sel ? 6 : 4, 0, Math.PI * 2);
|
|
1360
|
-
ctx.fillStyle = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
1361
|
-
ctx.fill();
|
|
1362
|
-
ctx.globalAlpha = 1;
|
|
1363
|
-
const light = document.body.classList.contains('light');
|
|
1364
|
-
ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
|
|
1365
|
-
ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
|
|
1366
|
-
ctx.fillText(s, x + 7, y + 3);
|
|
1367
|
-
});
|
|
1368
|
-
if (sel < 0) { $('#map-info').textContent = 'Рядом — похожий бизнес. Цвет — найденная группа. Нажмите на акцию, чтобы увидеть её самые выраженные свойства.'; return; }
|
|
1369
|
-
const prof = d.profiles[f.symbols[sel]];
|
|
1370
|
-
const top = (prof || []).map((v, k) => [v, k]).sort((a, b) => b[0] - a[0]).filter(([v]) => v >= 0.3).slice(0, 5);
|
|
1371
|
-
$('#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(', ')}` : ' · нет ярко выраженных свойств'}`;
|
|
1372
|
-
}
|
|
1373
|
-
async function showMapSec(sec) {
|
|
1374
|
-
document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
|
|
1375
|
-
saveNavPatch({ mapSec: sec });
|
|
1376
|
-
if (sec === 'journal') {
|
|
1377
|
-
$('#map-sec').hidden = true;
|
|
1378
|
-
$('#journal-box').hidden = false;
|
|
1379
|
-
await loadJournal();
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
$('#map-sec').hidden = false;
|
|
1383
|
-
$('#journal-box').hidden = true;
|
|
1384
|
-
state.mapMode = sec;
|
|
1385
|
-
$('#map-title-lbl').textContent = sec === 'text' ? 'Карта рынка · по описаниям бизнеса' : 'Карта рынка · по совместному поведению';
|
|
1386
|
-
if (sec === 'text' && !state.profiles) await loadProfilesData();
|
|
1387
|
-
drawMap();
|
|
1388
|
-
}
|
|
1389
|
-
$('#map-nav').addEventListener('click', (e) => {
|
|
1390
|
-
const b = e.target.closest('[data-msec]');
|
|
1391
|
-
if (b) showMapSec(b.dataset.msec);
|
|
1392
|
-
});
|
|
1393
|
-
$('#btn-profiles').addEventListener('click', async () => {
|
|
1394
|
-
try {
|
|
1395
|
-
const r = await post('/api/profiles/refresh');
|
|
1396
|
-
if (r.started === false) return toast('Описания уже составляются — подождите');
|
|
1397
|
-
state.profilesBusy = true;
|
|
1398
|
-
$('#btn-profiles').disabled = true;
|
|
1399
|
-
$('#profiles-msg').textContent = 'Составляем описания компаний…';
|
|
1400
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
// ======================================================================
|
|
1404
|
-
// ЖУРНАЛ И НАСТРОЙКИ
|
|
1013
|
+
// НАСТРОЙКИ (ИЗДЕРЖКИ И НОВОСТИ)
|
|
1405
1014
|
// ======================================================================
|
|
1406
|
-
const VERDICT = { confirmed: ['подтвердилась', 'ok'], rejected: ['не подтвердилась', 'no'], unclear: ['неясно', 'mid'] };
|
|
1407
|
-
async function loadJournal() {
|
|
1408
|
-
const list = await api('/api/journal');
|
|
1409
|
-
state.journalCount = list.length;
|
|
1410
|
-
updateTabBadges();
|
|
1411
|
-
if ($('#mnav-journal')) $('#mnav-journal').textContent = list.length ? `Журнал гипотез (${list.length})` : 'Журнал гипотез';
|
|
1412
|
-
$('#journal').innerHTML = list.length
|
|
1413
|
-
? `<p class="muted small">Память Стратега: он читает журнал в начале каждого разговора. Неверную запись можно удалить.</p><table><thead><tr><th>Гипотеза</th><th>Результат</th><th>Вывод</th><th></th></tr></thead><tbody>${list.map((j) => {
|
|
1414
|
-
const [vl, vc] = VERDICT[j.verdict] || [j.verdict, ''];
|
|
1415
|
-
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>`;
|
|
1416
|
-
}).join('')}</tbody></table>`
|
|
1417
|
-
: '<p class="muted">Журнал пуст. Попросите Стратега провести эксперимент — он запишет вывод сюда.</p>';
|
|
1418
|
-
$('#journal').querySelectorAll('[data-jdel]').forEach((b) => b.addEventListener('click', async () => {
|
|
1419
|
-
if (confirm('Удалить запись? Стратег её «забудет».')) await api(`/api/journal/${b.dataset.jdel}`, { method: 'DELETE' });
|
|
1420
|
-
}));
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// Логические группы настроек по смыслу:
|
|
1424
|
-
// g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
|
|
1425
1015
|
const GROUPS = [
|
|
1426
|
-
['
|
|
1427
|
-
['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
|
|
1428
|
-
['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
|
|
1016
|
+
['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
|
|
1429
1017
|
];
|
|
1430
1018
|
function buildForm() {
|
|
1431
1019
|
const field = (p) => {
|
|
@@ -1436,45 +1024,13 @@ function buildForm() {
|
|
|
1436
1024
|
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>`;
|
|
1437
1025
|
};
|
|
1438
1026
|
const shown = state.config.params.filter((p) => !p.hidden);
|
|
1439
|
-
const
|
|
1440
|
-
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>`;
|
|
1027
|
+
const curSec = document.querySelector('#main-nav [data-sec].active')?.dataset.sec || 'brokers';
|
|
1441
1028
|
$('#params').innerHTML = GROUPS.map(([name, keys], i) => {
|
|
1442
|
-
const items = keys.
|
|
1443
|
-
const
|
|
1444
|
-
return items.length
|
|
1029
|
+
const items = keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean);
|
|
1030
|
+
const secKey = `g${i}`;
|
|
1031
|
+
return items.length ? `<section class="ssec" data-sec="${secKey}"${curSec === secKey ? '' : ' hidden'}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}</div></section>` : '';
|
|
1445
1032
|
}).join('');
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1448
|
-
function showSettingsSec(sec) {
|
|
1449
|
-
$('#s-search').value = '';
|
|
1450
|
-
document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
|
|
1451
|
-
document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
1452
|
-
document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
1453
|
-
saveNavPatch({ settingsSec: sec });
|
|
1454
|
-
if (sec === 'cloud') renderCloudPane().catch(() => {});
|
|
1455
|
-
}
|
|
1456
|
-
document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
|
|
1457
|
-
const b = e.target.closest('[data-sec]');
|
|
1458
|
-
if (b) showSettingsSec(b.dataset.sec);
|
|
1459
|
-
});
|
|
1460
|
-
$('#s-search').addEventListener('input', (e) => {
|
|
1461
|
-
const q = e.target.value.trim().toLowerCase();
|
|
1462
|
-
if (!q) {
|
|
1463
|
-
const cur = document.querySelector('#tab-settings .snav [data-sec].active')?.dataset.sec || 'g0';
|
|
1464
|
-
showSettingsSec(cur);
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
document.querySelectorAll('#tab-settings .ssec').forEach((sec) => {
|
|
1468
|
-
if (!sec.dataset.sec.startsWith('g')) { sec.hidden = true; return; }
|
|
1469
|
-
let vis = 0;
|
|
1470
|
-
sec.querySelectorAll('.srow').forEach((r) => {
|
|
1471
|
-
const ok = r.textContent.toLowerCase().includes(q);
|
|
1472
|
-
r.hidden = !ok;
|
|
1473
|
-
if (ok) vis++;
|
|
1474
|
-
});
|
|
1475
|
-
sec.hidden = !vis;
|
|
1476
|
-
});
|
|
1477
|
-
});
|
|
1033
|
+
}
|
|
1478
1034
|
function fillForm(s) {
|
|
1479
1035
|
for (const el of $('#params').elements) {
|
|
1480
1036
|
if (!el.name || el === document.activeElement) continue;
|
|
@@ -1510,38 +1066,6 @@ $('#params').addEventListener('click', async (e) => {
|
|
|
1510
1066
|
await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
|
|
1511
1067
|
fillForm(state.settings);
|
|
1512
1068
|
});
|
|
1513
|
-
$('#btn-reset').addEventListener('click', async () => {
|
|
1514
|
-
if (!confirm('Вернуть все настройки по умолчанию?')) return;
|
|
1515
|
-
state.settings = await post('/api/settings/reset');
|
|
1516
|
-
fillForm(state.settings);
|
|
1517
|
-
loadHistory().catch(() => {});
|
|
1518
|
-
});
|
|
1519
|
-
|
|
1520
|
-
// ---------- история изменений настроек ----------
|
|
1521
|
-
async function loadHistory() {
|
|
1522
|
-
const list = await api('/api/settings/history');
|
|
1523
|
-
$('#snav-hist').textContent = list.length ? `История изменений (${list.length})` : 'История изменений';
|
|
1524
|
-
$('#history').innerHTML = list.length
|
|
1525
|
-
? `<table><thead><tr><th>Дата</th><th>Кто</th><th>Что изменилось</th><th></th></tr></thead><tbody>${list.map((h) => `<tr>
|
|
1526
|
-
<td class="muted small">${fmtTs(h.ts)}</td>
|
|
1527
|
-
<td>${h.source === 'ai' ? 'Стратег' : 'вы'}</td>
|
|
1528
|
-
<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>
|
|
1529
|
-
<td><button class="icon" data-revert="${h.id}" title="Вернуть значения «было»">Вернуть</button></td>
|
|
1530
|
-
</tr>`).join('')}</tbody></table>`
|
|
1531
|
-
: '<p class="muted">Изменений ещё не было.</p>';
|
|
1532
|
-
}
|
|
1533
|
-
$('#history').addEventListener('click', async (e) => {
|
|
1534
|
-
const b = e.target.closest('[data-revert]');
|
|
1535
|
-
if (!b) return;
|
|
1536
|
-
try {
|
|
1537
|
-
const r = await post(`/api/settings/history/${b.dataset.revert}/revert`);
|
|
1538
|
-
if (r.errors?.length) toast(r.errors.join('; '), 'error');
|
|
1539
|
-
else if (!Object.keys(r.changed).length) toast('Настройки уже в этом состоянии — откатывать нечего');
|
|
1540
|
-
state.settings = r.settings;
|
|
1541
|
-
fillForm(state.settings);
|
|
1542
|
-
await loadHistory();
|
|
1543
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
1544
|
-
});
|
|
1545
1069
|
|
|
1546
1070
|
// ======================================================================
|
|
1547
1071
|
// ЧАТ СО СТРАТЕГОМ
|
|
@@ -1551,6 +1075,7 @@ const TOOL_LABELS = {
|
|
|
1551
1075
|
run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
|
|
1552
1076
|
journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
|
|
1553
1077
|
set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
|
|
1078
|
+
broker_onboarding_guide: 'Смотрит инструкцию по брокеру', discover_broker_accounts: 'Ищет субсчета по токену', connect_broker_account: 'Подключение брокерского счёта',
|
|
1554
1079
|
};
|
|
1555
1080
|
const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
|
|
1556
1081
|
const feed = $('#feed');
|
|
@@ -1577,6 +1102,8 @@ function argsPreview(name, a) {
|
|
|
1577
1102
|
if (name === 'compare_runs') return `№${a.a} и №${a.b}`;
|
|
1578
1103
|
if (name === 'journal_add') return a.hypothesis || '';
|
|
1579
1104
|
if (name === 'market_neighbors') return a.symbol || '';
|
|
1105
|
+
if (name === 'broker_onboarding_guide') return BROKER_LABELS[a.broker] || a.broker || '';
|
|
1106
|
+
if (name === 'connect_broker_account') return `${BROKER_LABELS[a.broker] || a.broker || ''}${a.name ? ` · ${a.name}` : ''}${a.accountId ? ` (${a.accountId})` : ''}`;
|
|
1580
1107
|
return '';
|
|
1581
1108
|
}
|
|
1582
1109
|
|
|
@@ -1588,7 +1115,7 @@ function actHtml(m, call) {
|
|
|
1588
1115
|
const w = state.chat.state?.waiting;
|
|
1589
1116
|
const waiting = status === 'pending' && w && w.msgId === m.id && w.callId === call.id;
|
|
1590
1117
|
const prev = t.preview || argsPreview(call.function.name, args);
|
|
1591
|
-
const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop'].includes(call.function.name);
|
|
1118
|
+
const big = ['run_model_research', 'set_settings', 'paper_start', 'paper_stop', 'connect_broker_account'].includes(call.function.name);
|
|
1592
1119
|
if (!big && !waiting) {
|
|
1593
1120
|
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>`;
|
|
1594
1121
|
}
|
|
@@ -2354,10 +1881,9 @@ let brokerPollBusy = false;
|
|
|
2354
1881
|
let lastBrokerPollTs = 0;
|
|
2355
1882
|
|
|
2356
1883
|
async function pollActiveBrokerAccount(force = false) {
|
|
2357
|
-
if (brokerPollBusy || document.hidden) return;
|
|
2358
|
-
const
|
|
2359
|
-
|
|
2360
|
-
if (nowPageHidden || brokersSecHidden) return;
|
|
1884
|
+
if (brokerPollBusy || document.hidden || editingBrokerId) return;
|
|
1885
|
+
const brokersSecHidden = $('#brokers-trade-box')?.hidden;
|
|
1886
|
+
if (brokersSecHidden) return;
|
|
2361
1887
|
const accs = state.brokers?.accounts || [];
|
|
2362
1888
|
if (!accs.length) return;
|
|
2363
1889
|
const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
|
|
@@ -2393,58 +1919,23 @@ async function loadBrokers() {
|
|
|
2393
1919
|
|
|
2394
1920
|
function renderBrokers() {
|
|
2395
1921
|
const accs = state.brokers?.accounts || [];
|
|
2396
|
-
const navBtn = $('#snav-brokers');
|
|
2397
|
-
if (navBtn) navBtn.textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
2398
1922
|
if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
2399
1923
|
updateTabBadges();
|
|
2400
1924
|
|
|
2401
|
-
//
|
|
2402
|
-
const
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
}
|
|
2407
|
-
listEl.innerHTML = accs.length ? accs.map((a) => {
|
|
2408
|
-
const ls = a.lastSync;
|
|
2409
|
-
const isCfgOpen = a.id === openBrokerCfgId;
|
|
2410
|
-
return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2411
|
-
<div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
|
|
2412
|
-
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2413
|
-
<span class="acc-caret">▶</span>
|
|
2414
|
-
<b>${esc(a.name)}</b>
|
|
2415
|
-
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2416
|
-
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2417
|
-
${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
|
|
2418
|
-
</div>
|
|
2419
|
-
<div class="row-gap" style="align-items:center">
|
|
2420
|
-
${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
|
|
2421
|
-
<button type="button" data-bact="sync">Проверить связь</button>
|
|
2422
|
-
<button type="button" data-bact="edit" class="icon">Изменить</button>
|
|
2423
|
-
<button type="button" data-bact="del" class="icon">Удалить</button>
|
|
2424
|
-
</div>
|
|
2425
|
-
</div>
|
|
2426
|
-
<div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
|
|
2427
|
-
<span class="k">Брокер и субсчёт:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · ID <code>${esc(a.accountId || ls?.accountId || 'авто')}</code> (токен: ${a.hasToken ? '<span class="pos">задан ✓</span>' : '<span class="nokey">не задан</span>'})</span>
|
|
2428
|
-
<span class="k">Режим управления:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-расписание: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
|
|
2429
|
-
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
|
|
2430
|
-
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2431
|
-
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2432
|
-
<span class="k">Предохранители:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b></span>
|
|
2433
|
-
${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
|
|
2434
|
-
${ls ? `<span class="k">Синхронизация (${fmtTs(ls.at)}):</span><span class="v">${ls.ok ? `<span class="pos">на счёте <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽, бумаг ${ls.positions?.length || 0})</span>` : `<span class="neg">${esc(ls.error)}</span>`}</span>` : ''}
|
|
2435
|
-
</div>
|
|
2436
|
-
</div>`;
|
|
2437
|
-
}).join('') : '<p class="muted small">Подключённых брокерских счетов пока нет.</p>';
|
|
1925
|
+
// Перед перерисовкой карточек возвращаем форму настроек в верхний слот, чтобы она не уничтожилась при замене innerHTML
|
|
1926
|
+
const formBox = $('#broker-add-box');
|
|
1927
|
+
const formSlot = $('#broker-form-slot');
|
|
1928
|
+
if (formBox && formSlot && formBox.parentElement !== formSlot) {
|
|
1929
|
+
formSlot.appendChild(formBox);
|
|
2438
1930
|
}
|
|
2439
1931
|
|
|
2440
|
-
// 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
2441
1932
|
const tradeEl = $('#brokers-trade');
|
|
2442
1933
|
if (!tradeEl) return;
|
|
2443
1934
|
if (!accs.length) {
|
|
2444
|
-
tradeEl.innerHTML = '
|
|
1935
|
+
tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
|
|
2445
1936
|
return;
|
|
2446
1937
|
}
|
|
2447
|
-
if (
|
|
1938
|
+
if (openBrokerId === undefined) {
|
|
2448
1939
|
openBrokerId = accs[0].id;
|
|
2449
1940
|
}
|
|
2450
1941
|
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
@@ -2500,7 +1991,12 @@ function renderBrokers() {
|
|
|
2500
1991
|
<div class="brk-alloc-legend">${allocSegments.map((s) => `<span><i style="background:${s.color}"></i><b>${esc(s.label)}</b> — ${fmtNum(s.valRub, 0)} ₽ (${s.pct}%)</span>`).join('')}</div>
|
|
2501
1992
|
</div>` : '';
|
|
2502
1993
|
|
|
2503
|
-
// Считаем
|
|
1994
|
+
// Считаем стартовый лимит и реинвестированную прибыль сверх лимита
|
|
1995
|
+
const initBaseRub = a.initialTotalRub > 0 ? a.initialTotalRub : (a.equityHistory?.[0]?.totalRub > 0 ? a.equityHistory[0].totalRub : (ls?.totalRub || a.maxCapitalRub || 100000));
|
|
1996
|
+
const reinvProfitRub = ls?.totalRub > 0 ? Math.max(0, Math.round(ls.totalRub - Math.max(initBaseRub, a.maxCapitalRub || 100000))) : 0;
|
|
1997
|
+
const capSpecHtml = reinvProfitRub > 0
|
|
1998
|
+
? `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> <span class="pos">(+ реинвестировано прибыли +${fmtNum(reinvProfitRub, 0)} ₽ → в работе <b>${fmtNum((a.maxCapitalRub || 0) + reinvProfitRub, 0)} ₽</b>)</span>`
|
|
1999
|
+
: `базовый лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> (+ реинвестирование прибыли)`;
|
|
2504
2000
|
let strategyRub = 0, protectedRub = 0;
|
|
2505
2001
|
for (const p of ls?.positions || []) {
|
|
2506
2002
|
const aq = algoQtyOf(p);
|
|
@@ -2511,8 +2007,16 @@ function renderBrokers() {
|
|
|
2511
2007
|
const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
|
|
2512
2008
|
|
|
2513
2009
|
// Вкладка 1: Активы портфеля
|
|
2010
|
+
const fmtOwnership = (p) => {
|
|
2011
|
+
if (!(p.issueSize > 0) || !(p.qty > 0)) return '<span class="muted small">—</span>';
|
|
2012
|
+
const pct = (p.qty / p.issueSize) * 100;
|
|
2013
|
+
const dec = pct >= 0.01 ? 4 : pct >= 0.0001 ? 6 : 8;
|
|
2014
|
+
const pctStr = pct.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: dec });
|
|
2015
|
+
const tip = `Доля владения в компании ${p.name || p.symbol}: ${fmtNum(p.qty, 0)} из ${fmtNum(p.issueSize, 0)} акций всего выпуска на Мосбирже`;
|
|
2016
|
+
return `<span title="${esc(tip)}" style="white-space:nowrap"><b>${pctStr}%</b></span>`;
|
|
2017
|
+
};
|
|
2514
2018
|
const posHtml = ls?.positions?.length
|
|
2515
|
-
? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
|
|
2019
|
+
? `${allocBarHtml}<table><thead><tr><th>Актив</th><th>Тип</th><th class="num">Кол-во</th><th class="num" title="Доля ваших акций в общем выпуске ценных бумаг компании-эмитента на Мосбирже (ISSUESIZE)">Доля владения</th><th class="num">Средняя → Текущая</th><th class="num">Стоимость (доля)</th><th class="num">Финрезультат</th><th>Статус и прогноз</th></tr></thead><tbody>${ls.positions.map((p) => {
|
|
2516
2020
|
const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
|
|
2517
2021
|
const fcRow = fcRowMap?.get(p.symbol);
|
|
2518
2022
|
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
@@ -2520,6 +2024,7 @@ function renderBrokers() {
|
|
|
2520
2024
|
<td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
|
|
2521
2025
|
<td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
|
|
2522
2026
|
<td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
|
|
2027
|
+
<td class="num">${fmtOwnership(p)}</td>
|
|
2523
2028
|
<td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
|
|
2524
2029
|
<td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
|
|
2525
2030
|
<td class="num ${cls(p.pnlRub)}"><b>${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽</b>${p.pnlPct ? ` <span class="small">(${p.pnlPct >= 0 ? '+' : ''}${fmtNum(p.pnlPct, 1)}%)</span>` : ''}</td>
|
|
@@ -2530,7 +2035,19 @@ function renderBrokers() {
|
|
|
2530
2035
|
|
|
2531
2036
|
// Вкладка 2: Торговля и заявки стратегии
|
|
2532
2037
|
const activeOrdHtml = ls?.activeOrders?.length
|
|
2533
|
-
? `<div class="stab-box" style="margin-bottom:10px"
|
|
2038
|
+
? `<div class="stab-box" style="margin-bottom:10px">
|
|
2039
|
+
<div class="stab-head">
|
|
2040
|
+
<b>Активные заявки в стакане брокера (${ls.activeOrders.length})</b>
|
|
2041
|
+
<span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
|
|
2042
|
+
</div>
|
|
2043
|
+
<table><thead><tr><th>Бумага</th><th>Направление</th><th class="num">Лотов (исп./всего)</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${ls.activeOrders.map((o) => `<tr>
|
|
2044
|
+
<td><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></td>
|
|
2045
|
+
<td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
|
|
2046
|
+
<td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
|
|
2047
|
+
<td class="num">${fmtNum(o.price, 4)} ₽</td>
|
|
2048
|
+
<td class="num">${fmtNum(o.amountRub, 0)} ₽</td>
|
|
2049
|
+
</tr>`).join('')}</tbody></table>
|
|
2050
|
+
</div>`
|
|
2534
2051
|
: '';
|
|
2535
2052
|
let batchHtml = '';
|
|
2536
2053
|
if (pb) {
|
|
@@ -2570,7 +2087,7 @@ function renderBrokers() {
|
|
|
2570
2087
|
const rows = [];
|
|
2571
2088
|
for (const [, dayOps] of byDay) {
|
|
2572
2089
|
let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
|
|
2573
|
-
const symQty = new Map(), symCash = new Map();
|
|
2090
|
+
const symQty = new Map(), symCash = new Map(), symBuyRub = new Map();
|
|
2574
2091
|
for (const op of dayOps) {
|
|
2575
2092
|
const pay = Number(op.paymentRub) || 0;
|
|
2576
2093
|
if (op.kind === 'buy') {
|
|
@@ -2579,6 +2096,7 @@ function renderBrokers() {
|
|
|
2579
2096
|
if (op.symbol) {
|
|
2580
2097
|
symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2581
2098
|
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2099
|
+
symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
|
|
2582
2100
|
}
|
|
2583
2101
|
} else if (op.kind === 'sell') {
|
|
2584
2102
|
tradesCnt++;
|
|
@@ -2596,19 +2114,22 @@ function renderBrokers() {
|
|
|
2596
2114
|
inOutRub += pay;
|
|
2597
2115
|
}
|
|
2598
2116
|
}
|
|
2599
|
-
// Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
|
|
2600
|
-
let closedTradePnl = 0, closedSyms = 0;
|
|
2117
|
+
// Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
|
|
2118
|
+
let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
|
|
2601
2119
|
for (const [sym, netQ] of symQty) {
|
|
2602
2120
|
if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
|
|
2603
2121
|
closedTradePnl += symCash.get(sym);
|
|
2122
|
+
closedBaseRub += symBuyRub.get(sym) || 0;
|
|
2604
2123
|
closedSyms++;
|
|
2605
2124
|
}
|
|
2606
2125
|
}
|
|
2607
2126
|
if (closedSyms > 0) closedTradePnl += feeRub;
|
|
2127
|
+
const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
|
|
2608
2128
|
|
|
2609
2129
|
const summaryParts = [];
|
|
2610
2130
|
if (closedSyms > 0) {
|
|
2611
|
-
|
|
2131
|
+
const pctPart = closedTradePct !== null ? ` (${closedTradePct > 0 ? '+' : ''}${fmtNum(closedTradePct, 2)}%)` : '';
|
|
2132
|
+
summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽${pctPart}</b>`);
|
|
2612
2133
|
} else if (tradesCnt > 0) {
|
|
2613
2134
|
const tParts = [];
|
|
2614
2135
|
if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
|
|
@@ -2629,7 +2150,7 @@ function renderBrokers() {
|
|
|
2629
2150
|
for (const op of dayOps) {
|
|
2630
2151
|
rows.push(`<tr>
|
|
2631
2152
|
<td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
|
|
2632
|
-
<td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}"
|
|
2153
|
+
<td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}" title="${esc(op.title || op.kindLabel)}">${esc(op.kindLabel)}</span></td>
|
|
2633
2154
|
<td>${op.symbol ? `<b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span>` : '<span class="muted small">—</span>'}</td>
|
|
2634
2155
|
<td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2635
2156
|
<td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
|
|
@@ -2727,52 +2248,86 @@ function renderBrokers() {
|
|
|
2727
2248
|
${yearsHtml}`
|
|
2728
2249
|
: '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
|
|
2729
2250
|
|
|
2251
|
+
const isPaused = a.autoSchedule === false || a.mode === 'readonly';
|
|
2252
|
+
const isEditingThis = editingBrokerId === a.id;
|
|
2253
|
+
const collapsedMetricsHtml = !isOpen && ls?.ok
|
|
2254
|
+
? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b> · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b></span>`
|
|
2255
|
+
: '';
|
|
2256
|
+
|
|
2730
2257
|
return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2731
2258
|
<div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
2732
2259
|
<div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2733
2260
|
<span class="acc-caret">▶</span>
|
|
2734
2261
|
<b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
|
|
2735
|
-
<span class="tag ${
|
|
2262
|
+
<span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2736
2263
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2737
2264
|
${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
|
|
2265
|
+
${collapsedMetricsHtml}
|
|
2738
2266
|
</div>
|
|
2739
2267
|
<div class="row-gap" style="align-items:center">
|
|
2740
|
-
|
|
2741
|
-
|
|
2268
|
+
<button type="button" class="brk-toggle ${!isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
|
|
2269
|
+
${isOpen && ls?.positions?.length ? `<button type="button" class="brk-toggle ${lMode === 'isolate' && Object.keys(baseMap).length ? 'on' : ''}" data-btrade="freeze" data-bid="${esc(a.id)}" title="${lMode === 'isolate' && Object.keys(baseMap).length ? 'Жёсткая защита включена (режим «Изолированный карман»): старые бумаги на счёте неприкосновенны. Нажмите, чтобы переключить в «Плавный переход».' : 'Нажмите, чтобы зафиксировать все текущие бумаги на счёте как неприкосновенные (включить режим «Изолированный карман»).'}">${lMode === 'isolate' && Object.keys(baseMap).length ? '🔒 Защита бумаг: ВКЛ' : '🔓 Защита бумаг: ВЫКЛ'}</button>` : ''}
|
|
2270
|
+
${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
|
|
2742
2271
|
</div>
|
|
2743
2272
|
</div>
|
|
2744
2273
|
<div ${isOpen ? '' : 'hidden'}>
|
|
2745
|
-
<div class="brk-
|
|
2746
|
-
|
|
2747
|
-
<
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
<
|
|
2763
|
-
|
|
2274
|
+
<div class="brk-cfg-mount" data-cfg-mount="${esc(a.id)}" ${isEditingThis ? '' : 'hidden'} style="margin-top:12px"></div>
|
|
2275
|
+
<div class="brk-trade-view" ${isEditingThis ? 'hidden' : ''}>
|
|
2276
|
+
<div class="brk-specs">
|
|
2277
|
+
<span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
|
|
2278
|
+
<span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">на паузе</span>'}</b></span>
|
|
2279
|
+
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
|
|
2280
|
+
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2281
|
+
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2282
|
+
<span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
|
|
2283
|
+
</div>
|
|
2284
|
+
${ls?.ok ? `<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
2285
|
+
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2286
|
+
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2287
|
+
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
|
|
2288
|
+
<div class="kpi"><div class="v" style="white-space:nowrap">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
|
|
2289
|
+
</div>` : ''}
|
|
2290
|
+
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2291
|
+
<div class="brk-subtabs">
|
|
2292
|
+
<button type="button" data-btab="assets" data-bid="${esc(a.id)}" class="${curSubTab === 'assets' ? 'active' : ''}">📦 Активы портфеля (${ls?.positions?.length || 0})</button>
|
|
2293
|
+
<button type="button" data-btab="trade" data-bid="${esc(a.id)}" class="${curSubTab === 'trade' ? 'active' : ''}">⚡ Торговля и заявки${pb?.orders?.length ? ` (${pb.orders.length})` : ''}</button>
|
|
2294
|
+
<button type="button" data-btab="history" data-bid="${esc(a.id)}" class="${curSubTab === 'history' ? 'active' : ''}">📜 История счёта${lh?.totalCount ? ` (${lh.totalCount})` : ''}</button>
|
|
2295
|
+
</div>
|
|
2296
|
+
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2297
|
+
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
2298
|
+
<div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
|
|
2764
2299
|
</div>
|
|
2765
|
-
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2766
|
-
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
2767
|
-
<div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
|
|
2768
2300
|
</div>
|
|
2769
2301
|
</div>`;
|
|
2770
2302
|
}).join('');
|
|
2303
|
+
|
|
2304
|
+
// Если сейчас открыто редактирование конкретного счёта, монтируем форму прямо внутрь его карточки!
|
|
2305
|
+
if (editingBrokerId && formBox) {
|
|
2306
|
+
const mount = tradeEl.querySelector(`[data-cfg-mount="${CSS.escape(editingBrokerId)}"]`);
|
|
2307
|
+
if (mount) {
|
|
2308
|
+
mount.appendChild(formBox);
|
|
2309
|
+
formBox.hidden = false;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2771
2312
|
}
|
|
2772
2313
|
|
|
2773
|
-
$('#btn-
|
|
2774
|
-
|
|
2775
|
-
|
|
2314
|
+
$('#btn-broker-add')?.addEventListener('click', () => {
|
|
2315
|
+
const formBox = $('#broker-add-box');
|
|
2316
|
+
const formSlot = $('#broker-form-slot');
|
|
2317
|
+
if (editingBrokerId === null && formBox && !formBox.hidden && formBox.parentElement === formSlot) {
|
|
2318
|
+
stopBrokerEdit();
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
stopBrokerEdit();
|
|
2322
|
+
if (formBox && formSlot) {
|
|
2323
|
+
formSlot.appendChild(formBox);
|
|
2324
|
+
formBox.hidden = false;
|
|
2325
|
+
syncBrokerGuideUi();
|
|
2326
|
+
updateBrokerSaveBtnState();
|
|
2327
|
+
if (brokerForm?.elements?.broker?.value === 'tbank' && brokerForm.elements.token.value.trim()) {
|
|
2328
|
+
runBrokerDiscover({ silent: true });
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2776
2331
|
});
|
|
2777
2332
|
|
|
2778
2333
|
const brokerForm = $('#broker-form');
|
|
@@ -2787,7 +2342,6 @@ function getBrokerFormSnap() {
|
|
|
2787
2342
|
token: f.token.value.trim(),
|
|
2788
2343
|
mode: f.mode.value,
|
|
2789
2344
|
legacyMode: f.legacyMode?.value || 'isolate',
|
|
2790
|
-
baseRunId: f.baseRunId.value || '',
|
|
2791
2345
|
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
2792
2346
|
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
2793
2347
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
@@ -2801,15 +2355,49 @@ function syncBrokerGuideUi() {
|
|
|
2801
2355
|
const f = brokerForm?.elements;
|
|
2802
2356
|
if (!f) return;
|
|
2803
2357
|
const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
|
|
2358
|
+
const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
|
|
2804
2359
|
if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
|
|
2805
|
-
if ($('#broker-token-input')) $('#broker-token-input').placeholder =
|
|
2360
|
+
if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
|
|
2806
2361
|
if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
|
|
2807
2362
|
if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
|
|
2808
2363
|
if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
|
|
2364
|
+
if (!g.canDiscover && $('#broker-discovered')) {
|
|
2365
|
+
$('#broker-discovered').hidden = true;
|
|
2366
|
+
$('#broker-discovered').innerHTML = '';
|
|
2367
|
+
}
|
|
2809
2368
|
if ($('#broker-guide-sum')) $('#broker-guide-sum').textContent = g.summary;
|
|
2810
2369
|
if ($('#broker-guide-body')) $('#broker-guide-body').innerHTML = g.html;
|
|
2370
|
+
if ($('#btn-broker-ai-help')) $('#btn-broker-ai-help').textContent = `🤖 Помочь с подключением (${brkName})`;
|
|
2811
2371
|
}
|
|
2812
2372
|
|
|
2373
|
+
$('#btn-broker-ai-help')?.addEventListener('click', async () => {
|
|
2374
|
+
const f = brokerForm?.elements;
|
|
2375
|
+
if (!f) return;
|
|
2376
|
+
const brk = f.broker.value || 'tbank';
|
|
2377
|
+
const brkName = BROKER_LABELS[brk] || brk;
|
|
2378
|
+
const tok = f.token.value.trim();
|
|
2379
|
+
const accId = f.accountId.value.trim();
|
|
2380
|
+
const cap = Number(f.maxCapitalRub.value) || 100000;
|
|
2381
|
+
let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
|
|
2382
|
+
if (tok && accId) {
|
|
2383
|
+
promptText += ` Я уже указал в форме токен "${tok}" и номер счёта "${accId}" — проверь и подключи этот счёт.`;
|
|
2384
|
+
} else if (tok) {
|
|
2385
|
+
promptText += ` Я уже вставил в форму токен "${tok}" — найди мои субсчета по этому токену и помоги подключить подходящий счёт.`;
|
|
2386
|
+
} else if (accId) {
|
|
2387
|
+
promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
|
|
2388
|
+
} else {
|
|
2389
|
+
promptText += ` Подскажи, как быстро открыть счёт (если его ещё нет), как включить маржинальную торговлю и выпустить токен API, либо подключи счёт вместе со мной.`;
|
|
2390
|
+
}
|
|
2391
|
+
if (state.chat.state?.mode === 'discuss' && state.chat.state?.tools !== false) {
|
|
2392
|
+
try {
|
|
2393
|
+
state.chat.state = await post('/api/chat/mode', { mode: 'advisor' });
|
|
2394
|
+
renderChatState();
|
|
2395
|
+
} catch { /* ignore */ }
|
|
2396
|
+
}
|
|
2397
|
+
toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
|
|
2398
|
+
await sendChat(promptText);
|
|
2399
|
+
});
|
|
2400
|
+
|
|
2813
2401
|
function updateBrokerSaveBtnState() {
|
|
2814
2402
|
const btn = $('#broker-save-btn');
|
|
2815
2403
|
if (!btn) return;
|
|
@@ -2834,88 +2422,137 @@ brokerForm.addEventListener('change', () => {
|
|
|
2834
2422
|
updateBrokerSaveBtnState();
|
|
2835
2423
|
});
|
|
2836
2424
|
|
|
2425
|
+
let brokerDiscoverTimer = null;
|
|
2426
|
+
|
|
2427
|
+
async function runBrokerDiscover({ silent = false } = {}) {
|
|
2428
|
+
const btn = $('#btn-broker-discover');
|
|
2429
|
+
const box = $('#broker-discovered');
|
|
2430
|
+
const f = brokerForm?.elements;
|
|
2431
|
+
if (!f || !box) return;
|
|
2432
|
+
if (f.broker.value !== 'tbank') {
|
|
2433
|
+
box.hidden = true;
|
|
2434
|
+
box.innerHTML = '';
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
const tok = f.token.value.trim();
|
|
2438
|
+
if (!tok && !editingBrokerId) {
|
|
2439
|
+
box.hidden = true;
|
|
2440
|
+
box.innerHTML = '';
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
if (btn) btn.disabled = true;
|
|
2444
|
+
const old = btn ? btn.textContent : '';
|
|
2445
|
+
if (btn) btn.textContent = 'Ищем счета…';
|
|
2446
|
+
try {
|
|
2447
|
+
const res = await post('/api/brokers/discover', {
|
|
2448
|
+
id: editingBrokerId || undefined,
|
|
2449
|
+
broker: f.broker.value,
|
|
2450
|
+
token: tok,
|
|
2451
|
+
sandbox: f.sandbox.checked,
|
|
2452
|
+
});
|
|
2453
|
+
const list = res.accounts || [];
|
|
2454
|
+
const curAccId = f.accountId.value.trim();
|
|
2455
|
+
if (!list.length) {
|
|
2456
|
+
box.hidden = false;
|
|
2457
|
+
box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
|
|
2458
|
+
} else {
|
|
2459
|
+
box.hidden = false;
|
|
2460
|
+
box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => {
|
|
2461
|
+
const isSelected = curAccId && a.id === curAccId;
|
|
2462
|
+
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid ${isSelected ? 'var(--accent)' : 'var(--border)'};border-radius:6px;background:var(--bg1)">
|
|
2463
|
+
<div>
|
|
2464
|
+
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
|
|
2465
|
+
<div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
|
|
2466
|
+
</div>
|
|
2467
|
+
<button type="button" class="${isSelected ? '' : 'primary '}small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">${isSelected ? '✓ Выбран' : 'Выбрать этот счёт'}</button>
|
|
2468
|
+
</div>`;
|
|
2469
|
+
}).join('');
|
|
2470
|
+
}
|
|
2471
|
+
} catch (err) {
|
|
2472
|
+
if (!silent) toast(err.message, 'error');
|
|
2473
|
+
} finally {
|
|
2474
|
+
if (btn) {
|
|
2475
|
+
btn.disabled = false;
|
|
2476
|
+
btn.textContent = old || '🔍 Найти мои счета по токену';
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2837
2481
|
function startBrokerEdit(acc) {
|
|
2838
2482
|
editingBrokerId = acc.id;
|
|
2483
|
+
openBrokerId = acc.id;
|
|
2839
2484
|
const f = brokerForm.elements;
|
|
2840
2485
|
f.id.value = acc.id;
|
|
2841
2486
|
f.name.value = acc.name;
|
|
2842
2487
|
f.broker.value = acc.broker;
|
|
2843
2488
|
f.accountId.value = acc.accountId || '';
|
|
2844
|
-
f.token.value = '';
|
|
2489
|
+
f.token.value = acc.token || '';
|
|
2845
2490
|
f.mode.value = acc.mode || 'confirm';
|
|
2846
2491
|
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2847
|
-
if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
|
|
2848
|
-
const opt = document.createElement('option');
|
|
2849
|
-
opt.value = String(acc.baseRunId);
|
|
2850
|
-
opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
|
|
2851
|
-
f.baseRunId.appendChild(opt);
|
|
2852
|
-
}
|
|
2853
|
-
f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
|
|
2854
2492
|
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
2855
2493
|
f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
|
|
2856
2494
|
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2857
2495
|
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2858
2496
|
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
2859
2497
|
f.sandbox.checked = !!acc.sandbox;
|
|
2860
|
-
$('#broker-add-
|
|
2861
|
-
$('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
|
|
2498
|
+
$('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
|
|
2862
2499
|
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
2863
2500
|
$('#broker-cancel-btn').hidden = false;
|
|
2501
|
+
if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = false;
|
|
2864
2502
|
syncBrokerGuideUi();
|
|
2865
2503
|
brokerInitialSnap = getBrokerFormSnap();
|
|
2866
2504
|
updateBrokerSaveBtnState();
|
|
2505
|
+
renderBrokers();
|
|
2506
|
+
if (acc.broker === 'tbank' && (acc.token || acc.hasToken)) {
|
|
2507
|
+
runBrokerDiscover({ silent: true });
|
|
2508
|
+
}
|
|
2867
2509
|
}
|
|
2868
2510
|
|
|
2869
2511
|
function stopBrokerEdit() {
|
|
2512
|
+
const wasEditing = editingBrokerId !== null;
|
|
2870
2513
|
editingBrokerId = null;
|
|
2871
2514
|
brokerForm.reset();
|
|
2872
2515
|
brokerForm.elements.id.value = '';
|
|
2873
2516
|
if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
|
|
2874
2517
|
$('#broker-add-sum').textContent = 'Добавить брокерский счёт';
|
|
2875
2518
|
$('#broker-save-btn').textContent = 'Сохранить счёт';
|
|
2876
|
-
$('#broker-
|
|
2519
|
+
if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = true;
|
|
2520
|
+
const formBox = $('#broker-add-box');
|
|
2521
|
+
const formSlot = $('#broker-form-slot');
|
|
2522
|
+
if (formBox && formSlot) {
|
|
2523
|
+
formSlot.appendChild(formBox);
|
|
2524
|
+
formBox.hidden = true;
|
|
2525
|
+
}
|
|
2877
2526
|
syncBrokerGuideUi();
|
|
2878
2527
|
brokerInitialSnap = getBrokerFormSnap();
|
|
2879
2528
|
updateBrokerSaveBtnState();
|
|
2529
|
+
if (wasEditing) renderBrokers();
|
|
2880
2530
|
}
|
|
2881
2531
|
$('#broker-cancel-btn').addEventListener('click', stopBrokerEdit);
|
|
2532
|
+
$('#broker-delete-btn')?.addEventListener('click', async () => {
|
|
2533
|
+
if (!editingBrokerId) return;
|
|
2534
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId);
|
|
2535
|
+
if (!confirm(`Удалить брокерский счёт «${acc?.name || editingBrokerId}»?`)) return;
|
|
2536
|
+
try {
|
|
2537
|
+
const delId = editingBrokerId;
|
|
2538
|
+
stopBrokerEdit();
|
|
2539
|
+
state.brokers = await api(`/api/brokers/${encodeURIComponent(delId)}`, { method: 'DELETE' });
|
|
2540
|
+
renderBrokers();
|
|
2541
|
+
toast('Брокерский счёт удалён');
|
|
2542
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2543
|
+
});
|
|
2882
2544
|
syncBrokerGuideUi();
|
|
2883
2545
|
updateBrokerSaveBtnState();
|
|
2884
2546
|
|
|
2885
|
-
// Авто-поиск всех субсчетов в Т-Инвестициях по токену
|
|
2886
|
-
$('#btn-broker-discover')?.addEventListener('click',
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
const f = brokerForm
|
|
2890
|
-
|
|
2891
|
-
const
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
const res = await post('/api/brokers/discover', {
|
|
2895
|
-
id: editingBrokerId || undefined,
|
|
2896
|
-
broker: f.broker.value,
|
|
2897
|
-
token: f.token.value,
|
|
2898
|
-
sandbox: f.sandbox.checked,
|
|
2899
|
-
});
|
|
2900
|
-
const list = res.accounts || [];
|
|
2901
|
-
if (!list.length) {
|
|
2902
|
-
box.hidden = false;
|
|
2903
|
-
box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
|
|
2904
|
-
} else {
|
|
2905
|
-
box.hidden = false;
|
|
2906
|
-
box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg1)">
|
|
2907
|
-
<div>
|
|
2908
|
-
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
|
|
2909
|
-
<div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
|
|
2910
|
-
</div>
|
|
2911
|
-
<button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
|
|
2912
|
-
</div>`).join('');
|
|
2913
|
-
}
|
|
2914
|
-
} catch (err) {
|
|
2915
|
-
toast(err.message, 'error');
|
|
2916
|
-
} finally {
|
|
2917
|
-
btn.disabled = false;
|
|
2918
|
-
btn.textContent = old;
|
|
2547
|
+
// Авто-поиск всех субсчетов в Т-Инвестициях по токену (по кнопке, при открытии редактирования и автоматически при вводе/смене токена)
|
|
2548
|
+
$('#btn-broker-discover')?.addEventListener('click', () => runBrokerDiscover({ silent: false }));
|
|
2549
|
+
$('#broker-token-input')?.addEventListener('input', () => {
|
|
2550
|
+
clearTimeout(brokerDiscoverTimer);
|
|
2551
|
+
const f = brokerForm?.elements;
|
|
2552
|
+
if (!f || f.broker.value !== 'tbank') return;
|
|
2553
|
+
const tok = f.token.value.trim();
|
|
2554
|
+
if (tok.length >= 20) {
|
|
2555
|
+
brokerDiscoverTimer = setTimeout(() => runBrokerDiscover({ silent: true }), 350);
|
|
2919
2556
|
}
|
|
2920
2557
|
});
|
|
2921
2558
|
|
|
@@ -2927,6 +2564,13 @@ $('#broker-discovered')?.addEventListener('click', (e) => {
|
|
|
2927
2564
|
if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
|
|
2928
2565
|
f.name.value = `Т-Банк · ${b.dataset.pickName}`;
|
|
2929
2566
|
}
|
|
2567
|
+
$('#broker-discovered').querySelectorAll('button[data-pick-acc]').forEach((btn) => {
|
|
2568
|
+
const sel = btn.dataset.pickAcc === b.dataset.pickAcc;
|
|
2569
|
+
btn.classList.toggle('primary', !sel);
|
|
2570
|
+
btn.textContent = sel ? '✓ Выбран' : 'Выбрать этот счёт';
|
|
2571
|
+
const row = btn.closest('div[style]');
|
|
2572
|
+
if (row) row.style.borderColor = sel ? 'var(--accent)' : 'var(--border)';
|
|
2573
|
+
});
|
|
2930
2574
|
updateBrokerSaveBtnState();
|
|
2931
2575
|
toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
|
|
2932
2576
|
});
|
|
@@ -2942,7 +2586,7 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2942
2586
|
token: f.token.value,
|
|
2943
2587
|
mode: f.mode.value,
|
|
2944
2588
|
legacyMode: f.legacyMode?.value || 'isolate',
|
|
2945
|
-
baseRunId:
|
|
2589
|
+
baseRunId: null,
|
|
2946
2590
|
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
2947
2591
|
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
2948
2592
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
@@ -2969,48 +2613,6 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2969
2613
|
} catch (err) { toast(err.message, 'error'); }
|
|
2970
2614
|
});
|
|
2971
2615
|
|
|
2972
|
-
$('#brokers-list').addEventListener('click', async (e) => {
|
|
2973
|
-
const b = e.target.closest('button[data-bact]');
|
|
2974
|
-
if (!b) {
|
|
2975
|
-
const fold = e.target.closest('[data-bcfg-fold]');
|
|
2976
|
-
if (fold) {
|
|
2977
|
-
const fid = fold.dataset.bcfgFold;
|
|
2978
|
-
openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
|
|
2979
|
-
saveNavPatch({ openBrokerCfgId });
|
|
2980
|
-
renderBrokers();
|
|
2981
|
-
}
|
|
2982
|
-
return;
|
|
2983
|
-
}
|
|
2984
|
-
const id = b.closest('[data-bid]').dataset.bid;
|
|
2985
|
-
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2986
|
-
if (!acc) return;
|
|
2987
|
-
if (b.dataset.bact === 'edit') {
|
|
2988
|
-
startBrokerEdit(acc);
|
|
2989
|
-
$('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2990
|
-
return;
|
|
2991
|
-
}
|
|
2992
|
-
if (b.dataset.bact === 'del') {
|
|
2993
|
-
if (!confirm(`Удалить брокерский счёт «${acc.name}»?`)) return;
|
|
2994
|
-
try { state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, { method: 'DELETE' }); renderBrokers(); } catch (err) { toast(err.message, 'error'); }
|
|
2995
|
-
return;
|
|
2996
|
-
}
|
|
2997
|
-
if (b.dataset.bact === 'sync') {
|
|
2998
|
-
b.disabled = true;
|
|
2999
|
-
const old = b.textContent;
|
|
3000
|
-
b.textContent = 'Проверяем…';
|
|
3001
|
-
try {
|
|
3002
|
-
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
3003
|
-
renderBrokers();
|
|
3004
|
-
toast(`Связь с «${acc.name}» проверена`);
|
|
3005
|
-
} catch (err) {
|
|
3006
|
-
await loadBrokers().catch(() => {});
|
|
3007
|
-
toast(err.message, 'error');
|
|
3008
|
-
} finally {
|
|
3009
|
-
if (b.isConnected) { b.disabled = false; b.textContent = old; }
|
|
3010
|
-
}
|
|
3011
|
-
}
|
|
3012
|
-
});
|
|
3013
|
-
|
|
3014
2616
|
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
3015
2617
|
const fltBtn = e.target.closest('button[data-hfilter]');
|
|
3016
2618
|
if (fltBtn) {
|
|
@@ -3074,6 +2676,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
3074
2676
|
const fold = e.target.closest('[data-brk-fold]');
|
|
3075
2677
|
if (fold) {
|
|
3076
2678
|
const fid = fold.dataset.brkFold;
|
|
2679
|
+
if (editingBrokerId === fid) stopBrokerEdit();
|
|
3077
2680
|
openBrokerId = openBrokerId === fid ? null : fid;
|
|
3078
2681
|
saveNavPatch({ openBrokerId });
|
|
3079
2682
|
renderBrokers();
|
|
@@ -3083,19 +2686,30 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
3083
2686
|
const id = b.dataset.bid;
|
|
3084
2687
|
const act = b.dataset.btrade;
|
|
3085
2688
|
if (act === 'cfg') {
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
startBrokerEdit(acc);
|
|
3091
|
-
setTimeout(() => $('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
|
2689
|
+
if (editingBrokerId === id) {
|
|
2690
|
+
stopBrokerEdit();
|
|
2691
|
+
} else {
|
|
2692
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2693
|
+
if (acc) startBrokerEdit(acc);
|
|
3092
2694
|
}
|
|
3093
2695
|
return;
|
|
3094
2696
|
}
|
|
3095
2697
|
b.disabled = true;
|
|
3096
2698
|
const old = b.textContent;
|
|
3097
2699
|
try {
|
|
3098
|
-
if (act === '
|
|
2700
|
+
if (act === 'pause') {
|
|
2701
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2702
|
+
if (acc) {
|
|
2703
|
+
const currentlyPaused = acc.autoSchedule === false || acc.mode === 'readonly';
|
|
2704
|
+
const nextAuto = currentlyPaused;
|
|
2705
|
+
const nextMode = currentlyPaused && acc.mode === 'readonly' ? 'confirm' : acc.mode;
|
|
2706
|
+
state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, {
|
|
2707
|
+
method: 'PUT',
|
|
2708
|
+
body: { id, autoSchedule: nextAuto, mode: nextMode },
|
|
2709
|
+
});
|
|
2710
|
+
toast(nextAuto ? `▶ Торговля на счёте «${acc.name}» возобновлена` : `⏸ Счёт «${acc.name}» поставлен на паузу`);
|
|
2711
|
+
}
|
|
2712
|
+
} else if (act === 'sync') {
|
|
3099
2713
|
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
|
|
3100
2714
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
3101
2715
|
toast('Портфель обновлён');
|
|
@@ -3123,6 +2737,12 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
3123
2737
|
toast('Заявки отправлены брокеру');
|
|
3124
2738
|
} else if (act === 'rej') {
|
|
3125
2739
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reject`);
|
|
2740
|
+
} else if (act === 'cancel-ord') {
|
|
2741
|
+
const orderId = b.dataset.oid || 'all';
|
|
2742
|
+
b.textContent = 'Снимаем…';
|
|
2743
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/cancel-order`, { orderId });
|
|
2744
|
+
await post(`/api/brokers/${encodeURIComponent(id)}/prepare`).then((st) => { state.brokers = st; }).catch(() => {});
|
|
2745
|
+
toast(orderId === 'all' ? 'Все активные заявки сняты со стакана' : 'Заявка снята со стакана');
|
|
3126
2746
|
}
|
|
3127
2747
|
renderBrokers();
|
|
3128
2748
|
} catch (err) {
|
|
@@ -3160,7 +2780,6 @@ function connectWs() {
|
|
|
3160
2780
|
const m = JSON.parse(ev.data);
|
|
3161
2781
|
switch (m.type) {
|
|
3162
2782
|
case 'hello':
|
|
3163
|
-
renderLive(m.live);
|
|
3164
2783
|
refreshForecastIfStale();
|
|
3165
2784
|
// если исследование идёт на сервере (запущено раньше или Стратегом) — показать кнопку отмены
|
|
3166
2785
|
if (!state.active && m.running?.length) {
|
|
@@ -3195,13 +2814,12 @@ function connectWs() {
|
|
|
3195
2814
|
case 'settings:update':
|
|
3196
2815
|
state.settings = m.settings;
|
|
3197
2816
|
fillForm(m.settings);
|
|
3198
|
-
loadHistory().catch(() => {});
|
|
3199
2817
|
if (m.source === 'ai') {
|
|
3200
2818
|
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'); } }
|
|
3201
2819
|
toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
|
|
3202
2820
|
}
|
|
3203
2821
|
break;
|
|
3204
|
-
case 'journal:update':
|
|
2822
|
+
case 'journal:update': break;
|
|
3205
2823
|
case 'research:start':
|
|
3206
2824
|
state.active = m.runId;
|
|
3207
2825
|
resetLiveTrain(m.runId);
|
|
@@ -3246,28 +2864,30 @@ function connectWs() {
|
|
|
3246
2864
|
loadRuns(m.runId);
|
|
3247
2865
|
break;
|
|
3248
2866
|
case 'profiles:progress':
|
|
3249
|
-
state.profilesBusy = true;
|
|
3250
|
-
$('#btn-profiles').disabled = true;
|
|
3251
|
-
$('#profiles-msg').textContent = `Составляем описания компаний: ${m.done} из ${m.total}`;
|
|
3252
|
-
updateTabBadges();
|
|
3253
|
-
break;
|
|
3254
2867
|
case 'profiles:done':
|
|
3255
|
-
state.profilesBusy = false;
|
|
3256
|
-
$('#btn-profiles').disabled = false;
|
|
3257
|
-
state.profiles = null; // перечитаем с сервера
|
|
3258
|
-
updateTabBadges();
|
|
3259
|
-
loadProfilesData().then(() => { if (!$('#tab-map').hidden) drawMap(); });
|
|
3260
|
-
toast(m.error ? `Описания компаний: ошибка — ${m.error}` : `Описания компаний готовы (${m.count})`, m.error ? 'error' : 'info');
|
|
3261
2868
|
break;
|
|
3262
|
-
case 'paper:step':
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
2869
|
+
case 'paper:step':
|
|
2870
|
+
case 'paper:status':
|
|
2871
|
+
case 'paper:started':
|
|
2872
|
+
case 'paper:stopped':
|
|
2873
|
+
case 'paper:fill':
|
|
3267
2874
|
break;
|
|
3268
|
-
|
|
3269
|
-
case 'news:update': if (!$('#tab-now').hidden) loadNews(); break;
|
|
2875
|
+
case 'news:update': loadNews(); break;
|
|
3270
2876
|
case 'brokers:update': state.brokers = m.state; renderBrokers(); break;
|
|
2877
|
+
case 'trading:pulse': {
|
|
2878
|
+
if (m.kind === 'broker_calc') {
|
|
2879
|
+
triggerTradingPulse(m.text || `Пересчёт заявок брокера…`, 3000);
|
|
2880
|
+
} else if (m.kind === 'broker_done') {
|
|
2881
|
+
triggerTradingPulse(`Заявки рассчитаны (${m.ordersCount || 0})`, 2200);
|
|
2882
|
+
} else if (m.kind === 'broker_sync') {
|
|
2883
|
+
triggerTradingPulse(`Портфель «${m.name || 'брокера'}» синхронизирован`, 1800);
|
|
2884
|
+
} else if (m.kind === 'train') {
|
|
2885
|
+
triggerTradingPulse(m.text || 'Расчёт прогноза…', 2500);
|
|
2886
|
+
} else if (m.kind === 'radar') {
|
|
2887
|
+
triggerTradingPulse(`Радар обновлён · сигналов: ${m.signals ?? 0}`, 1600);
|
|
2888
|
+
}
|
|
2889
|
+
break;
|
|
2890
|
+
}
|
|
3271
2891
|
case 'stability:start':
|
|
3272
2892
|
stabilityUi(m.runId);
|
|
3273
2893
|
setProgress(2, `Проверка устойчивости эксперимента №${m.runId}…`);
|
|
@@ -3288,9 +2908,6 @@ function connectWs() {
|
|
|
3288
2908
|
stabilityUi(null);
|
|
3289
2909
|
toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
|
|
3290
2910
|
break;
|
|
3291
|
-
case 'paper:status': renderLive(m.status); break;
|
|
3292
|
-
case 'paper:started': case 'paper:stopped': api('/api/live/status').then(renderLive); break;
|
|
3293
|
-
case 'paper:fill': toast(`Живая проверка: куплено ${m.position.symbol}`); break;
|
|
3294
2911
|
case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
|
|
3295
2912
|
}
|
|
3296
2913
|
};
|
|
@@ -3399,21 +3016,22 @@ async function renderCloudPane() {
|
|
|
3399
3016
|
}
|
|
3400
3017
|
|
|
3401
3018
|
const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
|
|
3402
|
-
? 'без ограничений
|
|
3019
|
+
? 'по согласованию / без ограничений'
|
|
3403
3020
|
: u.limits?.maxCapitalPerAccountRub > 0
|
|
3404
|
-
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на
|
|
3405
|
-
: '
|
|
3021
|
+
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
|
|
3022
|
+
: 'подписка не активна';
|
|
3406
3023
|
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
3407
3024
|
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
3408
|
-
: 'без ограничений (
|
|
3025
|
+
: (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
|
|
3409
3026
|
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
3027
|
+
updateTabBadges();
|
|
3410
3028
|
|
|
3411
3029
|
box.innerHTML = `
|
|
3412
3030
|
<div class="srow sblock">
|
|
3413
3031
|
<div class="stxt">
|
|
3414
3032
|
<div class="slbl">${esc(dispName)}${dispEmail ? ` · <span class="muted">${esc(dispEmail)}</span>` : ''} <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
|
|
3415
3033
|
<div class="hint">
|
|
3416
|
-
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> ·
|
|
3034
|
+
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b> · Целевая доходность: <b>${retLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
|
|
3417
3035
|
</div>
|
|
3418
3036
|
</div>
|
|
3419
3037
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -3425,10 +3043,9 @@ async function renderCloudPane() {
|
|
|
3425
3043
|
<div class="stxt">
|
|
3426
3044
|
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
3427
3045
|
<div class="hint">
|
|
3428
|
-
• <b
|
|
3429
|
-
• <b
|
|
3430
|
-
• <b
|
|
3431
|
-
• <b>«VIP» (по согласованию)</b> — без ограничений по счетам, капиталу и доходности (полный форсаж <code>H/2</code> + <code>Conviction Sizing</code>).
|
|
3046
|
+
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)», задавленная доходность до 60% годовых;<br>
|
|
3047
|
+
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)», задавленная доходность до 120% годовых;<br>
|
|
3048
|
+
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами, задавленная доходность до 240% годовых.
|
|
3432
3049
|
</div>
|
|
3433
3050
|
</div>
|
|
3434
3051
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -3436,7 +3053,7 @@ async function renderCloudPane() {
|
|
|
3436
3053
|
<button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
|
|
3437
3054
|
<button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
|
|
3438
3055
|
<button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
|
|
3439
|
-
<a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none"
|
|
3056
|
+
<a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Согласовать VIP →</a>
|
|
3440
3057
|
</div>
|
|
3441
3058
|
</div>
|
|
3442
3059
|
<div class="srow sblock">
|
|
@@ -3520,7 +3137,6 @@ function applyTheme(light) {
|
|
|
3520
3137
|
};
|
|
3521
3138
|
eqChart.applyOptions(chartOpts);
|
|
3522
3139
|
icChart.applyOptions(chartOpts);
|
|
3523
|
-
if (!$('#tab-map').hidden) drawMap();
|
|
3524
3140
|
}
|
|
3525
3141
|
$('#btn-theme')?.addEventListener('click', () => {
|
|
3526
3142
|
const next = !document.body.classList.contains('light');
|
|
@@ -3566,14 +3182,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
3566
3182
|
// ---------- старт и восстановление сохранённого места в интерфейсе ----------
|
|
3567
3183
|
(async function init() {
|
|
3568
3184
|
state.config = await api('/api/config');
|
|
3185
|
+
updateTabBadges();
|
|
3569
3186
|
buildForm();
|
|
3570
3187
|
state.settings = await api('/api/settings');
|
|
3571
3188
|
fillForm(state.settings);
|
|
3572
|
-
loadHistory().catch(() => {});
|
|
3573
3189
|
connectWs();
|
|
3574
|
-
renderLive(await api('/api/live/status'));
|
|
3575
3190
|
loadBrokers().catch(() => {});
|
|
3576
|
-
|
|
3191
|
+
loadNews().catch(() => {});
|
|
3577
3192
|
renderCloudPane().catch(() => {});
|
|
3578
3193
|
const urlParams = new URLSearchParams(location.search);
|
|
3579
3194
|
if (urlParams.get('payment_check') === '1') {
|
|
@@ -3583,16 +3198,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
3583
3198
|
}).catch(() => {});
|
|
3584
3199
|
}
|
|
3585
3200
|
const savedNav = loadSavedNav();
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
if (
|
|
3591
|
-
|
|
3592
|
-
if (savedNav.
|
|
3593
|
-
|
|
3594
|
-
|
|
3201
|
+
if (isOwnerUser()) {
|
|
3202
|
+
const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
|
|
3203
|
+
await loadRuns(initRunId);
|
|
3204
|
+
}
|
|
3205
|
+
if (urlParams.get('run') && isOwnerUser()) {
|
|
3206
|
+
showMainSec('overview');
|
|
3207
|
+
} else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'g0', 'cloud', 'llm'].includes(savedNav.mainSec)) {
|
|
3208
|
+
showMainSec(savedNav.mainSec);
|
|
3209
|
+
} else {
|
|
3210
|
+
showMainSec('brokers');
|
|
3595
3211
|
}
|
|
3596
|
-
// В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
|
|
3597
3212
|
setTimeout(() => refreshForecastIfStale(), 600);
|
|
3598
3213
|
})().catch((e) => toast(e.message, 'error'));
|