neuroalpha 0.5.17 → 0.5.20

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/public/app.js CHANGED
@@ -58,7 +58,7 @@ const KNOWN_MOEX_TICKERS = new Set([
58
58
  'AQUA', 'FESH', 'ASTR', 'SOFL', 'DATA', 'WUSH', 'LENT', 'BELU', 'ETLN', 'LSRG',
59
59
  'SELG', 'UGLD', 'RAGR', 'AGRO', 'FIVE', 'NMTP', 'UPRO', 'MSNG', 'ELFV', 'TGKA',
60
60
  'BANEP', 'BANE', 'RNFT', 'SGZH', 'MTLR', 'MTLRP', 'RASP', 'VSMO', 'ENPG', 'POLY',
61
- 'GEMC', 'MDMG', 'PRMD', 'APTK', 'CNRU', 'DELI', 'DIAS', 'EUTR', 'FIXP', 'HNFG',
61
+ 'GEMC', 'MDMG', 'PRMD', 'APTK', 'CNRU', 'DELI', 'DIAS', 'EUTR', 'FIXP', 'FIXR', 'BAZA', 'UWGN', 'HNFG',
62
62
  'IVAT', 'LEAS', 'MBNK', 'MVID', 'OGKB', 'OKEY', 'RENI', 'RTKM', 'RTKMP', 'SPBE', 'SVAV',
63
63
  ]);
64
64
  // Текст ИИ и новостей недоверенный: HTML после markdown всегда проходит через DOMPurify.
@@ -72,8 +72,14 @@ const md = (s) => {
72
72
  ? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
73
73
  : m
74
74
  ));
75
+ // Подсвечиваем розовым цветом строки таблиц, где указана блокировка входа (защита от сквиза, запрет шорта, падающий нож)
76
+ const withBlockedRows = withSyms.replace(/<tr>([\s\S]*?)<\/tr>/g, (m, inner) => (
77
+ !inner.includes('<th') && /(🔴|⛔|🚫|заблокирован|запрещ[её]н|защита от сквиза|без шорта|падающ[а-я]*\s+нож)/i.test(inner)
78
+ ? `<tr class="fc-blocked">${inner}</tr>`
79
+ : m
80
+ ));
75
81
  // широкие таблицы листаются внутри своей обёртки, а не растягивают ленту чата
76
- const withWraps = withSyms.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
82
+ const withWraps = withBlockedRows.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
77
83
  return DOMPurify.sanitize(withWraps, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
78
84
  };
79
85
  const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
