neuroalpha 0.5.17 → 0.5.19

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,90 @@ 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 thrText = isShort ? `−${fmtNum(thrVal, 2)}%` : fmtPct(thrVal, 2);
1093
+ const tierDesc = isT1
1094
+ ? '1-й эшелон ликвидности (узкий спред стакана): базовый порог'
1095
+ : '2–3-й эшелон (широкий спред стакана): порог повышен в 1,20× для покрытия реального проскальзывания';
1096
+ const thrTitle = isShort
1097
+ ? `Индивидуальный порог шорта для ${r.symbol}: ниже −${fmtNum(thrVal, 2)}% (${tierDesc})`
1098
+ : `Индивидуальный порог покупки для ${r.symbol}: выше ${fmtPct(thrVal, 2)} (${tierDesc})`;
1099
+ return `<tr${trCls}>
1026
1100
  <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
1101
  <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
1028
1102
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
1029
1103
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
1030
- <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
1104
+ <td style="text-align:center;white-space:nowrap">${sigBadge(r, blk)}</td>
1031
1105
  <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>
1106
+ <td class="num muted small" style="white-space:nowrap" title="${esc(thrTitle)}">${thrText}</td>
1032
1107
  </tr>`;
1033
- const buyThr = f.buyThresholdPct ?? f.thresholdPct;
1034
- const shortThr = f.shortThresholdPct ?? f.thresholdPct;
1108
+ };
1035
1109
  // бумаги, выбывшие из индекса, остаются в модели как контекст, но в таблицах не показываются
1036
1110
  const shownRows = f.rows.filter((r) => r.inIndex !== false);
1037
1111
  const shortRows = [
@@ -1039,10 +1113,10 @@ async function loadForecast(force, silent = false) {
1039
1113
  ...shownRows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
1040
1114
  ].slice(0, 4);
1041
1115
  // общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
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>`;
1116
+ 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>`;
1117
+ box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6), '', 'long')}
1118
+ ${fcTable('Слабее рынка', shortRows, ';margin-top:14px', 'short')}</div>
1119
+ <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Индивидуальный порог каждой акции указан в колонке <b>«Порог»</b> (1-й эшелон / 2–3-й эшелон со спредом шире): «Купить» — ожидание выше <b>${fmtPct(buyThr, 2)} / ${fmtPct(buyThrT2, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}% / −${fmtNum(shortThrT2, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''} <span style="color:var(--blocked-fg)">Розовым</span> отмечены строки, по которым вход запрещён (запрет шорта у брокера, отсечка дивидендов, фильтр новостей или экстремальная свеча ±4,5%).</p>`;
1046
1120
  if (state.brokers?.accounts?.length) renderBrokers();
1047
1121
  } catch (e) {
1048
1122
  if (!silent) box.innerHTML = `<span class="neg">Не получилось: ${esc(e.message)}</span>`;
@@ -3984,9 +4058,13 @@ async function openStockModal(symbol) {
3984
4058
  ? '<span class="tag ok">сигнал: купить</span>'
3985
4059
  : fc?.shortSignal
3986
4060
  ? '<span class="tag no">сигнал: шорт</span>'
3987
- : fc?.shortBlockedByBroker
3988
- ? '<span class="tag">шорт запрещён брокером</span>'
3989
- : '<span class="tag">нейтрально (вне порога)</span>';
4061
+ : fc?.shortBlockedBySqueeze
4062
+ ? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
4063
+ : fc?.shortBlockedByBroker
4064
+ ? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
4065
+ : fc?.buyBlockedByDip
4066
+ ? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
4067
+ : '<span class="tag">нейтрально (вне порога)</span>';
3990
4068
  const shortBadge = d.shortAllowedByBroker === true
3991
4069
  ? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
3992
4070
  : d.shortAllowedByBroker === false
@@ -4003,7 +4081,7 @@ async function openStockModal(symbol) {
4003
4081
  const kpiHtml = `<div class="kpis">
4004
4082
  <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
4083
  <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>
4084
+ <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
4085
  <div class="kpi"><div class="v ${cls(fc?.lastMovePct || 0)}">${fc ? fmtPct(fc.lastMovePct, 2) : '—'}</div><div class="l">импульс текущей свечи (к IMOEX)</div></div>
4008
4086
  <div class="kpi"><div class="v">${capStr}</div><div class="l">капитализация${d.specs?.indexWeightPct ? ` · вес ${d.specs.indexWeightPct}%` : ''}</div></div>
4009
4087
  </div>`;
@@ -4023,7 +4101,7 @@ async function openStockModal(symbol) {
4023
4101
  const infHtml = fc?.influences?.length
4024
4102
  ? `<div class="scard spadded">
4025
4103
  <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>
4104
+ <div class="muted small" style="margin-bottom:8px">Вклад собственного движения акции (возврат к среднему после локального импульса): <b class="${cls(fc.selfPct)}">${fmtPct(fc.selfPct, 2)}</b> · Влияние связанных бумаг (переток импульса по карте):</div>
4027
4105
  <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
4028
4106
  ${fc.influences.map((inf) => `<div class="stab-box" style="padding:8px 10px;display:flex;justify-content:space-between;align-items:center">
4029
4107
  <div>