neuroalpha 0.3.14 → 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 +119 -0
- 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 +591 -832
- package/public/index.html +142 -186
- package/public/landing.html +19 -33
- package/public/legal.html +3 -4
- package/public/styles.css +10 -4
- package/server/api/routes.js +10 -3
- package/server/auth/index.js +53 -34
- package/server/brokers/index.js +718 -107
- 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,18 +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
|
};
|
|
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
|
+
};
|
|
981
963
|
const row = (r) => `<tr>
|
|
982
964
|
<td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
|
|
983
965
|
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
|
|
984
966
|
<td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
|
|
985
967
|
<td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
|
|
986
|
-
<td
|
|
987
|
-
<td class="muted small">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
|
|
968
|
+
<td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
|
|
969
|
+
<td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => x.symbol).join(', ')}</td>
|
|
988
970
|
</tr>`;
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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);
|
|
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>
|
|
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>`;
|
|
993
980
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
994
981
|
} catch (e) {
|
|
995
982
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
@@ -1007,424 +994,26 @@ function refreshForecastIfStale() {
|
|
|
1007
994
|
loadForecast(false, true);
|
|
1008
995
|
}
|
|
1009
996
|
}
|
|
1010
|
-
setInterval(() => { if (
|
|
1011
|
-
|
|
1012
|
-
function showNowSec(sec) {
|
|
1013
|
-
document.querySelectorAll('#now-nav [data-nsec]').forEach((b) => b.classList.toggle('active', b.dataset.nsec === sec));
|
|
1014
|
-
document.querySelectorAll('#tab-now .ssec').forEach((el) => { el.hidden = el.dataset.nsec !== sec; });
|
|
1015
|
-
saveNavPatch({ nowSec: sec });
|
|
1016
|
-
}
|
|
1017
|
-
$('#now-nav').addEventListener('click', (e) => {
|
|
1018
|
-
const b = e.target.closest('[data-nsec]');
|
|
1019
|
-
if (b) showNowSec(b.dataset.nsec);
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
let openLiveId = loadSavedNav().openLiveId ?? null;
|
|
1023
|
-
const liveTabById = new Map(Object.entries(loadSavedNav().liveTabs || {})); // runId -> 'positions' | 'trades' | 'steps'
|
|
1024
|
-
function setLiveSubTab(id, tab) {
|
|
1025
|
-
liveTabById.set(String(id), tab);
|
|
1026
|
-
saveNavPatch({ liveTabs: Object.fromEntries(liveTabById) });
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
function renderLive(st) {
|
|
1030
|
-
state.live = st;
|
|
1031
|
-
const accs = st?.accounts || [];
|
|
1032
|
-
if ($('#nnav-virtual')) $('#nnav-virtual').textContent = accs.length ? `Виртуальные счета (${accs.length})` : 'Виртуальные счета';
|
|
1033
|
-
updateTabBadges();
|
|
1034
|
-
if (!accs.length) {
|
|
1035
|
-
$('#live').innerHTML = 'Стратегия торгует виртуальными деньгами на текущих ценах: раз в несколько часов прогноз и пересмотр портфеля. Запустите счёт кнопкой выше или прямо из удачного эксперимента («▶ В торговлю» во вкладке «Эксперименты»).';
|
|
1036
|
-
return;
|
|
1037
|
-
}
|
|
1038
|
-
if (!accs.some((a) => String(a.runId) === String(openLiveId))) {
|
|
1039
|
-
openLiveId = accs[0].runId;
|
|
1040
|
-
}
|
|
1041
|
-
$('#live').innerHTML = accs.map((a) => {
|
|
1042
|
-
const isOpen = String(a.runId) === String(openLiveId);
|
|
1043
|
-
const curSubTab = liveTabById.get(String(a.runId)) || (a.snapshot?.positions?.length ? 'positions' : (a.trades?.length ? 'trades' : 'positions'));
|
|
1044
|
-
const s = a.snapshot || { equity: a.params?.capital || 0, cash: a.params?.capital || 0, positions: [], pending: [] };
|
|
1045
|
-
const pnl = a.pnl ?? (s.equity - (a.params?.capital || 0));
|
|
1046
|
-
const retPct = a.returnPct ?? 0;
|
|
1047
|
-
const step = a.recent?.[0];
|
|
1048
|
-
const p = a.params || {};
|
|
1049
|
-
const mf = a.metrics?.full || {};
|
|
1050
|
-
const trades = Array.isArray(a.trades) ? a.trades : [];
|
|
1051
|
-
const openPnl = (s.positions || []).reduce((sum, pos) => sum + (Number(pos.pnl) || 0), 0);
|
|
1052
|
-
const realizedPnl = a.realizedPnl ?? trades.reduce((sum, t) => sum + (Number(t.pnl) || 0), 0);
|
|
1053
|
-
const totalFee = a.totalCommission ?? trades.reduce((sum, t) => sum + (Number(t.commission) || 0), 0);
|
|
1054
|
-
const winTrades = trades.filter((t) => Number(t.pnl) > 0).length;
|
|
1055
|
-
const winRate = trades.length ? Math.round((winTrades / trades.length) * 100) : (mf.winRate ?? 0);
|
|
1056
|
-
const frozen = `${MODEL_LABEL[p.modelType] || p.modelType} · ${p.modelHorizon}ч · порог ${p.modelMinEdge} · акций ${p.modelTopK}${p.modelDirection && p.modelDirection !== 'long' ? ' · в рост и в падение' : ' · только в рост'}`;
|
|
1057
|
-
let corrHtml = '<div class="kpi"><div class="v muted">—</div><div class="l">ориентир истории</div></div>';
|
|
1058
|
-
if (a.corridor) {
|
|
1059
|
-
const c = a.corridor;
|
|
1060
|
-
const statusMap = {
|
|
1061
|
-
inside: ['в коридоре истории', 'ok'],
|
|
1062
|
-
above: ['опережает историю', 'ok'],
|
|
1063
|
-
below: ['ниже коридора истории — проверьте', 'no'],
|
|
1064
|
-
};
|
|
1065
|
-
const [sLbl, sCls] = statusMap[c.status] || ['', ''];
|
|
1066
|
-
corrHtml = `<div class="kpi" title="Ожидание по темпу эксперимента №${c.runId} (история: ${fmtPct(c.histReturnPct)}, макс. просадка ${fmtNum(c.histMaxDdPct, 1)}%)"><div class="v ${cls(c.expectedPct)}">${fmtPct(c.expectedPct, 2)} <span class="muted small">(${fmtPct(c.minPct, 1)}…${fmtPct(c.maxPct, 1)})</span></div><div class="l">ориентир №${c.runId} · <span class="${sCls === 'no' ? 'neg' : 'pos'}">${sLbl}</span></div></div>`;
|
|
1067
|
-
}
|
|
1068
|
-
const dispName = String(a.name || `Счёт №${a.runId}`).replace(/^Из прогона/i, 'Из эксперимента');
|
|
1069
|
-
|
|
1070
|
-
// Вкладка 1: Текущие позиции и ожидающие заявки
|
|
1071
|
-
const posPaneHtml = `
|
|
1072
|
-
${s.pending?.length ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head"><b>Ожидают исполнения на открытии следующей свечи (${s.pending.length})</b></div><div class="muted small">${s.pending.map((o) => `<span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${{ buy: 'купить', short: 'открыть шорт', cover: 'закрыть шорт' }[o.action] || 'продать'} <b>${esc(o.symbol)}</b></span>`).join(' ')}</div></div>` : ''}
|
|
1073
|
-
${s.positions.length ? `<table><thead><tr><th>Бумага</th><th>Направление</th><th>Открыта</th><th class="num">Кол-во</th><th class="num">Цена входа → Сейчас</th><th class="num">Текущий доход</th></tr></thead><tbody>${s.positions.map((pos) => {
|
|
1074
|
-
const pPct = pos.entryPrice > 0 ? ((pos.side === 'short' ? (1 - pos.lastPrice / pos.entryPrice) : (pos.lastPrice / pos.entryPrice - 1)) * 100) : 0;
|
|
1075
|
-
return `<tr>
|
|
1076
|
-
<td><b>${esc(pos.symbol)}</b></td>
|
|
1077
|
-
<td><span class="tag ${pos.side === 'short' ? 'no' : 'ok'}">${pos.side === 'short' ? 'шорт (на падение)' : 'покупка (в рост)'}</span></td>
|
|
1078
|
-
<td class="muted small">${fmtTs(pos.entryTs)}</td>
|
|
1079
|
-
<td class="num">${fmtNum(pos.qty, 0)} шт.</td>
|
|
1080
|
-
<td class="num"><span class="muted">${fmtNum(pos.entryPrice, 3)} →</span> <b>${fmtNum(pos.lastPrice, 3)} ₽</b></td>
|
|
1081
|
-
<td class="num ${cls(pos.pnl)}"><b>${pos.pnl >= 0 ? '+' : ''}${fmtNum(pos.pnl, 0)} ₽</b> <span class="small">(${fmtPct(pPct, 2)})</span></td>
|
|
1082
|
-
</tr>`;
|
|
1083
|
-
}).join('')}</tbody></table>` : `<p class="muted small" style="margin:6px 0">Сейчас ${p.modelDirection && p.modelDirection !== 'long' ? 'открытых позиций нет' : 'ничего не куплено'} (все <b>${fmtNum(s.cash ?? s.equity, 0)} ₽</b> в свободном кэше) — стратегия ждёт сильного сигнала.</p>`}
|
|
1084
|
-
${step ? `<p class="muted small" style="margin:8px 0 0">Последняя проверка рынка (${fmtTs(step.ts)}): ${step.decisions?.length ? step.decisions.map((d) => `<b>${esc(d.symbol)}</b>: ${esc(d.text)}`).join(' · ') : 'без изменений'}${step.top?.length ? ` · лидеры прогноза: ${step.top.slice(0, 3).map((x) => `${esc(x.symbol)} (${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%)`).join(', ')}` : ''}</p>` : ''}
|
|
1085
|
-
`;
|
|
1086
|
-
|
|
1087
|
-
// Вкладка 2: История закрытых сделок и по-дневной доход
|
|
1088
|
-
let tradesPaneHtml = '<p class="muted small" style="margin:6px 0">Закрытых сделок на этом виртуальном счёте пока нет.</p>';
|
|
1089
|
-
if (trades.length) {
|
|
1090
|
-
const byDay = new Map();
|
|
1091
|
-
for (const t of trades) {
|
|
1092
|
-
const dKey = t.exitTs ? new Date(t.exitTs + MSK).toISOString().slice(0, 10) : 'none';
|
|
1093
|
-
if (!byDay.has(dKey)) byDay.set(dKey, []);
|
|
1094
|
-
byDay.get(dKey).push(t);
|
|
1095
|
-
}
|
|
1096
|
-
const rows = [];
|
|
1097
|
-
for (const [, dayTrades] of byDay) {
|
|
1098
|
-
const dayPnl = dayTrades.reduce((sum, x) => sum + (Number(x.pnl) || 0), 0);
|
|
1099
|
-
const dayFee = dayTrades.reduce((sum, x) => sum + (Number(x.commission) || 0), 0);
|
|
1100
|
-
const dayWins = dayTrades.filter((x) => Number(x.pnl) > 0).length;
|
|
1101
|
-
rows.push(`<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTrades[0]?.exitTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">сделок: <b>${dayTrades.length}</b> (в плюс: ${dayWins}) · комиссия: ${fmtNum(dayFee, 0)} ₽ · доход за день: <b class="${cls(dayPnl)}">${dayPnl >= 0 ? '+' : ''}${fmtNum(dayPnl, 0)} ₽</b></span></div></td></tr>`);
|
|
1102
|
-
for (const t of dayTrades) {
|
|
1103
|
-
rows.push(`<tr>
|
|
1104
|
-
<td><b>${esc(t.symbol)}</b></td>
|
|
1105
|
-
<td><span class="tag ${t.side === 'short' ? 'no' : 'ok'}">${t.side === 'short' ? 'шорт' : 'покупка'}</span></td>
|
|
1106
|
-
<td class="muted small" style="white-space:nowrap">${fmtTs(t.entryTs)} → ${fmtTs(t.exitTs)}</td>
|
|
1107
|
-
<td class="num" style="white-space:nowrap">${t.qty ? `${fmtNum(t.qty, 0)} шт.` : '—'}</td>
|
|
1108
|
-
<td class="num" style="white-space:nowrap">${t.entryPrice ? `${fmtNum(t.entryPrice, 2)} → ${fmtNum(t.exitPrice, 2)} ₽` : '—'}</td>
|
|
1109
|
-
<td class="num ${cls(t.pnl)}" style="white-space:nowrap"><b>${t.pnl >= 0 ? '+' : ''}${fmtNum(t.pnl, 0)} ₽</b> <span class="small">(${fmtPct(t.pnlPct, 2)})</span></td>
|
|
1110
|
-
<td class="muted small">${esc(t.reasonExit || t.reasonEntry || 'По сигналу модели')}</td>
|
|
1111
|
-
</tr>`);
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
tradesPaneHtml = `<table><thead><tr><th>Бумага</th><th>Тип</th><th>Вход → Выход</th><th class="num">Кол-во</th><th class="num">Цена входа → выхода</th><th class="num">Чистый доход</th><th>Причина</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
// Вкладка 3: Журнал шагов и решений ИИ по свечам
|
|
1118
|
-
const stepsList = Array.isArray(a.recent) ? a.recent : [];
|
|
1119
|
-
const stepsPaneHtml = stepsList.length
|
|
1120
|
-
? `<table><thead><tr><th style="width:120px">Свеча (МСК)</th><th>Решения стратегии</th><th>Топ-3 прогноза на шаге</th></tr></thead><tbody>${stepsList.map((stItem) => `<tr>
|
|
1121
|
-
<td class="muted small" style="white-space:nowrap">${fmtTs(stItem.ts)}</td>
|
|
1122
|
-
<td>${stItem.decisions?.length ? stItem.decisions.map((d) => `<span class="tag ${d.action === 'buy' ? 'ok' : d.action === 'short' || d.action === 'sell' ? 'mid' : ''}">${esc(d.symbol)}</span> <span class="small">${esc(d.text)}</span>`).join('<br>') : '<span class="muted small">Позиции без изменений</span>'}</td>
|
|
1123
|
-
<td class="small muted">${stItem.top?.length ? stItem.top.slice(0, 3).map((x) => `<b>${esc(x.symbol)}</b> <span class="${cls(x.expectedPct)}">${x.expectedPct > 0 ? '+' : ''}${fmtNum(x.expectedPct, 2)}%</span>`).join(' · ') : '—'}</td>
|
|
1124
|
-
</tr>`).join('')}</tbody></table>`
|
|
1125
|
-
: '<p class="muted small" style="margin:6px 0">Шагов проверки рынка пока не зафиксировано.</p>';
|
|
1126
|
-
|
|
1127
|
-
return `<div class="live-card${a.running ? '' : ' stopped'}${isOpen ? '' : ' collapsed'}" data-acc="${a.runId}">
|
|
1128
|
-
<div class="acc-fold-head" data-live-fold="${a.runId}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
1129
|
-
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1130
|
-
<span class="acc-caret">▶</span>
|
|
1131
|
-
<b>${esc(dispName)}</b>
|
|
1132
|
-
<span class="tag ${a.running ? 'ok' : ''}">${a.running ? 'работает' : 'остановлен'}</span>
|
|
1133
|
-
${a.baseRunId ? `<button type="button" class="icon small" data-open-run="${a.baseRunId}" title="Открыть исходный эксперимент">эксперимент №${a.baseRunId}</button>` : ''}
|
|
1134
|
-
</div>
|
|
1135
|
-
<div class="row-gap" style="align-items:center">
|
|
1136
|
-
<button type="button" data-live-ask="${a.runId}" title="Попросить Стратега разобрать этот виртуальный счёт">💬 Спросить Стратега</button>
|
|
1137
|
-
${a.running ? `<button type="button" data-live-stop="${a.runId}">Остановить</button>` : ''}
|
|
1138
|
-
<button type="button" class="icon" data-live-del="${a.runId}" title="Удалить виртуальный счёт">✕</button>
|
|
1139
|
-
</div>
|
|
1140
|
-
</div>
|
|
1141
|
-
<div ${isOpen ? 'style="margin-top:10px"' : 'hidden'}>
|
|
1142
|
-
<div class="brk-specs" style="margin-bottom:10px">
|
|
1143
|
-
<span class="k">Замороженные настройки:</span><span class="v"><b>${esc(frozen)}</b> · стартовый капитал <b>${fmtNum(p.capital, 0)} ₽</b></span>
|
|
1144
|
-
<span class="k">Статус и время работы:</span><span class="v">запущен <b>${fmtTs(a.startedAt)}</b>${a.stoppedAt ? ` · остановлен ${fmtTs(a.stoppedAt)}` : ''} · последняя свеча <b>${a.lastBarTs ? fmtTs(a.lastBarTs) : '—'}</b>${a.error ? ` · <span class="neg">${esc(a.error)}</span>` : ''}</span>
|
|
1145
|
-
<span class="k">Структура дохода:</span><span class="v">зафиксировано по сделкам: <b class="${cls(realizedPnl)}">${realizedPnl >= 0 ? '+' : ''}${fmtNum(realizedPnl, 0)} ₽</b> · в открытых позициях: <b class="${cls(openPnl)}">${openPnl >= 0 ? '+' : ''}${fmtNum(openPnl, 0)} ₽</b> · учтено комиссий: <span class="muted">−${fmtNum(totalFee, 0)} ₽</span> · макс. просадка: <b>${fmtNum(mf.maxDrawdownPct || 0, 2)}%</b></span>
|
|
1146
|
-
</div>
|
|
1147
|
-
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
|
1148
|
-
<div class="kpi"><div class="v">${fmtNum(s.equity, 0)} ₽</div><div class="l">капитал счёта (свободно ${fmtNum(s.cash ?? s.equity, 0)} ₽)</div></div>
|
|
1149
|
-
<div class="kpi"><div class="v ${cls(pnl)}">${fmtPct(retPct, 2)} <span class="muted small">(${pnl >= 0 ? '+' : ''}${fmtNum(pnl, 0)} ₽)</span></div><div class="l">чистый доход с начала</div></div>
|
|
1150
|
-
${corrHtml}
|
|
1151
|
-
<div class="kpi"><div class="v">${trades.length || (mf.trades ?? 0)} <span class="muted small">(${winRate}% в плюс)</span></div><div class="l">закрыто сделок · профит-фактор ${fmtNum(mf.profitFactor || 0, 2)}</div></div>
|
|
1152
|
-
</div>
|
|
1153
|
-
<div class="brk-subtabs" style="margin-top:10px">
|
|
1154
|
-
<button type="button" data-ltab="positions" data-lid="${a.runId}" class="${curSubTab === 'positions' ? 'active' : ''}">📦 Текущие позиции (${s.positions.length})</button>
|
|
1155
|
-
<button type="button" data-ltab="trades" data-lid="${a.runId}" class="${curSubTab === 'trades' ? 'active' : ''}">📜 История сделок и доход (${trades.length})</button>
|
|
1156
|
-
<button type="button" data-ltab="steps" data-lid="${a.runId}" class="${curSubTab === 'steps' ? 'active' : ''}">🧭 Журнал решений (${stepsList.length})</button>
|
|
1157
|
-
</div>
|
|
1158
|
-
<div class="brk-subpane" ${curSubTab === 'positions' ? '' : 'hidden'}>${posPaneHtml}</div>
|
|
1159
|
-
<div class="brk-subpane" ${curSubTab === 'trades' ? '' : 'hidden'}>${tradesPaneHtml}</div>
|
|
1160
|
-
<div class="brk-subpane" ${curSubTab === 'steps' ? '' : 'hidden'}>${stepsPaneHtml}</div>
|
|
1161
|
-
</div>
|
|
1162
|
-
</div>`;
|
|
1163
|
-
}).join('');
|
|
1164
|
-
syncUiAvailability();
|
|
1165
|
-
}
|
|
1166
|
-
$('#btn-live').addEventListener('click', async () => {
|
|
1167
|
-
const rawCap = prompt('Стартовая сумма нового виртуального счёта (с текущими настройками), ₽:', String(state.settings?.capital || 1000000));
|
|
1168
|
-
if (rawCap === null) return;
|
|
1169
|
-
const capital = Math.max(10000, Math.round(Number(String(rawCap).replace(/\s/g, '')) || 1000000));
|
|
1170
|
-
try {
|
|
1171
|
-
const res = await post('/api/live/start', { capital });
|
|
1172
|
-
renderLive(res.status);
|
|
1173
|
-
toast(`Виртуальный счёт №${res.runId} запущен`);
|
|
1174
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1175
|
-
});
|
|
1176
|
-
$('#live').addEventListener('click', async (e) => {
|
|
1177
|
-
const tabBtn = e.target.closest('button[data-ltab]');
|
|
1178
|
-
if (tabBtn) {
|
|
1179
|
-
setLiveSubTab(tabBtn.dataset.lid, tabBtn.dataset.ltab);
|
|
1180
|
-
renderLive(state.live);
|
|
1181
|
-
return;
|
|
1182
|
-
}
|
|
1183
|
-
const stopBtn = e.target.closest('[data-live-stop]');
|
|
1184
|
-
if (stopBtn) {
|
|
1185
|
-
const id = Number(stopBtn.dataset.liveStop);
|
|
1186
|
-
if (!confirm(`Остановить виртуальный счёт №${id}?`)) return;
|
|
1187
|
-
try { const r = await post('/api/live/stop', { runId: id }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
const delBtn = e.target.closest('[data-live-del]');
|
|
1191
|
-
if (delBtn) {
|
|
1192
|
-
const id = Number(delBtn.dataset.liveDel);
|
|
1193
|
-
if (!confirm(`Удалить виртуальный счёт №${id} и его сделки?`)) return;
|
|
1194
|
-
try { const r = await api(`/api/live/${id}`, { method: 'DELETE' }); renderLive(r.status); } catch (err) { toast(err.message, 'error'); }
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
const openBtn = e.target.closest('[data-open-run]');
|
|
1198
|
-
if (openBtn) {
|
|
1199
|
-
const id = Number(openBtn.dataset.openRun);
|
|
1200
|
-
document.querySelector('[data-tab=overview]')?.click();
|
|
1201
|
-
openRun(id);
|
|
1202
|
-
return;
|
|
1203
|
-
}
|
|
1204
|
-
const askBtn = e.target.closest('[data-live-ask]');
|
|
1205
|
-
if (askBtn) {
|
|
1206
|
-
const id = Number(askBtn.dataset.liveAsk);
|
|
1207
|
-
const acc = (state.live?.accounts || []).find((x) => x.runId === id);
|
|
1208
|
-
sendChat(`Разбери состояние виртуального счёта «${acc?.name || `№${id}`}» (доход ${fmtPct(acc?.returnPct ?? 0, 2)}, сделок ${acc?.metrics?.full?.trades ?? 0}${acc?.baseRunId ? `, запущен из эксперимента №${acc.baseRunId}` : ''}): как он идёт относительно ожиданий и есть ли замечания по открытым позициям?`);
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
const foldHead = e.target.closest('[data-live-fold]');
|
|
1212
|
-
if (foldHead) {
|
|
1213
|
-
const id = foldHead.dataset.liveFold;
|
|
1214
|
-
openLiveId = String(openLiveId) === String(id) ? null : id;
|
|
1215
|
-
saveNavPatch({ openLiveId });
|
|
1216
|
-
renderLive(state.live);
|
|
1217
|
-
}
|
|
1218
|
-
});
|
|
997
|
+
setInterval(() => { if (!document.hidden) refreshForecastIfStale(); }, FORECAST_TICK_MS);
|
|
1219
998
|
|
|
1220
|
-
// ----------
|
|
999
|
+
// ---------- новости ----------
|
|
1221
1000
|
async function loadNews() {
|
|
1222
1001
|
try {
|
|
1223
1002
|
const { items } = await api('/api/news?limit=20');
|
|
1224
1003
|
if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
|
|
1225
|
-
$('#news-list')
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
+
}
|
|
1228
1009
|
} catch { /* ignore */ }
|
|
1229
1010
|
}
|
|
1230
1011
|
|
|
1231
1012
|
// ======================================================================
|
|
1232
|
-
//
|
|
1233
|
-
// ======================================================================
|
|
1234
|
-
function mapFrames() {
|
|
1235
|
-
const ex = state.run?.extra;
|
|
1236
|
-
const fr = (ex?.map?.snapshots || []).map((s) => ({ ts: s.ts, pts: s.pts, cl: s.cl, symbols: ex.symbols, names: ex.names }));
|
|
1237
|
-
if (state.forecast?.map) fr.push({ ts: state.forecast.barTs, now: true, ...state.forecast.map });
|
|
1238
|
-
return fr;
|
|
1239
|
-
}
|
|
1240
|
-
function setupMapSlider() {
|
|
1241
|
-
const s = $('#map-slider');
|
|
1242
|
-
const atEnd = Number(s.value) >= Number(s.max);
|
|
1243
|
-
s.max = Math.max(0, mapFrames().length - 1);
|
|
1244
|
-
if (atEnd) s.value = s.max;
|
|
1245
|
-
}
|
|
1246
|
-
function layout(f, W, H) {
|
|
1247
|
-
const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
|
|
1248
|
-
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
1249
|
-
const pad = 36;
|
|
1250
|
-
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)]);
|
|
1251
|
-
}
|
|
1252
|
-
// ближайшая к клику точка карты (общая для карты по поведению и по описанию)
|
|
1253
|
-
function pickPoint(f, e) {
|
|
1254
|
-
const cv = $('#map-canvas');
|
|
1255
|
-
const rect = cv.getBoundingClientRect();
|
|
1256
|
-
const xy = layout(f, cv.clientWidth, cv.clientHeight);
|
|
1257
|
-
let best = -1, bd = 16;
|
|
1258
|
-
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; } });
|
|
1259
|
-
return best;
|
|
1260
|
-
}
|
|
1261
|
-
function drawMap() {
|
|
1262
|
-
if (state.mapMode === 'text') return drawProfilesMap();
|
|
1263
|
-
const cv = $('#map-canvas');
|
|
1264
|
-
const W = cv.clientWidth, H = cv.clientHeight;
|
|
1265
|
-
if (!W) return;
|
|
1266
|
-
$('#map-slider').hidden = false;
|
|
1267
|
-
$('#map-text-box').hidden = true;
|
|
1268
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1269
|
-
cv.width = W * dpr; cv.height = H * dpr;
|
|
1270
|
-
const ctx = cv.getContext('2d');
|
|
1271
|
-
ctx.scale(dpr, dpr);
|
|
1272
|
-
const frames = mapFrames();
|
|
1273
|
-
if (!frames.length) {
|
|
1274
|
-
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
|
1275
|
-
ctx.fillText('Карта появится после исследования или прогноза.', 20, 30);
|
|
1276
|
-
$('#map-date').textContent = '';
|
|
1277
|
-
$('#map-info').textContent = '';
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
|
|
1281
|
-
$('#map-date').textContent = f.now ? `Сейчас · ${fmtDate(f.ts)} · заливка — прогноз` : `Месяц с ${fmtDate(f.ts)}`;
|
|
1282
|
-
const xy = layout(f, W, H);
|
|
1283
|
-
const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
|
|
1284
|
-
const fc = f.now && state.forecast ? Object.fromEntries(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
1285
|
-
if (sel >= 0) {
|
|
1286
|
-
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);
|
|
1287
|
-
ctx.strokeStyle = document.body.classList.contains('light') ? 'rgba(36, 87, 214, 0.38)' : '#ffffff40';
|
|
1288
|
-
for (const [i] of near) { ctx.beginPath(); ctx.moveTo(...xy[sel]); ctx.lineTo(...xy[i]); ctx.stroke(); }
|
|
1289
|
-
}
|
|
1290
|
-
f.symbols.forEach((s, i) => {
|
|
1291
|
-
const [x, y] = xy[i];
|
|
1292
|
-
const col = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
1293
|
-
const r = fc?.[s] ? 3 + Math.min(7, Math.abs(fc[s].expectedPct) * 15) : 4;
|
|
1294
|
-
ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
|
|
1295
|
-
ctx.beginPath(); ctx.arc(x, y, i === sel ? r + 2 : r, 0, Math.PI * 2);
|
|
1296
|
-
ctx.fillStyle = fc?.[s] ? (fc[s].expectedPct >= 0 ? '#3fb950' : '#f85149') : col;
|
|
1297
|
-
ctx.fill();
|
|
1298
|
-
ctx.globalAlpha = 1;
|
|
1299
|
-
const light = document.body.classList.contains('light');
|
|
1300
|
-
ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
|
|
1301
|
-
ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
|
|
1302
|
-
ctx.fillText(s, x + r + 3, y + 3);
|
|
1303
|
-
});
|
|
1304
|
-
if (sel < 0) { $('#map-info').textContent = 'Близко — значит акции обычно движутся вместе; цвет — найденная группа. Нажмите на акцию, чтобы увидеть соседей.'; return; }
|
|
1305
|
-
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]);
|
|
1306
|
-
const r = state.forecast?.rows.find((x) => x.symbol === f.symbols[sel]);
|
|
1307
|
-
$('#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)}` : ''}`;
|
|
1308
|
-
}
|
|
1309
|
-
$('#map-slider').addEventListener('input', drawMap);
|
|
1310
|
-
$('#map-canvas').addEventListener('click', (e) => {
|
|
1311
|
-
if (state.mapMode === 'text') {
|
|
1312
|
-
const d = state.profiles;
|
|
1313
|
-
if (!d?.map) return;
|
|
1314
|
-
const best = pickPoint(d.map, e);
|
|
1315
|
-
state.mapSel = best >= 0 ? d.map.symbols[best] : null;
|
|
1316
|
-
drawMap();
|
|
1317
|
-
return;
|
|
1318
|
-
}
|
|
1319
|
-
const frames = mapFrames();
|
|
1320
|
-
if (!frames.length) return;
|
|
1321
|
-
const f = frames[Math.min(Number($('#map-slider').value), frames.length - 1)];
|
|
1322
|
-
const best = pickPoint(f, e);
|
|
1323
|
-
state.mapSel = best >= 0 ? f.symbols[best] : null;
|
|
1324
|
-
drawMap();
|
|
1325
|
-
});
|
|
1326
|
-
window.addEventListener('resize', debounce(() => { if (!$('#tab-map').hidden) drawMap(); }, 150));
|
|
1327
|
-
|
|
1328
|
-
// ---------- карта «по описанию»: 2D-проекция устойчивых признаков бизнеса ----------
|
|
1329
|
-
async function loadProfilesData() {
|
|
1330
|
-
try { state.profiles = await api('/api/profiles'); } catch (e) { toast(e.message, 'error'); }
|
|
1331
|
-
}
|
|
1332
|
-
function drawProfilesMap() {
|
|
1333
|
-
const cv = $('#map-canvas');
|
|
1334
|
-
const W = cv.clientWidth, H = cv.clientHeight;
|
|
1335
|
-
if (!W) return;
|
|
1336
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1337
|
-
cv.width = W * dpr; cv.height = H * dpr;
|
|
1338
|
-
const ctx = cv.getContext('2d');
|
|
1339
|
-
ctx.scale(dpr, dpr);
|
|
1340
|
-
$('#map-slider').hidden = true;
|
|
1341
|
-
const d = state.profiles;
|
|
1342
|
-
$('#map-date').textContent = d?.map ? `Описания бизнеса · ${d.count} из ${d.total} компаний` : 'Описания компаний';
|
|
1343
|
-
if (!d?.map) {
|
|
1344
|
-
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
|
1345
|
-
ctx.fillText(d?.count ? 'Описаний пока мало — карту по описаниям построить не из чего.' : 'Карты по описаниям пока нет.', 20, 30);
|
|
1346
|
-
$('#map-text-box').hidden = false;
|
|
1347
|
-
$('#btn-profiles').disabled = state.profilesBusy;
|
|
1348
|
-
$('#map-info').textContent = 'Компании рядом, если похож их бизнес: экспортёр, банк, дивиденды и другие устойчивые свойства.';
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
$('#map-text-box').hidden = true;
|
|
1352
|
-
const f = d.map;
|
|
1353
|
-
const xy = layout(f, W, H);
|
|
1354
|
-
const sel = state.mapSel ? f.symbols.indexOf(state.mapSel) : -1;
|
|
1355
|
-
f.symbols.forEach((s, i) => {
|
|
1356
|
-
const [x, y] = xy[i];
|
|
1357
|
-
ctx.globalAlpha = sel >= 0 && i !== sel ? 0.6 : 1;
|
|
1358
|
-
ctx.beginPath(); ctx.arc(x, y, i === sel ? 6 : 4, 0, Math.PI * 2);
|
|
1359
|
-
ctx.fillStyle = CLUSTER_COLORS[f.cl[i] % CLUSTER_COLORS.length];
|
|
1360
|
-
ctx.fill();
|
|
1361
|
-
ctx.globalAlpha = 1;
|
|
1362
|
-
const light = document.body.classList.contains('light');
|
|
1363
|
-
ctx.fillStyle = i === sel ? (light ? '#1f2328' : '#fff') : (light ? '#57606a' : '#8b949e');
|
|
1364
|
-
ctx.font = `${i === sel ? '600 12px' : '10px'} system-ui`;
|
|
1365
|
-
ctx.fillText(s, x + 7, y + 3);
|
|
1366
|
-
});
|
|
1367
|
-
if (sel < 0) { $('#map-info').textContent = 'Рядом — похожий бизнес. Цвет — найденная группа. Нажмите на акцию, чтобы увидеть её самые выраженные свойства.'; return; }
|
|
1368
|
-
const prof = d.profiles[f.symbols[sel]];
|
|
1369
|
-
const top = (prof || []).map((v, k) => [v, k]).sort((a, b) => b[0] - a[0]).filter(([v]) => v >= 0.3).slice(0, 5);
|
|
1370
|
-
$('#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(', ')}` : ' · нет ярко выраженных свойств'}`;
|
|
1371
|
-
}
|
|
1372
|
-
async function showMapSec(sec) {
|
|
1373
|
-
document.querySelectorAll('#map-nav [data-msec]').forEach((b) => b.classList.toggle('active', b.dataset.msec === sec));
|
|
1374
|
-
saveNavPatch({ mapSec: sec });
|
|
1375
|
-
if (sec === 'journal') {
|
|
1376
|
-
$('#map-sec').hidden = true;
|
|
1377
|
-
$('#journal-box').hidden = false;
|
|
1378
|
-
await loadJournal();
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1381
|
-
$('#map-sec').hidden = false;
|
|
1382
|
-
$('#journal-box').hidden = true;
|
|
1383
|
-
state.mapMode = sec;
|
|
1384
|
-
$('#map-title-lbl').textContent = sec === 'text' ? 'Карта рынка · по описаниям бизнеса' : 'Карта рынка · по совместному поведению';
|
|
1385
|
-
if (sec === 'text' && !state.profiles) await loadProfilesData();
|
|
1386
|
-
drawMap();
|
|
1387
|
-
}
|
|
1388
|
-
$('#map-nav').addEventListener('click', (e) => {
|
|
1389
|
-
const b = e.target.closest('[data-msec]');
|
|
1390
|
-
if (b) showMapSec(b.dataset.msec);
|
|
1391
|
-
});
|
|
1392
|
-
$('#btn-profiles').addEventListener('click', async () => {
|
|
1393
|
-
try {
|
|
1394
|
-
const r = await post('/api/profiles/refresh');
|
|
1395
|
-
if (r.started === false) return toast('Описания уже составляются — подождите');
|
|
1396
|
-
state.profilesBusy = true;
|
|
1397
|
-
$('#btn-profiles').disabled = true;
|
|
1398
|
-
$('#profiles-msg').textContent = 'Составляем описания компаний…';
|
|
1399
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1400
|
-
});
|
|
1401
|
-
|
|
1402
|
-
// ======================================================================
|
|
1403
|
-
// ЖУРНАЛ И НАСТРОЙКИ
|
|
1013
|
+
// НАСТРОЙКИ (ИЗДЕРЖКИ И НОВОСТИ)
|
|
1404
1014
|
// ======================================================================
|
|
1405
|
-
const VERDICT = { confirmed: ['подтвердилась', 'ok'], rejected: ['не подтвердилась', 'no'], unclear: ['неясно', 'mid'] };
|
|
1406
|
-
async function loadJournal() {
|
|
1407
|
-
const list = await api('/api/journal');
|
|
1408
|
-
state.journalCount = list.length;
|
|
1409
|
-
updateTabBadges();
|
|
1410
|
-
if ($('#mnav-journal')) $('#mnav-journal').textContent = list.length ? `Журнал гипотез (${list.length})` : 'Журнал гипотез';
|
|
1411
|
-
$('#journal').innerHTML = list.length
|
|
1412
|
-
? `<p class="muted small">Память Стратега: он читает журнал в начале каждого разговора. Неверную запись можно удалить.</p><table><thead><tr><th>Гипотеза</th><th>Результат</th><th>Вывод</th><th></th></tr></thead><tbody>${list.map((j) => {
|
|
1413
|
-
const [vl, vc] = VERDICT[j.verdict] || [j.verdict, ''];
|
|
1414
|
-
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>`;
|
|
1415
|
-
}).join('')}</tbody></table>`
|
|
1416
|
-
: '<p class="muted">Журнал пуст. Попросите Стратега провести эксперимент — он запишет вывод сюда.</p>';
|
|
1417
|
-
$('#journal').querySelectorAll('[data-jdel]').forEach((b) => b.addEventListener('click', async () => {
|
|
1418
|
-
if (confirm('Удалить запись? Стратег её «забудет».')) await api(`/api/journal/${b.dataset.jdel}`, { method: 'DELETE' });
|
|
1419
|
-
}));
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
// Логические группы настроек по смыслу:
|
|
1423
|
-
// g0 — Стратегия и сигналы; g1 — Издержки и риск портфеля; g2 — Автопилот, обучение и система
|
|
1424
1015
|
const GROUPS = [
|
|
1425
|
-
['
|
|
1426
|
-
['Издержки и риск портфеля', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'modelAllocMode', 'modelMarketFilter', 'newsFilter']],
|
|
1427
|
-
['Автопилот, обучение и система', ['autopilotBudget', 'paperDailyReview', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelTextWeight']],
|
|
1016
|
+
['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
|
|
1428
1017
|
];
|
|
1429
1018
|
function buildForm() {
|
|
1430
1019
|
const field = (p) => {
|
|
@@ -1435,45 +1024,13 @@ function buildForm() {
|
|
|
1435
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>`;
|
|
1436
1025
|
};
|
|
1437
1026
|
const shown = state.config.params.filter((p) => !p.hidden);
|
|
1438
|
-
const
|
|
1439
|
-
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';
|
|
1440
1028
|
$('#params').innerHTML = GROUPS.map(([name, keys], i) => {
|
|
1441
|
-
const items = keys.
|
|
1442
|
-
const
|
|
1443
|
-
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>` : '';
|
|
1444
1032
|
}).join('');
|
|
1445
|
-
|
|
1446
|
-
}
|
|
1447
|
-
function showSettingsSec(sec) {
|
|
1448
|
-
$('#s-search').value = '';
|
|
1449
|
-
document.querySelectorAll('#tab-settings .srow').forEach((r) => { r.hidden = false; });
|
|
1450
|
-
document.querySelectorAll('#tab-settings .snav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
1451
|
-
document.querySelectorAll('#tab-settings .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
1452
|
-
saveNavPatch({ settingsSec: sec });
|
|
1453
|
-
if (sec === 'cloud') renderCloudPane().catch(() => {});
|
|
1454
|
-
}
|
|
1455
|
-
document.querySelector('#tab-settings .snav').addEventListener('click', (e) => {
|
|
1456
|
-
const b = e.target.closest('[data-sec]');
|
|
1457
|
-
if (b) showSettingsSec(b.dataset.sec);
|
|
1458
|
-
});
|
|
1459
|
-
$('#s-search').addEventListener('input', (e) => {
|
|
1460
|
-
const q = e.target.value.trim().toLowerCase();
|
|
1461
|
-
if (!q) {
|
|
1462
|
-
const cur = document.querySelector('#tab-settings .snav [data-sec].active')?.dataset.sec || 'g0';
|
|
1463
|
-
showSettingsSec(cur);
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
document.querySelectorAll('#tab-settings .ssec').forEach((sec) => {
|
|
1467
|
-
if (!sec.dataset.sec.startsWith('g')) { sec.hidden = true; return; }
|
|
1468
|
-
let vis = 0;
|
|
1469
|
-
sec.querySelectorAll('.srow').forEach((r) => {
|
|
1470
|
-
const ok = r.textContent.toLowerCase().includes(q);
|
|
1471
|
-
r.hidden = !ok;
|
|
1472
|
-
if (ok) vis++;
|
|
1473
|
-
});
|
|
1474
|
-
sec.hidden = !vis;
|
|
1475
|
-
});
|
|
1476
|
-
});
|
|
1033
|
+
}
|
|
1477
1034
|
function fillForm(s) {
|
|
1478
1035
|
for (const el of $('#params').elements) {
|
|
1479
1036
|
if (!el.name || el === document.activeElement) continue;
|
|
@@ -1509,38 +1066,6 @@ $('#params').addEventListener('click', async (e) => {
|
|
|
1509
1066
|
await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
|
|
1510
1067
|
fillForm(state.settings);
|
|
1511
1068
|
});
|
|
1512
|
-
$('#btn-reset').addEventListener('click', async () => {
|
|
1513
|
-
if (!confirm('Вернуть все настройки по умолчанию?')) return;
|
|
1514
|
-
state.settings = await post('/api/settings/reset');
|
|
1515
|
-
fillForm(state.settings);
|
|
1516
|
-
loadHistory().catch(() => {});
|
|
1517
|
-
});
|
|
1518
|
-
|
|
1519
|
-
// ---------- история изменений настроек ----------
|
|
1520
|
-
async function loadHistory() {
|
|
1521
|
-
const list = await api('/api/settings/history');
|
|
1522
|
-
$('#snav-hist').textContent = list.length ? `История изменений (${list.length})` : 'История изменений';
|
|
1523
|
-
$('#history').innerHTML = list.length
|
|
1524
|
-
? `<table><thead><tr><th>Дата</th><th>Кто</th><th>Что изменилось</th><th></th></tr></thead><tbody>${list.map((h) => `<tr>
|
|
1525
|
-
<td class="muted small">${fmtTs(h.ts)}</td>
|
|
1526
|
-
<td>${h.source === 'ai' ? 'Стратег' : 'вы'}</td>
|
|
1527
|
-
<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>
|
|
1528
|
-
<td><button class="icon" data-revert="${h.id}" title="Вернуть значения «было»">Вернуть</button></td>
|
|
1529
|
-
</tr>`).join('')}</tbody></table>`
|
|
1530
|
-
: '<p class="muted">Изменений ещё не было.</p>';
|
|
1531
|
-
}
|
|
1532
|
-
$('#history').addEventListener('click', async (e) => {
|
|
1533
|
-
const b = e.target.closest('[data-revert]');
|
|
1534
|
-
if (!b) return;
|
|
1535
|
-
try {
|
|
1536
|
-
const r = await post(`/api/settings/history/${b.dataset.revert}/revert`);
|
|
1537
|
-
if (r.errors?.length) toast(r.errors.join('; '), 'error');
|
|
1538
|
-
else if (!Object.keys(r.changed).length) toast('Настройки уже в этом состоянии — откатывать нечего');
|
|
1539
|
-
state.settings = r.settings;
|
|
1540
|
-
fillForm(state.settings);
|
|
1541
|
-
await loadHistory();
|
|
1542
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
1543
|
-
});
|
|
1544
1069
|
|
|
1545
1070
|
// ======================================================================
|
|
1546
1071
|
// ЧАТ СО СТРАТЕГОМ
|
|
@@ -1550,6 +1075,7 @@ const TOOL_LABELS = {
|
|
|
1550
1075
|
run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
|
|
1551
1076
|
journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
|
|
1552
1077
|
set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
|
|
1078
|
+
broker_onboarding_guide: 'Смотрит инструкцию по брокеру', discover_broker_accounts: 'Ищет субсчета по токену', connect_broker_account: 'Подключение брокерского счёта',
|
|
1553
1079
|
};
|
|
1554
1080
|
const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
|
|
1555
1081
|
const feed = $('#feed');
|
|
@@ -1576,6 +1102,8 @@ function argsPreview(name, a) {
|
|
|
1576
1102
|
if (name === 'compare_runs') return `№${a.a} и №${a.b}`;
|
|
1577
1103
|
if (name === 'journal_add') return a.hypothesis || '';
|
|
1578
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})` : ''}`;
|
|
1579
1107
|
return '';
|
|
1580
1108
|
}
|
|
1581
1109
|
|
|
@@ -1587,7 +1115,7 @@ function actHtml(m, call) {
|
|
|
1587
1115
|
const w = state.chat.state?.waiting;
|
|
1588
1116
|
const waiting = status === 'pending' && w && w.msgId === m.id && w.callId === call.id;
|
|
1589
1117
|
const prev = t.preview || argsPreview(call.function.name, args);
|
|
1590
|
-
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);
|
|
1591
1119
|
if (!big && !waiting) {
|
|
1592
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>`;
|
|
1593
1121
|
}
|
|
@@ -2181,7 +1709,130 @@ addForm.addEventListener('submit', async (e) => {
|
|
|
2181
1709
|
// ======================================================================
|
|
2182
1710
|
// БРОКЕРСКИЕ СЧЕТА (Т-БАНК, ФИНАМ, АЛОР, БКС)
|
|
2183
1711
|
// ======================================================================
|
|
2184
|
-
const BROKER_LABELS = {
|
|
1712
|
+
const BROKER_LABELS = {
|
|
1713
|
+
tbank: 'Т-Банк',
|
|
1714
|
+
finam: 'Финам',
|
|
1715
|
+
bcs: 'БКС',
|
|
1716
|
+
alor: 'Алор',
|
|
1717
|
+
cifra: 'Цифра брокер',
|
|
1718
|
+
iticapital: 'ITI Capital',
|
|
1719
|
+
sber: 'СберИнвестиции',
|
|
1720
|
+
vtb: 'ВТБ Инвестиции',
|
|
1721
|
+
alfa: 'Альфа-Инвестиции',
|
|
1722
|
+
};
|
|
1723
|
+
const BROKER_GUIDES = {
|
|
1724
|
+
tbank: {
|
|
1725
|
+
tokenLbl: 'Токен T-Invest API',
|
|
1726
|
+
tokenPh: 't.… (токен Т-Инвестиций)',
|
|
1727
|
+
accLbl: 'ID субсчёта Т-Банка',
|
|
1728
|
+
accPh: 'ID счёта (или нажмите кнопку справа →)',
|
|
1729
|
+
canDiscover: true,
|
|
1730
|
+
summary: '📖 Как получить токен T-Invest API в Т-Банке (за 1 минуту)',
|
|
1731
|
+
html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1732
|
+
<li>Откройте веб-кабинет <b>Т-Инвестиций</b> на сайте <code>tbank.ru/invest/settings</code> (раздел <b>Настройки → Токены T-Invest API</b>).</li>
|
|
1733
|
+
<li>Нажмите <b>«Выпустить токен»</b>, выберите права: <b>«Полный доступ»</b> (для выставления заявок) или «Только чтение», и укажите <b>«Все счета»</b>.</li>
|
|
1734
|
+
<li>Скопируйте полученный токен (начинается на <code>t.…</code>) в поле «Токен T-Invest API» ниже и нажмите кнопку <b>«🔍 Найти мои счета по токену»</b> — платформа сама покажет все ваши субсчета, их баланс и статус маржинальной торговли.</li>
|
|
1735
|
+
<li><b>Для работы с шортами (тарифы Профи и VIP):</b> в мобильном приложении Т-Инвестиций зайдите в настройки выбранного счёта (шестерёнка в правом верхнем углу счёта) и включите тумблер <b>«Маржинальная торговля»</b>.</li>
|
|
1736
|
+
</ol>`,
|
|
1737
|
+
},
|
|
1738
|
+
finam: {
|
|
1739
|
+
tokenLbl: 'Токен Finam Trade API',
|
|
1740
|
+
tokenPh: 'CA… (токен доступа Finam Trade API)',
|
|
1741
|
+
accLbl: 'Код клиента (ClientId)',
|
|
1742
|
+
accPh: 'Например, 123456R (Единый счёт Мосбиржи)',
|
|
1743
|
+
canDiscover: false,
|
|
1744
|
+
summary: '📖 Как получить токен Trade API и ClientId в Финаме',
|
|
1745
|
+
html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1746
|
+
<li>Войдите в личный кабинет клиента Финам (<code>edox.finam.ru</code> / <code>finam.ru</code>) → раздел <b>«Торговля» → «Токены доступа (Finam Trade API)»</b>.</li>
|
|
1747
|
+
<li>Нажмите <b>«Создать новый токен»</b>, подтвердите выпуск по СМС и скопируйте строку токена (начинается на <code>CA…</code>) в поле «Токен Finam Trade API».</li>
|
|
1748
|
+
<li>В поле <b>«Код клиента (ClientId)»</b> укажите торговый код вашего фондового счёта Мосбиржи (обычно вида <code>123456R</code> или <code>КлФ-…</code>, он отображается рядом с токеном и в терминале FinamTrade).</li>
|
|
1749
|
+
</ol>`,
|
|
1750
|
+
},
|
|
1751
|
+
bcs: {
|
|
1752
|
+
tokenLbl: 'Refresh-токен БКС Trade API',
|
|
1753
|
+
tokenPh: 'Refresh-токен из веб-терминала БКС Мир инвестиций',
|
|
1754
|
+
accLbl: 'Номер соглашения / субсчёта',
|
|
1755
|
+
accPh: 'Оставьте пустым для основного счёта (или укажите номер соглашения)',
|
|
1756
|
+
canDiscover: false,
|
|
1757
|
+
summary: '📖 Как получить Refresh-токен в БКС Мир инвестиций',
|
|
1758
|
+
html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1759
|
+
<li>Откройте веб-терминал <b>«БКС Мир инвестиций»</b> в браузере → перейдите в <b>Профиль / Настройки → «Торговый API (BCS Trade API)»</b>.</li>
|
|
1760
|
+
<li>Выпустите новый токен с правом <b>«Чтение и торговля»</b> (<code>trade-api-write</code>).</li>
|
|
1761
|
+
<li>Скопируйте полученный <b>Refresh-токен</b> в поле ниже. Платформа автоматически обменивает его на короткоживущий JWT-токен при каждом обращении.</li>
|
|
1762
|
+
</ol>`,
|
|
1763
|
+
},
|
|
1764
|
+
alor: {
|
|
1765
|
+
tokenLbl: 'Refresh-токен Alor OpenAPI',
|
|
1766
|
+
tokenPh: 'Refresh-токен с портала alor.dev',
|
|
1767
|
+
accLbl: 'Фондовый портфель Мосбиржи',
|
|
1768
|
+
accPh: 'Например, D12345',
|
|
1769
|
+
canDiscover: false,
|
|
1770
|
+
summary: '📖 Как получить Refresh-токен и номер портфеля в Алор Брокере',
|
|
1771
|
+
html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1772
|
+
<li>Авторизуйтесь под учётной записью клиента Алор на портале разработчика <code>alor.dev</code> (или в личном кабинете <code>my.alor.ru</code> → раздел <b>OpenAPI</b>).</li>
|
|
1773
|
+
<li>Нажмите <b>«Выпустить Refresh Token»</b> для боевого контура (или контура Разработки, если включён тумблер «Песочница»).</li>
|
|
1774
|
+
<li>В поле <b>«Фондовый портфель Мосбиржи»</b> укажите ваш фондовый портфель с литерой <code>D</code> (например, <code>D39004</code>).</li>
|
|
1775
|
+
</ol>`,
|
|
1776
|
+
},
|
|
1777
|
+
cifra: {
|
|
1778
|
+
tokenLbl: 'Ключи Tradernet (Public:Secret)',
|
|
1779
|
+
tokenPh: 'PublicKey:SecretKey (через двоеточие)',
|
|
1780
|
+
accLbl: 'Номер счёта Tradernet',
|
|
1781
|
+
accPh: 'Необязательно (определяется автоматически по ключу)',
|
|
1782
|
+
canDiscover: false,
|
|
1783
|
+
summary: '📖 Как получить API-ключи Tradernet в «Цифра брокер» (Freedom24)',
|
|
1784
|
+
html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1785
|
+
<li>Войдите в веб-терминал <b>Tradernet («Цифра брокер»)</b> на сайте <code>tradernet.ru</code> → откройте <b>Настройки профиля → «API ключи»</b>.</li>
|
|
1786
|
+
<li>Сгенерируйте пару ключей: <b>Публичный ключ (Public Key)</b> и <b>Секретный ключ (Secret Key)</b>.</li>
|
|
1787
|
+
<li>Вставьте их в поле ниже <b>через двоеточие без пробелов</b>: <code>ВашPublicKey:ВашSecretKey</code>. Платформа подписывает каждый запрос алгоритмом HMAC-SHA256.</li>
|
|
1788
|
+
</ol>`,
|
|
1789
|
+
},
|
|
1790
|
+
iticapital: {
|
|
1791
|
+
tokenLbl: 'Refresh-токен ITI Capital',
|
|
1792
|
+
tokenPh: 'Refresh-токен OpenAPI ITI Capital',
|
|
1793
|
+
accLbl: 'Код портфеля Мосбиржи',
|
|
1794
|
+
accPh: 'Например, BP12345',
|
|
1795
|
+
canDiscover: false,
|
|
1796
|
+
summary: '📖 Как подключить счёт ITI Capital по OpenAPI',
|
|
1797
|
+
html: `<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1798
|
+
<li>В личном кабинете <b>ITI Capital</b> откройте раздел <b>Торговые системы → OpenAPI</b> и выпустите <b>Refresh Token</b>.</li>
|
|
1799
|
+
<li>Скопируйте токен в поле ниже и укажите ваш фондовый портфель Мосбиржи вида <code>BP12345</code>.</li>
|
|
1800
|
+
</ol>`,
|
|
1801
|
+
},
|
|
1802
|
+
sber: {
|
|
1803
|
+
tokenLbl: 'Адрес моста QUIK (необязательно)',
|
|
1804
|
+
tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://127.0.0.1:8080)',
|
|
1805
|
+
accLbl: 'Номер счёта СберИнвестиций',
|
|
1806
|
+
accPh: 'Например, 400ABCD (для отображения и учёта)',
|
|
1807
|
+
canDiscover: false,
|
|
1808
|
+
summary: '📖 Особенности подключения «СберИнвестиций» (Сбербанк)',
|
|
1809
|
+
html: `<div style="display:grid;gap:6px">
|
|
1810
|
+
<div>У розничного брокера <b>«СберИнвестиции»</b> нет прямого облачного REST-токена для физлиц (алготорговля в Сбере предоставляется через терминал <b>QUIK</b>). В «НейроАльфе» доступны <b>2 режима работы со счётом Сбера</b>:</div>
|
|
1811
|
+
<ol style="margin:0;padding-left:18px;display:grid;gap:4px">
|
|
1812
|
+
<li><b>Режим «Подтверждение заявок» через Telegram / MAX (рекомендуется, без настройки QUIK):</b> оставьте поле «Адрес моста QUIK» <b>пустым</b>, укажите ваш лимит суммы на стратегию и сохраните счёт. Платформа будет рассчитывать точные лимитные заявки в лотах Мосбиржи под ваш баланс и присылать их в Telegram / MAX и во вкладку «Торговля и заявки». После выставления заявки в приложении СберИнвестиций нажмите <b>«Исполнить заявки»</b> — платформа сама учтёт купленные бумаги и остаток кэша в портфеле.</li>
|
|
1813
|
+
<li><b>Автоматический режим через HTTP-мост QUIK:</b> если у вас установлен терминал QUIK Сбербанка с запущенным локальным HTTP REST-шлюзом (<code>QuikHttpBridge</code>), укажите его адрес (например, <code>http://ваш_ip:8080</code>) в поле выше.</li>
|
|
1814
|
+
</ol>
|
|
1815
|
+
</div>`,
|
|
1816
|
+
},
|
|
1817
|
+
vtb: {
|
|
1818
|
+
tokenLbl: 'Адрес моста QUIK (необязательно)',
|
|
1819
|
+
tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://…)',
|
|
1820
|
+
accLbl: 'Номер счёта ВТБ Инвестиций',
|
|
1821
|
+
accPh: 'Например, номер субсчёта ВТБ',
|
|
1822
|
+
canDiscover: false,
|
|
1823
|
+
summary: '📖 Особенности подключения «ВТБ Мои Инвестиции»',
|
|
1824
|
+
html: `<div>У <b>«ВТБ Мои Инвестиции»</b> нет облачного REST-токена для физлиц (доступен только терминал QUIK). Оставьте поле адреса QUIK <b>пустым</b>, чтобы работать в режиме <b>«Подтверждение заявок»</b> (расчёт точных лотов Мосбиржи с уведомлениями в Telegram / MAX и автоматическим учётом позиций после подтверждения), либо укажите URL вашего HTTP-шлюза QUIK.</div>`,
|
|
1825
|
+
},
|
|
1826
|
+
alfa: {
|
|
1827
|
+
tokenLbl: 'Адрес моста QUIK (необязательно)',
|
|
1828
|
+
tokenPh: 'Оставьте пустым для режима подтверждения (или укажите http://…)',
|
|
1829
|
+
accLbl: 'Номер счёта Альфа-Инвестиций',
|
|
1830
|
+
accPh: 'Например, номер счёта Альфа-Инвестиций',
|
|
1831
|
+
canDiscover: false,
|
|
1832
|
+
summary: '📖 Особенности подключения «Альфа-Инвестиций»',
|
|
1833
|
+
html: `<div>У <b>«Альфа-Инвестиций»</b> для физлиц нет облачного REST-токена. Оставьте поле адреса <b>пустым</b> для работы в режиме <b>«Подтверждение заявок»</b> (точные лимитные заявки в лотах с пушами в Telegram / MAX и ведением портфеля внутри платформы) или укажите адрес вашего торгового моста.</div>`,
|
|
1834
|
+
},
|
|
1835
|
+
};
|
|
2185
1836
|
const BROKER_MODES = { readonly: 'Только чтение', confirm: 'Подтверждение заявок', auto: 'Автомат' };
|
|
2186
1837
|
const LEGACY_MODES = {
|
|
2187
1838
|
isolate: 'Изолированный карман (старые бумаги не трогаются)',
|
|
@@ -2230,10 +1881,9 @@ let brokerPollBusy = false;
|
|
|
2230
1881
|
let lastBrokerPollTs = 0;
|
|
2231
1882
|
|
|
2232
1883
|
async function pollActiveBrokerAccount(force = false) {
|
|
2233
|
-
if (brokerPollBusy || document.hidden) return;
|
|
2234
|
-
const
|
|
2235
|
-
|
|
2236
|
-
if (nowPageHidden || brokersSecHidden) return;
|
|
1884
|
+
if (brokerPollBusy || document.hidden || editingBrokerId) return;
|
|
1885
|
+
const brokersSecHidden = $('#brokers-trade-box')?.hidden;
|
|
1886
|
+
if (brokersSecHidden) return;
|
|
2237
1887
|
const accs = state.brokers?.accounts || [];
|
|
2238
1888
|
if (!accs.length) return;
|
|
2239
1889
|
const activeId = openBrokerId && accs.some((a) => a.id === openBrokerId) ? openBrokerId : accs[0].id;
|
|
@@ -2269,58 +1919,23 @@ async function loadBrokers() {
|
|
|
2269
1919
|
|
|
2270
1920
|
function renderBrokers() {
|
|
2271
1921
|
const accs = state.brokers?.accounts || [];
|
|
2272
|
-
const navBtn = $('#snav-brokers');
|
|
2273
|
-
if (navBtn) navBtn.textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
2274
1922
|
if ($('#nnav-brokers')) $('#nnav-brokers').textContent = accs.length ? `Брокерские счета (${accs.length})` : 'Брокерские счета';
|
|
2275
1923
|
updateTabBadges();
|
|
2276
1924
|
|
|
2277
|
-
//
|
|
2278
|
-
const
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
}
|
|
2283
|
-
listEl.innerHTML = accs.length ? accs.map((a) => {
|
|
2284
|
-
const ls = a.lastSync;
|
|
2285
|
-
const isCfgOpen = a.id === openBrokerCfgId;
|
|
2286
|
-
return `<div class="llm-prov live-card${isCfgOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2287
|
-
<div class="acc-fold-head" data-bcfg-fold="${esc(a.id)}" title="${isCfgOpen ? 'Нажмите, чтобы свернуть' : 'Нажмите, чтобы развернуть'}">
|
|
2288
|
-
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2289
|
-
<span class="acc-caret">▶</span>
|
|
2290
|
-
<b>${esc(a.name)}</b>
|
|
2291
|
-
<span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2292
|
-
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2293
|
-
${a.haltedReason ? `<span class="tag no" title="${esc(a.haltedReason)}">стоп</span>` : ''}
|
|
2294
|
-
</div>
|
|
2295
|
-
<div class="row-gap" style="align-items:center">
|
|
2296
|
-
${ls?.ok ? `<span class="small muted">На счёте: <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽)</span>` : ''}
|
|
2297
|
-
<button type="button" data-bact="sync">Проверить связь</button>
|
|
2298
|
-
<button type="button" data-bact="edit" class="icon">Изменить</button>
|
|
2299
|
-
<button type="button" data-bact="del" class="icon">Удалить</button>
|
|
2300
|
-
</div>
|
|
2301
|
-
</div>
|
|
2302
|
-
<div class="brk-specs" ${isCfgOpen ? '' : 'hidden'}>
|
|
2303
|
-
<span class="k">Брокер и субсчёт:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · ID <code>${esc(a.accountId || ls?.accountId || 'авто')}</code> (токен: ${a.hasToken ? '<span class="pos">задан ✓</span>' : '<span class="nokey">не задан</span>'})</span>
|
|
2304
|
-
<span class="k">Режим управления:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-расписание: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">выкл.</span>'}</b></span>
|
|
2305
|
-
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[a.legacyMode || 'isolate'] || a.legacyMode)}</span>
|
|
2306
|
-
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2307
|
-
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (запрещено офертой)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">проверится при синхронизации</span>'}</span>
|
|
2308
|
-
<span class="k">Предохранители:</span><span class="v">лимит <b>${fmtNum(a.maxCapitalRub, 0)} ₽</b> · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b></span>
|
|
2309
|
-
${a.baseRunLabel ? `<span class="k">Настройки стратегии:</span><span class="v">${esc(a.baseRunLabel)}</span>` : ''}
|
|
2310
|
-
${ls ? `<span class="k">Синхронизация (${fmtTs(ls.at)}):</span><span class="v">${ls.ok ? `<span class="pos">на счёте <b>${fmtNum(ls.totalRub, 0)} ₽</b> (свободно ${fmtNum(ls.cashRub, 0)} ₽, бумаг ${ls.positions?.length || 0})</span>` : `<span class="neg">${esc(ls.error)}</span>`}</span>` : ''}
|
|
2311
|
-
</div>
|
|
2312
|
-
</div>`;
|
|
2313
|
-
}).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);
|
|
2314
1930
|
}
|
|
2315
1931
|
|
|
2316
|
-
// 2) карточки во вкладке «Торговля → Брокерские счета» (аккордеон: открыта одна карточка, по умолчанию первая)
|
|
2317
1932
|
const tradeEl = $('#brokers-trade');
|
|
2318
1933
|
if (!tradeEl) return;
|
|
2319
1934
|
if (!accs.length) {
|
|
2320
|
-
tradeEl.innerHTML = '
|
|
1935
|
+
tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
|
|
2321
1936
|
return;
|
|
2322
1937
|
}
|
|
2323
|
-
if (
|
|
1938
|
+
if (openBrokerId === undefined) {
|
|
2324
1939
|
openBrokerId = accs[0].id;
|
|
2325
1940
|
}
|
|
2326
1941
|
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
@@ -2376,7 +1991,12 @@ function renderBrokers() {
|
|
|
2376
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>
|
|
2377
1992
|
</div>` : '';
|
|
2378
1993
|
|
|
2379
|
-
// Считаем
|
|
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> (+ реинвестирование прибыли)`;
|
|
2380
2000
|
let strategyRub = 0, protectedRub = 0;
|
|
2381
2001
|
for (const p of ls?.positions || []) {
|
|
2382
2002
|
const aq = algoQtyOf(p);
|
|
@@ -2387,8 +2007,16 @@ function renderBrokers() {
|
|
|
2387
2007
|
const totalPnlRub = ls?.totalPnlRub ?? (ls?.positions || []).reduce((s, p) => s + (p.pnlRub || 0), 0);
|
|
2388
2008
|
|
|
2389
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
|
+
};
|
|
2390
2018
|
const posHtml = ls?.positions?.length
|
|
2391
|
-
? `${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) => {
|
|
2392
2020
|
const [kLbl, kCls] = INST_KIND_LABEL[p.instrumentType || 'share'] || ['Актив', ''];
|
|
2393
2021
|
const fcRow = fcRowMap?.get(p.symbol);
|
|
2394
2022
|
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
@@ -2396,6 +2024,7 @@ function renderBrokers() {
|
|
|
2396
2024
|
<td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
|
|
2397
2025
|
<td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
|
|
2398
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>
|
|
2399
2028
|
<td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
|
|
2400
2029
|
<td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
|
|
2401
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>
|
|
@@ -2406,7 +2035,19 @@ function renderBrokers() {
|
|
|
2406
2035
|
|
|
2407
2036
|
// Вкладка 2: Торговля и заявки стратегии
|
|
2408
2037
|
const activeOrdHtml = ls?.activeOrders?.length
|
|
2409
|
-
? `<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>`
|
|
2410
2051
|
: '';
|
|
2411
2052
|
let batchHtml = '';
|
|
2412
2053
|
if (pb) {
|
|
@@ -2446,7 +2087,7 @@ function renderBrokers() {
|
|
|
2446
2087
|
const rows = [];
|
|
2447
2088
|
for (const [, dayOps] of byDay) {
|
|
2448
2089
|
let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
|
|
2449
|
-
const symQty = new Map(), symCash = new Map();
|
|
2090
|
+
const symQty = new Map(), symCash = new Map(), symBuyRub = new Map();
|
|
2450
2091
|
for (const op of dayOps) {
|
|
2451
2092
|
const pay = Number(op.paymentRub) || 0;
|
|
2452
2093
|
if (op.kind === 'buy') {
|
|
@@ -2455,6 +2096,7 @@ function renderBrokers() {
|
|
|
2455
2096
|
if (op.symbol) {
|
|
2456
2097
|
symQty.set(op.symbol, (symQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2457
2098
|
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2099
|
+
symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
|
|
2458
2100
|
}
|
|
2459
2101
|
} else if (op.kind === 'sell') {
|
|
2460
2102
|
tradesCnt++;
|
|
@@ -2472,19 +2114,22 @@ function renderBrokers() {
|
|
|
2472
2114
|
inOutRub += pay;
|
|
2473
2115
|
}
|
|
2474
2116
|
}
|
|
2475
|
-
// Считаем чистый результат полностью закрытых внутри дня сделок (интрадей)
|
|
2476
|
-
let closedTradePnl = 0, closedSyms = 0;
|
|
2117
|
+
// Считаем чистый результат полностью закрытых внутри дня сделок (интрадей) в рублях и в %
|
|
2118
|
+
let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
|
|
2477
2119
|
for (const [sym, netQ] of symQty) {
|
|
2478
2120
|
if (netQ === 0 && (symCash.get(sym) || 0) !== 0) {
|
|
2479
2121
|
closedTradePnl += symCash.get(sym);
|
|
2122
|
+
closedBaseRub += symBuyRub.get(sym) || 0;
|
|
2480
2123
|
closedSyms++;
|
|
2481
2124
|
}
|
|
2482
2125
|
}
|
|
2483
2126
|
if (closedSyms > 0) closedTradePnl += feeRub;
|
|
2127
|
+
const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
|
|
2484
2128
|
|
|
2485
2129
|
const summaryParts = [];
|
|
2486
2130
|
if (closedSyms > 0) {
|
|
2487
|
-
|
|
2131
|
+
const pctPart = closedTradePct !== null ? ` (${closedTradePct > 0 ? '+' : ''}${fmtNum(closedTradePct, 2)}%)` : '';
|
|
2132
|
+
summaryParts.push(`итог закрытых сделок: <b class="${cls(closedTradePnl)}">${closedTradePnl > 0 ? '+' : ''}${fmtNum(closedTradePnl, 2)} ₽${pctPart}</b>`);
|
|
2488
2133
|
} else if (tradesCnt > 0) {
|
|
2489
2134
|
const tParts = [];
|
|
2490
2135
|
if (buyRub > 0) tParts.push(`куплено на ${fmtNum(buyRub, 0)} ₽`);
|
|
@@ -2505,7 +2150,7 @@ function renderBrokers() {
|
|
|
2505
2150
|
for (const op of dayOps) {
|
|
2506
2151
|
rows.push(`<tr>
|
|
2507
2152
|
<td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
|
|
2508
|
-
<td><span class="tag ${op.kind === 'buy' || op.kind === 'div' || op.kind === 'in' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}"
|
|
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>
|
|
2509
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>
|
|
2510
2155
|
<td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2511
2156
|
<td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
|
|
@@ -2603,52 +2248,86 @@ function renderBrokers() {
|
|
|
2603
2248
|
${yearsHtml}`
|
|
2604
2249
|
: '<p class="muted small">Загружаем выписку операций текущего года с брокера…</p>';
|
|
2605
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
|
+
|
|
2606
2257
|
return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
|
|
2607
2258
|
<div class="acc-fold-head" data-brk-fold="${esc(a.id)}" title="${isOpen ? 'Нажмите, чтобы свернуть карточку счёта' : 'Нажмите, чтобы развернуть карточку счёта'}">
|
|
2608
2259
|
<div style="font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2609
2260
|
<span class="acc-caret">▶</span>
|
|
2610
2261
|
<b>${esc(a.name)}</b>${ls?.accountName && !a.name.includes(ls.accountName) ? `<span class="tag ok">«${esc(ls.accountName)}»</span>` : ''}
|
|
2611
|
-
<span class="tag ${
|
|
2262
|
+
<span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2612
2263
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2613
2264
|
${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
|
|
2265
|
+
${collapsedMetricsHtml}
|
|
2614
2266
|
</div>
|
|
2615
2267
|
<div class="row-gap" style="align-items:center">
|
|
2616
|
-
|
|
2617
|
-
|
|
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>` : ''}
|
|
2618
2271
|
</div>
|
|
2619
2272
|
</div>
|
|
2620
2273
|
<div ${isOpen ? '' : 'hidden'}>
|
|
2621
|
-
<div class="brk-
|
|
2622
|
-
|
|
2623
|
-
<
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
<
|
|
2639
|
-
|
|
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>
|
|
2640
2299
|
</div>
|
|
2641
|
-
<div class="brk-subpane" ${curSubTab === 'assets' ? '' : 'hidden'}>${posHtml}</div>
|
|
2642
|
-
<div class="brk-subpane" ${curSubTab === 'trade' ? '' : 'hidden'}>${activeOrdHtml}${batchHtml}</div>
|
|
2643
|
-
<div class="brk-subpane" ${curSubTab === 'history' ? '' : 'hidden'}>${histHtml}</div>
|
|
2644
2300
|
</div>
|
|
2645
2301
|
</div>`;
|
|
2646
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
|
+
}
|
|
2647
2312
|
}
|
|
2648
2313
|
|
|
2649
|
-
$('#btn-
|
|
2650
|
-
|
|
2651
|
-
|
|
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
|
+
}
|
|
2652
2331
|
});
|
|
2653
2332
|
|
|
2654
2333
|
const brokerForm = $('#broker-form');
|
|
@@ -2663,7 +2342,6 @@ function getBrokerFormSnap() {
|
|
|
2663
2342
|
token: f.token.value.trim(),
|
|
2664
2343
|
mode: f.mode.value,
|
|
2665
2344
|
legacyMode: f.legacyMode?.value || 'isolate',
|
|
2666
|
-
baseRunId: f.baseRunId.value || '',
|
|
2667
2345
|
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
2668
2346
|
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
2669
2347
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
@@ -2673,12 +2351,60 @@ function getBrokerFormSnap() {
|
|
|
2673
2351
|
});
|
|
2674
2352
|
}
|
|
2675
2353
|
|
|
2354
|
+
function syncBrokerGuideUi() {
|
|
2355
|
+
const f = brokerForm?.elements;
|
|
2356
|
+
if (!f) return;
|
|
2357
|
+
const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
|
|
2358
|
+
const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
|
|
2359
|
+
if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
|
|
2360
|
+
if ($('#broker-token-input')) $('#broker-token-input').placeholder = g.tokenPh;
|
|
2361
|
+
if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
|
|
2362
|
+
if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
|
|
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
|
+
}
|
|
2368
|
+
if ($('#broker-guide-sum')) $('#broker-guide-sum').textContent = g.summary;
|
|
2369
|
+
if ($('#broker-guide-body')) $('#broker-guide-body').innerHTML = g.html;
|
|
2370
|
+
if ($('#btn-broker-ai-help')) $('#btn-broker-ai-help').textContent = `🤖 Помочь с подключением (${brkName})`;
|
|
2371
|
+
}
|
|
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
|
+
|
|
2676
2401
|
function updateBrokerSaveBtnState() {
|
|
2677
2402
|
const btn = $('#broker-save-btn');
|
|
2678
2403
|
if (!btn) return;
|
|
2679
2404
|
if (!editingBrokerId) {
|
|
2680
2405
|
const f = brokerForm.elements;
|
|
2681
|
-
const
|
|
2406
|
+
const isManualCapable = ['sber', 'vtb', 'alfa'].includes(f.broker.value);
|
|
2407
|
+
const hasNewInput = Boolean(f.token.value.trim() || f.accountId.value.trim() || f.name.value.trim() || isManualCapable);
|
|
2682
2408
|
btn.disabled = !hasNewInput;
|
|
2683
2409
|
btn.classList.toggle('primary', hasNewInput);
|
|
2684
2410
|
btn.title = hasNewInput ? '' : 'Заполните токен или данные счёта';
|
|
@@ -2691,87 +2417,142 @@ function updateBrokerSaveBtnState() {
|
|
|
2691
2417
|
}
|
|
2692
2418
|
|
|
2693
2419
|
brokerForm.addEventListener('input', updateBrokerSaveBtnState);
|
|
2694
|
-
brokerForm.addEventListener('change',
|
|
2420
|
+
brokerForm.addEventListener('change', () => {
|
|
2421
|
+
syncBrokerGuideUi();
|
|
2422
|
+
updateBrokerSaveBtnState();
|
|
2423
|
+
});
|
|
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
|
+
}
|
|
2695
2480
|
|
|
2696
2481
|
function startBrokerEdit(acc) {
|
|
2697
2482
|
editingBrokerId = acc.id;
|
|
2483
|
+
openBrokerId = acc.id;
|
|
2698
2484
|
const f = brokerForm.elements;
|
|
2699
2485
|
f.id.value = acc.id;
|
|
2700
2486
|
f.name.value = acc.name;
|
|
2701
2487
|
f.broker.value = acc.broker;
|
|
2702
2488
|
f.accountId.value = acc.accountId || '';
|
|
2703
|
-
f.token.value = '';
|
|
2489
|
+
f.token.value = acc.token || '';
|
|
2704
2490
|
f.mode.value = acc.mode || 'confirm';
|
|
2705
2491
|
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2706
|
-
if (acc.baseRunId && f.baseRunId && !Array.from(f.baseRunId.options).some((o) => o.value === String(acc.baseRunId))) {
|
|
2707
|
-
const opt = document.createElement('option');
|
|
2708
|
-
opt.value = String(acc.baseRunId);
|
|
2709
|
-
opt.textContent = acc.baseRunLabel || `№${acc.baseRunId}`;
|
|
2710
|
-
f.baseRunId.appendChild(opt);
|
|
2711
|
-
}
|
|
2712
|
-
f.baseRunId.value = acc.baseRunId ? String(acc.baseRunId) : '';
|
|
2713
2492
|
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
2714
2493
|
f.maxDailyDrawdownPct.value = acc.maxDailyDrawdownPct || 3;
|
|
2715
2494
|
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2716
2495
|
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2717
2496
|
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
2718
2497
|
f.sandbox.checked = !!acc.sandbox;
|
|
2719
|
-
$('#broker-add-
|
|
2720
|
-
$('#broker-add-sum').textContent = `Изменить счёт «${acc.name}»`;
|
|
2498
|
+
$('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
|
|
2721
2499
|
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
2722
2500
|
$('#broker-cancel-btn').hidden = false;
|
|
2501
|
+
if ($('#broker-delete-btn')) $('#broker-delete-btn').hidden = false;
|
|
2502
|
+
syncBrokerGuideUi();
|
|
2723
2503
|
brokerInitialSnap = getBrokerFormSnap();
|
|
2724
2504
|
updateBrokerSaveBtnState();
|
|
2505
|
+
renderBrokers();
|
|
2506
|
+
if (acc.broker === 'tbank' && (acc.token || acc.hasToken)) {
|
|
2507
|
+
runBrokerDiscover({ silent: true });
|
|
2508
|
+
}
|
|
2725
2509
|
}
|
|
2726
2510
|
|
|
2727
2511
|
function stopBrokerEdit() {
|
|
2512
|
+
const wasEditing = editingBrokerId !== null;
|
|
2728
2513
|
editingBrokerId = null;
|
|
2729
2514
|
brokerForm.reset();
|
|
2730
2515
|
brokerForm.elements.id.value = '';
|
|
2731
2516
|
if ($('#broker-discovered')) { $('#broker-discovered').hidden = true; $('#broker-discovered').innerHTML = ''; }
|
|
2732
2517
|
$('#broker-add-sum').textContent = 'Добавить брокерский счёт';
|
|
2733
2518
|
$('#broker-save-btn').textContent = 'Сохранить счёт';
|
|
2734
|
-
$('#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
|
+
}
|
|
2526
|
+
syncBrokerGuideUi();
|
|
2735
2527
|
brokerInitialSnap = getBrokerFormSnap();
|
|
2736
2528
|
updateBrokerSaveBtnState();
|
|
2529
|
+
if (wasEditing) renderBrokers();
|
|
2737
2530
|
}
|
|
2738
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
|
+
});
|
|
2544
|
+
syncBrokerGuideUi();
|
|
2739
2545
|
updateBrokerSaveBtnState();
|
|
2740
2546
|
|
|
2741
|
-
// Авто-поиск всех субсчетов в Т-Инвестициях по токену
|
|
2742
|
-
$('#btn-broker-discover')?.addEventListener('click',
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
const f = brokerForm
|
|
2746
|
-
|
|
2747
|
-
const
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
const res = await post('/api/brokers/discover', {
|
|
2751
|
-
id: editingBrokerId || undefined,
|
|
2752
|
-
broker: f.broker.value,
|
|
2753
|
-
token: f.token.value,
|
|
2754
|
-
sandbox: f.sandbox.checked,
|
|
2755
|
-
});
|
|
2756
|
-
const list = res.accounts || [];
|
|
2757
|
-
if (!list.length) {
|
|
2758
|
-
box.hidden = false;
|
|
2759
|
-
box.innerHTML = '<div class="muted small">Открытых счетов по этому токену не найдено.</div>';
|
|
2760
|
-
} else {
|
|
2761
|
-
box.hidden = false;
|
|
2762
|
-
box.innerHTML = `<div class="muted small">Найдено счетов в Т-Инвестициях: <b>${list.length}</b> (нажмите, чтобы выбрать субсчёт):</div>` + list.map((a) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg1)">
|
|
2763
|
-
<div>
|
|
2764
|
-
<b>${esc(a.name)}</b> <span class="tag">${esc(a.typeLabel)}</span> ${a.marginEnabled ? '<span class="tag ok">Маржинальная торговля: вкл.</span>' : '<span class="tag">Маржинальная торговля: выкл. (только в рост)</span>'} ${a.autoFollowDetected ? '<span class="tag no">Автоследование: обнаружено ✕</span>' : '<span class="tag ok">Автоследование: нет ✓</span>'} ${a.accessLabel ? `<span class="muted small">(${esc(a.accessLabel)})</span>` : ''}
|
|
2765
|
-
<div class="muted small">ID: <code>${esc(a.id)}</code>${a.totalRub !== null ? ` · На счёте: <b>${fmtNum(a.totalRub, 0)} ₽</b> (свободно ${fmtNum(a.cashRub, 0)} ₽, бумаг: ${a.positionsCount})` : ''}</div>
|
|
2766
|
-
</div>
|
|
2767
|
-
<button type="button" class="primary small" data-pick-acc="${esc(a.id)}" data-pick-name="${esc(a.name)}">Выбрать этот счёт</button>
|
|
2768
|
-
</div>`).join('');
|
|
2769
|
-
}
|
|
2770
|
-
} catch (err) {
|
|
2771
|
-
toast(err.message, 'error');
|
|
2772
|
-
} finally {
|
|
2773
|
-
btn.disabled = false;
|
|
2774
|
-
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);
|
|
2775
2556
|
}
|
|
2776
2557
|
});
|
|
2777
2558
|
|
|
@@ -2783,6 +2564,13 @@ $('#broker-discovered')?.addEventListener('click', (e) => {
|
|
|
2783
2564
|
if (!f.name.value.trim() || f.name.value.startsWith('Т-Банк · ')) {
|
|
2784
2565
|
f.name.value = `Т-Банк · ${b.dataset.pickName}`;
|
|
2785
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
|
+
});
|
|
2786
2574
|
updateBrokerSaveBtnState();
|
|
2787
2575
|
toast(`Выбран счёт «${b.dataset.pickName}» (${b.dataset.pickAcc})`);
|
|
2788
2576
|
});
|
|
@@ -2798,7 +2586,7 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2798
2586
|
token: f.token.value,
|
|
2799
2587
|
mode: f.mode.value,
|
|
2800
2588
|
legacyMode: f.legacyMode?.value || 'isolate',
|
|
2801
|
-
baseRunId:
|
|
2589
|
+
baseRunId: null,
|
|
2802
2590
|
maxCapitalRub: Number(f.maxCapitalRub.value) || 100000,
|
|
2803
2591
|
maxDailyDrawdownPct: Number(f.maxDailyDrawdownPct.value) || 3,
|
|
2804
2592
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
@@ -2825,48 +2613,6 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2825
2613
|
} catch (err) { toast(err.message, 'error'); }
|
|
2826
2614
|
});
|
|
2827
2615
|
|
|
2828
|
-
$('#brokers-list').addEventListener('click', async (e) => {
|
|
2829
|
-
const b = e.target.closest('button[data-bact]');
|
|
2830
|
-
if (!b) {
|
|
2831
|
-
const fold = e.target.closest('[data-bcfg-fold]');
|
|
2832
|
-
if (fold) {
|
|
2833
|
-
const fid = fold.dataset.bcfgFold;
|
|
2834
|
-
openBrokerCfgId = openBrokerCfgId === fid ? null : fid;
|
|
2835
|
-
saveNavPatch({ openBrokerCfgId });
|
|
2836
|
-
renderBrokers();
|
|
2837
|
-
}
|
|
2838
|
-
return;
|
|
2839
|
-
}
|
|
2840
|
-
const id = b.closest('[data-bid]').dataset.bid;
|
|
2841
|
-
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2842
|
-
if (!acc) return;
|
|
2843
|
-
if (b.dataset.bact === 'edit') {
|
|
2844
|
-
startBrokerEdit(acc);
|
|
2845
|
-
$('#broker-add-box')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
2846
|
-
return;
|
|
2847
|
-
}
|
|
2848
|
-
if (b.dataset.bact === 'del') {
|
|
2849
|
-
if (!confirm(`Удалить брокерский счёт «${acc.name}»?`)) return;
|
|
2850
|
-
try { state.brokers = await api(`/api/brokers/${encodeURIComponent(id)}`, { method: 'DELETE' }); renderBrokers(); } catch (err) { toast(err.message, 'error'); }
|
|
2851
|
-
return;
|
|
2852
|
-
}
|
|
2853
|
-
if (b.dataset.bact === 'sync') {
|
|
2854
|
-
b.disabled = true;
|
|
2855
|
-
const old = b.textContent;
|
|
2856
|
-
b.textContent = 'Проверяем…';
|
|
2857
|
-
try {
|
|
2858
|
-
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
2859
|
-
renderBrokers();
|
|
2860
|
-
toast(`Связь с «${acc.name}» проверена`);
|
|
2861
|
-
} catch (err) {
|
|
2862
|
-
await loadBrokers().catch(() => {});
|
|
2863
|
-
toast(err.message, 'error');
|
|
2864
|
-
} finally {
|
|
2865
|
-
if (b.isConnected) { b.disabled = false; b.textContent = old; }
|
|
2866
|
-
}
|
|
2867
|
-
}
|
|
2868
|
-
});
|
|
2869
|
-
|
|
2870
2616
|
$('#brokers-trade').addEventListener('click', async (e) => {
|
|
2871
2617
|
const fltBtn = e.target.closest('button[data-hfilter]');
|
|
2872
2618
|
if (fltBtn) {
|
|
@@ -2930,6 +2676,7 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2930
2676
|
const fold = e.target.closest('[data-brk-fold]');
|
|
2931
2677
|
if (fold) {
|
|
2932
2678
|
const fid = fold.dataset.brkFold;
|
|
2679
|
+
if (editingBrokerId === fid) stopBrokerEdit();
|
|
2933
2680
|
openBrokerId = openBrokerId === fid ? null : fid;
|
|
2934
2681
|
saveNavPatch({ openBrokerId });
|
|
2935
2682
|
renderBrokers();
|
|
@@ -2939,19 +2686,30 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2939
2686
|
const id = b.dataset.bid;
|
|
2940
2687
|
const act = b.dataset.btrade;
|
|
2941
2688
|
if (act === 'cfg') {
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
startBrokerEdit(acc);
|
|
2947
|
-
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);
|
|
2948
2694
|
}
|
|
2949
2695
|
return;
|
|
2950
2696
|
}
|
|
2951
2697
|
b.disabled = true;
|
|
2952
2698
|
const old = b.textContent;
|
|
2953
2699
|
try {
|
|
2954
|
-
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') {
|
|
2955
2713
|
if (!b.classList.contains('brk-icon-btn')) b.textContent = 'Обновляем…';
|
|
2956
2714
|
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/sync`);
|
|
2957
2715
|
toast('Портфель обновлён');
|
|
@@ -2979,6 +2737,12 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2979
2737
|
toast('Заявки отправлены брокеру');
|
|
2980
2738
|
} else if (act === 'rej') {
|
|
2981
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' ? 'Все активные заявки сняты со стакана' : 'Заявка снята со стакана');
|
|
2982
2746
|
}
|
|
2983
2747
|
renderBrokers();
|
|
2984
2748
|
} catch (err) {
|
|
@@ -3016,7 +2780,6 @@ function connectWs() {
|
|
|
3016
2780
|
const m = JSON.parse(ev.data);
|
|
3017
2781
|
switch (m.type) {
|
|
3018
2782
|
case 'hello':
|
|
3019
|
-
renderLive(m.live);
|
|
3020
2783
|
refreshForecastIfStale();
|
|
3021
2784
|
// если исследование идёт на сервере (запущено раньше или Стратегом) — показать кнопку отмены
|
|
3022
2785
|
if (!state.active && m.running?.length) {
|
|
@@ -3051,13 +2814,12 @@ function connectWs() {
|
|
|
3051
2814
|
case 'settings:update':
|
|
3052
2815
|
state.settings = m.settings;
|
|
3053
2816
|
fillForm(m.settings);
|
|
3054
|
-
loadHistory().catch(() => {});
|
|
3055
2817
|
if (m.source === 'ai') {
|
|
3056
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'); } }
|
|
3057
2819
|
toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
|
|
3058
2820
|
}
|
|
3059
2821
|
break;
|
|
3060
|
-
case 'journal:update':
|
|
2822
|
+
case 'journal:update': break;
|
|
3061
2823
|
case 'research:start':
|
|
3062
2824
|
state.active = m.runId;
|
|
3063
2825
|
resetLiveTrain(m.runId);
|
|
@@ -3102,28 +2864,30 @@ function connectWs() {
|
|
|
3102
2864
|
loadRuns(m.runId);
|
|
3103
2865
|
break;
|
|
3104
2866
|
case 'profiles:progress':
|
|
3105
|
-
state.profilesBusy = true;
|
|
3106
|
-
$('#btn-profiles').disabled = true;
|
|
3107
|
-
$('#profiles-msg').textContent = `Составляем описания компаний: ${m.done} из ${m.total}`;
|
|
3108
|
-
updateTabBadges();
|
|
3109
|
-
break;
|
|
3110
2867
|
case 'profiles:done':
|
|
3111
|
-
state.profilesBusy = false;
|
|
3112
|
-
$('#btn-profiles').disabled = false;
|
|
3113
|
-
state.profiles = null; // перечитаем с сервера
|
|
3114
|
-
updateTabBadges();
|
|
3115
|
-
loadProfilesData().then(() => { if (!$('#tab-map').hidden) drawMap(); });
|
|
3116
|
-
toast(m.error ? `Описания компаний: ошибка — ${m.error}` : `Описания компаний готовы (${m.count})`, m.error ? 'error' : 'info');
|
|
3117
2868
|
break;
|
|
3118
|
-
case 'paper:step':
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
2869
|
+
case 'paper:step':
|
|
2870
|
+
case 'paper:status':
|
|
2871
|
+
case 'paper:started':
|
|
2872
|
+
case 'paper:stopped':
|
|
2873
|
+
case 'paper:fill':
|
|
3123
2874
|
break;
|
|
3124
|
-
|
|
3125
|
-
case 'news:update': if (!$('#tab-now').hidden) loadNews(); break;
|
|
2875
|
+
case 'news:update': loadNews(); break;
|
|
3126
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
|
+
}
|
|
3127
2891
|
case 'stability:start':
|
|
3128
2892
|
stabilityUi(m.runId);
|
|
3129
2893
|
setProgress(2, `Проверка устойчивости эксперимента №${m.runId}…`);
|
|
@@ -3144,9 +2908,6 @@ function connectWs() {
|
|
|
3144
2908
|
stabilityUi(null);
|
|
3145
2909
|
toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
|
|
3146
2910
|
break;
|
|
3147
|
-
case 'paper:status': renderLive(m.status); break;
|
|
3148
|
-
case 'paper:started': case 'paper:stopped': api('/api/live/status').then(renderLive); break;
|
|
3149
|
-
case 'paper:fill': toast(`Живая проверка: куплено ${m.position.symbol}`); break;
|
|
3150
2911
|
case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
|
|
3151
2912
|
}
|
|
3152
2913
|
};
|
|
@@ -3255,21 +3016,22 @@ async function renderCloudPane() {
|
|
|
3255
3016
|
}
|
|
3256
3017
|
|
|
3257
3018
|
const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
|
|
3258
|
-
? 'без ограничений
|
|
3019
|
+
? 'по согласованию / без ограничений'
|
|
3259
3020
|
: u.limits?.maxCapitalPerAccountRub > 0
|
|
3260
|
-
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на
|
|
3261
|
-
: '
|
|
3021
|
+
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
|
|
3022
|
+
: 'подписка не активна';
|
|
3262
3023
|
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
3263
3024
|
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
3264
|
-
: 'без ограничений (
|
|
3025
|
+
: (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
|
|
3265
3026
|
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
3027
|
+
updateTabBadges();
|
|
3266
3028
|
|
|
3267
3029
|
box.innerHTML = `
|
|
3268
3030
|
<div class="srow sblock">
|
|
3269
3031
|
<div class="stxt">
|
|
3270
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>
|
|
3271
3033
|
<div class="hint">
|
|
3272
|
-
Брокерских счетов: до <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)}` : ''}.
|
|
3273
3035
|
</div>
|
|
3274
3036
|
</div>
|
|
3275
3037
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -3281,10 +3043,9 @@ async function renderCloudPane() {
|
|
|
3281
3043
|
<div class="stxt">
|
|
3282
3044
|
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
3283
3045
|
<div class="hint">
|
|
3284
|
-
• <b
|
|
3285
|
-
• <b
|
|
3286
|
-
• <b
|
|
3287
|
-
• <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% годовых.
|
|
3288
3049
|
</div>
|
|
3289
3050
|
</div>
|
|
3290
3051
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -3292,7 +3053,7 @@ async function renderCloudPane() {
|
|
|
3292
3053
|
<button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
|
|
3293
3054
|
<button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
|
|
3294
3055
|
<button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
|
|
3295
|
-
<a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none"
|
|
3056
|
+
<a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Согласовать VIP →</a>
|
|
3296
3057
|
</div>
|
|
3297
3058
|
</div>
|
|
3298
3059
|
<div class="srow sblock">
|
|
@@ -3376,7 +3137,6 @@ function applyTheme(light) {
|
|
|
3376
3137
|
};
|
|
3377
3138
|
eqChart.applyOptions(chartOpts);
|
|
3378
3139
|
icChart.applyOptions(chartOpts);
|
|
3379
|
-
if (!$('#tab-map').hidden) drawMap();
|
|
3380
3140
|
}
|
|
3381
3141
|
$('#btn-theme')?.addEventListener('click', () => {
|
|
3382
3142
|
const next = !document.body.classList.contains('light');
|
|
@@ -3422,14 +3182,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
3422
3182
|
// ---------- старт и восстановление сохранённого места в интерфейсе ----------
|
|
3423
3183
|
(async function init() {
|
|
3424
3184
|
state.config = await api('/api/config');
|
|
3185
|
+
updateTabBadges();
|
|
3425
3186
|
buildForm();
|
|
3426
3187
|
state.settings = await api('/api/settings');
|
|
3427
3188
|
fillForm(state.settings);
|
|
3428
|
-
loadHistory().catch(() => {});
|
|
3429
3189
|
connectWs();
|
|
3430
|
-
renderLive(await api('/api/live/status'));
|
|
3431
3190
|
loadBrokers().catch(() => {});
|
|
3432
|
-
|
|
3191
|
+
loadNews().catch(() => {});
|
|
3433
3192
|
renderCloudPane().catch(() => {});
|
|
3434
3193
|
const urlParams = new URLSearchParams(location.search);
|
|
3435
3194
|
if (urlParams.get('payment_check') === '1') {
|
|
@@ -3439,16 +3198,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
3439
3198
|
}).catch(() => {});
|
|
3440
3199
|
}
|
|
3441
3200
|
const savedNav = loadSavedNav();
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
if (
|
|
3447
|
-
|
|
3448
|
-
if (savedNav.
|
|
3449
|
-
|
|
3450
|
-
|
|
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');
|
|
3451
3211
|
}
|
|
3452
|
-
// В фоне подгружаем актуальный 10-минутный прогноз с серверного кэша (чтобы он сразу был готов и в «Прогноз сейчас», и в бейджах брокерского портфеля)
|
|
3453
3212
|
setTimeout(() => refreshForecastIfStale(), 600);
|
|
3454
3213
|
})().catch((e) => toast(e.message, 'error'));
|