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 +1 -1
- package/public/app.js +80 -29
- package/server/model/forecast.js +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.6.
|
|
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}`;
|
package/server/model/forecast.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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'
|