@@ -985,6 +991,7 @@ async function loadForecast(force, silent = false) {
985
991
  const f = await api(`/api/model/forecast${force ? '?force=1' : ''}`, { timeoutMs: force ? 90000 : 18000 });
986
992
  // Считаем клиентские дельты и смещения в рейтинге относительно предыдущего кадра
987
993
  f.rows.forEach((r, idx) => {
994
+ KNOWN_MOEX_TICKERS.add(r.symbol);
988
995
  const rank = idx + 1;
989
996
  if (prevClientExp.has(r.symbol)) {
990
997
  const d = Number((r.expectedPct - prevClientExp.get(r.symbol)).toFixed(3));
@@ -1015,23 +1022,91 @@ async function loadForecast(force, silent = false) {
1015
1022
  if (rd < 0) return ` <span class="neg small" title="Опустилась на ${Math.abs(rd)} поз. в рейтинге">↓${Math.abs(rd)}</span>`;
1016
1023
  return '';
1017
1024
  };
1018
- const colgroup = `<colgroup><col style="width:33%"><col style="width:12%"><col style="width:12%"><col style="width:11%"><col style="width:12%"><col style="width:20%"></colgroup>`;
1019
- const sigBadge = (r) => {
1025
+ const buyThr = f.buyThresholdPct ?? f.thresholdPct;
1026
+ const shortThr = f.shortThresholdPct ?? f.thresholdPct;
1027
+ const buyThrT2 = f.buyThresholdTier2Pct ?? (buyThr * 1.2);
1028
+ const shortThrT2 = f.shortThresholdTier2Pct ?? (shortThr * 1.2);
1029
+ const TIER1_SET = new Set([
1030
+ 'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
1031
+ 'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
1032
+ 'OZON', 'HEAD', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
1033
+ ]);
1034
+ const colgroup = `<colgroup><col style="width:27%"><col style="width:11%"><col style="width:11%"><col style="width:10%"><col style="width:13%"><col style="width:18%"><col style="width:10%"></colgroup>`;
1035
+ const blockInfoOf = (r, side) => {
1036
+ const reasons = [];
1037
+ let label = '';
1038
+ if (side === 'short' || (side !== 'long' && r.expectedPct < 0)) {
1039
+ const squeeze = Boolean(r.shortBlockedBySqueeze || (r.expectedPct < 0 && (r.lastMovePct ?? 0) > 4.5));
1040
+ const noShort = Boolean(r.shortBlockedByBroker || r.shortAllowed === false);
1041
+ if (squeeze) {
1042
+ reasons.push(`Аварийная защита от вертикального сквиза / планки: бумага взлетела на текущей свече (+${fmtNum(r.lastMovePct, 2)}% > +4,5%), открытие шорта запрещено`);
1043
+ label = '⛔ сквиз';
1044
+ }
1045
+ if (noShort) {
1046
+ reasons.push('Блокировка шорта: брокер запрещает короткие продажи по этой бумаге (shortEnabledFlag = false)');
1047
+ label = label ? '⛔ сквиз · шорт' : '⛔ без шорта';
1048
+ }
1049
+ if (r.shortBlockedByDiv) {
1050
+ reasons.push(`Защита от дивидендной отсечки: ${r.shortBlockedByDiv}`);
1051
+ if (!label) label = '⛔ отсечка';
1052
+ }
1053
+ if (r.shortBlockedByNews) {
1054
+ reasons.push(`Фильтр новостей (сильный позитив): ${r.shortBlockedByNews}`);
1055
+ if (!label) label = '⛔ новости';
1056
+ }
1057
+ } else {
1058
+ const dip = Boolean(r.buyBlockedByDip || (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5));
1059
+ if (dip) {
1060
+ reasons.push(`Аварийная защита от обвала свечи: бумага падает на текущей свече (${fmtNum(r.lastMovePct, 2)}% < −4,5%), покупка запрещена`);
1061
+ label = '⛔ провал';
1062
+ }
1063
+ if (r.buyBlockedByDiv) {
1064
+ reasons.push(`Защита от дивидендного гэпа: ${r.buyBlockedByDiv}`);
1065
+ if (!label) label = '⛔ отсечка';
1066
+ }
1067
+ if (r.buyBlockedByNews) {
1068
+ reasons.push(`Фильтр новостей (сильный негатив): ${r.buyBlockedByNews}`);
1069
+ if (!label) label = '⛔ новости';
1070
+ }
1071
+ }
1072
+ if (r.liquid === false) {
1073
+ reasons.push('Ставки запрещены из-за низкой ликвидности бумаги');
1074
+ if (!label) label = '⛔ неликвид';
1075
+ }
1076
+ return reasons.length ? { blocked: true, label, title: reasons.join(' · ') } : { blocked: false, label: '', title: '' };
1077
+ };
1078
+ const sigBadge = (r, blk) => {
1020
1079
  if (r.signal) return '<span class="tag ok">купить</span>';
1021
1080
  if (r.shortSignal) return '<span class="tag no">шорт</span>';
1022
- if (r.shortBlockedByBroker) return '<span class="tag" title="Ожидание пробило порог шорта, но брокер запрещает короткие продажи по этой бумаге (shortEnabledFlag = false)">без шорта</span>';
1081
+ if (blk?.blocked) return `<span class="tag blocked" title="${esc(blk.title)}">${esc(blk.label)}</span>`;
1023
1082
  return '<span class="muted small">—</span>';
1024
1083
  };
1025
- const row = (r) => `<tr>
1084
+ const row = (r, side) => {
1085
+ const blk = !r.signal && !r.shortSignal ? blockInfoOf(r, side) : { blocked: false };
1086
+ const trCls = (r.signal || r.shortSignal) ? ' class="best"' : blk.blocked ? ` class="fc-blocked" title="${esc(blk.title)}"` : '';
1087
+ const isShort = side === 'short' || (side !== 'long' && r.expectedPct < 0);
1088
+ const isT1 = r.tier ? r.tier === 1 : TIER1_SET.has(r.symbol);
1089
+ const thrVal = isShort
1090
+ ? (r.shortThresholdPct ?? (isT1 ? shortThr : shortThrT2))
1091
+ : (r.buyThresholdPct ?? (isT1 ? buyThr : buyThrT2));
1092
+ const thrFmt = Number(thrVal || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1093
+ const thrText = isShort ? `−${thrFmt}%` : `+${thrFmt}%`;
1094
+ const multStr = r.edgeMult ? `×${fmtNum(r.edgeMult, 2)}` : (isT1 ? '×1,00' : '×1,20');
1095
+ const sigStr = r.sigmaPct ? `, волатильность 10-мин свечи σ = ${fmtNum(r.sigmaPct, 2)}%` : '';
1096
+ const tierDesc = `${isT1 ? '1-й эшелон' : '2–3-й эшелон'}, индивидуальный множитель ${multStr}${sigStr}`;
1097
+ const thrTitle = isShort
1098
+ ? `Индивидуальный порог шорта для ${r.symbol}: ниже −${fmtNum(thrVal, 2)}% (${tierDesc})`
1099
+ : `Индивидуальный порог покупки для ${r.symbol}: выше ${fmtPct(thrVal, 2)} (${tierDesc})`;
1100
+ return `<tr${trCls}>
1026
1101
  <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>
1027
1102
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
1028
1103
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
1029
1104
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
1030
- <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
1105
+ <td style="text-align:center;white-space:nowrap">${sigBadge(r, blk)}</td>
1031
1106
  <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>
1107
+ <td class="num muted small" style="white-space:nowrap" title="${esc(thrTitle)}">${thrText}</td>
1032
1108
  </tr>`;
1033
- const buyThr = f.buyThresholdPct ?? f.thresholdPct;
1034
- const shortThr = f.shortThresholdPct ?? f.thresholdPct;
1109
+ };
1035
1110
  // бумаги, выбывшие из индекса, остаются в модели как контекст, но в таблицах не показываются
1036
1111
  const shownRows = f.rows.filter((r) => r.inIndex !== false);
1037
1112
  const shortRows = [
@@ -1039,10 +1114,10 @@ async function loadForecast(force, silent = false) {
1039
1114
  ...shownRows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1040
1115
  ].slice(0, 4);
1041
1116
  // общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
1042
- const fcTable = (title, rows, extraStyle = '') => `<table class="fc-table" style="table-layout:fixed;width:100%${extraStyle}">${colgroup}<thead><tr><th>${title}</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table>`;
1043
- box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6))}
1044
- ${fcTable('Слабее рынка', shortRows, ';margin-top:14px')}</div>
1045
- <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
1117
+ const fcTable = (title, rows, extraStyle = '', side = 'long') => `<table class="fc-table" style="table-layout:fixed;width:100%${extraStyle}">${colgroup}<thead><tr><th>${title}</th><th class="num">Цена</th><th class="num">Ожидание</th><th class="num">Динамика</th><th style="text-align:center">Сигнал</th><th>Под влиянием</th><th class="num" title="Индивидуальный порог входа с учётом ликвидности бумаги (1-й или 2–3-й эшелон) и тренда индекса">Порог</th></tr></thead><tbody>${rows.map((r) => row(r, side)).join('')}</tbody></table>`;
1118
+ box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6), '', 'long')}
1119
+ ${fcTable('Слабее рынка', shortRows, ';margin-top:14px', 'short')}</div>
1120
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Точный индивидуальный порог каждой акции рассчитывается в колонке <b>«Порог»</b> с учётом её ликвидности (оборота торгов), волатильности свечи и тренда индекса. <span style="color:var(--blocked-fg)">Розовым</span> отмечены строки, по которым вход запрещён (запрет шорта у брокера, отсечка дивидендов, фильтр новостей или экстремальная свеча ±4,5%).</p>`;
1046
1121
  if (state.brokers?.accounts?.length) renderBrokers();
1047
1122
  } catch (e) {
1048
1123
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -3629,10 +3704,10 @@ async function renderCloudPane() {
3629
3704
  Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
3630
3705
  </div>
3631
3706
  </div>
3632
- ${me.cloudMode ? `<div class="row-gap" style="flex-wrap:wrap">
3633
- <button type="button" id="btn-cloud-verify">Проверить оплату</button>
3634
- <button type="button" id="btn-cloud-logout">Выйти</button>
3635
- </div>` : ''}
3707
+ <div class="row-gap" style="flex-wrap:wrap">
3708
+ ${me.cloudMode ? `<button type="button" id="btn-cloud-verify">Проверить оплату</button>` : ''}
3709
+ ${(me.cloudMode || me.role === 'instance' || !isDefaultLocal) ? `<button type="button" id="btn-cloud-logout">Выйти</button>` : ''}
3710
+ </div>
3636
3711
  </div>
3637
3712
  <div class="srow sblock">
3638
3713
  <div class="stxt">
@@ -3984,9 +4059,13 @@ async function openStockModal(symbol) {
3984
4059
  ? '<span class="tag ok">сигнал: купить</span>'
3985
4060
  : fc?.shortSignal
3986
4061
  ? '<span class="tag no">сигнал: шорт</span>'
3987
- : fc?.shortBlockedByBroker
3988
- ? '<span class="tag">шорт запрещён брокером</span>'
3989
- : '<span class="tag">нейтрально (вне порога)</span>';
4062
+ : fc?.shortBlockedBySqueeze
4063
+ ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4064
+ : fc?.shortBlockedByBroker
4065
+ ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4066
+ : fc?.buyBlockedByDip
4067
+ ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4068
+ : '<span class="tag">нейтрально (вне порога)</span>';
3990
4069
  const shortBadge = d.shortAllowedByBroker === true
3991
4070
  ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
3992
4071
  : d.shortAllowedByBroker === false
@@ -4003,7 +4082,7 @@ async function openStockModal(symbol) {
4003
4082
  const kpiHtml = `<div class="kpis">
4004
4083
  <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>
4005
4084
  <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>
4006
- <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>
4085
+ <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)}%${d.shortThresholdPct ? ` / −${fmtNum(d.shortThresholdPct, 2)}%` : ''})</div></div>
4007
4086
  <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
4008
4087
  <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
4009
4088
  </div>`;
@@ -4023,7 +4102,7 @@ async function openStockModal(symbol) {
4023
4102
  const infHtml = fc?.influences?.length
4024
4103
  ? `<div class="scard spadded">
4025
4104
  <div style="font-weight:700;margin-bottom:6px">На что смотрит ИИ по ${esc(d.symbol)} (ближайшие соседи на спектральной карте рынка)</div>
4026
- <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (инерция): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
4105
+ <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (возврат к среднему после локального импульса): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
4027
4106
  <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
4028
4107
  ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
4029
4108
  <div>