neuroalpha 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -37,6 +37,7 @@ function fmtDayGroup(ts) {
37
37
  const nowIso = new Date(Date.now() + MSK).toISOString();
38
38
  const todayKey = nowIso.slice(0, 10);
39
39
  const yestKey = new Date(Date.now() + MSK - 86400000).toISOString().slice(0, 10);
40
+ const beforeYestKey = new Date(Date.now() + MSK - 2 * 86400000).toISOString().slice(0, 10);
40
41
  const dNum = Number(iso.slice(8, 10));
41
42
  const mName = MONTHS_GEN[Number(iso.slice(5, 7)) - 1] || '';
42
43
  const y = iso.slice(0, 4);
@@ -45,8 +46,21 @@ function fmtDayGroup(ts) {
45
46
  const base = `${dNum} ${mName}${y !== curY ? ` ${y} г.` : ''}`;
46
47
  if (dayKey === todayKey) return `Сегодня, ${base}`;
47
48
  if (dayKey === yestKey) return `Вчера, ${base}`;
49
+ if (dayKey === beforeYestKey) return `Позавчера, ${base}`;
48
50
  return `${base} (${dow})`;
49
51
  }
52
+ function fmtDayChip(dayKey) {
53
+ if (!dayKey) return '—';
54
+ const todayKey = new Date(Date.now() + MSK).toISOString().slice(0, 10);
55
+ const yestKey = new Date(Date.now() + MSK - 86400000).toISOString().slice(0, 10);
56
+ const beforeYestKey = new Date(Date.now() + MSK - 2 * 86400000).toISOString().slice(0, 10);
57
+ const dm = `${dayKey.slice(8, 10)}.${dayKey.slice(5, 7)}`;
58
+ if (dayKey === todayKey) return `Сегодня (${dm})`;
59
+ if (dayKey === yestKey) return `Вчера (${dm})`;
60
+ if (dayKey === beforeYestKey) return `Позавчера (${dm})`;
61
+ const dow = DOW_SHORT[new Date(`${dayKey}T12:00:00Z`).getUTCDay()] || '';
62
+ return `${dm} (${dow})`;
63
+ }
50
64
  const fmtDate = (ts) => { const d = new Date(ts + MSK).toISOString(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(2, 4)}`; };
51
65
  const cls = (v) => (v > 0 ? 'pos' : v < 0 ? 'neg' : '');
52
66
  const KNOWN_MOEX_TICKERS = new Set([
@@ -945,6 +959,14 @@ let forecastBusyAt = 0;
945
959
  let pulseClearTimer = null;
946
960
  const prevClientExp = new Map();
947
961
  const prevClientRank = new Map();
962
+ let nearDayFilter = ''; // '' — последний день с сигналами (обычно сегодня), 'YYYY-MM-DD' — выбранный день, 'all' — все дни
963
+ let renderNearSignalsNow = null;
964
+ $('#forecast')?.addEventListener('click', (e) => {
965
+ const btn = e.target.closest('button[data-near-day]');
966
+ if (!btn) return;
967
+ nearDayFilter = btn.dataset.nearDay || '';
968
+ if (renderNearSignalsNow) renderNearSignalsNow();
969
+ });
948
970
 
949
971
  function triggerTradingPulse(text = 'Обновление котировок и сигналов…', durationMs = 2400) {
950
972
  state.tradingPulseActive = true;
@@ -1136,37 +1158,66 @@ async function loadForecast(force, silent = false) {
1136
1158
  // общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
1137
1159
  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>`;
1138
1160
  const nearList = Array.isArray(f.nearSignals) ? f.nearSignals : [];
1161
+ const nearByDay = new Map();
1162
+ for (const ep of nearList) {
1163
+ const dk = ep.day || (ep.lastBarTs ? new Date(ep.lastBarTs + MSK).toISOString().slice(0, 10) : '');
1164
+ if (!dk) continue;
1165
+ if (!nearByDay.has(dk)) nearByDay.set(dk, []);
1166
+ nearByDay.get(dk).push(ep);
1167
+ }
1168
+ const nearDays = [...nearByDay.keys()].sort((a, b) => b.localeCompare(a));
1169
+ const dayCountsStr = (arr) => `${arr.filter((x) => x.status === 'signal').length} сигн., ${arr.filter((x) => x.status === 'blocked').length} блок., ${arr.filter((x) => x.status === 'near').length} почти`;
1170
+ const nearEpRow = (ep) => {
1171
+ const tRange = ep.firstBar && ep.lastBar && ep.firstBar !== ep.lastBar ? `${ep.firstBar}–${ep.lastBar} (${ep.barsCount} св.)` : `${ep.peakBar || ep.lastBar || ''}`;
1172
+ const isSh = ep.side === 'short';
1173
+ const thrStr = `${isSh ? '−' : '+'}${fmtNum(ep.thresholdPct, 2)}%`;
1174
+ const stBadge = ep.status === 'signal'
1175
+ ? `<span class="tag ok">${isSh ? '✓ сигнал шорт' : '✓ сигнал купить'}</span>`
1176
+ : ep.status === 'blocked'
1177
+ ? '<span class="tag blocked">⛔ блок входа</span>'
1178
+ : `<span class="tag mid">почти (${ep.peakRatioPct}%)</span>`;
1179
+ const rowCls = ep.status === 'signal' ? ' class="best"' : ep.status === 'blocked' ? ' class="fc-blocked"' : '';
1180
+ return `<tr${rowCls}>
1181
+ <td class="muted small" style="white-space:nowrap">${esc(tRange)}</td>
1182
+ <td style="white-space:nowrap"><span class="sym-link" data-sym="${esc(ep.symbol)}" title="Открыть досье акции ${esc(ep.symbol)}"><b>${esc(ep.symbol)}</b> <span class="muted small">${esc(ep.name || '')}</span></span></td>
1183
+ <td class="${isSh ? 'neg' : 'pos'} small" style="white-space:nowrap">${isSh ? 'В шорт' : 'В покупку'}</td>
1184
+ <td class="num ${cls(ep.peakExpectedPct)}" style="white-space:nowrap"><b>${fmtPct(ep.peakExpectedPct, 2)}</b></td>
1185
+ <td class="num muted small" style="white-space:nowrap">${thrStr}</td>
1186
+ <td class="num small" style="white-space:nowrap"><b>${ep.peakRatioPct}%</b></td>
1187
+ <td class="small" style="white-space:normal">${stBadge} <span class="muted">${esc(ep.reasonText || '')}</span></td>
1188
+ </tr>`;
1189
+ };
1190
+ const buildNearInnerHtml = () => {
1191
+ if (nearDayFilter !== 'all' && !nearByDay.has(nearDayFilter)) nearDayFilter = '';
1192
+ const activeNearDay = nearDayFilter || nearDays[0] || 'all';
1193
+ const shownNearDays = activeNearDay === 'all' ? nearDays : (nearByDay.has(activeNearDay) ? [activeNearDay] : nearDays.slice(0, 1));
1194
+ const nearBodyHtml = shownNearDays.map((dk) => {
1195
+ const arr = nearByDay.get(dk) || [];
1196
+ const dayTs = arr[0]?.lastBarTs || Date.parse(`${dk}T12:00:00+03:00`);
1197
+ const hdr = `<tr class="day-group-row"><td colspan="7"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><span>📅 ${esc(fmtDayGroup(dayTs))}</span><span class="small" style="font-weight:500;color:var(--muted)">${dayCountsStr(arr)}</span></div></td></tr>`;
1198
+ return hdr + arr.map(nearEpRow).join('');
1199
+ }).join('');
1200
+ const nearButtonsHtml = `<div class="row-gap" style="margin:8px 0;align-items:center;flex-wrap:wrap">
1201
+ ${nearDays.map((dk) => {
1202
+ const arr = nearByDay.get(dk) || [];
1203
+ const on = activeNearDay === dk;
1204
+ return `<button type="button" class="brk-toggle small${on ? ' on' : ''}" data-near-day="${esc(dk)}" title="${esc(dayCountsStr(arr))}">${on ? '✓ ' : ''}${esc(fmtDayChip(dk))} (${arr.length})</button>`;
1205
+ }).join('')}
1206
+ ${nearDays.length > 1 ? `<button type="button" class="brk-toggle small${activeNearDay === 'all' ? ' on' : ''}" data-near-day="all">${activeNearDay === 'all' ? '✓ ' : ''}Все дни (${nearList.length})</button>` : ''}
1207
+ </div>`;
1208
+ return `<summary style="font-weight:500">Журнал сигналов и почти-сигналов за неделю (≥75% порога) · ${dayCountsStr(nearList)}</summary>
1209
+ ${nearButtonsHtml}
1210
+ <div style="overflow-x:auto">
1211
+ <table class="fc-table" style="width:100%">
1212
+ <thead><tr><th>Время МСК</th><th>Бумага</th><th>Направление</th><th class="num">Пик ожидания</th><th class="num">Порог</th><th class="num">К порогу</th><th>Статус и причина</th></tr></thead>
1213
+ <tbody>${nearBodyHtml}</tbody>
1214
+ </table>
1215
+ </div>`;
1216
+ };
1217
+ renderNearSignalsNow = () => { const el = $('#fc-near-box'); if (el) el.innerHTML = buildNearInnerHtml(); };
1139
1218
  const prevNearEl = $('#fc-near-box');
1140
1219
  const nearOpenAttr = (!prevNearEl || prevNearEl.open) ? ' open' : '';
1141
- const nearHtml = nearList.length ? `<details id="fc-near-box"${nearOpenAttr} style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
1142
- <summary style="font-weight:500">Журнал сигналов и почти-сигналов за неделю (≥75% порога) · ${nearList.filter((x) => x.status === 'signal').length} сигн., ${nearList.filter((x) => x.status === 'blocked').length} блок., ${nearList.filter((x) => x.status === 'near').length} почти</summary>
1143
- <div style="overflow-x:auto;margin-top:8px">
1144
- <table class="fc-table" style="width:100%">
1145
- <thead><tr><th>Дата · время МСК</th><th>Бумага</th><th>Направление</th><th class="num">Пик ожидания</th><th class="num">Порог</th><th class="num">К порогу</th><th>Статус и причина</th></tr></thead>
1146
- <tbody>${nearList.slice(0, 18).map((ep) => {
1147
- const dStr = ep.day ? `${ep.day.slice(8, 10)}.${ep.day.slice(5, 7)}` : '';
1148
- const tRange = ep.firstBar && ep.lastBar && ep.firstBar !== ep.lastBar ? `${ep.firstBar}–${ep.lastBar} (${ep.barsCount} св.)` : `${ep.peakBar || ep.lastBar || ''}`;
1149
- const isSh = ep.side === 'short';
1150
- const thrStr = `${isSh ? '−' : '+'}${fmtNum(ep.thresholdPct, 2)}%`;
1151
- const stBadge = ep.status === 'signal'
1152
- ? `<span class="tag ok">${isSh ? '✓ сигнал шорт' : '✓ сигнал купить'}</span>`
1153
- : ep.status === 'blocked'
1154
- ? '<span class="tag blocked">⛔ блок входа</span>'
1155
- : `<span class="tag mid">почти (${ep.peakRatioPct}%)</span>`;
1156
- const rowCls = ep.status === 'signal' ? ' class="best"' : ep.status === 'blocked' ? ' class="fc-blocked"' : '';
1157
- return `<tr${rowCls}>
1158
- <td class="muted small" style="white-space:nowrap">${esc(dStr)} ${esc(tRange)}</td>
1159
- <td style="white-space:nowrap"><span class="sym-link" data-sym="${esc(ep.symbol)}" title="Открыть досье акции ${esc(ep.symbol)}"><b>${esc(ep.symbol)}</b> <span class="muted small">${esc(ep.name || '')}</span></span></td>
1160
- <td class="${isSh ? 'neg' : 'pos'} small" style="white-space:nowrap">${isSh ? 'В шорт' : 'В покупку'}</td>
1161
- <td class="num ${cls(ep.peakExpectedPct)}" style="white-space:nowrap"><b>${fmtPct(ep.peakExpectedPct, 2)}</b></td>
1162
- <td class="num muted small" style="white-space:nowrap">${thrStr}</td>
1163
- <td class="num small" style="white-space:nowrap"><b>${ep.peakRatioPct}%</b></td>
1164
- <td class="small" style="white-space:normal">${stBadge} <span class="muted">${esc(ep.reasonText || '')}</span></td>
1165
- </tr>`;
1166
- }).join('')}</tbody>
1167
- </table>
1168
- </div>
1169
- </details>` : '';
1220
+ const nearHtml = nearList.length ? `<details id="fc-near-box"${nearOpenAttr} style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">${buildNearInnerHtml()}</details>` : '';
1170
1221
  box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6), '', 'long')}
