neuroalpha 0.3.16 → 0.5.1
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 +0 -1
- package/config/certs/russian_trusted_root_ca.pem +33 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -6
- 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 +2 -2
- package/package.json +60 -53
- package/public/activate.html +36 -0
- package/public/activate.js +33 -0
- package/public/admin.html +126 -7
- package/public/app.js +608 -182
- package/public/index.html +57 -47
- package/public/landing.html +106 -37
- package/public/legal.html +4 -4
- package/public/styles.css +152 -36
- package/server/api/routes.js +308 -37
- package/server/auth/index.js +79 -15
- package/server/brokers/index.js +1022 -172
- package/server/build-info.js +7 -0
- package/server/config.js +13 -6
- package/server/data/moex.js +6 -4
- package/server/db.js +22 -18
- package/server/desktop-entry.js +103 -0
- package/server/hub.js +28 -14
- package/server/index.js +82 -12
- package/server/instance/agent.js +259 -0
- package/server/instance/central.js +491 -0
- package/server/instance/identity.js +79 -0
- package/server/instance/provision.js +166 -0
- package/server/instance/signing.js +71 -0
- package/server/model/edge.js +19 -0
- package/server/model/forecast.js +107 -57
- package/server/model/live.js +1 -1
- package/server/model/models.js +12 -3
- package/server/model/research.js +218 -30
- package/server/notify/index.js +51 -16
- package/server/params-meta.js +3 -3
- package/server/role.js +44 -0
- package/server/security.js +40 -0
- package/server/strategist/index.js +462 -488
- package/server/strategist/tools.js +675 -532
- package/server/tls-ca.js +32 -0
package/public/app.js
CHANGED
|
@@ -4,7 +4,13 @@ const MSK = 3 * 3600000;
|
|
|
4
4
|
|
|
5
5
|
// ---------- утилиты ----------
|
|
6
6
|
const api = async (url, opts = {}) => {
|
|
7
|
-
const
|
|
7
|
+
const timeoutMs = opts.timeoutMs || 35000;
|
|
8
|
+
const res = await fetch(url, {
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
11
|
+
...opts,
|
|
12
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
13
|
+
});
|
|
8
14
|
const j = await res.json().catch(() => ({}));
|
|
9
15
|
if (!res.ok) throw new Error(j.error || res.statusText);
|
|
10
16
|
return j;
|
|
@@ -43,9 +49,30 @@ function fmtDayGroup(ts) {
|
|
|
43
49
|
}
|
|
44
50
|
const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
|
|
45
51
|
const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
|
|
52
|
+
const KNOWN_MOEX_TICKERS = new Set([
|
|
53
|
+
'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
|
|
54
|
+
'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
|
|
55
|
+
'OZON', 'HEAD', 'PHOR', 'AFLT', 'VKCO', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'AFKS',
|
|
56
|
+
'PIKK', 'SMLT', 'POSI', 'BSPB', 'SVCB', 'CBOM', 'FLOT', 'X5', 'FEES', 'HYDR',
|
|
57
|
+
'TRMK', 'MRKC', 'MRKP', 'MRKV', 'MRKU', 'MRKZ', 'NKHP', 'NKNCP', 'NKNC', 'SFIN',
|
|
58
|
+
'AQUA', 'FESH', 'ASTR', 'SOFL', 'DATA', 'WUSH', 'LENT', 'BELU', 'ETLN', 'LSRG',
|
|
59
|
+
'SELG', 'UGLD', 'RAGR', 'AGRO', 'FIVE', 'NMTP', 'UPRO', 'MSNG', 'ELFV', 'TGKA',
|
|
60
|
+
'BANEP', 'BANE', 'RNFT', 'SGZH', 'MTLR', 'MTLRP', 'RASP', 'VSMO', 'ENPG', 'POLY',
|
|
61
|
+
'GEMC', 'MDMG', 'PRMD', 'APTK', 'CNRU', 'DELI', 'DIAS', 'EUTR', 'FIXP', 'HNFG',
|
|
62
|
+
'IVAT', 'LEAS', 'MBNK', 'MVID', 'OGKB', 'OKEY', 'RENI', 'RTKM', 'RTKMP', 'SPBE', 'SVAV',
|
|
63
|
+
]);
|
|
64
|
+
// Текст ИИ и новостей недоверенный: HTML после markdown всегда проходит через DOMPurify.
|
|
65
|
+
// Если библиотека очистки не загрузилась — показываем только экранированный текст.
|
|
46
66
|
const md = (s) => {
|
|
47
|
-
|
|
48
|
-
|
|
67
|
+
if (!window.marked || !window.DOMPurify) return `<p>${esc(s).replace(/\n/g, '<br>')}</p>`;
|
|
68
|
+
const raw = marked.parse(String(s || ''), { breaks: true });
|
|
69
|
+
const withRuns = raw.replace(/№(\d{1,6})\b/g, '<span class="link" data-run="$1" title="Открыть эксперимент №$1">№$1</span>');
|
|
70
|
+
const withSyms = withRuns.replace(/(^|[^A-Za-z0-9_/"'=])([A-Z]{2,6})(?=$|[^A-Za-z0-9_])/g, (m, p1, sym) => (
|
|
71
|
+
KNOWN_MOEX_TICKERS.has(sym)
|
|
72
|
+
? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
|
|
73
|
+
: m
|
|
74
|
+
));
|
|
75
|
+
return DOMPurify.sanitize(withSyms, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
|
|
49
76
|
};
|
|
50
77
|
const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
|
|
51
78
|
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
|
@@ -165,8 +192,7 @@ function updateTabBadges() {
|
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
function showMainSec(sec) {
|
|
168
|
-
if (sec === 'now') sec = 'brokers';
|
|
169
|
-
if (sec === 'settings' || sec === 'g1') sec = 'g0';
|
|
195
|
+
if (sec === 'now' || sec === 'settings' || sec === 'g0' || sec === 'g1') sec = 'brokers';
|
|
170
196
|
if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
|
|
171
197
|
document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
|
|
172
198
|
document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
|
|
@@ -372,7 +398,11 @@ function syncUiAvailability() {
|
|
|
372
398
|
if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
|
|
373
399
|
if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
|
|
374
400
|
|
|
375
|
-
// 3. Кнопки обращения к Стратегу в прогнозе
|
|
401
|
+
// 3. Кнопки обновления и обращения к Стратегу в прогнозе
|
|
402
|
+
if ($('#btn-refresh-forecast')) {
|
|
403
|
+
$('#btn-refresh-forecast').disabled = Boolean(forecastBusy);
|
|
404
|
+
$('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
|
|
405
|
+
}
|
|
376
406
|
if ($('#btn-ask-forecast')) {
|
|
377
407
|
$('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
|
|
378
408
|
$('#btn-ask-forecast').title = !state.forecast
|
|
@@ -391,35 +421,8 @@ function syncUiAvailability() {
|
|
|
391
421
|
}
|
|
392
422
|
|
|
393
423
|
function syncSettingsDependencies() {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const s = state.settings;
|
|
397
|
-
const autoOn = s.modelAutoAdapt !== false;
|
|
398
|
-
const dir = s.modelDirection || 'long';
|
|
399
|
-
|
|
400
|
-
const setRowEnabled = (key, enabled, whyDisabled = '') => {
|
|
401
|
-
const row = form.querySelector(`.srow[data-key="${key}"]`);
|
|
402
|
-
if (!row) return;
|
|
403
|
-
row.style.opacity = enabled ? '1' : '0.45';
|
|
404
|
-
const ctrl = form.elements[key];
|
|
405
|
-
if (ctrl) {
|
|
406
|
-
ctrl.disabled = !enabled;
|
|
407
|
-
ctrl.title = enabled ? '' : whyDisabled;
|
|
408
|
-
}
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
|
|
412
|
-
setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
413
|
-
setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
|
|
414
|
-
setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
|
|
415
|
-
setRowEnabled('modelMarketFilter', true, '');
|
|
416
|
-
|
|
417
|
-
const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
|
|
418
|
-
if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
|
|
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)';
|
|
424
|
+
if ($('#chk-news-filter') && state.settings) {
|
|
425
|
+
$('#chk-news-filter').checked = Boolean(state.settings.newsFilter);
|
|
423
426
|
}
|
|
424
427
|
}
|
|
425
428
|
function syncShowcaseBtn() {
|
|
@@ -475,10 +478,12 @@ function verdictHtml(c, oosA) {
|
|
|
475
478
|
: '';
|
|
476
479
|
let vs = '';
|
|
477
480
|
if (base) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
481
|
+
const ar = a.realisticReturnPct ?? a.returnPct, br = base.realisticReturnPct ?? base.returnPct;
|
|
482
|
+
const pf = a.realisticProfitFactor ?? a.profitFactor ?? 0, bpf = base.realisticProfitFactor ?? base.profitFactor ?? 0;
|
|
483
|
+
const lbl = a.realisticReturnPct !== undefined && a.realisticReturnPct !== null ? ' при реалистичном исполнении' : '';
|
|
484
|
+
if (ar - br > 5 && pf >= bpf) vs = ` · Карта рынка даёт преимущество над простой инерцией${lbl} (<b>${fmtPct(ar)}</b> против <b>${fmtPct(br)}</b>)`;
|
|
485
|
+
else if (Math.abs(ar - br) <= 5) vs = ` · Карта рынка и инерция дали близкий результат${lbl} (${fmtPct(ar)} и ${fmtPct(br)})`;
|
|
486
|
+
else vs = ` · Простая инерция опередила карту рынка${lbl} (${fmtPct(br)} против ${fmtPct(ar)})`;
|
|
482
487
|
}
|
|
483
488
|
if (a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) {
|
|
484
489
|
return `<span class="tag ok">Стратегия прибыльна</span> Превосходит индекс Мосбиржи на <b class="pos">${fmtPct(a.returnPct - a.marketPct)}</b>${vs}${oosPart}`;
|
|
@@ -545,6 +550,31 @@ function renderStability(st) {
|
|
|
545
550
|
box.innerHTML = `<div class="stab-head"><span class="tag ${vCls}">${vLbl}</span><span><b>Медиана ${fmtPct(st.medianPct)}</b> · разброс от <span class="${cls(st.minPct)}">${fmtPct(st.minPct)}</span> до <span class="${cls(st.maxPct)}">${fmtPct(st.maxPct)}</span> · в плюсе ${st.positive} из ${st.count} · худшая просадка ${fmtNum(st.maxDrawdownPct, 1)}%</span><span class="muted small">(${kindLbl})</span></div><div class="stab-chips">${chips}</div>`;
|
|
546
551
|
}
|
|
547
552
|
|
|
553
|
+
// Стресс-тест исполнения: та же стратегия с реалистичными издержками, задержкой входа, ликвидностью, лотами и шортами
|
|
554
|
+
function renderStress(sx) {
|
|
555
|
+
const box = $('#stress');
|
|
556
|
+
if (!box) return;
|
|
557
|
+
if (!sx?.scenarios?.length) { box.hidden = true; return; }
|
|
558
|
+
box.hidden = false;
|
|
559
|
+
const base = sx.scenarios.find((s) => s.key === 'base');
|
|
560
|
+
const real = sx.scenarios.find((s) => s.key === 'realistic');
|
|
561
|
+
const verdict = !real ? '' : real.full.returnPct > (sx.market?.full ?? 0) && (real.full.profitFactor ?? 0) > 1.1
|
|
562
|
+
? '<span class="tag ok">Держится при реалистичном исполнении</span>'
|
|
563
|
+
: real.full.returnPct > 0 ? '<span class="tag mid">При реалистичном исполнении преимущество слабое</span>'
|
|
564
|
+
: '<span class="tag no">При реалистичном исполнении — убыток</span>';
|
|
565
|
+
const cell = (v, d = 1, pct = true) => (v === null || v === undefined ? '<span class="muted">—</span>' : `<span class="${pct ? cls(v) : ''}">${pct ? fmtPct(v, d) : fmtNum(v, d)}</span>`);
|
|
566
|
+
const rows = sx.scenarios.map((s) => `<tr${s.key === 'realistic' ? ' style="font-weight:600"' : ''}>
|
|
567
|
+
<td title="${esc(s.note || '')}">${esc(s.label)}</td>
|
|
568
|
+
<td class="num">${cell(s.full.returnPct)}</td><td class="num">${cell(s.full.cagrPct)}</td>
|
|
569
|
+
<td class="num">${cell(s.full.profitFactor, 2, false)}</td><td class="num">${cell(s.full.maxDrawdownPct === null ? null : -s.full.maxDrawdownPct)}</td>
|
|
570
|
+
<td class="num">${fmtNum(s.full.trades, 0)}</td><td class="num">${cell(s.full.avgTradePct, 3)}</td>
|
|
571
|
+
<td class="num">${cell(s.last12.returnPct)}</td><td class="num">${cell(s.last6.returnPct)}</td></tr>`).join('');
|
|
572
|
+
box.innerHTML = `<div class="stab-head">${verdict}<span><b>Стресс-тест исполнения</b>${base && real ? ` · итог ${fmtPct(base.full.returnPct)} → <b class="${cls(real.full.returnPct)}">${fmtPct(real.full.returnPct)}</b> при реалистичном исполнении` : ''} · рынок за период ${fmtPct(sx.market?.full)}</span></div>
|
|
573
|
+
<div style="overflow-x:auto"><table class="cmp"><thead><tr><th>Сценарий</th><th class="num">Итог</th><th class="num">Годовых</th><th class="num">PF</th><th class="num">Просадка</th><th class="num">Сделок</th><th class="num">Ср. сделка</th><th class="num">12 мес.</th><th class="num">6 мес.</th></tr></thead><tbody>${rows}
|
|
574
|
+
<tr class="muted"><td>Рынок (индекс МосБиржи)</td><td class="num">${cell(sx.market?.full)}</td><td></td><td></td><td></td><td></td><td></td><td class="num">${cell(sx.market?.last12)}</td><td class="num">${cell(sx.market?.last6)}</td></tr></tbody></table></div>
|
|
575
|
+
<p class="muted small note">${(sx.caveats || []).map(esc).join(' ')}</p>`;
|
|
576
|
+
}
|
|
577
|
+
|
|
548
578
|
// Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
|
|
549
579
|
const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
|
|
550
580
|
|
|
@@ -557,6 +587,16 @@ function resetLiveTrain(runId) {
|
|
|
557
587
|
liveTrainState.text = '';
|
|
558
588
|
}
|
|
559
589
|
|
|
590
|
+
function layout(f, W, H) {
|
|
591
|
+
const xs = f.pts.map((p) => p[0]), ys = f.pts.map((p) => p[1]);
|
|
592
|
+
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
593
|
+
const pad = 28;
|
|
594
|
+
return f.pts.map(([x, y]) => [
|
|
595
|
+
pad + ((x - x0) / (x1 - x0 || 1)) * (W - 2 * pad),
|
|
596
|
+
H - pad - ((y - y0) / (y1 - y0 || 1)) * (H - 2 * pad),
|
|
597
|
+
]);
|
|
598
|
+
}
|
|
599
|
+
|
|
560
600
|
function drawLiveTrainMap(viz) {
|
|
561
601
|
const cv = $('#ld-map-cv');
|
|
562
602
|
if (!cv || !viz?.pts?.length) return;
|
|
@@ -565,7 +605,7 @@ function drawLiveTrainMap(viz) {
|
|
|
565
605
|
cv.width = W * dpr; cv.height = H * dpr;
|
|
566
606
|
const ctx = cv.getContext('2d');
|
|
567
607
|
ctx.scale(dpr, dpr);
|
|
568
|
-
const syms = liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
|
|
608
|
+
const syms = viz.symbols?.length ? viz.symbols : liveTrainState.symbols.length ? liveTrainState.symbols : viz.pts.map((_, i) => `#${i + 1}`);
|
|
569
609
|
const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
|
|
570
610
|
const xy = layout(f, W, H);
|
|
571
611
|
const light = document.body.classList.contains('light');
|
|
@@ -656,7 +696,7 @@ function renderLiveTraining() {
|
|
|
656
696
|
|
|
657
697
|
empty.innerHTML = `
|
|
658
698
|
${calcBanner}
|
|
659
|
-
<div class="kpis" style="
|
|
699
|
+
<div class="kpis" style="margin-bottom:12px">
|
|
660
700
|
<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>
|
|
661
701
|
<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>
|
|
662
702
|
<div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
|
|
@@ -764,6 +804,7 @@ function renderOverview() {
|
|
|
764
804
|
].join('');
|
|
765
805
|
$('#sides').hidden = true;
|
|
766
806
|
renderStability(ex.stability);
|
|
807
|
+
renderStress(ex.stress);
|
|
767
808
|
|
|
768
809
|
const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
|
|
769
810
|
for (const n of ['market', ...MODELS]) eqLines[n].setData(uniq(ex.curves[n]).map((p) => ({ time: chartTime(p.ts), value: p.equity })));
|
|
@@ -776,11 +817,18 @@ function renderOverview() {
|
|
|
776
817
|
|
|
777
818
|
renderPeriods();
|
|
778
819
|
|
|
779
|
-
const
|
|
820
|
+
const hasReal = MODELS.some((n) => c[n]?.realisticReturnPct !== undefined && c[n]?.realisticReturnPct !== null);
|
|
821
|
+
const best = MODELS.reduce((b, n) => {
|
|
822
|
+
if (!c[n]) return b;
|
|
823
|
+
if (!b) return n;
|
|
824
|
+
const vn = hasReal ? (c[n].realisticReturnPct ?? c[n].returnPct) : c[n].returnPct;
|
|
825
|
+
const vb = hasReal ? (c[b].realisticReturnPct ?? c[b].returnPct) : c[b].returnPct;
|
|
826
|
+
return vn > vb ? n : b;
|
|
827
|
+
}, null);
|
|
780
828
|
const th = (t, h, num = true) => `<th class="${num ? 'num' : ''}" title="${esc(h)}">${t}</th>`;
|
|
781
|
-
$('#cmp').innerHTML = `<table><thead><tr>${th('Стратегия', '', false)}${th('Совпадение', 'Насколько порядок акций «кто сильнее рынка» по прогнозу совпадает с фактом (−1…1). На рынке 0,03+ уже ценно.')}${th('Недель с плюсом', 'В какой доле недель прогноз угадывал лучше случайного')}${th('Итог', '
|
|
829
|
+
$('#cmp').innerHTML = `<table><thead><tr>${th('Стратегия', '', false)}${th('Совпадение', 'Насколько порядок акций «кто сильнее рынка» по прогнозу совпадает с фактом (−1…1). На рынке 0,03+ уже ценно.')}${th('Недель с плюсом', 'В какой доле недель прогноз угадывал лучше случайного')}${hasReal ? th('Реалистично', 'Исполнение как в живом контуре: издержки по эшелонам (0,065% / 0,125%), вход внутри свечи, ≤5% оборота свечи, целые лоты, шорты по списку Т-Банка') : ''}${th('Итог (отчёт)', 'При издержках 0,05% на сторону и входе по цене открытия')}${th('Без комиссий', 'Если бы торговля была бесплатной — есть ли сигнал вообще')}${th('Сделок', '')}${th('На 1 ₽ убытка', 'Сколько заработано на каждый потерянный рубль')}</tr></thead><tbody>${
|
|
782
830
|
MODELS.map((n) => c[n] && `<tr class="${n === best ? 'best' : ''}"><td title="${esc(MODEL_HINT[n])}">${MODEL_LABEL[n]}${n === chosen ? ' <span class="tag">торгует</span>' : ''}</td>
|
|
783
|
-
<td class="num">${fmtNum(c[n].ic, 3)}</td><td class="num">${fmtNum(c[n].weeksPositivePct, 0)}%</td
|
|
831
|
+
<td class="num">${fmtNum(c[n].ic, 3)}</td><td class="num">${fmtNum(c[n].weeksPositivePct, 0)}%</td>${hasReal ? `<td class="num ${cls(c[n].realisticReturnPct)}" title="PF при реалистичном исполнении: ${fmtNum(c[n].realisticProfitFactor)}">${c[n].realisticReturnPct !== null && c[n].realisticReturnPct !== undefined ? fmtPct(c[n].realisticReturnPct) : '—'}</td>` : ''}<td class="num ${cls(c[n].returnPct)}">${fmtPct(c[n].returnPct)}</td>
|
|
784
832
|
<td class="num ${cls(c[n].grossReturnPct)}">${fmtPct(c[n].grossReturnPct)}</td><td class="num">${c[n].trades}</td><td class="num">${fmtNum(c[n].profitFactor)}</td></tr>`).join('')}</tbody></table>`;
|
|
785
833
|
|
|
786
834
|
const w = uniq(ex.weeklyIc?.[chosen]);
|
|
@@ -798,7 +846,7 @@ function renderOverview() {
|
|
|
798
846
|
$('#trades-sum').textContent = `Сделки торгующей стратегии (${trades.length})`;
|
|
799
847
|
const shorts = trades.some((t) => t.side === 'short');
|
|
800
848
|
$('#trades').innerHTML = trades.length ? `<table><thead><tr><th>Акция</th>${shorts ? '<th></th>' : ''}<th>${shorts ? 'Открыли' : 'Купили'}</th><th>${shorts ? 'Закрыли' : 'Продали'}</th><th class="num">Результат</th><th class="num">%</th></tr></thead><tbody>${
|
|
801
|
-
trades.slice(-300).reverse().map((t) => `<tr><td>${t.symbol}</td>${shorts ? `<td class="muted small">${t.side === 'short' ? 'продажа' : 'покупка'}</td>` : ''}<td>${fmtTs(t.entry_ts)}</td><td>${fmtTs(t.exit_ts)}</td><td class="num ${cls(t.pnl)}">${fmtNum(t.pnl, 0)} ₽</td><td class="num ${cls(t.pnl_pct)}">${fmtPct(t.pnl_pct, 2)}</td></tr>`).join('')}</tbody></table>${trades.length > 300 ? '<p class="muted small">Показаны последние 300.</p>' : ''}` : '<p class="muted">Сделок нет.</p>';
|
|
849
|
+
trades.slice(-300).reverse().map((t) => `<tr><td><span class="sym-link" data-sym="${esc(t.symbol)}" title="Открыть досье акции ${esc(t.symbol)}"><b>${esc(t.symbol)}</b></span></td>${shorts ? `<td class="muted small">${t.side === 'short' ? 'продажа' : 'покупка'}</td>` : ''}<td>${fmtTs(t.entry_ts)}</td><td>${fmtTs(t.exit_ts)}</td><td class="num ${cls(t.pnl)}">${fmtNum(t.pnl, 0)} ₽</td><td class="num ${cls(t.pnl_pct)}">${fmtPct(t.pnl_pct, 2)}</td></tr>`).join('')}</tbody></table>${trades.length > 300 ? '<p class="muted small">Показаны последние 300.</p>' : ''}` : '<p class="muted">Сделок нет.</p>';
|
|
802
850
|
|
|
803
851
|
}
|
|
804
852
|
|
|
@@ -821,6 +869,10 @@ $('#run-bar').addEventListener('click', async (e) => {
|
|
|
821
869
|
sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
|
|
822
870
|
}
|
|
823
871
|
});
|
|
872
|
+
$('#btn-refresh-forecast')?.addEventListener('click', () => {
|
|
873
|
+
forecastBusy = false;
|
|
874
|
+
loadForecast(false, false);
|
|
875
|
+
});
|
|
824
876
|
$('#btn-ask-forecast').addEventListener('click', () => {
|
|
825
877
|
sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
|
|
826
878
|
});
|
|
@@ -877,6 +929,7 @@ $('#btn-cancel').addEventListener('click', async () => {
|
|
|
877
929
|
// СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
|
|
878
930
|
// ======================================================================
|
|
879
931
|
let forecastBusy = false;
|
|
932
|
+
let forecastBusyAt = 0;
|
|
880
933
|
let pulseClearTimer = null;
|
|
881
934
|
const prevClientExp = new Map();
|
|
882
935
|
const prevClientRank = new Map();
|
|
@@ -907,8 +960,10 @@ function triggerTradingPulse(text = 'Обновление котировок и
|
|
|
907
960
|
}
|
|
908
961
|
|
|
909
962
|
async function loadForecast(force, silent = false) {
|
|
910
|
-
if (forecastBusy) return;
|
|
963
|
+
if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
|
|
911
964
|
forecastBusy = true;
|
|
965
|
+
forecastBusyAt = Date.now();
|
|
966
|
+
syncUiAvailability();
|
|
912
967
|
triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
|
|
913
968
|
const box = $('#forecast');
|
|
914
969
|
if (!silent && !state.forecast) {
|
|
@@ -921,7 +976,7 @@ async function loadForecast(force, silent = false) {
|
|
|
921
976
|
</div>`;
|
|
922
977
|
}
|
|
923
978
|
try {
|
|
924
|
-
const f = await api(`/api/model/forecast${force ? '?force=1' : ''}
|
|
979
|
+
const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`, { timeoutMs: force ? 90000 : 18000 });
|
|
925
980
|
// Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
|
|
926
981
|
f.rows.forEach((r, idx) => {
|
|
927
982
|
const rank = idx + 1;
|
|
@@ -961,12 +1016,12 @@ async function loadForecast(force, silent = false) {
|
|
|
961
1016
|
return '<span class="muted small">—</span>';
|
|
962
1017
|
};
|
|
963
1018
|
const row = (r) => `<tr>
|
|
964
|
-
<td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
|
|
965
|
-
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
|
|
1019
|
+
<td><span class="sym-link" data-sym="${esc(r.symbol)}" title="Открыть досье акции ${esc(r.symbol)} (${esc(r.name)})"><b>${esc(r.symbol)}</b> <span class="muted small">${esc(r.name)}</span></span>${rankBadge(r)}${dot(r.news)}</td>
|
|
1020
|
+
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
|
|
966
1021
|
<td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
|
|
967
1022
|
<td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
|
|
968
1023
|
<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>
|
|
1024
|
+
<td class="muted small" style="white-space:nowrap">${(r.influences || []).slice(0, 3).map((x) => `<span class="sym-link" data-sym="${esc(x.symbol)}" title="Сосед по карте рынка: вклад ${x.contribPct > 0 ? '+' : ''}${x.contribPct}%, близость ${x.closeness}">${esc(x.symbol)}</span>`).join(', ')}</td>
|
|
970
1025
|
</tr>`;
|
|
971
1026
|
const buyThr = f.buyThresholdPct ?? f.thresholdPct;
|
|
972
1027
|
const shortThr = f.shortThresholdPct ?? f.thresholdPct;
|
|
@@ -974,9 +1029,9 @@ async function loadForecast(force, silent = false) {
|
|
|
974
1029
|
...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
975
1030
|
...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
976
1031
|
].slice(0, 4);
|
|
977
|
-
box.innerHTML = `<
|
|
978
|
-
<table style="margin-top:14px
|
|
979
|
-
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые
|
|
1032
|
+
box.innerHTML = `<div style="overflow-x:auto"><table><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>
|
|
1033
|
+
<table style="margin-top:14px"><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></div>
|
|
1034
|
+
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
|
|
980
1035
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
981
1036
|
} catch (e) {
|
|
982
1037
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
@@ -985,12 +1040,12 @@ async function loadForecast(force, silent = false) {
|
|
|
985
1040
|
syncUiAvailability();
|
|
986
1041
|
}
|
|
987
1042
|
}
|
|
988
|
-
const FORECAST_TICK_MS =
|
|
1043
|
+
const FORECAST_TICK_MS = 15000; // живое обновление каждые 15 секунд
|
|
989
1044
|
function refreshForecastIfStale() {
|
|
990
|
-
if (forecastBusy) return;
|
|
1045
|
+
if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
|
|
991
1046
|
if (!state.forecast?.at) {
|
|
992
1047
|
loadForecast(false, false);
|
|
993
|
-
} else if (Date.now() - state.forecast.at >=
|
|
1048
|
+
} else if (Date.now() - state.forecast.at >= 12000) {
|
|
994
1049
|
loadForecast(false, true);
|
|
995
1050
|
}
|
|
996
1051
|
}
|
|
@@ -1003,50 +1058,21 @@ async function loadNews() {
|
|
|
1003
1058
|
if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
|
|
1004
1059
|
if ($('#news-list')) {
|
|
1005
1060
|
$('#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('')
|
|
1061
|
+
? items.map((n) => `<div class="news-item">${n.symbols.length ? `${n.symbols.map((s) => `<b class="sym-link ${s.tone >= 0.4 ? 'pos' : s.tone <= -0.4 ? 'neg' : ''}" data-sym="${esc(s.symbol)}" title="Открыть досье акции ${esc(s.symbol)}">${esc(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
1062
|
: '<span class="muted">Свежих размеченных новостей пока нет.</span>';
|
|
1008
1063
|
}
|
|
1009
1064
|
} catch { /* ignore */ }
|
|
1010
1065
|
}
|
|
1011
1066
|
|
|
1012
1067
|
// ======================================================================
|
|
1013
|
-
//
|
|
1068
|
+
// НАСТРОЙКА ФИЛЬТРА НОВОСТЕЙ
|
|
1014
1069
|
// ======================================================================
|
|
1015
|
-
|
|
1016
|
-
['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
|
|
1017
|
-
];
|
|
1018
|
-
function buildForm() {
|
|
1019
|
-
const field = (p) => {
|
|
1020
|
-
let input;
|
|
1021
|
-
if (p.type === 'select') input = `<select name="${p.key}" class="sctrl">${p.options.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select>`;
|
|
1022
|
-
else if (p.type === 'checkbox') input = `<input type="checkbox" name="${p.key}" class="switch">`;
|
|
1023
|
-
else input = `<input type="number" step="${p.step ?? 'any'}" name="${p.key}" min="${p.min}" max="${p.max}" class="sctrl">`;
|
|
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>`;
|
|
1025
|
-
};
|
|
1026
|
-
const shown = state.config.params.filter((p) => !p.hidden);
|
|
1027
|
-
const curSec = document.querySelector('#main-nav [data-sec].active')?.dataset.sec || 'brokers';
|
|
1028
|
-
$('#params').innerHTML = GROUPS.map(([name, keys], i) => {
|
|
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>` : '';
|
|
1032
|
-
}).join('');
|
|
1033
|
-
}
|
|
1070
|
+
function buildForm() {}
|
|
1034
1071
|
function fillForm(s) {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
else el.value = s[el.name];
|
|
1039
|
-
}
|
|
1040
|
-
markDefaults(s);
|
|
1041
|
-
}
|
|
1042
|
-
// Метка «изменено» у параметров, отличающихся от умолчания
|
|
1043
|
-
function markDefaults(s) {
|
|
1044
|
-
const d = state.config?.defaults || {};
|
|
1045
|
-
$('#params').querySelectorAll('[data-def]').forEach((b) => {
|
|
1046
|
-
b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
|
|
1047
|
-
});
|
|
1072
|
+
if ($('#chk-news-filter') && s) {
|
|
1073
|
+
$('#chk-news-filter').checked = Boolean(s.newsFilter);
|
|
1074
|
+
}
|
|
1048
1075
|
syncApplyBtn();
|
|
1049
|
-
syncSettingsDependencies();
|
|
1050
1076
|
updateTabBadges();
|
|
1051
1077
|
}
|
|
1052
1078
|
const saveSettings = async (body) => {
|
|
@@ -1054,28 +1080,30 @@ const saveSettings = async (body) => {
|
|
|
1054
1080
|
const r = await api('/api/settings', { method: 'PUT', body });
|
|
1055
1081
|
if (r.errors?.length) toast(r.errors.join('; '), 'error');
|
|
1056
1082
|
state.settings = r.settings;
|
|
1057
|
-
|
|
1083
|
+
fillForm(state.settings);
|
|
1058
1084
|
} catch (e) { toast(e.message, 'error'); }
|
|
1059
1085
|
};
|
|
1060
1086
|
const saveSetting = (k, v) => saveSettings({ [k]: v });
|
|
1061
|
-
$('#
|
|
1062
|
-
|
|
1063
|
-
$('#params').addEventListener('click', async (e) => {
|
|
1064
|
-
const b = e.target.closest('[data-def]');
|
|
1065
|
-
if (!b) return;
|
|
1066
|
-
await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
|
|
1067
|
-
fillForm(state.settings);
|
|
1087
|
+
$('#chk-news-filter')?.addEventListener('change', (e) => {
|
|
1088
|
+
saveSetting('newsFilter', e.target.checked);
|
|
1068
1089
|
});
|
|
1069
1090
|
|
|
1070
1091
|
// ======================================================================
|
|
1071
1092
|
// ЧАТ СО СТРАТЕГОМ
|
|
1072
1093
|
// ======================================================================
|
|
1073
1094
|
const TOOL_LABELS = {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1095
|
+
get_brokers_status: 'Смотрит брокерские счета и портфель',
|
|
1096
|
+
get_settings: 'Смотрит настройки издержек',
|
|
1097
|
+
list_runs: 'Смотрит список исследований',
|
|
1098
|
+
analyze_run: 'Разбирает исследование',
|
|
1099
|
+
compare_runs: 'Сравнивает исследования',
|
|
1100
|
+
model_forecast: 'Смотрит живой прогноз',
|
|
1101
|
+
get_news: 'Смотрит свежие новости',
|
|
1102
|
+
market_neighbors: 'Смотрит соседей на карте',
|
|
1103
|
+
set_settings: 'Изменение настроек',
|
|
1104
|
+
broker_onboarding_guide: 'Смотрит инструкцию по брокеру',
|
|
1105
|
+
discover_broker_accounts: 'Ищет субсчета по токену',
|
|
1106
|
+
connect_broker_account: 'Подключение брокерского счёта',
|
|
1079
1107
|
};
|
|
1080
1108
|
const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
|
|
1081
1109
|
const feed = $('#feed');
|
|
@@ -1252,34 +1280,28 @@ function renderChatState() {
|
|
|
1252
1280
|
const s = state.chat.state;
|
|
1253
1281
|
if (!s) return;
|
|
1254
1282
|
const noTools = s.tools === false;
|
|
1255
|
-
const curMode = noTools ? 'discuss' : (s.mode in MODE_META ? s.mode : 'advisor');
|
|
1256
|
-
const mm = MODE_META[curMode] || MODE_META.advisor;
|
|
1257
|
-
if ($('#chat-mode-ico')) $('#chat-mode-ico').innerHTML = mm.svg;
|
|
1258
|
-
if ($('#chat-mode-lbl')) $('#chat-mode-lbl').textContent = mm.label;
|
|
1259
|
-
if ($('#chat-mode-btn')) $('#chat-mode-btn').title = noTools ? 'Выбранная модель не умеет пользоваться инструментами — Стратег только беседует' : MODE_TITLE;
|
|
1260
|
-
document.querySelectorAll('#mode-menu [data-mode-val]').forEach((b) => {
|
|
1261
|
-
const v = b.dataset.modeVal;
|
|
1262
|
-
b.disabled = noTools && v !== 'discuss';
|
|
1263
|
-
b.classList.toggle('active', v === curMode);
|
|
1264
|
-
});
|
|
1265
1283
|
$('#chat-stop').hidden = !s.busy && !s.waiting;
|
|
1266
1284
|
syncActionBtn();
|
|
1267
1285
|
let st = '';
|
|
1268
|
-
if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (
|
|
1269
|
-
else if (s.waiting) st = 'Стратег ждёт вашего
|
|
1286
|
+
if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Модели ИИ)';
|
|
1287
|
+
else if (s.waiting) st = 'Стратег ждёт вашего подтверждения (или напишите ответ)';
|
|
1270
1288
|
else if (s.busy) {
|
|
1271
1289
|
const m = [...state.chat.msgs.values()].reverse().find((x) => x.tool_calls && Object.values(x.data?.tools || {}).some((t) => t.status === 'running'));
|
|
1272
1290
|
const c = m?.tool_calls.find((x) => m.data.tools[x.id]?.status === 'running');
|
|
1273
1291
|
st = c ? `${TOOL_LABELS[c.function.name] || 'Работает'}…` : 'Думает…';
|
|
1274
|
-
|
|
1275
|
-
} else if (noTools) st = 'У этой модели нет инструментов: Стратег только беседует';
|
|
1292
|
+
} else if (noTools) st = 'У этой модели нет инструментов: Стратег отвечает по контексту';
|
|
1276
1293
|
$('#chat-status').textContent = st;
|
|
1277
1294
|
for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
|
|
1278
1295
|
renderSuggest();
|
|
1279
1296
|
syncUiAvailability();
|
|
1280
1297
|
}
|
|
1281
1298
|
|
|
1282
|
-
const SUGGEST = [
|
|
1299
|
+
const SUGGEST = [
|
|
1300
|
+
'Помоги подключить брокерский счёт',
|
|
1301
|
+
'Что стратегия ждёт в ближайшие часы?',
|
|
1302
|
+
'Разбери мой портфель и текущие заявки',
|
|
1303
|
+
'Какие сейчас важные новости по рынку?',
|
|
1304
|
+
];
|
|
1283
1305
|
function renderSuggest() {
|
|
1284
1306
|
const has = [...state.chat.msgs.values()].some((m) => m.kind === 'user');
|
|
1285
1307
|
$('#suggest').innerHTML = has || state.chat.state?.busy ? '' : SUGGEST.map((s) => `<button>${esc(s)}</button>`).join('');
|
|
@@ -1312,31 +1334,12 @@ function closeModeMenu() {
|
|
|
1312
1334
|
if (m) m.hidden = true;
|
|
1313
1335
|
}
|
|
1314
1336
|
|
|
1315
|
-
$('#chat-mode-btn')?.addEventListener('click', (e) => {
|
|
1316
|
-
e.stopPropagation();
|
|
1317
|
-
closeModelMenu();
|
|
1318
|
-
const m = $('#mode-menu');
|
|
1319
|
-
if (m) m.hidden = !m.hidden;
|
|
1320
|
-
});
|
|
1321
|
-
|
|
1322
|
-
$('#mode-menu')?.addEventListener('click', async (e) => {
|
|
1323
|
-
const b = e.target.closest('button[data-mode-val]');
|
|
1324
|
-
if (!b || b.disabled) return;
|
|
1325
|
-
closeModeMenu();
|
|
1326
|
-
try {
|
|
1327
|
-
state.chat.state = await post('/api/chat/mode', { mode: b.dataset.modeVal });
|
|
1328
|
-
renderChatState();
|
|
1329
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
1330
|
-
});
|
|
1331
|
-
|
|
1332
1337
|
async function sendChat(text) {
|
|
1333
1338
|
if (speechRec && speechActive) stopVoiceInput(false);
|
|
1334
1339
|
text = (text ?? $('#chat-input').value).trim();
|
|
1335
1340
|
if (!text) return;
|
|
1336
1341
|
try {
|
|
1337
|
-
|
|
1338
|
-
const mode = s?.tools === false ? 'discuss' : (s?.mode || 'advisor');
|
|
1339
|
-
await post('/api/chat/send', { text, mode });
|
|
1342
|
+
await post('/api/chat/send', { text, mode: 'advisor' });
|
|
1340
1343
|
$('#chat-input').value = '';
|
|
1341
1344
|
resizeChatInput();
|
|
1342
1345
|
toBottom();
|
|
@@ -1939,7 +1942,11 @@ function renderBrokers() {
|
|
|
1939
1942
|
openBrokerId = accs[0].id;
|
|
1940
1943
|
}
|
|
1941
1944
|
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
1942
|
-
|
|
1945
|
+
const gate = state.brokers?.trading;
|
|
1946
|
+
const gateHtml = gate && !gate.allowNew
|
|
1947
|
+
? `<div class="live-card" style="border-color:var(--yellow);margin-bottom:10px"><b style="color:var(--yellow)">⏸ Новые заявки приостановлены.</b> <span class="muted">${esc(gate.reason || '')}</span></div>`
|
|
1948
|
+
: '';
|
|
1949
|
+
tradeEl.innerHTML = gateHtml + accs.map((a) => {
|
|
1943
1950
|
const isOpen = a.id === openBrokerId;
|
|
1944
1951
|
const ls = a.lastSync;
|
|
1945
1952
|
const pb = a.pendingBatch;
|
|
@@ -2021,11 +2028,11 @@ function renderBrokers() {
|
|
|
2021
2028
|
const fcRow = fcRowMap?.get(p.symbol);
|
|
2022
2029
|
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
2023
2030
|
return `<tr>
|
|
2024
|
-
<td><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></td>
|
|
2031
|
+
<td><span class="sym-link" data-sym="${esc(p.symbol)}" title="Открыть досье ${esc(p.symbol)}"><b>${esc(p.name || p.symbol)}</b> <span class="muted small">(${esc(p.symbol)})</span></span></td>
|
|
2025
2032
|
<td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
|
|
2026
2033
|
<td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
|
|
2027
2034
|
<td class="num">${fmtOwnership(p)}</td>
|
|
2028
|
-
<td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, 2)} →</span> ` : ''}<b>${fmtNum(p.price, 3)} ₽</b></td>
|
|
2035
|
+
<td class="num">${p.avgPrice > 0 ? `<span class="muted">${fmtNum(p.avgPrice, p.avgPrice < 1 ? 5 : p.avgPrice < 10 ? 3 : 2)} →</span> ` : ''}<b>${fmtNum(p.price, p.price < 1 ? 5 : p.price < 10 ? 3 : 2)} ₽</b></td>
|
|
2029
2036
|
<td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
|
|
2030
2037
|
<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>
|
|
2031
2038
|
<td>${posStatus(p)}${fcBadge}</td>
|
|
@@ -2041,7 +2048,7 @@ function renderBrokers() {
|
|
|
2041
2048
|
<span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
|
|
2042
2049
|
</div>
|
|
2043
2050
|
<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>
|
|
2051
|
+
<td><span class="sym-link" data-sym="${esc(o.symbol)}" title="Открыть досье ${esc(o.symbol)}"><b>${esc(o.name || o.symbol)}</b> <span class="muted small">(${esc(o.symbol)})</span></span></td>
|
|
2045
2052
|
<td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
|
|
2046
2053
|
<td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
|
|
2047
2054
|
<td class="num">${fmtNum(o.price, 4)} ₽</td>
|
|
@@ -2052,7 +2059,7 @@ function renderBrokers() {
|
|
|
2052
2059
|
let batchHtml = '';
|
|
2053
2060
|
if (pb) {
|
|
2054
2061
|
const ordRows = pb.orders?.length
|
|
2055
|
-
? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><b>${esc(o.symbol)}</b></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
|
|
2062
|
+
? `<table><thead><tr><th>Бумага</th><th>Действие</th><th class="num">Лотов (штук)</th><th class="num">Лимитная цена</th><th class="num">Сумма</th><th>Основание</th></tr></thead><tbody>${pb.orders.map((o) => `<tr><td><span class="sym-link" data-sym="${esc(o.symbol)}" title="Открыть досье ${esc(o.symbol)}"><b>${esc(o.symbol)}</b></span></td><td><span class="tag ${o.action === 'buy' || o.action === 'cover' ? 'ok' : 'no'}">${o.action === 'buy' ? 'купить' : o.action === 'cover' ? 'закрыть шорт' : o.action === 'short' ? 'шорт' : 'продать'}</span></td><td class="num">${o.lots} л. (${o.shares} шт.)</td><td class="num">${fmtNum(o.limitPrice, 4)} ₽</td><td class="num">${fmtNum(o.amountRub, 0)} ₽</td><td class="muted small">${esc(o.reason)}</td></tr>`).join('')}</tbody></table>`
|
|
2056
2063
|
: `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
|
|
2057
2064
|
const resHtml = pb.results?.length
|
|
2058
2065
|
? `<div class="muted small" style="margin-top:6px">Итог отправки (${fmtTs(pb.executedAt)}): ${pb.results.map((r) => `${esc(r.symbol)}: ${r.ok ? `<span class="pos">отправлена (${esc(r.orderId)})</span>` : `<span class="neg">${esc(r.error)}</span>`}`).join(' · ')}</div>`
|
|
@@ -2087,43 +2094,58 @@ function renderBrokers() {
|
|
|
2087
2094
|
const rows = [];
|
|
2088
2095
|
for (const [, dayOps] of byDay) {
|
|
2089
2096
|
let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
|
|
2090
|
-
const
|
|
2097
|
+
const symBuyQty = new Map(), symSellQty = new Map(), symBuyRub = new Map(), symSellRub = new Map(), symFeeRub = new Map();
|
|
2098
|
+
let unlinkedFeeRub = 0;
|
|
2091
2099
|
for (const op of dayOps) {
|
|
2092
2100
|
const pay = Number(op.paymentRub) || 0;
|
|
2093
2101
|
if (op.kind === 'buy') {
|
|
2094
2102
|
tradesCnt++;
|
|
2095
2103
|
buyRub += Math.abs(pay);
|
|
2096
2104
|
if (op.symbol) {
|
|
2097
|
-
|
|
2098
|
-
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2105
|
+
symBuyQty.set(op.symbol, (symBuyQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2099
2106
|
symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
|
|
2100
2107
|
}
|
|
2101
2108
|
} else if (op.kind === 'sell') {
|
|
2102
2109
|
tradesCnt++;
|
|
2103
2110
|
sellRub += Math.abs(pay);
|
|
2104
2111
|
if (op.symbol) {
|
|
2105
|
-
|
|
2106
|
-
|
|
2112
|
+
symSellQty.set(op.symbol, (symSellQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2113
|
+
symSellRub.set(op.symbol, (symSellRub.get(op.symbol) || 0) + Math.abs(pay));
|
|
2107
2114
|
}
|
|
2108
2115
|
} else if (op.kind === 'fee') {
|
|
2109
|
-
if (/дивиденд|купон/i.test(op.title || ''))
|
|
2110
|
-
|
|
2116
|
+
if (/дивиденд|купон/i.test(op.title || '')) {
|
|
2117
|
+
divTaxRub += pay;
|
|
2118
|
+
} else {
|
|
2119
|
+
feeRub += pay;
|
|
2120
|
+
if (op.symbol) symFeeRub.set(op.symbol, (symFeeRub.get(op.symbol) || 0) + pay);
|
|
2121
|
+
else unlinkedFeeRub += pay;
|
|
2122
|
+
}
|
|
2111
2123
|
} else if (op.kind === 'div') {
|
|
2112
2124
|
divTaxRub += pay;
|
|
2113
2125
|
} else if (op.kind === 'in' || op.kind === 'out') {
|
|
2114
2126
|
inOutRub += pay;
|
|
2115
2127
|
}
|
|
2116
2128
|
}
|
|
2117
|
-
// Считаем чистый результат
|
|
2129
|
+
// Считаем точный чистый результат закрытого объёма внутри дня по каждой бумаге (включая частичные закрытия и комиссии закрытых сделок)
|
|
2118
2130
|
let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2131
|
+
const allDaySyms = new Set([...symBuyQty.keys(), ...symSellQty.keys()]);
|
|
2132
|
+
for (const sym of allDaySyms) {
|
|
2133
|
+
const bQ = symBuyQty.get(sym) || 0;
|
|
2134
|
+
const sQ = symSellQty.get(sym) || 0;
|
|
2135
|
+
const matchedQ = Math.min(bQ, sQ);
|
|
2136
|
+
if (matchedQ > 0) {
|
|
2137
|
+
const avgBuy = (symBuyRub.get(sym) || 0) / bQ;
|
|
2138
|
+
const avgSell = (symSellRub.get(sym) || 0) / sQ;
|
|
2139
|
+
const matchedBuyCost = matchedQ * avgBuy;
|
|
2140
|
+
const matchedGrossPnl = matchedQ * (avgSell - avgBuy);
|
|
2141
|
+
const totalSymQ = bQ + sQ;
|
|
2142
|
+
const matchedFeeShare = totalSymQ > 0 ? (symFeeRub.get(sym) || 0) * ((2 * matchedQ) / totalSymQ) : 0;
|
|
2143
|
+
closedTradePnl += matchedGrossPnl + matchedFeeShare;
|
|
2144
|
+
closedBaseRub += matchedBuyCost;
|
|
2123
2145
|
closedSyms++;
|
|
2124
2146
|
}
|
|
2125
2147
|
}
|
|
2126
|
-
if (closedSyms > 0) closedTradePnl +=
|
|
2148
|
+
if (closedSyms > 0) closedTradePnl += unlinkedFeeRub;
|
|
2127
2149
|
const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
|
|
2128
2150
|
|
|
2129
2151
|
const summaryParts = [];
|
|
@@ -2151,9 +2173,9 @@ function renderBrokers() {
|
|
|
2151
2173
|
rows.push(`<tr>
|
|
2152
2174
|
<td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
|
|
2153
2175
|
<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>
|
|
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>
|
|
2176
|
+
<td>${op.symbol ? `<span class="sym-link" data-sym="${esc(op.symbol)}" title="Открыть досье ${esc(op.symbol)}"><b>${esc(op.name || op.symbol)}</b> <span class="muted small">(${esc(op.symbol)})</span></span>` : '<span class="muted small">—</span>'}</td>
|
|
2155
2177
|
<td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2156
|
-
<td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, 2)} ₽` : '—'}</td>
|
|
2178
|
+
<td class="num" style="white-space:nowrap">${op.price ? `${fmtNum(op.price, op.price < 1 ? 5 : op.price < 10 ? 3 : 2)} ₽` : '—'}</td>
|
|
2157
2179
|
<td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
2158
2180
|
</tr>`);
|
|
2159
2181
|
}
|
|
@@ -2238,7 +2260,7 @@ function renderBrokers() {
|
|
|
2238
2260
|
}).join('');
|
|
2239
2261
|
|
|
2240
2262
|
const histHtml = lh?.operations?.length || lh?.loadedYears?.length
|
|
2241
|
-
? `<div class="kpis" style="
|
|
2263
|
+
? `<div class="kpis" style="margin-bottom:10px">
|
|
2242
2264
|
<div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
|
|
2243
2265
|
<div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
|
|
2244
2266
|
<div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
|
|
@@ -2262,11 +2284,13 @@ function renderBrokers() {
|
|
|
2262
2284
|
<span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2263
2285
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2264
2286
|
${a.haltedReason ? `<span class="tag no">${esc(a.haltedReason)}</span>` : ''}
|
|
2287
|
+
${a.haltedReason && a.haltedReason.startsWith('Сработал дневной стоп') ? `<button type="button" class="brk-toggle" data-btrade="resetstop" data-bid="${esc(a.id)}" title="Снять дневной стоп по просадке вручную — до конца сегодняшнего дня он больше не сработает">Снять стоп до конца дня</button>` : ''}
|
|
2265
2288
|
${collapsedMetricsHtml}
|
|
2266
2289
|
</div>
|
|
2267
2290
|
<div class="row-gap" style="align-items:center">
|
|
2268
|
-
<button type="button" class="brk-toggle ${
|
|
2291
|
+
<button type="button" class="brk-toggle ${isPaused ? 'on' : ''}" data-btrade="pause" data-bid="${esc(a.id)}" title="${isPaused ? 'Авто-торговля на паузе. Нажмите, чтобы возобновить торговлю по расписанию' : 'Нажмите, чтобы поставить авто-торговлю на этом счёте на паузу'}">${isPaused ? '▶ Запустить' : '⏸ Пауза'}</button>
|
|
2269
2292
|
${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>` : ''}
|
|
2293
|
+
${isOpen ? `<button type="button" class="brk-icon-btn" data-btrade="events" data-bid="${esc(a.id)}" title="Журнал исполнения: отправленные заявки, ошибки брокера, стопы, ручные действия" aria-label="Журнал исполнения">📋</button>` : ''}
|
|
2270
2294
|
${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
|
|
2271
2295
|
</div>
|
|
2272
2296
|
</div>
|
|
@@ -2279,13 +2303,13 @@ function renderBrokers() {
|
|
|
2279
2303
|
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
|
|
2280
2304
|
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2281
2305
|
<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>
|
|
2306
|
+
<span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
|
|
2283
2307
|
</div>
|
|
2284
|
-
${ls?.ok ? `<div class="kpis"
|
|
2308
|
+
${ls?.ok ? `<div class="kpis">
|
|
2285
2309
|
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2286
2310
|
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2287
2311
|
<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"
|
|
2312
|
+
<div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
|
|
2289
2313
|
</div>` : ''}
|
|
2290
2314
|
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2291
2315
|
<div class="brk-subtabs">
|
|
@@ -2311,6 +2335,18 @@ function renderBrokers() {
|
|
|
2311
2335
|
}
|
|
2312
2336
|
}
|
|
2313
2337
|
|
|
2338
|
+
$('#btn-broker-stop-all')?.addEventListener('click', async () => {
|
|
2339
|
+
const n = (state.brokers?.accounts || []).length;
|
|
2340
|
+
if (!n) return toast('Брокерских счетов нет');
|
|
2341
|
+
if (!confirm(`Аварийная остановка ${n} счёт(ов):\n\n• авто-торговля на всех счетах встанет на паузу;\n• все активные заявки у брокера будут сняты;\n• открытые позиции НЕ закрываются, защитные стопы продолжают работать.\n\nВозобновить торговлю можно кнопкой «▶ Запустить» на каждом счёте.`)) return;
|
|
2342
|
+
try {
|
|
2343
|
+
const r = await post('/api/brokers-stop-all');
|
|
2344
|
+
state.brokers = r;
|
|
2345
|
+
renderBrokers();
|
|
2346
|
+
toast(r.stopErrors?.length ? `Остановлено, но не все заявки сняты: ${r.stopErrors.join('; ')}` : 'Авто-торговля остановлена, активные заявки сняты', r.stopErrors?.length ? 'error' : 'info');
|
|
2347
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2314
2350
|
$('#btn-broker-add')?.addEventListener('click', () => {
|
|
2315
2351
|
const formBox = $('#broker-add-box');
|
|
2316
2352
|
const formSlot = $('#broker-form-slot');
|
|
@@ -2347,6 +2383,8 @@ function getBrokerFormSnap() {
|
|
|
2347
2383
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2348
2384
|
autoSchedule: Boolean(f.autoSchedule.checked),
|
|
2349
2385
|
skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
|
|
2386
|
+
brokerStops: Boolean(f.brokerStops?.checked),
|
|
2387
|
+
cashSweep: Boolean(f.cashSweep?.checked),
|
|
2350
2388
|
sandbox: Boolean(f.sandbox.checked),
|
|
2351
2389
|
});
|
|
2352
2390
|
}
|
|
@@ -2356,8 +2394,23 @@ function syncBrokerGuideUi() {
|
|
|
2356
2394
|
if (!f) return;
|
|
2357
2395
|
const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
|
|
2358
2396
|
const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
|
|
2397
|
+
if ($('#broker-stops-row')) $('#broker-stops-row').hidden = f.broker.value !== 'tbank';
|
|
2398
|
+
if ($('#broker-cash-sweep-row')) $('#broker-cash-sweep-row').hidden = f.broker.value !== 'tbank';
|
|
2399
|
+
// «Автомат» — только для Т-Банка (у остальных нет средней цены для защитных стопов, отмены заявок и истории)
|
|
2400
|
+
const autoOpt = f.mode?.querySelector('option[value="auto"]');
|
|
2401
|
+
if (autoOpt) {
|
|
2402
|
+
const autoAllowed = f.broker.value === 'tbank';
|
|
2403
|
+
autoOpt.disabled = !autoAllowed;
|
|
2404
|
+
autoOpt.title = autoAllowed ? '' : 'Режим «Автомат» пока доступен только для Т-Банка';
|
|
2405
|
+
if (!autoAllowed && f.mode.value === 'auto') f.mode.value = 'confirm';
|
|
2406
|
+
}
|
|
2359
2407
|
if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
|
|
2360
|
-
if ($('#broker-token-input'))
|
|
2408
|
+
if ($('#broker-token-input')) {
|
|
2409
|
+
const editAcc = editingBrokerId ? (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId) : null;
|
|
2410
|
+
$('#broker-token-input').placeholder = editAcc?.hasToken && editAcc.broker === f.broker.value
|
|
2411
|
+
? `Токен сохранён (${editAcc.tokenMask || '…'}) — оставьте пустым или вставьте новый`
|
|
2412
|
+
: g.tokenPh;
|
|
2413
|
+
}
|
|
2361
2414
|
if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
|
|
2362
2415
|
if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
|
|
2363
2416
|
if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
|
|
@@ -2379,10 +2432,11 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
|
|
|
2379
2432
|
const accId = f.accountId.value.trim();
|
|
2380
2433
|
const cap = Number(f.maxCapitalRub.value) || 100000;
|
|
2381
2434
|
let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
|
|
2435
|
+
// Сам токен в чат не передаётся: он уходил бы во внешнюю модель ИИ и сохранялся в истории переписки
|
|
2382
2436
|
if (tok && accId) {
|
|
2383
|
-
promptText += `
|
|
2437
|
+
promptText += ` Токен я уже вставил в форму (в чат его не пишу), номер счёта "${accId}". Подскажи, что проверить перед сохранением счёта.`;
|
|
2384
2438
|
} else if (tok) {
|
|
2385
|
-
promptText += `
|
|
2439
|
+
promptText += ` Токен я уже вставил в форму (в чат его не пишу). Подскажи, как выбрать подходящий субсчёт кнопкой «🔍 Найти мои счета по токену» и что проверить перед сохранением.`;
|
|
2386
2440
|
} else if (accId) {
|
|
2387
2441
|
promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
|
|
2388
2442
|
} else {
|
|
@@ -2486,7 +2540,8 @@ function startBrokerEdit(acc) {
|
|
|
2486
2540
|
f.name.value = acc.name;
|
|
2487
2541
|
f.broker.value = acc.broker;
|
|
2488
2542
|
f.accountId.value = acc.accountId || '';
|
|
2489
|
-
|
|
2543
|
+
// токен с сервера не приходит: пустое поле = оставить сохранённый токен без изменений
|
|
2544
|
+
f.token.value = '';
|
|
2490
2545
|
f.mode.value = acc.mode || 'confirm';
|
|
2491
2546
|
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2492
2547
|
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
@@ -2494,6 +2549,8 @@ function startBrokerEdit(acc) {
|
|
|
2494
2549
|
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2495
2550
|
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2496
2551
|
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
2552
|
+
if (f.brokerStops) f.brokerStops.checked = Boolean(acc.brokerStops);
|
|
2553
|
+
if (f.cashSweep) f.cashSweep.checked = Boolean(acc.cashSweep);
|
|
2497
2554
|
f.sandbox.checked = !!acc.sandbox;
|
|
2498
2555
|
$('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
|
|
2499
2556
|
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
@@ -2592,8 +2649,9 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2592
2649
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2593
2650
|
autoSchedule: f.autoSchedule.checked,
|
|
2594
2651
|
skipEdgeMinutes: f.skipEdgeMinutes.checked,
|
|
2652
|
+
brokerStops: Boolean(f.brokerStops?.checked),
|
|
2653
|
+
cashSweep: Boolean(f.cashSweep?.checked),
|
|
2595
2654
|
sandbox: f.sandbox.checked,
|
|
2596
|
-
resetHalt: true,
|
|
2597
2655
|
};
|
|
2598
2656
|
try {
|
|
2599
2657
|
const savedId = editingBrokerId;
|
|
@@ -2694,6 +2752,20 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2694
2752
|
}
|
|
2695
2753
|
return;
|
|
2696
2754
|
}
|
|
2755
|
+
if (act === 'events') {
|
|
2756
|
+
openBrokerEvents(id);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
if (act === 'resetstop') {
|
|
2760
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2761
|
+
if (!confirm(`Снять дневной стоп на счёте «${acc?.name || id}»?\n\n${acc?.haltedReason || ''}\n\nДо конца сегодняшнего дня стоп по просадке на этом счёте больше не сработает, и авто-торговля продолжится с текущими убытками.`)) return;
|
|
2762
|
+
try {
|
|
2763
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reset-daily-stop`);
|
|
2764
|
+
renderBrokers();
|
|
2765
|
+
toast('Дневной стоп снят до конца дня');
|
|
2766
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2697
2769
|
b.disabled = true;
|
|
2698
2770
|
const old = b.textContent;
|
|
2699
2771
|
try {
|
|
@@ -2775,7 +2847,16 @@ function connectWs() {
|
|
|
2775
2847
|
loadChat().catch(() => {});
|
|
2776
2848
|
loadLlm().catch(() => {});
|
|
2777
2849
|
};
|
|
2778
|
-
ws.onclose = () => {
|
|
2850
|
+
ws.onclose = (ev) => {
|
|
2851
|
+
connected = false;
|
|
2852
|
+
if (ev.code === 4401) {
|
|
2853
|
+
setStatus('bad', 'Сессия не авторизована — войдите в аккаунт');
|
|
2854
|
+
location.href = '/landing';
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)');
|
|
2858
|
+
setTimeout(connectWs, 2000);
|
|
2859
|
+
};
|
|
2779
2860
|
ws.onmessage = (ev) => {
|
|
2780
2861
|
const m = JSON.parse(ev.data);
|
|
2781
2862
|
switch (m.type) {
|
|
@@ -2815,7 +2896,6 @@ function connectWs() {
|
|
|
2815
2896
|
state.settings = m.settings;
|
|
2816
2897
|
fillForm(m.settings);
|
|
2817
2898
|
if (m.source === 'ai') {
|
|
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'); } }
|
|
2819
2899
|
toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
|
|
2820
2900
|
}
|
|
2821
2901
|
break;
|
|
@@ -2978,7 +3058,7 @@ async function renderCloudPane() {
|
|
|
2978
3058
|
}
|
|
2979
3059
|
|
|
2980
3060
|
const nt = me.notify || {};
|
|
2981
|
-
const code = u.notifyCode || '
|
|
3061
|
+
const code = u.notifyCode || '';
|
|
2982
3062
|
const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2983
3063
|
const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2984
3064
|
|
|
@@ -3020,9 +3100,6 @@ async function renderCloudPane() {
|
|
|
3020
3100
|
: u.limits?.maxCapitalPerAccountRub > 0
|
|
3021
3101
|
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
|
|
3022
3102
|
: 'подписка не активна';
|
|
3023
|
-
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
3024
|
-
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
3025
|
-
: (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
|
|
3026
3103
|
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
3027
3104
|
updateTabBadges();
|
|
3028
3105
|
|
|
@@ -3031,7 +3108,7 @@ async function renderCloudPane() {
|
|
|
3031
3108
|
<div class="stxt">
|
|
3032
3109
|
<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>
|
|
3033
3110
|
<div class="hint">
|
|
3034
|
-
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b
|
|
3111
|
+
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
|
|
3035
3112
|
</div>
|
|
3036
3113
|
</div>
|
|
3037
3114
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -3043,9 +3120,9 @@ async function renderCloudPane() {
|
|
|
3043
3120
|
<div class="stxt">
|
|
3044
3121
|
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
3045
3122
|
<div class="hint">
|
|
3046
|
-
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)
|
|
3047
|
-
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)
|
|
3048
|
-
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с
|
|
3123
|
+
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
|
|
3124
|
+
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
|
|
3125
|
+
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
|
|
3049
3126
|
</div>
|
|
3050
3127
|
</div>
|
|
3051
3128
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -3076,8 +3153,8 @@ async function renderCloudPane() {
|
|
|
3076
3153
|
`;
|
|
3077
3154
|
|
|
3078
3155
|
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
3079
|
-
await post('/api/auth/logout');
|
|
3080
|
-
location.href = '/landing';
|
|
3156
|
+
const r = await post('/api/auth/logout').catch(() => ({}));
|
|
3157
|
+
location.href = r?.redirect || '/landing';
|
|
3081
3158
|
});
|
|
3082
3159
|
box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
|
|
3083
3160
|
try {
|
|
@@ -3147,6 +3224,38 @@ if (localStorage.getItem('theme') === 'light') applyTheme(true);
|
|
|
3147
3224
|
|
|
3148
3225
|
// ---------- справка ----------
|
|
3149
3226
|
const helpCache = new Map();
|
|
3227
|
+
// ---------- журнал исполнения по счёту ----------
|
|
3228
|
+
const EVENT_LABELS = {
|
|
3229
|
+
order_sent: ['заявка отправлена', ''],
|
|
3230
|
+
order_error: ['ошибка заявки', 'neg'],
|
|
3231
|
+
protective_stop: ['защитный стоп', 'neg'],
|
|
3232
|
+
protective_stop_error: ['ошибка защитного стопа', 'neg'],
|
|
3233
|
+
daily_stop: ['дневной стоп', 'neg'],
|
|
3234
|
+
daily_stop_reset: ['стоп снят вручную', ''],
|
|
3235
|
+
stop_all: ['аварийная остановка', 'neg'],
|
|
3236
|
+
broker_stop_set: ['стоп-заявка у брокера выставлена', 'pos'],
|
|
3237
|
+
broker_stop_cancel: ['стоп-заявка у брокера снята', ''],
|
|
3238
|
+
broker_stop_gone: ['стоп-заявка исчезла у брокера', 'neg'],
|
|
3239
|
+
broker_stop_error: ['ошибка стоп-заявки', 'neg'],
|
|
3240
|
+
};
|
|
3241
|
+
async function openBrokerEvents(id) {
|
|
3242
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
3243
|
+
$('#events-title').textContent = `Журнал исполнения · ${acc?.name || id}`;
|
|
3244
|
+
$('#events-body').textContent = 'Загрузка…';
|
|
3245
|
+
$('#events-modal').hidden = false;
|
|
3246
|
+
try {
|
|
3247
|
+
const { events } = await api(`/api/brokers/${encodeURIComponent(id)}/events?limit=300`);
|
|
3248
|
+
$('#events-body').innerHTML = events.length
|
|
3249
|
+
? `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Событие</th><th>Подробности</th></tr></thead><tbody>${events.map((ev) => {
|
|
3250
|
+
const [label, cls] = EVENT_LABELS[ev.kind] || [ev.kind, ''];
|
|
3251
|
+
return `<tr><td class="small" style="white-space:nowrap">${esc(new Date(ev.ts + MSK).toISOString().slice(0, 19).replace('T', ' '))}</td><td class="small ${cls}">${esc(label)}</td><td class="small">${esc(ev.text)}</td></tr>`;
|
|
3252
|
+
}).join('')}</tbody></table></div>`
|
|
3253
|
+
: '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>';
|
|
3254
|
+
} catch (e) { $('#events-body').textContent = e.message; }
|
|
3255
|
+
}
|
|
3256
|
+
$('#events-close')?.addEventListener('click', () => { $('#events-modal').hidden = true; });
|
|
3257
|
+
$('#events-modal')?.addEventListener('click', (e) => { if (e.target.id === 'events-modal') $('#events-modal').hidden = true; });
|
|
3258
|
+
|
|
3150
3259
|
async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
|
|
3151
3260
|
$('#help').hidden = false;
|
|
3152
3261
|
document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
|
|
@@ -3171,9 +3280,326 @@ $('#help-tabs')?.addEventListener('click', (e) => {
|
|
|
3171
3280
|
});
|
|
3172
3281
|
$('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
|
|
3173
3282
|
$('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
|
|
3283
|
+
|
|
3284
|
+
// ---------- интерактивное досье акции (#stock-modal) ----------
|
|
3285
|
+
let stockModalChart = null;
|
|
3286
|
+
let stockChartRo = null;
|
|
3287
|
+
let currentStockData = null;
|
|
3288
|
+
let currentStockRange = '1m';
|
|
3289
|
+
|
|
3290
|
+
function renderStockChart(d, range = '1m') {
|
|
3291
|
+
currentStockRange = range;
|
|
3292
|
+
const box = $('#stock-chart-box');
|
|
3293
|
+
if (!box) return;
|
|
3294
|
+
if (stockChartRo) {
|
|
3295
|
+
try { stockChartRo.disconnect(); } catch { /* ignore */ }
|
|
3296
|
+
stockChartRo = null;
|
|
3297
|
+
}
|
|
3298
|
+
if (stockModalChart) {
|
|
3299
|
+
try { stockModalChart.remove(); } catch { /* ignore */ }
|
|
3300
|
+
stockModalChart = null;
|
|
3301
|
+
}
|
|
3302
|
+
box.innerHTML = '';
|
|
3303
|
+
const light = document.body.classList.contains('light');
|
|
3304
|
+
const initW = Math.max(260, box.clientWidth || 720);
|
|
3305
|
+
stockModalChart = LightweightCharts.createChart(box, {
|
|
3306
|
+
width: initW,
|
|
3307
|
+
height: 260,
|
|
3308
|
+
layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
|
|
3309
|
+
grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
|
|
3310
|
+
rightPriceScale: { borderVisible: false },
|
|
3311
|
+
timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
|
|
3312
|
+
crosshair: { mode: 0 },
|
|
3313
|
+
});
|
|
3314
|
+
if (window.ResizeObserver) {
|
|
3315
|
+
stockChartRo = new ResizeObserver(() => {
|
|
3316
|
+
if (stockModalChart && box.clientWidth > 100) {
|
|
3317
|
+
stockModalChart.applyOptions({ width: box.clientWidth });
|
|
3318
|
+
}
|
|
3319
|
+
});
|
|
3320
|
+
stockChartRo.observe(box);
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
const nowTs = Date.now();
|
|
3324
|
+
let rawPts = [];
|
|
3325
|
+
if (range === '1d') {
|
|
3326
|
+
const cut = nowTs - 2 * 86400000;
|
|
3327
|
+
rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
|
|
3328
|
+
if (rawPts.length < 5) rawPts = (d.intraday || []).slice(-95);
|
|
3329
|
+
} else if (range === '1w') {
|
|
3330
|
+
rawPts = (d.intraday || []).filter((b) => b.ts >= nowTs - 8 * 86400000);
|
|
3331
|
+
} else if (range === '1m') {
|
|
3332
|
+
rawPts = d.intraday?.length ? d.intraday : (d.daily || []).slice(-30);
|
|
3333
|
+
} else if (range === '1y') {
|
|
3334
|
+
rawPts = (d.daily || []).filter((b) => b.ts >= nowTs - 370 * 86400000);
|
|
3335
|
+
} else {
|
|
3336
|
+
rawPts = d.daily || [];
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
const seenT = new Set();
|
|
3340
|
+
const seriesData = [];
|
|
3341
|
+
for (const b of rawPts) {
|
|
3342
|
+
const t = chartTime(b.ts);
|
|
3343
|
+
if (seenT.has(t) || !(b.c > 0)) continue;
|
|
3344
|
+
seenT.add(t);
|
|
3345
|
+
seriesData.push({ time: t, value: b.c });
|
|
3346
|
+
}
|
|
3347
|
+
seriesData.sort((a, b) => a.time - b.time);
|
|
3348
|
+
|
|
3349
|
+
const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
|
|
3350
|
+
const mainColor = isUp ? '#3fb950' : '#f85149';
|
|
3351
|
+
const areaSeries = stockModalChart.addAreaSeries({
|
|
3352
|
+
lineColor: mainColor,
|
|
3353
|
+
topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
|
|
3354
|
+
bottomColor: 'rgba(0, 0, 0, 0.0)',
|
|
3355
|
+
lineWidth: 2,
|
|
3356
|
+
priceFormat: {
|
|
3357
|
+
type: 'price',
|
|
3358
|
+
precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
|
|
3359
|
+
minMove: d.specs?.minStep || 0.01,
|
|
3360
|
+
},
|
|
3361
|
+
});
|
|
3362
|
+
areaSeries.setData(seriesData);
|
|
3363
|
+
|
|
3364
|
+
// Маркеры сделок пользователя и дивидендов на графике
|
|
3365
|
+
const markers = [];
|
|
3366
|
+
if (seriesData.length > 0) {
|
|
3367
|
+
const minT = seriesData[0].time;
|
|
3368
|
+
const maxT = seriesData[seriesData.length - 1].time;
|
|
3369
|
+
for (const op of (d.userOperations || [])) {
|
|
3370
|
+
if (op.kind !== 'buy' && op.kind !== 'sell') continue;
|
|
3371
|
+
const t = chartTime(op.date);
|
|
3372
|
+
if (t >= minT && t <= maxT) {
|
|
3373
|
+
markers.push({
|
|
3374
|
+
time: t,
|
|
3375
|
+
position: op.kind === 'buy' ? 'belowBar' : 'aboveBar',
|
|
3376
|
+
color: op.kind === 'buy' ? '#3fb950' : '#f85149',
|
|
3377
|
+
shape: op.kind === 'buy' ? 'arrowUp' : 'arrowDown',
|
|
3378
|
+
text: `${op.kind === 'buy' ? 'Пок.' : 'Прод.'} ${op.qty} шт.`,
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
if (range === '1y' || range === 'all') {
|
|
3383
|
+
for (const dv of (d.dividends || [])) {
|
|
3384
|
+
const dtMs = Date.parse(`${dv.registry}T10:00:00Z`);
|
|
3385
|
+
if (!Number.isFinite(dtMs)) continue;
|
|
3386
|
+
const t = chartTime(dtMs);
|
|
3387
|
+
if (t >= minT && t <= maxT) {
|
|
3388
|
+
markers.push({
|
|
3389
|
+
time: t,
|
|
3390
|
+
position: 'aboveBar',
|
|
3391
|
+
color: '#4f8cff',
|
|
3392
|
+
shape: 'circle',
|
|
3393
|
+
text: `Див. ${dv.value} ₽`,
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
markers.sort((a, b) => a.time - b.time);
|
|
3399
|
+
if (markers.length) {
|
|
3400
|
+
try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
stockModalChart.timeScale().fitContent();
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
async function openStockModal(symbol) {
|
|
3407
|
+
const sym = String(symbol || '').toUpperCase().trim();
|
|
3408
|
+
if (!sym) return;
|
|
3409
|
+
const modal = $('#stock-modal');
|
|
3410
|
+
const titleEl = $('#stock-modal-title');
|
|
3411
|
+
const bodyEl = $('#stock-modal-body');
|
|
3412
|
+
if (!modal || !bodyEl) return;
|
|
3413
|
+
modal.hidden = false;
|
|
3414
|
+
titleEl.innerHTML = `<b>${esc(sym)}</b> <span class="muted small">· загрузка досье инструмента…</span>`;
|
|
3415
|
+
bodyEl.innerHTML = `<div class="ld-calc-banner"><div class="ld-calc-spin"></div><div><b>Загружаем котировки, прогноз ИИ, дивиденды и историю сделок по ${esc(sym)}…</b></div></div>`;
|
|
3416
|
+
|
|
3417
|
+
try {
|
|
3418
|
+
const d = await api(`/api/stock/${encodeURIComponent(sym)}`);
|
|
3419
|
+
currentStockData = d;
|
|
3420
|
+
KNOWN_MOEX_TICKERS.add(d.symbol);
|
|
3421
|
+
const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
|
|
3422
|
+
const fc = d.forecast;
|
|
3423
|
+
const sigTag = fc?.signal
|
|
3424
|
+
? '<span class="tag ok">сигнал: купить</span>'
|
|
3425
|
+
: fc?.shortSignal
|
|
3426
|
+
? '<span class="tag no">сигнал: шорт</span>'
|
|
3427
|
+
: fc?.shortBlockedByBroker
|
|
3428
|
+
? '<span class="tag">шорт запрещён брокером</span>'
|
|
3429
|
+
: '<span class="tag">нейтрально (вне порога)</span>';
|
|
3430
|
+
const shortBadge = d.shortAllowedByBroker === true
|
|
3431
|
+
? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
|
|
3432
|
+
: d.shortAllowedByBroker === false
|
|
3433
|
+
? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
|
|
3434
|
+
: '';
|
|
3435
|
+
|
|
3436
|
+
titleEl.innerHTML = `<b>${esc(d.name)}</b> <span class="tag">${esc(d.symbol)}</span> <span class="tag ${d.tier === 1 ? 'ok' : ''}">${esc(d.tierLabel)}</span> ${sigTag} ${shortBadge}`;
|
|
3437
|
+
|
|
3438
|
+
const capStr = d.specs?.marketCapRub
|
|
3439
|
+
? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
|
|
3440
|
+
: '—';
|
|
3441
|
+
|
|
3442
|
+
// 1. Верхние KPI
|
|
3443
|
+
const kpiHtml = `<div class="kpis">
|
|
3444
|
+
<div class="kpi"><div class="v">${d.price ? `${fmtNum(d.price, pxDec)} ₽` : '—'}</div><div class="l">текущая цена (1 лот = ${fmtNum(d.specs?.lotSize || 1, 0)} шт. ≈ ${fmtNum(d.specs?.lotCostRub || 0, 1)} ₽)</div></div>
|
|
3445
|
+
<div class="kpi"><div class="v ${cls(d.dayChangePct)}">${fmtPct(d.dayChangePct, 2)}</div><div class="l">за день · за 30 дн.: <b class="${cls(d.monthChangePct)}">${fmtPct(d.monthChangePct, 1)}</b></div></div>
|
|
3446
|
+
<div class="kpi"><div class="v ${cls(fc?.expectedPct || 0)}">${fc ? fmtPct(fc.expectedPct, 2) : '—'}</div><div class="l">прогноз ИИ на ${fc?.horizonHours || 2.5} ч (порог +${fmtNum(d.buyThresholdPct, 2)}%)</div></div>
|
|
3447
|
+
<div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
|
|
3448
|
+
<div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
|
|
3449
|
+
</div>`;
|
|
3450
|
+
|
|
3451
|
+
// 2. График с выбором периода
|
|
3452
|
+
const chartWrapHtml = `<div class="scard spadded">
|
|
3453
|
+
<div class="stitle bar" style="margin:0 0 8px">
|
|
3454
|
+
<span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
|
|
3455
|
+
<div class="seg" id="stock-chart-range" style="margin:0">
|
|
3456
|
+
<button type="button" data-srange="1d">1Д</button>
|
|
3457
|
+
<button type="button" data-srange="1w">1Н</button>
|
|
3458
|
+
<button type="button" data-srange="1m" class="active">1М</button>
|
|
3459
|
+
<button type="button" data-srange="1y">1Г</button>
|
|
3460
|
+
<button type="button" data-srange="all">Все</button>
|
|
3461
|
+
</div>
|
|
3462
|
+
</div>
|
|
3463
|
+
<div id="stock-chart-box" class="chart"></div>
|
|
3464
|
+
</div>`;
|
|
3465
|
+
|
|
3466
|
+
// 3. Соседи по карте рынка (кто влияет на акцию прямо сейчас)
|
|
3467
|
+
const infHtml = fc?.influences?.length
|
|
3468
|
+
? `<div class="scard spadded">
|
|
3469
|
+
<div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
|
|
3470
|
+
<div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (инерция): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
|
|
3471
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
|
|
3472
|
+
${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
|
|
3473
|
+
<div>
|
|
3474
|
+
<span class="sym-link" data-sym="${esc(inf.symbol)}" title="Перейти в досье ${esc(inf.symbol)}"><b>${esc(inf.symbol)}</b> <span class="muted small">${esc(inf.name || '')}</span></span>
|
|
3475
|
+
<div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
|
|
3476
|
+
</div>
|
|
3477
|
+
<div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
|
|
3478
|
+
</div>`).join('')}
|
|
3479
|
+
</div>
|
|
3480
|
+
</div>`
|
|
3481
|
+
: '';
|
|
3482
|
+
|
|
3483
|
+
// 4. Позиция пользователя и история реальных сделок на брокерском счёте по этой бумаге
|
|
3484
|
+
const userPosBanner = d.userPositions?.length
|
|
3485
|
+
? d.userPositions.map((p) => `<div style="padding:8px 12px;border:1px solid var(--accent);border-radius:8px;background:rgba(59,130,246,0.06);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
|
|
3486
|
+
<div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
|
|
3487
|
+
<div>Стоимость: <b>${fmtNum(p.valueRub, 0)} ₽</b> · Финрезультат: <b class="${cls(p.pnlRub)}">${p.pnlRub >= 0 ? '+' : ''}${fmtNum(p.pnlRub, 0)} ₽ (${fmtPct(p.pnlPct, 2)})</b></div>
|
|
3488
|
+
</div>`).join('')
|
|
3489
|
+
: '';
|
|
3490
|
+
|
|
3491
|
+
const userOpsHtml = d.userOperations?.length
|
|
3492
|
+
? `<table><thead><tr><th>Дата и время</th><th>Операция</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${d.userOperations.slice(0, 15).map((op) => `<tr>
|
|
3493
|
+
<td class="muted small">${fmtTs(op.date)}</td>
|
|
3494
|
+
<td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
|
|
3495
|
+
<td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
3496
|
+
<td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
|
|
3497
|
+
<td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
3498
|
+
</tr>`).join('')}</tbody></table>`
|
|
3499
|
+
: '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
|
|
3500
|
+
|
|
3501
|
+
const btInfo = d.backtestStat
|
|
3502
|
+
? `<div class="muted small" style="margin-top:8px">В эталонном Walk-Forward тесте (№${d.backtestStat.runId}): сделок по ${esc(d.symbol)}: <b>${d.backtestStat.tradesCount}</b> · плюсовых: <b>${d.backtestStat.winRate}%</b> · средний результат сделки: <b class="${cls(d.backtestStat.avgPnlPct)}">${fmtPct(d.backtestStat.avgPnlPct, 2)}</b></div>`
|
|
3503
|
+
: '';
|
|
3504
|
+
|
|
3505
|
+
const userBoxHtml = `<div class="scard spadded">
|
|
3506
|
+
<div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
|
|
3507
|
+
${userPosBanner}
|
|
3508
|
+
${userOpsHtml}
|
|
3509
|
+
${btInfo}
|
|
3510
|
+
</div>`;
|
|
3511
|
+
|
|
3512
|
+
// 5. Фундаментальный бизнес-профиль (20 признаков) + Дивиденды (в 2 колонки)
|
|
3513
|
+
const topTraits = [...(d.traits || [])].filter((t) => t.value >= 0.15).sort((a, b) => b.value - a.value).slice(0, 10);
|
|
3514
|
+
const traitsColHtml = topTraits.length
|
|
3515
|
+
? `<div class="scard spadded">
|
|
3516
|
+
<div style="font-weight:700;margin-bottom:8px">Фундаментальный профиль бизнеса (${esc(d.symbol)})</div>
|
|
3517
|
+
<div style="display:grid;gap:6px">
|
|
3518
|
+
${topTraits.map((t) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12.5px" title="${esc(t.ask)}">
|
|
3519
|
+
<span>${esc(t.label)}</span>
|
|
3520
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
3521
|
+
<div class="stock-trait-bar"><div style="width:${Math.round(t.value * 100)}%"></div></div>
|
|
3522
|
+
<b class="num" style="width:34px;text-align:right">${Math.round(t.value * 100)}%</b>
|
|
3523
|
+
</div>
|
|
3524
|
+
</div>`).join('')}
|
|
3525
|
+
</div>
|
|
3526
|
+
</div>`
|
|
3527
|
+
: `<div class="scard spadded"><div style="font-weight:700;margin-bottom:6px">Фундаментальный профиль бизнеса</div><p class="muted small" style="margin:0">Отраслевой профиль компании учитывается через спектральную матрицу совместных движений цен.</p></div>`;
|
|
3528
|
+
|
|
3529
|
+
const divsColHtml = `<div class="scard spadded">
|
|
3530
|
+
<div style="font-weight:700;margin-bottom:8px">Дивидендная история ${esc(d.symbol)} (${d.dividends?.length || 0})</div>
|
|
3531
|
+
${d.dividends?.length
|
|
3532
|
+
? `<div style="max-height:220px;overflow-y:auto"><table><thead><tr><th>Реестр (отсечка)</th><th class="num">Выплата на 1 акцию</th><th class="num">К текущей цене</th></tr></thead><tbody>${d.dividends.slice(0, 15).map((dv) => {
|
|
3533
|
+
const yld = d.price > 0 ? (dv.value / d.price) * 100 : null;
|
|
3534
|
+
return `<tr>
|
|
3535
|
+
<td>${esc(dv.registry.split('-').reverse().join('.'))}</td>
|
|
3536
|
+
<td class="num pos"><b>+${fmtNum(dv.value, dv.value < 1 ? 4 : 2)} ₽</b></td>
|
|
3537
|
+
<td class="num muted">${yld ? `${fmtNum(yld, 2)}%` : '—'}</td>
|
|
3538
|
+
</tr>`;
|
|
3539
|
+
}).join('')}</tbody></table></div>`
|
|
3540
|
+
: '<p class="muted small" style="margin:0">Дивидендных выплат в рублях по данной бумаге не зафиксировано.</p>'}
|
|
3541
|
+
</div>`;
|
|
3542
|
+
|
|
3543
|
+
// 6. Новости по акции
|
|
3544
|
+
const newsHtml = `<div class="scard spadded">
|
|
3545
|
+
<div style="font-weight:700;margin-bottom:8px">Новости и раскрытия по ${esc(d.symbol)} (${d.news?.length || 0})</div>
|
|
3546
|
+
${d.news?.length
|
|
3547
|
+
? `<div style="display:grid;gap:6px">${d.news.map((n) => {
|
|
3548
|
+
const symTag = (n.symbols || []).find((s) => s.symbol === d.symbol);
|
|
3549
|
+
const tone = symTag?.tone ?? n.tone ?? 0;
|
|
3550
|
+
const toneBadge = tone >= 0.3 ? `<span class="tag ok">позитив +${fmtNum(tone, 1)}</span>` : tone <= -0.3 ? `<span class="tag no">негатив ${fmtNum(tone, 1)}</span>` : `<span class="tag">нейтр.</span>`;
|
|
3551
|
+
return `<div class="news-item">${toneBadge} <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>`;
|
|
3552
|
+
}).join('')}</div>`
|
|
3553
|
+
: '<p class="muted small" style="margin:0">За последние 30 дней размеченных новостей по этому эмитенту не было.</p>'}
|
|
3554
|
+
</div>`;
|
|
3555
|
+
|
|
3556
|
+
bodyEl.innerHTML = `
|
|
3557
|
+
${kpiHtml}
|
|
3558
|
+
${chartWrapHtml}
|
|
3559
|
+
${infHtml}
|
|
3560
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">${traitsColHtml}${divsColHtml}</div>
|
|
3561
|
+
${userBoxHtml}
|
|
3562
|
+
${newsHtml}
|
|
3563
|
+
`;
|
|
3564
|
+
|
|
3565
|
+
renderStockChart(d, '1m');
|
|
3566
|
+
} catch (err) {
|
|
3567
|
+
bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
$('#stock-close')?.addEventListener('click', () => { $('#stock-modal').hidden = true; });
|
|
3572
|
+
$('#stock-modal')?.addEventListener('click', (e) => {
|
|
3573
|
+
if (e.target.id === 'stock-modal') {
|
|
3574
|
+
$('#stock-modal').hidden = true;
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
const rBtn = e.target.closest('button[data-srange]');
|
|
3578
|
+
if (rBtn && currentStockData) {
|
|
3579
|
+
$('#stock-chart-range')?.querySelectorAll('button[data-srange]').forEach((b) => b.classList.toggle('active', b === rBtn));
|
|
3580
|
+
renderStockChart(currentStockData, rBtn.dataset.srange);
|
|
3581
|
+
}
|
|
3582
|
+
});
|
|
3583
|
+
$('#btn-stock-ask')?.addEventListener('click', () => {
|
|
3584
|
+
if (!currentStockData?.symbol) return;
|
|
3585
|
+
const sym = currentStockData.symbol;
|
|
3586
|
+
$('#stock-modal').hidden = true;
|
|
3587
|
+
sendChat(`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`);
|
|
3588
|
+
});
|
|
3589
|
+
|
|
3590
|
+
// Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
|
|
3591
|
+
document.addEventListener('click', (e) => {
|
|
3592
|
+
const symEl = e.target.closest('[data-sym]');
|
|
3593
|
+
if (!symEl) return;
|
|
3594
|
+
e.preventDefault();
|
|
3595
|
+
e.stopPropagation();
|
|
3596
|
+
openStockModal(symEl.dataset.sym);
|
|
3597
|
+
});
|
|
3598
|
+
|
|
3174
3599
|
document.addEventListener('keydown', (e) => {
|
|
3175
3600
|
if (e.key === 'Escape') {
|
|
3176
3601
|
$('#help').hidden = true;
|
|
3602
|
+
if ($('#stock-modal')) $('#stock-modal').hidden = true;
|
|
3177
3603
|
closeModelMenu();
|
|
3178
3604
|
closeModeMenu();
|
|
3179
3605
|
}
|
|
@@ -3204,7 +3630,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
3204
3630
|
}
|
|
3205
3631
|
if (urlParams.get('run') && isOwnerUser()) {
|
|
3206
3632
|
showMainSec('overview');
|
|
3207
|
-
} else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', '
|
|
3633
|
+
} else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
|
|
3208
3634
|
showMainSec(savedNav.mainSec);
|
|
3209
3635
|
} else {
|
|
3210
3636
|
showMainSec('brokers');
|