neuroalpha 0.3.17 → 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/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 +573 -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 +1014 -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,13 @@ 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>
|
|
2203
2307
|
</div>
|
|
2204
|
-
${ls?.ok ? `<div class="kpis"
|
|
2308
|
+
${ls?.ok ? `<div class="kpis">
|
|
2205
2309
|
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
2206
2310
|
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
|
|
2207
2311
|
<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"
|
|
2312
|
+
<div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
|
|
2209
2313
|
</div>` : ''}
|
|
2210
2314
|
${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
|
|
2211
2315
|
<div class="brk-subtabs">
|
|
@@ -2231,6 +2335,18 @@ function renderBrokers() {
|
|
|
2231
2335
|
}
|
|
2232
2336
|
}
|
|
2233
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
|
+
|
|
2234
2350
|
$('#btn-broker-add')?.addEventListener('click', () => {
|
|
2235
2351
|
const formBox = $('#broker-add-box');
|
|
2236
2352
|
const formSlot = $('#broker-form-slot');
|
|
@@ -2267,6 +2383,8 @@ function getBrokerFormSnap() {
|
|
|
2267
2383
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2268
2384
|
autoSchedule: Boolean(f.autoSchedule.checked),
|
|
2269
2385
|
skipEdgeMinutes: Boolean(f.skipEdgeMinutes.checked),
|
|
2386
|
+
brokerStops: Boolean(f.brokerStops?.checked),
|
|
2387
|
+
cashSweep: Boolean(f.cashSweep?.checked),
|
|
2270
2388
|
sandbox: Boolean(f.sandbox.checked),
|
|
2271
2389
|
});
|
|
2272
2390
|
}
|
|
@@ -2276,8 +2394,23 @@ function syncBrokerGuideUi() {
|
|
|
2276
2394
|
if (!f) return;
|
|
2277
2395
|
const g = BROKER_GUIDES[f.broker.value] || BROKER_GUIDES.tbank;
|
|
2278
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
|
+
}
|
|
2279
2407
|
if ($('#broker-token-lbl')) $('#broker-token-lbl').textContent = g.tokenLbl;
|
|
2280
|
-
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
|
+
}
|
|
2281
2414
|
if ($('#broker-acc-lbl')) $('#broker-acc-lbl').textContent = g.accLbl;
|
|
2282
2415
|
if ($('#broker-acc-input')) $('#broker-acc-input').placeholder = g.accPh;
|
|
2283
2416
|
if ($('#btn-broker-discover')) $('#btn-broker-discover').hidden = !g.canDiscover;
|
|
@@ -2299,10 +2432,11 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
|
|
|
2299
2432
|
const accId = f.accountId.value.trim();
|
|
2300
2433
|
const cap = Number(f.maxCapitalRub.value) || 100000;
|
|
2301
2434
|
let promptText = `Помоги мне открыть и подключить брокерский счёт у брокера «${brkName}» (код: ${brk}, лимит капитала на стратегию: ${cap} ₽).`;
|
|
2435
|
+
// Сам токен в чат не передаётся: он уходил бы во внешнюю модель ИИ и сохранялся в истории переписки
|
|
2302
2436
|
if (tok && accId) {
|
|
2303
|
-
promptText += `
|
|
2437
|
+
promptText += ` Токен я уже вставил в форму (в чат его не пишу), номер счёта "${accId}". Подскажи, что проверить перед сохранением счёта.`;
|
|
2304
2438
|
} else if (tok) {
|
|
2305
|
-
promptText += `
|
|
2439
|
+
promptText += ` Токен я уже вставил в форму (в чат его не пишу). Подскажи, как выбрать подходящий субсчёт кнопкой «🔍 Найти мои счета по токену» и что проверить перед сохранением.`;
|
|
2306
2440
|
} else if (accId) {
|
|
2307
2441
|
promptText += ` Номер счёта: "${accId}". Подскажи пошагово, как получить токен (или подключи счёт сразу, если для этого брокера токен не нужен).`;
|
|
2308
2442
|
} else {
|
|
@@ -2406,7 +2540,8 @@ function startBrokerEdit(acc) {
|
|
|
2406
2540
|
f.name.value = acc.name;
|
|
2407
2541
|
f.broker.value = acc.broker;
|
|
2408
2542
|
f.accountId.value = acc.accountId || '';
|
|
2409
|
-
|
|
2543
|
+
// токен с сервера не приходит: пустое поле = оставить сохранённый токен без изменений
|
|
2544
|
+
f.token.value = '';
|
|
2410
2545
|
f.mode.value = acc.mode || 'confirm';
|
|
2411
2546
|
if (f.legacyMode) f.legacyMode.value = acc.legacyMode || 'isolate';
|
|
2412
2547
|
f.maxCapitalRub.value = acc.maxCapitalRub || 100000;
|
|
@@ -2414,6 +2549,8 @@ function startBrokerEdit(acc) {
|
|
|
2414
2549
|
f.limitOffsetPct.value = acc.limitOffsetPct ?? 0.1;
|
|
2415
2550
|
f.autoSchedule.checked = acc.autoSchedule !== false;
|
|
2416
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);
|
|
2417
2554
|
f.sandbox.checked = !!acc.sandbox;
|
|
2418
2555
|
$('#broker-add-sum').textContent = `Настройки счёта «${acc.name}»`;
|
|
2419
2556
|
$('#broker-save-btn').textContent = 'Сохранить изменения';
|
|
@@ -2512,8 +2649,9 @@ brokerForm.addEventListener('submit', async (e) => {
|
|
|
2512
2649
|
limitOffsetPct: Number(f.limitOffsetPct.value ?? 0.1),
|
|
2513
2650
|
autoSchedule: f.autoSchedule.checked,
|
|
2514
2651
|
skipEdgeMinutes: f.skipEdgeMinutes.checked,
|
|
2652
|
+
brokerStops: Boolean(f.brokerStops?.checked),
|
|
2653
|
+
cashSweep: Boolean(f.cashSweep?.checked),
|
|
2515
2654
|
sandbox: f.sandbox.checked,
|
|
2516
|
-
resetHalt: true,
|
|
2517
2655
|
};
|
|
2518
2656
|
try {
|
|
2519
2657
|
const savedId = editingBrokerId;
|
|
@@ -2614,6 +2752,20 @@ $('#brokers-trade').addEventListener('click', async (e) => {
|
|
|
2614
2752
|
}
|
|
2615
2753
|
return;
|
|
2616
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
|
+
}
|
|
2617
2769
|
b.disabled = true;
|
|
2618
2770
|
const old = b.textContent;
|
|
2619
2771
|
try {
|
|
@@ -2695,7 +2847,16 @@ function connectWs() {
|
|
|
2695
2847
|
loadChat().catch(() => {});
|
|
2696
2848
|
loadLlm().catch(() => {});
|
|
2697
2849
|
};
|
|
2698
|
-
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
|
+
};
|
|
2699
2860
|
ws.onmessage = (ev) => {
|
|
2700
2861
|
const m = JSON.parse(ev.data);
|
|
2701
2862
|
switch (m.type) {
|
|
@@ -2897,7 +3058,7 @@ async function renderCloudPane() {
|
|
|
2897
3058
|
}
|
|
2898
3059
|
|
|
2899
3060
|
const nt = me.notify || {};
|
|
2900
|
-
const code = u.notifyCode || '
|
|
3061
|
+
const code = u.notifyCode || '';
|
|
2901
3062
|
const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2902
3063
|
const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
|
|
2903
3064
|
|
|
@@ -2939,9 +3100,6 @@ async function renderCloudPane() {
|
|
|
2939
3100
|
: u.limits?.maxCapitalPerAccountRub > 0
|
|
2940
3101
|
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
|
|
2941
3102
|
: 'подписка не активна';
|
|
2942
|
-
const retLimStr = u.limits?.maxAnnualReturnPct > 0
|
|
2943
|
-
? `до ${u.limits.maxAnnualReturnPct}% годовых`
|
|
2944
|
-
: (u.isOwner ? 'без ограничений (режим «Хозяин»)' : 'не активна');
|
|
2945
3103
|
const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
|
|
2946
3104
|
updateTabBadges();
|
|
2947
3105
|
|
|
@@ -2950,7 +3108,7 @@ async function renderCloudPane() {
|
|
|
2950
3108
|
<div class="stxt">
|
|
2951
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>
|
|
2952
3110
|
<div class="hint">
|
|
2953
|
-
Брокерских счетов: до <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)}` : ''}.
|
|
2954
3112
|
</div>
|
|
2955
3113
|
</div>
|
|
2956
3114
|
${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -2962,9 +3120,9 @@ async function renderCloudPane() {
|
|
|
2962
3120
|
<div class="stxt">
|
|
2963
3121
|
<div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
|
|
2964
3122
|
<div class="hint">
|
|
2965
|
-
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)
|
|
2966
|
-
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)
|
|
2967
|
-
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с
|
|
3123
|
+
• <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
|
|
3124
|
+
• <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
|
|
3125
|
+
• <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
|
|
2968
3126
|
</div>
|
|
2969
3127
|
</div>
|
|
2970
3128
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -2995,8 +3153,8 @@ async function renderCloudPane() {
|
|
|
2995
3153
|
`;
|
|
2996
3154
|
|
|
2997
3155
|
box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
|
|
2998
|
-
await post('/api/auth/logout');
|
|
2999
|
-
location.href = '/landing';
|
|
3156
|
+
const r = await post('/api/auth/logout').catch(() => ({}));
|
|
3157
|
+
location.href = r?.redirect || '/landing';
|
|
3000
3158
|
});
|
|
3001
3159
|
box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
|
|
3002
3160
|
try {
|
|
@@ -3066,6 +3224,38 @@ if (localStorage.getItem('theme') === 'light') applyTheme(true);
|
|
|
3066
3224
|
|
|
3067
3225
|
// ---------- справка ----------
|
|
3068
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
|
+
|
|
3069
3259
|
async function openHelpDoc(docName = 'ИНСТРУКЦИЯ.md') {
|
|
3070
3260
|
$('#help').hidden = false;
|
|
3071
3261
|
document.querySelectorAll('#help-tabs [data-hdoc]').forEach((b) => b.classList.toggle('active', b.dataset.hdoc === docName));
|
|
@@ -3090,9 +3280,326 @@ $('#help-tabs')?.addEventListener('click', (e) => {
|
|
|
3090
3280
|
});
|
|
3091
3281
|
$('#help-close').addEventListener('click', () => { $('#help').hidden = true; });
|
|
3092
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
|
+
|
|
3093
3599
|
document.addEventListener('keydown', (e) => {
|
|
3094
3600
|
if (e.key === 'Escape') {
|
|
3095
3601
|
$('#help').hidden = true;
|
|
3602
|
+
if ($('#stock-modal')) $('#stock-modal').hidden = true;
|
|
3096
3603
|
closeModelMenu();
|
|
3097
3604
|
closeModeMenu();
|
|
3098
3605
|
}
|