1171
1222
  ${fcTable('Слабее рынка', shortRows, ';margin-top:14px', 'short')}</div>
1172
1223
  <p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). Точный индивидуальный порог каждой акции рассчитывается в колонке <b>«Порог»</b> с учётом её ликвидности (оборота торгов), волатильности свечи и тренда индекса. <span style="color:var(--blocked-fg)">Розовым</span> выделяются только те бумаги, где ожидание <b>уже пробило порог</b>, но сделка остановлена защитным фильтром (если ожидание ещё ниже порога, фильтр указывается справочно рядом с «ниже порога»).</p>${nearHtml}`;
@@ -539,7 +539,26 @@ function withNews(result, p) {
539
539
  r.thresholdRatioPct = Math.round(ratio * 100);
540
540
  r.crossedThreshold = ratio >= 1;
541
541
  }
542
- if (!Array.isArray(result.nearSignals) || !result.nearSignals.length) {
542
+ // Если в сохранённом кэше список был обрезан прежним лимитом (40), пересобираем полный список из накопленных записей
543
+ const saved = !nearSignalsCache.episodesMap.size ? kvGet('nearSignalsLog') : null;
544
+ if (saved?.entries?.length && (saved.list?.length || 0) < Math.min(120, saved.entries.length)) {
545
+ nearSignalsCache = { key: saved.key, scannedBarTs: saved.scannedBarTs || 0, episodesMap: new Map(saved.entries), list: [] };
546
+ }
547
+ if (!Array.isArray(result.nearSignals) || !result.nearSignals.length || (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length)) {
548
+ if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
549
+ nearSignalsCache.list = [...nearSignalsCache.episodesMap.values()]
550
+ .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
551
+ .slice(0, 120)
552
+ .map((ep) => {
553
+ const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
554
+ const reasonText = ep.status === 'signal'
555
+ ? 'Порог пройден — сигнал сформирован'
556
+ : ep.status === 'blocked'
557
+ ? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
558
+ : `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
559
+ return { ...ep, gapPct, reasonText };
560
+ });
561
+ }
543
562
  const savedNear = nearSignalsCache.list?.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
544
563
  result.nearSignals = savedNear;
545
564
  }
@@ -662,7 +681,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
662
681
  if (updated || !nearSignalsCache.list.length) {
663
682
  const list = [...nearSignalsCache.episodesMap.values()]
664
683
  .sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
665
- .slice(0, 40)
684
+ .slice(0, 120)
666
685
  .map((ep) => {
667
686
  const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
668
687
  const reasonText = ep.status === 'signal'