neuroalpha 0.3.17 → 0.5.2
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/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 +1 -4
- 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 +589 -66
- package/public/index.html +48 -21
- 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 +1095 -168
- 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/role.js +44 -0
- package/server/security.js +40 -0
- package/server/strategist/index.js +86 -47
- package/server/strategist/tools.js +231 -42
- 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); }; };
|
|
@@ -371,7 +398,11 @@ function syncUiAvailability() {
|
|
|
371
398
|
if ($('#btn-cancel')) $('#btn-cancel').hidden = !anyJobRunning;
|
|
372
399
|
if ($('#run-select')) $('#run-select').disabled = state.runs.length === 0;
|
|
373
400
|
|
|
374
|
-
// 3. Кнопки обращения к Стратегу в прогнозе
|
|
401
|
+
// 3. Кнопки обновления и обращения к Стратегу в прогнозе
|
|
402
|
+
if ($('#btn-refresh-forecast')) {
|
|
403
|
+
$('#btn-refresh-forecast').disabled = Boolean(forecastBusy);
|
|
404
|
+
$('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
|
|
405
|
+
}
|
|
375
406
|
if ($('#btn-ask-forecast')) {
|
|
376
407
|
$('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
|
|
377
408
|
$('#btn-ask-forecast').title = !state.forecast
|
|
@@ -447,10 +478,12 @@ function verdictHtml(c, oosA) {
|
|
|
447
478
|
: '';
|
|
448
479
|
let vs = '';
|
|
449
480
|
if (base) {
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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)})`;
|
|
454
487
|
}
|
|
455
488
|
if (a.returnPct > 0 && a.returnPct > a.marketPct && (a.profitFactor ?? 0) >= 1.1) {
|
|
456
489
|
return `<span class="tag ok">Стратегия прибыльна</span> Превосходит индекс Мосбиржи на <b class="pos">${fmtPct(a.returnPct - a.marketPct)}</b>${vs}${oosPart}`;
|
|
@@ -517,6 +550,31 @@ function renderStability(st) {
|
|
|
517
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>`;
|
|
518
551
|
}
|
|
519
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
|
+
|
|
520
578
|
// Живая инфографика во время проведения исследования (скользящее окно, карта рынка, головы внимания, точность по шагам)
|
|
521
579
|
const liveTrainState = { runId: null, stage: 'data', symbols: [], loadedSymbols: [], foldIcs: [], lastViz: null, text: '' };
|
|
522
580
|
|
|
@@ -529,6 +587,16 @@ function resetLiveTrain(runId) {
|
|
|
529
587
|
liveTrainState.text = '';
|
|
530
588
|
}
|
|
531
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
|
+
|
|
532
600
|
function drawLiveTrainMap(viz) {
|
|
533
601
|
const cv = $('#ld-map-cv');
|
|
534
602
|
if (!cv || !viz?.pts?.length) return;
|
|
@@ -537,7 +605,7 @@ function drawLiveTrainMap(viz) {
|
|
|
537
605
|
cv.width = W * dpr; cv.height = H * dpr;
|
|
538
606
|
const ctx = cv.getContext('2d');
|
|
539
607
|
ctx.scale(dpr, dpr);
|
|
540
|
-
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}`);
|
|
541
609
|
const f = { pts: viz.pts, cl: viz.cl || viz.pts.map(() => 0), symbols: syms };
|
|
542
610
|
const xy = layout(f, W, H);
|
|
543
611
|
const light = document.body.classList.contains('light');
|
|
@@ -628,7 +696,7 @@ function renderLiveTraining() {
|
|
|
628
696
|
|
|
629
697
|
empty.innerHTML = `
|
|
630
698
|
${calcBanner}
|
|
631
|
-
<div class="kpis" style="
|
|
699
|
+
<div class="kpis" style="margin-bottom:12px">
|
|
632
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>
|
|
633
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>
|
|
634
702
|
<div class="kpi"><div class="v">${v.tokens || '—'}</div><div class="l">акций в матрице рынка</div><div class="sub">горизонт прогноза: ${v.horizon} ч</div></div>
|
|
@@ -736,6 +804,7 @@ function renderOverview() {
|
|
|
736
804
|
].join('');
|
|
737
805
|
$('#sides').hidden = true;
|
|
738
806
|
renderStability(ex.stability);
|
|
807
|
+
renderStress(ex.stress);
|
|
739
808
|
|
|
740
809
|
const uniq = (arr) => (arr || []).filter((p, i, x) => i === 0 || chartTime(p.ts) > chartTime(x[i - 1].ts));
|
|
741
810
|
for (const n of ['market', ...MODELS]) eqLines[n].setData(uniq(ex.curves[n]).map((p) => ({ time: chartTime(p.ts), value: p.equity })));
|
|
@@ -748,11 +817,18 @@ function renderOverview() {
|
|
|
748
817
|
|
|
749
818
|
renderPeriods();
|
|
750
819
|
|
|
751
|
-
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);
|
|
752
828
|
const th = (t, h, num = true) => `<th class="${num ? 'num' : ''}" title="${esc(h)}">${t}</th>`;
|
|
753
|
-
$('#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>${
|
|
754
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>
|
|
755
|
-
<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>
|
|
756
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>`;
|
|
757
833
|
|
|
758
834
|
const w = uniq(ex.weeklyIc?.[chosen]);
|
|
@@ -770,7 +846,7 @@ function renderOverview() {
|
|
|
770
846
|
$('#trades-sum').textContent = `Сделки торгующей стратегии (${trades.length})`;
|
|
771
847
|
const shorts = trades.some((t) => t.side === 'short');
|
|
772
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>${
|
|
773
|
-
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>';
|
|
774
850
|
|
|
775
851
|
}
|
|
776
852
|
|
|
@@ -793,6 +869,10 @@ $('#run-bar').addEventListener('click', async (e) => {
|
|
|
793
869
|
sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
|
|
794
870
|
}
|
|
795
871
|
});
|
|
872
|
+
$('#btn-refresh-forecast')?.addEventListener('click', () => {
|
|
873
|
+
forecastBusy = false;
|
|
874
|
+
loadForecast(false, false);
|
|
875
|
+
});
|
|
796
876
|
$('#btn-ask-forecast').addEventListener('click', () => {
|
|
797
877
|
sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
|
|
798
878
|
});
|
|
@@ -849,6 +929,7 @@ $('#btn-cancel').addEventListener('click', async () => {
|
|
|
849
929
|
// СЕЙЧАС: живой авто-радар прогноза и виртуальные счета
|
|
850
930
|
// ======================================================================
|
|
851
931
|
let forecastBusy = false;
|
|
932
|
+
let forecastBusyAt = 0;
|
|
852
933
|
let pulseClearTimer = null;
|
|
853
934
|
const prevClientExp = new Map();
|
|
854
935
|
const prevClientRank = new Map();
|
|
@@ -879,8 +960,10 @@ function triggerTradingPulse(text = 'Обновление котировок и
|
|
|
879
960
|
}
|
|
880
961
|
|
|
881
962
|
async function loadForecast(force, silent = false) {
|
|
882
|
-
if (forecastBusy) return;
|
|
963
|
+
if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
|
|
883
964
|
forecastBusy = true;
|
|
965
|
+
forecastBusyAt = Date.now();
|
|
966
|
+
syncUiAvailability();
|
|
884
967
|
triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
|
|
885
968
|
const box = $('#forecast');
|
|
886
969
|
if (!silent && !state.forecast) {
|
|
@@ -893,7 +976,7 @@ async function loadForecast(force, silent = false) {
|
|
|
893
976
|
</div>`;
|
|
894
977
|
}
|
|
895
978
|
try {
|
|
896
|
-
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 });
|
|
897
980
|
// Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
|
|
898
981
|
f.rows.forEach((r, idx) => {
|
|
899
982
|
const rank = idx + 1;
|
|
@@ -933,12 +1016,12 @@ async function loadForecast(force, silent = false) {
|
|
|
933
1016
|
return '<span class="muted small">—</span>';
|
|
934
1017
|
};
|
|
935
1018
|
const row = (r) => `<tr>
|
|
936
|
-
<td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</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>
|
|
937
1020
|
<td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
|
|
938
1021
|
<td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
|
|
939
1022
|
<td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
|
|
940
1023
|
<td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
|
|
941
|
-
<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>
|
|
942
1025
|
</tr>`;
|
|
943
1026
|
const buyThr = f.buyThresholdPct ?? f.thresholdPct;
|
|
944
1027
|
const shortThr = f.shortThresholdPct ?? f.thresholdPct;
|
|
@@ -946,9 +1029,9 @@ async function loadForecast(force, silent = false) {
|
|
|
946
1029
|
...f.rows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
947
1030
|
...f.rows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
948
1031
|
].slice(0, 4);
|
|
949
|
-
box.innerHTML = `<
|
|
950
|
-
<table style="margin-top:14px
|
|
951
|
-
<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>`;
|
|
952
1035
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
953
1036
|
} catch (e) {
|
|
954
1037
|
if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
|
|
@@ -957,12 +1040,12 @@ async function loadForecast(force, silent = false) {
|
|
|
957
1040
|
syncUiAvailability();
|
|
958
1041
|
}
|
|
959
1042
|
}
|
|
960
|
-
const FORECAST_TICK_MS =
|
|
1043
|
+
const FORECAST_TICK_MS = 15000; // живое обновление каждые 15 секунд
|
|
961
1044
|
function refreshForecastIfStale() {
|
|
962
|
-
if (forecastBusy) return;
|
|
1045
|
+
if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
|
|
963
1046
|
if (!state.forecast?.at) {
|
|
964
1047
|
loadForecast(false, false);
|
|
965
|
-
} else if (Date.now() - state.forecast.at >=
|
|
1048
|
+
} else if (Date.now() - state.forecast.at >= 12000) {
|
|
966
1049
|
loadForecast(false, true);
|
|
967
1050
|
}
|
|
968
1051
|
}
|
|
@@ -975,7 +1058,7 @@ async function loadNews() {
|
|
|
975
1058
|
if ($('#nnav-news')) $('#nnav-news').textContent = items.length ? `Новости рынка (${items.length})` : 'Новости рынка';
|
|
976
1059
|
if ($('#news-list')) {
|
|
977
1060
|
$('#news-list').innerHTML = items.length
|
|
978
|
-
? 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('')
|
|
979
1062
|
: '<span class="muted">Свежих размеченных новостей пока нет.</span>';
|
|
980
1063
|
}
|
|
981
1064
|
} catch { /* ignore */ }
|
|
@@ -1859,7 +1942,11 @@ function renderBrokers() {
|
|
|
1859
1942
|
openBrokerId = accs[0].id;
|
|
1860
1943
|
}
|
|
1861
1944
|
const fcRowMap = state.forecast?.rows ? new Map(state.forecast.rows.map((r) => [r.symbol, r])) : null;
|
|
1862
|
-
|
|
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) => {
|
|
1863
1950
|
const isOpen = a.id === openBrokerId;
|
|
1864
1951
|
const ls = a.lastSync;
|
|
1865
1952
|
const pb = a.pendingBatch;
|
|
@@ -1941,11 +2028,11 @@ function renderBrokers() {
|
|
|
1941
2028
|
const fcRow = fcRowMap?.get(p.symbol);
|
|
1942
2029
|
const fcBadge = fcRow ? ` · <span class="${cls(fcRow.expectedPct)}" title="Прогноз модели на ближайшие 2,5 ч">прогноз ${fcRow.expectedPct > 0 ? '+' : ''}${fmtNum(fcRow.expectedPct, 2)}%</span>` : '';
|
|
1943
2030
|
return `<tr>
|
|
1944
|
-
<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>
|
|
1945
2032
|
<td><span class="tag ${kCls}">${esc(kLbl)}</span></td>
|
|
1946
2033
|
<td class="num">${fmtNum(p.qty, 0)} шт.${p.lots && p.lots !== p.qty ? ` <span class="muted small">(${fmtNum(p.lots, 0)} л.)</span>` : ''}</td>
|
|
1947
2034
|
<td class="num">${fmtOwnership(p)}</td>
|
|
1948
|
-
<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>
|
|
1949
2036
|
<td class="num"><b>${fmtNum(p.valueRub, 0)} ₽</b>${p.sharePct ? ` <span class="muted small">(${fmtNum(p.sharePct, 1)}%)</span>` : ''}</td>
|
|
1950
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>
|
|
1951
2038
|
<td>${posStatus(p)}${fcBadge}</td>
|
|
@@ -1961,7 +2048,7 @@ function renderBrokers() {
|
|
|
1961
2048
|
<span class="muted small">· автоматически заменяются при следующем шаге стратегии</span>
|
|
1962
2049
|
</div>
|
|
1963
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>
|
|
1964
|
-
<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>
|
|
1965
2052
|
<td><span class="tag ${o.direction === 'buy' ? 'ok' : 'no'}">${o.direction === 'buy' ? 'покупка' : 'продажа'}</span></td>
|
|
1966
2053
|
<td class="num">${o.lotsExecuted}/${o.lotsRequested} л.</td>
|
|
1967
2054
|
<td class="num">${fmtNum(o.price, 4)} ₽</td>
|
|
@@ -1972,7 +2059,7 @@ function renderBrokers() {
|
|
|
1972
2059
|
let batchHtml = '';
|
|
1973
2060
|
if (pb) {
|
|
1974
2061
|
const ordRows = pb.orders?.length
|
|
1975
|
-
? `<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>`
|
|
1976
2063
|
: `<div class="muted small" style="padding:4px 0">${pb.diagNote ? `<span style="color:var(--yellow)">⚠ ${esc(pb.diagNote)}</span>` : 'По текущему прогнозу новых сделок не требуется.'}</div>`;
|
|
1977
2064
|
const resHtml = pb.results?.length
|
|
1978
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>`
|
|
@@ -2007,43 +2094,58 @@ function renderBrokers() {
|
|
|
2007
2094
|
const rows = [];
|
|
2008
2095
|
for (const [, dayOps] of byDay) {
|
|
2009
2096
|
let buyRub = 0, sellRub = 0, feeRub = 0, divTaxRub = 0, inOutRub = 0, tradesCnt = 0;
|
|
2010
|
-
const
|
|
2097
|
+
const symBuyQty = new Map(), symSellQty = new Map(), symBuyRub = new Map(), symSellRub = new Map(), symFeeRub = new Map();
|
|
2098
|
+
let unlinkedFeeRub = 0;
|
|
2011
2099
|
for (const op of dayOps) {
|
|
2012
2100
|
const pay = Number(op.paymentRub) || 0;
|
|
2013
2101
|
if (op.kind === 'buy') {
|
|
2014
2102
|
tradesCnt++;
|
|
2015
2103
|
buyRub += Math.abs(pay);
|
|
2016
2104
|
if (op.symbol) {
|
|
2017
|
-
|
|
2018
|
-
symCash.set(op.symbol, (symCash.get(op.symbol) || 0) + pay);
|
|
2105
|
+
symBuyQty.set(op.symbol, (symBuyQty.get(op.symbol) || 0) + (Number(op.qty) || 0));
|
|
2019
2106
|
symBuyRub.set(op.symbol, (symBuyRub.get(op.symbol) || 0) + Math.abs(pay));
|
|
2020
2107
|
}
|
|
2021
2108
|
} else if (op.kind === 'sell') {
|
|
2022
2109
|
tradesCnt++;
|
|
2023
2110
|
sellRub += Math.abs(pay);
|
|
2024
2111
|
if (op.symbol) {
|
|
2025
|
-
|
|
2026
|
-
|
|
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));
|
|
2027
2114
|
}
|
|
2028
2115
|
} else if (op.kind === 'fee') {
|
|
2029
|
-
if (/дивиденд|купон/i.test(op.title || ''))
|
|
2030
|
-
|
|
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
|
+
}
|
|
2031
2123
|
} else if (op.kind === 'div') {
|
|
2032
2124
|
divTaxRub += pay;
|
|
2033
2125
|
} else if (op.kind === 'in' || op.kind === 'out') {
|
|
2034
2126
|
inOutRub += pay;
|
|
2035
2127
|
}
|
|
2036
2128
|
}
|
|
2037
|
-
// Считаем чистый результат
|
|
2129
|
+
// Считаем точный чистый результат закрытого объёма внутри дня по каждой бумаге (включая частичные закрытия и комиссии закрытых сделок)
|
|
2038
2130
|
let closedTradePnl = 0, closedBaseRub = 0, closedSyms = 0;
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
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;
|
|
2043
2145
|
closedSyms++;
|
|
2044
2146
|
}
|
|
2045
2147
|
}
|
|
2046
|
-
if (closedSyms > 0) closedTradePnl +=
|
|
2148
|
+
if (closedSyms > 0) closedTradePnl += unlinkedFeeRub;
|
|
2047
2149
|
const closedTradePct = closedBaseRub > 0 ? (closedTradePnl / closedBaseRub) * 100 : null;
|
|
2048
2150
|
|
|
2049
2151
|
const summaryParts = [];
|
|
@@ -2071,9 +2173,9 @@ function renderBrokers() {
|
|
|
2071
2173
|
rows.push(`<tr>
|
|
2072
2174
|
<td class="muted small" style="white-space:nowrap">${op.date ? fmtTimeOnly(op.date) : '—'}</td>
|
|
2073
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>
|
|
2074
|
-
<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>
|
|
2075
2177
|
<td class="num" style="white-space:nowrap">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
2076
|
-
<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>
|
|
2077
2179
|
<td class="num ${cls(op.paymentRub)}" style="white-space:nowrap"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
2078
2180
|
</tr>`);
|
|
2079
2181
|
}
|
|
@@ -2158,7 +2260,7 @@ function renderBrokers() {
|
|
|
2158
2260
|
}).join('');
|
|
2159
2261
|
|
|
2160
2262
|
const histHtml = lh?.operations?.length || lh?.loadedYears?.length
|
|
2161
|
-
? `<div class="kpis" style="
|
|
2263
|
+
? `<div class="kpis" style="margin-bottom:10px">
|
|
2162
2264
|
<div class="kpi"><div class="v">${fmtNum(lh.summary?.tradesCount || 0, 0)}</div><div class="l">исполнено сделок (всего ${lh.totalCount || 0})</div></div>
|
|
2163
2265
|
<div class="kpi"><div class="v">${fmtNum((lh.summary?.buySumRub || 0) + (lh.summary?.sellSumRub || 0), 0)} ₽</div><div class="l">оборот покупок и продаж</div></div>
|
|
2164
2266
|
<div class="kpi"><div class="v pos">+${fmtNum(lh.summary?.dividendsRub || 0, 0)} ₽</div><div class="l">дивиденды и купоны</div></div>
|
|
@@ -2182,11 +2284,13 @@ function renderBrokers() {
|
|
|
2182
2284
|
<span class="tag ${isPaused ? '' : a.mode === 'auto' ? 'mid' : 'ok'}">${isPaused ? '⏸ на паузе' : esc(BROKER_MODES[a.mode] || a.mode)}</span>
|
|
2183
2285
|
${a.sandbox ? '<span class="tag mid">песочница</span>' : ''}
|
|
2184
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>` : ''}
|
|
2185
2288
|
${collapsedMetricsHtml}
|
|
2186
2289
|
</div>
|
|
2187
2290
|
<div class="row-gap" style="align-items:center">
|
|
2188
|
-
<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>
|
|
2189
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>` : ''}
|
|
2190
2294
|
${isOpen ? `<button type="button" class="brk-icon-btn${isEditingThis ? ' primary' : ''}" data-btrade="cfg" data-bid="${esc(a.id)}" title="${isEditingThis ? 'Закрыть настройки и вернуться к торговле' : 'Настроить счёт и предохранители'}">⚙</button>` : ''}
|
|
2191
2295
|
</div>
|
|
2192
2296
|
</div>
|
|
@@ -2199,13 +2303,14 @@ function renderBrokers() {
|
|
|
2199
2303
|
<span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
|
|
2200
2304
|
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2201
2305
|
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2202
|
-
<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>
|
|
2307
|
+
${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
|
|
2203
2308
|
</div>
|
|
2204
|
-
${ls?.ok ? `<div class="kpis"
|
|
2309
|
+
${ls?.ok ? `<div class="kpis">
|
|
2205
2310
|
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2206
2311
|
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2207
2312
|
<div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
|
|
2208
|
-
<div class="kpi"><div class="v"
|
|
2313
|
+
<div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
|
|
2209
2314
|
</div>` : ''}
|
|
2210
2315
|
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2211
2316
|
<div class="brk-subtabs">
|
|
@@ -2231,6 +2336,18 @@ function renderBrokers() {
|
|
|
2231
2336
|
}
|
|
2232
2337
|
}
|
|
2233
2338
|
|
|
2339
|
+
$('#btn-broker-stop-all')?.addEventListener('click', async () => {
|
|
2340
|
+
const n = (state.brokers?.accounts || []).length;
|
|
2341
|
+
if (!n) return toast('Брокерских счетов нет');
|
|
2342
|
+
if (!confirm(`Аварийная остановка ${n} счёт(ов):\n\n• авто-торговля на всех счетах встанет на паузу;\n• все активные заявки у брокера будут сняты;\n• открытые позиции НЕ закрываются, защитные стопы продолжают работать.\n\nВозобновить торговлю можно кнопкой «▶ Запустить» на каждом счёте.`)) return;
|
|
2343
|
+
try {
|
|
2344
|
+
const r = await post('/api/brokers-stop-all');
|
|
2345
|
+
state.brokers = r;
|
|
2346
|
+
renderBrokers();
|
|
2347
|
+
toast(r.stopErrors?.length ? `Остановлено, но не все заявки сняты: ${r.stopErrors.join('; ')}` : 'Авто-торговля остановлена, активные заявки сняты', r.stopErrors?.length ? 'error' : 'info');
|
|
2348
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2234
2351
|
$('#btn-broker-add')?.addEventListener('click', () => {
|
|
2235
2352
|
const formBox = $('#broker-add-box');
|
|
2236
2353
|
const formSlot = $('#broker-form-slot');
|
|
@@ -2267,6 +2384,8 @@ function getBrokerFormSnap() {
|
|
|
2267
2384
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2268
2385
|
autoSchedule: Boolean(f.autoSchedule.checked),
|
|
2269
2386
|
skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
|
|
2387
|
+
brokerStops: Boolean(f.brokerStops?.checked),
|
|
2388
|
+
cashSweep: Boolean(f.cashSweep?.checked),
|
|
2270
2389
|
sandbox: Boolean(f.sandbox.checked),
|
|
2271
2390
|
});
|
|
2272
2391
|
}
|
|
@@ -2276,8 +2395,23 @@ function syncBrokerGuideUi() {
|
|
|
2276
2395
|
if (!f) return;
|
|
2277
2396
|
const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
|
|
2278
2397
|
const brkName = BROKER_LABELS[f.broker.value] || f.broker.value;
|
|
2398
|
+
if ($('#broker-stops-row')) $('#broker-stops-row').hidden = f.broker.value !== 'tbank';
|
|
2399
|
+
if ($('#broker-cash-sweep-row')) $('#broker-cash-sweep-row').hidden = f.broker.value !== 'tbank';
|
|
2400
|
+
// «Автомат» — только для Т-Банка (у остальных нет средней цены для защитных стопов, отмены заявок и истории)
|
|
2401
|
+
const autoOpt = f.mode?.querySelector('option[value="auto"]');
|
|
2402
|
+
if (autoOpt) {
|
|
2403
|
+
const autoAllowed = f.broker.value === 'tbank';
|
|
2404
|
+
autoOpt.disabled = !autoAllowed;
|
|
2405
|
+
autoOpt.title = autoAllowed ? '' : 'Режим «Автомат» пока доступен только для Т-Банка';
|
|
2406
|
+
if (!autoAllowed && f.mode.value === 'auto') f.mode.value = 'confirm';
|
|
2407
|
+
}
|
|
2279
2408
|
if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
|
|
2280
|
-
if ($('#broker-token-input'))
|
|
2409
|
+
if ($('#broker-token-input')) {
|
|
2410
|
+
const editAcc = editingBrokerId ? (state.brokers?.accounts || []).find((x) => x.id === editingBrokerId) : null;
|
|
2411
|
+
$('#broker-token-input').placeholder = editAcc?.hasToken && editAcc.broker === f.broker.value
|
|
2412
|
+
? `Токен сохранён (${editAcc.tokenMask || '…'}) — оставьте пустым или вставьте новый`
|
|
2413
|
+
: g.tokenPh;
|
|
2414
|
+
}
|
|
2281
2415
|
if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
|
|
2282
2416
|
if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
|
|
2283
2417
|
if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
|
|
@@ -2299,10 +2433,11 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
|
|
|
2299
2433
|
const accId = f.accountId.value.trim();
|
|
2300
2434
|
const cap = Number(f.maxCapitalRub.value) || 100000;
|
|
2301
2435
|
let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
|
|
2436
|
+
// Сам токен в чат не передаётся: он уходил бы во внешнюю модель ИИ и сохранялся в истории переписки
|
|
2302
2437
|
if (tok && accId) {
|
|
2303
|
-
promptText += `
|
|
2438
|
+
promptText += ` Токен я уже вставил в форму (в чат его не пишу), номер счёта "${accId}". Подскажи, что проверить перед сохранением счёта.`;
|
|
2304
2439
|
} else if (tok) {
|
|
2305
|
-
promptText += `
|
|
2440
|
+
promptText += ` Токен я уже вставил в форму (в чат его не пишу). Подскажи, как выбрать подходящий субсчёт кнопкой «🔍 Найти мои счета по токену» и что проверить перед сохранением.`;
|
|
2306
2441
|
} else if (accId) {
|
|
2307
2442
|
promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
|
|
2308
2443
|
} else {
|
|
@@ -2406,7 +2541,8 @@ function startBrokerEdit(acc) {
|
|
|
2406
2541
|
f.name.value = acc.name;
|
|
2407
2542
|
f.broker.value = acc.broker;
|
|
2408
2543
|
f.accountId.value = acc.accountId || '';
|
|
2409
|
-
|
|
2544
|
+
// токен с сервера не приходит: пустое поле = оставить сохранённый токен без изменений
|
|
2545
|
+
f.token.value = '';
|
|
2410
2546
|
f.mode.value = acc.mode || 'confirm';
|
|
2411
2547
|
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2412
2548
|
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
@@ -2414,6 +2550,8 @@ function startBrokerEdit(acc) {
|
|
|
2414
2550
|
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2415
2551
|
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2416
2552
|
f.skipEdgeMinutes.checked = acc.skipEdgeMinutes !== false;
|
|
2553
|
+
if (f.brokerStops) f.brokerStops.checked = Boolean(acc.brokerStops);
|
|
2554
|
+
if (f.cashSweep) f.cashSweep.checked = Boolean(acc.cashSweep);
|
|
2417
2555
|
f.sandbox.checked = !!acc.sandbox;
|
|
2418
2556
|
$('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
|
|
2419
2557
|
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
@@ -2512,8 +2650,9 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2512
2650
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2513
2651
|
autoSchedule: f.autoSchedule.checked,
|
|
2514
2652
|
skipEdgeMinutes: f.skipEdgeMinutes.checked,
|
|
2653
|
+
brokerStops: Boolean(f.brokerStops?.checked),
|
|
2654
|
+
cashSweep: Boolean(f.cashSweep?.checked),
|
|
2515
2655
|
sandbox: f.sandbox.checked,
|
|
2516
|
-
resetHalt: true,
|
|
2517
2656
|
};
|
|
2518
2657
|
try {
|
|
2519
2658
|
const savedId = editingBrokerId;
|
|
@@ -2614,6 +2753,20 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2614
2753
|
}
|
|
2615
2754
|
return;
|
|
2616
2755
|
}
|
|
2756
|
+
if (act === 'events') {
|
|
2757
|
+
openBrokerEvents(id);
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
if (act === 'resetstop') {
|
|
2761
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
2762
|
+
if (!confirm(`Снять дневной стоп на счёте «${acc?.name || id}»?\n\n${acc?.haltedReason || ''}\n\nДо конца сегодняшнего дня стоп по просадке на этом счёте больше не сработает, и авто-торговля продолжится с текущими убытками.`)) return;
|
|
2763
|
+
try {
|
|
2764
|
+
state.brokers = await post(`/api/brokers/${encodeURIComponent(id)}/reset-daily-stop`);
|
|
2765
|
+
renderBrokers();
|
|
2766
|
+
toast('Дневной стоп снят до конца дня');
|
|
2767
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2617
2770
|
b.disabled = true;
|
|
2618
2771
|
const old = b.textContent;
|
|
2619
2772
|
try {
|
|
@@ -2695,7 +2848,16 @@ function connectWs() {
|
|
|
2695
2848
|
loadChat().catch(() => {});
|
|
2696
2849
|
loadLlm().catch(() => {});
|
|
2697
2850
|
};
|
|
2698
|
-
ws.onclose = () => {
|
|
2851
|
+
ws.onclose = (ev) => {
|
|
2852
|
+
connected = false;
|
|
2853
|
+
if (ev.code === 4401) {
|
|
2854
|
+
setStatus('bad', 'Сессия не авторизована — войдите в аккаунт');
|
|
2855
|
+
location.href = '/landing';
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)');
|
|
2859
|
+
setTimeout(connectWs, 2000);
|
|
2860
|
+
};
|
|
2699
2861
|
ws.onmessage = (ev) => {
|
|
2700
2862
|
const m = JSON.parse(ev.data);
|
|
2701
2863
|
switch (m.type) {
|
|
@@ -2897,7 +3059,7 @@ async function renderCloudPane() {
|
|
|
2897
3059
|
}
|
|
2898
3060
|
|
|
2899
3061
|
const nt = me.notify || {};
|
|
2900
|
-
const code = u.notifyCode || '
|
|
3062
|
+
const code = u.notifyCode || '';
|
|
2901
3063
|
const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2902
3064
|
const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2903
3065
|
|
|
@@ -2939,9 +3101,6 @@ async function renderCloudPane() {
|
|
|
2939
3101
|
: u.limits?.maxCapitalPerAccountRub > 0
|
|
2940
3102
|
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
|
|
2941
3103
|
: 'подписка не активна';
|
|
2942
|
-
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
2943
|
-
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
2944
|
-
: (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
|
|
2945
3104
|
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
2946
3105
|
updateTabBadges();
|
|
2947
3106
|
|
|
@@ -2950,7 +3109,7 @@ async function renderCloudPane() {
|
|
|
2950
3109
|
<div class="stxt">
|
|
2951
3110
|
<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>
|
|
2952
3111
|
<div class="hint">
|
|
2953
|
-
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b
|
|
3112
|
+
Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
|
|
2954
3113
|
</div>
|
|
2955
3114
|
</div>
|
|
2956
3115
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -2962,9 +3121,9 @@ async function renderCloudPane() {
|
|
|
2962
3121
|
<div class="stxt">
|
|
2963
3122
|
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
2964
3123
|
<div class="hint">
|
|
2965
|
-
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)
|
|
2966
|
-
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)
|
|
2967
|
-
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с
|
|
3124
|
+
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
|
|
3125
|
+
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
|
|
3126
|
+
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
|
|
2968
3127
|
</div>
|
|
2969
3128
|
</div>
|
|
2970
3129
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -2995,8 +3154,8 @@ async function renderCloudPane() {
|
|
|
2995
3154
|
`;
|
|
2996
3155
|
|
|
2997
3156
|
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
2998
|
-
await post('/api/auth/logout');
|
|
2999
|
-
location.href = '/landing';
|
|
3157
|
+
const r = await post('/api/auth/logout').catch(() => ({}));
|
|
3158
|
+
location.href = r?.redirect || '/landing';
|
|
3000
3159
|
});
|
|
3001
3160
|
box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
|
|
3002
3161
|
try {
|
|
@@ -3066,6 +3225,53 @@ if (localStorage.getItem('theme') === 'light') applyTheme(true);
|
|
|
3066
3225
|
|
|
3067
3226
|
// ---------- справка ----------
|
|
3068
3227
|
const helpCache = new Map();
|
|
3228
|
+
// ---------- журнал исполнения по счёту ----------
|
|
3229
|
+
const EVENT_LABELS = {
|
|
3230
|
+
order_sent: ['заявка отправлена', ''],
|
|
3231
|
+
order_expired: ['заявка снята (>12 мин)', 'muted'],
|
|
3232
|
+
spread_skip: ['пропуск входа (широкий спред)', 'mid'],
|
|
3233
|
+
order_error: ['ошибка заявки', 'neg'],
|
|
3234
|
+
protective_stop: ['защитный стоп', 'neg'],
|
|
3235
|
+
protective_stop_error: ['ошибка защитного стопа', 'neg'],
|
|
3236
|
+
daily_stop: ['дневной стоп', 'neg'],
|
|
3237
|
+
daily_stop_reset: ['стоп снят вручную', ''],
|
|
3238
|
+
stop_all: ['аварийная остановка', 'neg'],
|
|
3239
|
+
broker_stop_set: ['стоп-заявка у брокера выставлена', 'pos'],
|
|
3240
|
+
broker_stop_cancel: ['стоп-заявка у брокера снята', ''],
|
|
3241
|
+
broker_stop_gone: ['стоп-заявка исчезла у брокера', 'neg'],
|
|
3242
|
+
broker_stop_error: ['ошибка стоп-заявки', 'neg'],
|
|
3243
|
+
};
|
|
3244
|
+
async function openBrokerEvents(id) {
|
|
3245
|
+
const acc = (state.brokers?.accounts || []).find((x) => x.id === id);
|
|
3246
|
+
$('#events-title').textContent = `Журнал исполнения · ${acc?.name || id}`;
|
|
3247
|
+
$('#events-body').textContent = 'Загрузка…';
|
|
3248
|
+
$('#events-modal').hidden = false;
|
|
3249
|
+
try {
|
|
3250
|
+
const { events } = await api(`/api/brokers/${encodeURIComponent(id)}/events?limit=300`);
|
|
3251
|
+
const sentCnt = events.filter((e) => e.kind === 'order_sent').length;
|
|
3252
|
+
const expCnt = events.filter((e) => e.kind === 'order_expired').length;
|
|
3253
|
+
const skipCnt = events.filter((e) => e.kind === 'spread_skip').length;
|
|
3254
|
+
const errCnt = events.filter((e) => e.kind === 'order_error').length;
|
|
3255
|
+
const fillPct = sentCnt > 0 ? Math.max(0, Math.round(((sentCnt - expCnt) / sentCnt) * 100)) : null;
|
|
3256
|
+
const ea = acc?.executionAudit;
|
|
3257
|
+
const summaryHtml = (ea || sentCnt > 0)
|
|
3258
|
+
? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head" style="flex-wrap:wrap;gap:10px">
|
|
3259
|
+
<b>Сверка исполнения со стресс-тестом</b>
|
|
3260
|
+
${ea ? `<span class="small">Сделок: <b>${ea.tradesCount}</b> (закрыто <b>${ea.closedCount}</b> поз.) · Комиссия: <b>${fmtNum(ea.commPct, 3)}%</b> · Отклонение от сигнала: <b>${ea.slipAllPct !== null ? `${ea.slipAllPct > 0 ? '+' : ''}${fmtNum(ea.slipAllPct, 3)}%` : '—'}</b> · Итого издержки: <b class="${ea.totalCostPerSidePct <= ea.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(ea.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности ≤ ${fmtNum(ea.breakEvenTargetPct, 3)}%)</span>` : ''}
|
|
3261
|
+
${sentCnt > 0 ? `<span class="small muted">По журналу: отправлено ${sentCnt}, исполнено ~${fillPct}% (снято >12 мин: ${expCnt}, пропущено по спреду: ${skipCnt}, ошибок: ${errCnt})</span>` : ''}
|
|
3262
|
+
</div></div>`
|
|
3263
|
+
: '';
|
|
3264
|
+
$('#events-body').innerHTML = summaryHtml + (events.length
|
|
3265
|
+
? `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Событие</th><th>Подробности</th></tr></thead><tbody>${events.map((ev) => {
|
|
3266
|
+
const [label, cls] = EVENT_LABELS[ev.kind] || [ev.kind, ''];
|
|
3267
|
+
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>`;
|
|
3268
|
+
}).join('')}</tbody></table></div>`
|
|
3269
|
+
: '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>');
|
|
3270
|
+
} catch (e) { $('#events-body').textContent = e.message; }
|
|
3271
|
+
}
|
|
3272
|
+
$('#events-close')?.addEventListener('click', () => { $('#events-modal').hidden = true; });
|
|
3273
|
+
$('#events-modal')?.addEventListener('click', (e) => { if (e.target.id === 'events-modal') $('#events-modal').hidden = true; });
|
|
3274
|
+
|
|
3069
3275
|
async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
|
|
3070
3276
|
$('#help').hidden = false;
|
|
3071
3277
|
document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
|
|
@@ -3090,9 +3296,326 @@ $('#help-tabs')?.addEventListener('click', (e) => {
|
|
|
3090
3296
|
});
|
|
3091
3297
|
$('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
|
|
3092
3298
|
$('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#help').hidden = true; });
|
|
3299
|
+
|
|
3300
|
+
// ---------- интерактивное досье акции (#stock-modal) ----------
|
|
3301
|
+
let stockModalChart = null;
|
|
3302
|
+
let stockChartRo = null;
|
|
3303
|
+
let currentStockData = null;
|
|
3304
|
+
let currentStockRange = '1m';
|
|
3305
|
+
|
|
3306
|
+
function renderStockChart(d, range = '1m') {
|
|
3307
|
+
currentStockRange = range;
|
|
3308
|
+
const box = $('#stock-chart-box');
|
|
3309
|
+
if (!box) return;
|
|
3310
|
+
if (stockChartRo) {
|
|
3311
|
+
try { stockChartRo.disconnect(); } catch { /* ignore */ }
|
|
3312
|
+
stockChartRo = null;
|
|
3313
|
+
}
|
|
3314
|
+
if (stockModalChart) {
|
|
3315
|
+
try { stockModalChart.remove(); } catch { /* ignore */ }
|
|
3316
|
+
stockModalChart = null;
|
|
3317
|
+
}
|
|
3318
|
+
box.innerHTML = '';
|
|
3319
|
+
const light = document.body.classList.contains('light');
|
|
3320
|
+
const initW = Math.max(260, box.clientWidth || 720);
|
|
3321
|
+
stockModalChart = LightweightCharts.createChart(box, {
|
|
3322
|
+
width: initW,
|
|
3323
|
+
height: 260,
|
|
3324
|
+
layout: { background: { color: 'transparent' }, textColor: light ? '#57606a' : '#8b949e', fontSize: 11 },
|
|
3325
|
+
grid: { vertLines: { visible: false }, horzLines: { color: light ? '#eaeef2' : '#1b2029' } },
|
|
3326
|
+
rightPriceScale: { borderVisible: false },
|
|
3327
|
+
timeScale: { borderVisible: false, timeVisible: range === '1d' || range === '1w' },
|
|
3328
|
+
crosshair: { mode: 0 },
|
|
3329
|
+
});
|
|
3330
|
+
if (window.ResizeObserver) {
|
|
3331
|
+
stockChartRo = new ResizeObserver(() => {
|
|
3332
|
+
if (stockModalChart && box.clientWidth > 100) {
|
|
3333
|
+
stockModalChart.applyOptions({ width: box.clientWidth });
|
|
3334
|
+
}
|
|
3335
|
+
});
|
|
3336
|
+
stockChartRo.observe(box);
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
const nowTs = Date.now();
|
|
3340
|
+
let rawPts = [];
|
|
3341
|
+
if (range === '1d') {
|
|
3342
|
+
const cut = nowTs - 2 * 86400000;
|
|
3343
|
+
rawPts = (d.intraday || []).filter((b) => b.ts >= cut);
|
|
3344
|
+
if (rawPts.length < 5) rawPts = (d.intraday || []).slice(-95);
|
|
3345
|
+
} else if (range === '1w') {
|
|
3346
|
+
rawPts = (d.intraday || []).filter((b) => b.ts >= nowTs - 8 * 86400000);
|
|
3347
|
+
} else if (range === '1m') {
|
|
3348
|
+
rawPts = d.intraday?.length ? d.intraday : (d.daily || []).slice(-30);
|
|
3349
|
+
} else if (range === '1y') {
|
|
3350
|
+
rawPts = (d.daily || []).filter((b) => b.ts >= nowTs - 370 * 86400000);
|
|
3351
|
+
} else {
|
|
3352
|
+
rawPts = d.daily || [];
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
const seenT = new Set();
|
|
3356
|
+
const seriesData = [];
|
|
3357
|
+
for (const b of rawPts) {
|
|
3358
|
+
const t = chartTime(b.ts);
|
|
3359
|
+
if (seenT.has(t) || !(b.c > 0)) continue;
|
|
3360
|
+
seenT.add(t);
|
|
3361
|
+
seriesData.push({ time: t, value: b.c });
|
|
3362
|
+
}
|
|
3363
|
+
seriesData.sort((a, b) => a.time - b.time);
|
|
3364
|
+
|
|
3365
|
+
const isUp = seriesData.length < 2 || seriesData[seriesData.length - 1].value >= seriesData[0].value;
|
|
3366
|
+
const mainColor = isUp ? '#3fb950' : '#f85149';
|
|
3367
|
+
const areaSeries = stockModalChart.addAreaSeries({
|
|
3368
|
+
lineColor: mainColor,
|
|
3369
|
+
topColor: isUp ? 'rgba(63, 185, 80, 0.24)' : 'rgba(248, 81, 73, 0.24)',
|
|
3370
|
+
bottomColor: 'rgba(0, 0, 0, 0.0)',
|
|
3371
|
+
lineWidth: 2,
|
|
3372
|
+
priceFormat: {
|
|
3373
|
+
type: 'price',
|
|
3374
|
+
precision: d.price < 1 ? 5 : d.price < 10 ? 3 : 2,
|
|
3375
|
+
minMove: d.specs?.minStep || 0.01,
|
|
3376
|
+
},
|
|
3377
|
+
});
|
|
3378
|
+
areaSeries.setData(seriesData);
|
|
3379
|
+
|
|
3380
|
+
// Маркеры сделок пользователя и дивидендов на графике
|
|
3381
|
+
const markers = [];
|
|
3382
|
+
if (seriesData.length > 0) {
|
|
3383
|
+
const minT = seriesData[0].time;
|
|
3384
|
+
const maxT = seriesData[seriesData.length - 1].time;
|
|
3385
|
+
for (const op of (d.userOperations || [])) {
|
|
3386
|
+
if (op.kind !== 'buy' && op.kind !== 'sell') continue;
|
|
3387
|
+
const t = chartTime(op.date);
|
|
3388
|
+
if (t >= minT && t <= maxT) {
|
|
3389
|
+
markers.push({
|
|
3390
|
+
time: t,
|
|
3391
|
+
position: op.kind === 'buy' ? 'belowBar' : 'aboveBar',
|
|
3392
|
+
color: op.kind === 'buy' ? '#3fb950' : '#f85149',
|
|
3393
|
+
shape: op.kind === 'buy' ? 'arrowUp' : 'arrowDown',
|
|
3394
|
+
text: `${op.kind === 'buy' ? 'Пок.' : 'Прод.'} ${op.qty} шт.`,
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
if (range === '1y' || range === 'all') {
|
|
3399
|
+
for (const dv of (d.dividends || [])) {
|
|
3400
|
+
const dtMs = Date.parse(`${dv.registry}T10:00:00Z`);
|
|
3401
|
+
if (!Number.isFinite(dtMs)) continue;
|
|
3402
|
+
const t = chartTime(dtMs);
|
|
3403
|
+
if (t >= minT && t <= maxT) {
|
|
3404
|
+
markers.push({
|
|
3405
|
+
time: t,
|
|
3406
|
+
position: 'aboveBar',
|
|
3407
|
+
color: '#4f8cff',
|
|
3408
|
+
shape: 'circle',
|
|
3409
|
+
text: `Див. ${dv.value} ₽`,
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
markers.sort((a, b) => a.time - b.time);
|
|
3415
|
+
if (markers.length) {
|
|
3416
|
+
try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
stockModalChart.timeScale().fitContent();
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
async function openStockModal(symbol) {
|
|
3423
|
+
const sym = String(symbol || '').toUpperCase().trim();
|
|
3424
|
+
if (!sym) return;
|
|
3425
|
+
const modal = $('#stock-modal');
|
|
3426
|
+
const titleEl = $('#stock-modal-title');
|
|
3427
|
+
const bodyEl = $('#stock-modal-body');
|
|
3428
|
+
if (!modal || !bodyEl) return;
|
|
3429
|
+
modal.hidden = false;
|
|
3430
|
+
titleEl.innerHTML = `<b>${esc(sym)}</b> <span class="muted small">· загрузка досье инструмента…</span>`;
|
|
3431
|
+
bodyEl.innerHTML = `<div class="ld-calc-banner"><div class="ld-calc-spin"></div><div><b>Загружаем котировки, прогноз ИИ, дивиденды и историю сделок по ${esc(sym)}…</b></div></div>`;
|
|
3432
|
+
|
|
3433
|
+
try {
|
|
3434
|
+
const d = await api(`/api/stock/${encodeURIComponent(sym)}`);
|
|
3435
|
+
currentStockData = d;
|
|
3436
|
+
KNOWN_MOEX_TICKERS.add(d.symbol);
|
|
3437
|
+
const pxDec = d.price < 1 ? 5 : d.price < 10 ? 3 : 2;
|
|
3438
|
+
const fc = d.forecast;
|
|
3439
|
+
const sigTag = fc?.signal
|
|
3440
|
+
? '<span class="tag ok">сигнал: купить</span>'
|
|
3441
|
+
: fc?.shortSignal
|
|
3442
|
+
? '<span class="tag no">сигнал: шорт</span>'
|
|
3443
|
+
: fc?.shortBlockedByBroker
|
|
3444
|
+
? '<span class="tag">шорт запрещён брокером</span>'
|
|
3445
|
+
: '<span class="tag">нейтрально (вне порога)</span>';
|
|
3446
|
+
const shortBadge = d.shortAllowedByBroker === true
|
|
3447
|
+
? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
|
|
3448
|
+
: d.shortAllowedByBroker === false
|
|
3449
|
+
? '<span class="tag" title="Брокер запрещает короткие продажи по этой бумаге">шорт в Т-Банке: запрещён</span>'
|
|
3450
|
+
: '';
|
|
3451
|
+
|
|
3452
|
+
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}`;
|
|
3453
|
+
|
|
3454
|
+
const capStr = d.specs?.marketCapRub
|
|
3455
|
+
? (d.specs.marketCapRub >= 1e12 ? `${fmtNum(d.specs.marketCapRub / 1e12, 2)} трлн ₽` : `${fmtNum(d.specs.marketCapRub / 1e9, 1)} млрд ₽`)
|
|
3456
|
+
: '—';
|
|
3457
|
+
|
|
3458
|
+
// 1. Верхние KPI
|
|
3459
|
+
const kpiHtml = `<div class="kpis">
|
|
3460
|
+
<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>
|
|
3461
|
+
<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>
|
|
3462
|
+
<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>
|
|
3463
|
+
<div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
|
|
3464
|
+
<div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
|
|
3465
|
+
</div>`;
|
|
3466
|
+
|
|
3467
|
+
// 2. График с выбором периода
|
|
3468
|
+
const chartWrapHtml = `<div class="scard spadded">
|
|
3469
|
+
<div class="stitle bar" style="margin:0 0 8px">
|
|
3470
|
+
<span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
|
|
3471
|
+
<div class="seg" id="stock-chart-range" style="margin:0">
|
|
3472
|
+
<button type="button" data-srange="1d">1Д</button>
|
|
3473
|
+
<button type="button" data-srange="1w">1Н</button>
|
|
3474
|
+
<button type="button" data-srange="1m" class="active">1М</button>
|
|
3475
|
+
<button type="button" data-srange="1y">1Г</button>
|
|
3476
|
+
<button type="button" data-srange="all">Все</button>
|
|
3477
|
+
</div>
|
|
3478
|
+
</div>
|
|
3479
|
+
<div id="stock-chart-box" class="chart"></div>
|
|
3480
|
+
</div>`;
|
|
3481
|
+
|
|
3482
|
+
// 3. Соседи по карте рынка (кто влияет на акцию прямо сейчас)
|
|
3483
|
+
const infHtml = fc?.influences?.length
|
|
3484
|
+
? `<div class="scard spadded">
|
|
3485
|
+
<div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
|
|
3486
|
+
<div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (инерция): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
|
|
3487
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
|
|
3488
|
+
${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
|
|
3489
|
+
<div>
|
|
3490
|
+
<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>
|
|
3491
|
+
<div class="muted small">близость на карте: <b>${fmtNum((inf.closeness || 0) * 100, 0)}%</b>${inf.expectedPct !== null ? ` · прогноз ${fmtPct(inf.expectedPct, 2)}` : ''}</div>
|
|
3492
|
+
</div>
|
|
3493
|
+
<div class="num ${cls(inf.contribPct)}" style="font-weight:700">${fmtPct(inf.contribPct, 3)}</div>
|
|
3494
|
+
</div>`).join('')}
|
|
3495
|
+
</div>
|
|
3496
|
+
</div>`
|
|
3497
|
+
: '';
|
|
3498
|
+
|
|
3499
|
+
// 4. Позиция пользователя и история реальных сделок на брокерском счёте по этой бумаге
|
|
3500
|
+
const userPosBanner = d.userPositions?.length
|
|
3501
|
+
? 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">
|
|
3502
|
+
<div><b>В вашем портфеле (${esc(p.accountName)}):</b> ${fmtNum(p.qty, 0)} шт. · средняя <b>${fmtNum(p.avgPrice, pxDec)} ₽</b> → текущая <b>${fmtNum(p.price, pxDec)} ₽</b></div>
|
|
3503
|
+
<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>
|
|
3504
|
+
</div>`).join('')
|
|
3505
|
+
: '';
|
|
3506
|
+
|
|
3507
|
+
const userOpsHtml = d.userOperations?.length
|
|
3508
|
+
? `<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>
|
|
3509
|
+
<td class="muted small">${fmtTs(op.date)}</td>
|
|
3510
|
+
<td><span class="tag ${op.kind === 'buy' || op.kind === 'div' ? 'ok' : op.kind === 'sell' ? 'mid' : ''}">${esc(op.kindLabel)}</span></td>
|
|
3511
|
+
<td class="num">${op.qty ? `${fmtNum(op.qty, 0)} шт.` : '—'}</td>
|
|
3512
|
+
<td class="num">${op.price ? `${fmtNum(op.price, pxDec)} ₽` : '—'}</td>
|
|
3513
|
+
<td class="num ${cls(op.paymentRub)}"><b>${op.paymentRub > 0 ? '+' : ''}${fmtNum(op.paymentRub, 2)} ₽</b></td>
|
|
3514
|
+
</tr>`).join('')}</tbody></table>`
|
|
3515
|
+
: '<p class="muted small" style="margin:0">На вашем брокерском счёте сделок по этой бумаге пока не было.</p>';
|
|
3516
|
+
|
|
3517
|
+
const btInfo = d.backtestStat
|
|
3518
|
+
? `<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>`
|
|
3519
|
+
: '';
|
|
3520
|
+
|
|
3521
|
+
const userBoxHtml = `<div class="scard spadded">
|
|
3522
|
+
<div style="font-weight:700;margin-bottom:8px">Ваша позиция и история операций по ${esc(d.symbol)}</div>
|
|
3523
|
+
${userPosBanner}
|
|
3524
|
+
${userOpsHtml}
|
|
3525
|
+
${btInfo}
|
|
3526
|
+
</div>`;
|
|
3527
|
+
|
|
3528
|
+
// 5. Фундаментальный бизнес-профиль (20 признаков) + Дивиденды (в 2 колонки)
|
|
3529
|
+
const topTraits = [...(d.traits || [])].filter((t) => t.value >= 0.15).sort((a, b) => b.value - a.value).slice(0, 10);
|
|
3530
|
+
const traitsColHtml = topTraits.length
|
|
3531
|
+
? `<div class="scard spadded">
|
|
3532
|
+
<div style="font-weight:700;margin-bottom:8px">Фундаментальный профиль бизнеса (${esc(d.symbol)})</div>
|
|
3533
|
+
<div style="display:grid;gap:6px">
|
|
3534
|
+
${topTraits.map((t) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12.5px" title="${esc(t.ask)}">
|
|
3535
|
+
<span>${esc(t.label)}</span>
|
|
3536
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
3537
|
+
<div class="stock-trait-bar"><div style="width:${Math.round(t.value * 100)}%"></div></div>
|
|
3538
|
+
<b class="num" style="width:34px;text-align:right">${Math.round(t.value * 100)}%</b>
|
|
3539
|
+
</div>
|
|
3540
|
+
</div>`).join('')}
|
|
3541
|
+
</div>
|
|
3542
|
+
</div>`
|
|
3543
|
+
: `<div class="scard spadded"><div style="font-weight:700;margin-bottom:6px">Фундаментальный профиль бизнеса</div><p class="muted small" style="margin:0">Отраслевой профиль компании учитывается через спектральную матрицу совместных движений цен.</p></div>`;
|
|
3544
|
+
|
|
3545
|
+
const divsColHtml = `<div class="scard spadded">
|
|
3546
|
+
<div style="font-weight:700;margin-bottom:8px">Дивидендная история ${esc(d.symbol)} (${d.dividends?.length || 0})</div>
|
|
3547
|
+
${d.dividends?.length
|
|
3548
|
+
? `<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) => {
|
|
3549
|
+
const yld = d.price > 0 ? (dv.value / d.price) * 100 : null;
|
|
3550
|
+
return `<tr>
|
|
3551
|
+
<td>${esc(dv.registry.split('-').reverse().join('.'))}</td>
|
|
3552
|
+
<td class="num pos"><b>+${fmtNum(dv.value, dv.value < 1 ? 4 : 2)} ₽</b></td>
|
|
3553
|
+
<td class="num muted">${yld ? `${fmtNum(yld, 2)}%` : '—'}</td>
|
|
3554
|
+
</tr>`;
|
|
3555
|
+
}).join('')}</tbody></table></div>`
|
|
3556
|
+
: '<p class="muted small" style="margin:0">Дивидендных выплат в рублях по данной бумаге не зафиксировано.</p>'}
|
|
3557
|
+
</div>`;
|
|
3558
|
+
|
|
3559
|
+
// 6. Новости по акции
|
|
3560
|
+
const newsHtml = `<div class="scard spadded">
|
|
3561
|
+
<div style="font-weight:700;margin-bottom:8px">Новости и раскрытия по ${esc(d.symbol)} (${d.news?.length || 0})</div>
|
|
3562
|
+
${d.news?.length
|
|
3563
|
+
? `<div style="display:grid;gap:6px">${d.news.map((n) => {
|
|
3564
|
+
const symTag = (n.symbols || []).find((s) => s.symbol === d.symbol);
|
|
3565
|
+
const tone = symTag?.tone ?? n.tone ?? 0;
|
|
3566
|
+
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>`;
|
|
3567
|
+
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>`;
|
|
3568
|
+
}).join('')}</div>`
|
|
3569
|
+
: '<p class="muted small" style="margin:0">За последние 30 дней размеченных новостей по этому эмитенту не было.</p>'}
|
|
3570
|
+
</div>`;
|
|
3571
|
+
|
|
3572
|
+
bodyEl.innerHTML = `
|
|
3573
|
+
${kpiHtml}
|
|
3574
|
+
${chartWrapHtml}
|
|
3575
|
+
${infHtml}
|
|
3576
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">${traitsColHtml}${divsColHtml}</div>
|
|
3577
|
+
${userBoxHtml}
|
|
3578
|
+
${newsHtml}
|
|
3579
|
+
`;
|
|
3580
|
+
|
|
3581
|
+
renderStockChart(d, '1m');
|
|
3582
|
+
} catch (err) {
|
|
3583
|
+
bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
$('#stock-close')?.addEventListener('click', () => { $('#stock-modal').hidden = true; });
|
|
3588
|
+
$('#stock-modal')?.addEventListener('click', (e) => {
|
|
3589
|
+
if (e.target.id === 'stock-modal') {
|
|
3590
|
+
$('#stock-modal').hidden = true;
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
const rBtn = e.target.closest('button[data-srange]');
|
|
3594
|
+
if (rBtn && currentStockData) {
|
|
3595
|
+
$('#stock-chart-range')?.querySelectorAll('button[data-srange]').forEach((b) => b.classList.toggle('active', b === rBtn));
|
|
3596
|
+
renderStockChart(currentStockData, rBtn.dataset.srange);
|
|
3597
|
+
}
|
|
3598
|
+
});
|
|
3599
|
+
$('#btn-stock-ask')?.addEventListener('click', () => {
|
|
3600
|
+
if (!currentStockData?.symbol) return;
|
|
3601
|
+
const sym = currentStockData.symbol;
|
|
3602
|
+
$('#stock-modal').hidden = true;
|
|
3603
|
+
sendChat(`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`);
|
|
3604
|
+
});
|
|
3605
|
+
|
|
3606
|
+
// Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
|
|
3607
|
+
document.addEventListener('click', (e) => {
|
|
3608
|
+
const symEl = e.target.closest('[data-sym]');
|
|
3609
|
+
if (!symEl) return;
|
|
3610
|
+
e.preventDefault();
|
|
3611
|
+
e.stopPropagation();
|
|
3612
|
+
openStockModal(symEl.dataset.sym);
|
|
3613
|
+
});
|
|
3614
|
+
|
|
3093
3615
|
document.addEventListener('keydown', (e) => {
|
|
3094
3616
|
if (e.key === 'Escape') {
|
|
3095
3617
|
$('#help').hidden = true;
|
|
3618
|
+
if ($('#stock-modal')) $('#stock-modal').hidden = true;
|
|
3096
3619
|
closeModelMenu();
|
|
3097
3620
|
closeModeMenu();
|
|
3098
3621
|
}
|