neuroalpha 0.5.7 → 0.5.9
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 +94 -24
- package/public/styles.css +18 -5
- package/server/brokers/index.js +1 -1
- package/server/model/dataset.js +35 -6
- package/server/model/forecast.js +5 -2
- package/server/model/research.js +14 -10
- package/server/model/vocab.js +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -72,7 +72,9 @@ 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
|
-
|
|
75
|
+
// широкие таблицы листаются внутри своей обёртки, а не растягивают ленту чата
|
|
76
|
+
const withWraps = withSyms.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
|
|
77
|
+
return DOMPurify.sanitize(withWraps, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
|
|
76
78
|
};
|
|
77
79
|
const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
|
|
78
80
|
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
|
@@ -1026,12 +1028,16 @@ async function loadForecast(force, silent = false) {
|
|
|
1026
1028
|
</tr>`;
|
|
1027
1029
|
const buyThr = f.buyThresholdPct ?? f.thresholdPct;
|
|
1028
1030
|
const shortThr = f.shortThresholdPct ?? f.thresholdPct;
|
|
1031
|
+
// бумаги, выбывшие из индекса, остаются в модели как контекст, но в таблицах не показываются
|
|
1032
|
+
const shownRows = f.rows.filter((r) => r.inIndex !== false);
|
|
1029
1033
|
const shortRows = [
|
|
1030
|
-
...
|
|
1031
|
-
...
|
|
1034
|
+
...shownRows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
1035
|
+
...shownRows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
1032
1036
|
].slice(0, 4);
|
|
1033
|
-
|
|
1034
|
-
|
|
1037
|
+
// общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
|
|
1038
|
+
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>`;
|
|
1039
|
+
box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6))}
|
|
1040
|
+
${fcTable('Слабее рынка', shortRows, ';margin-top:14px')}</div>
|
|
1035
1041
|
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
|
|
1036
1042
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
1037
1043
|
} catch (e) {
|
|
@@ -1111,18 +1117,58 @@ const TOOL_LABELS = {
|
|
|
1111
1117
|
connect_broker_account: 'Подключение брокерского счёта',
|
|
1112
1118
|
};
|
|
1113
1119
|
const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
|
|
1120
|
+
// Автопрокрутка ленты чата («прилипание к низу»):
|
|
1121
|
+
// • пока пользователь внизу, новые сообщения и потоковый ответ прокручивают ленту вниз;
|
|
1122
|
+
// • как только пользователь сам прокрутил вверх (колесо, тач, клавиши, полоса прокрутки) — лента его не дёргает;
|
|
1123
|
+
// • прилипание возвращается, когда пользователь сам дошёл до низа, нажал «↓» или отправил новый запрос.
|
|
1114
1124
|
const feed = $('#feed');
|
|
1115
|
-
const nearBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
|
|
1116
|
-
const scrollDown = (force) => { if (force || nearBottom()) requestAnimationFrame(() => { feed.scrollTop = feed.scrollHeight; }); };
|
|
1117
1125
|
const feedDown = $('#feed-down');
|
|
1126
|
+
const BOTTOM_EPS = 24;
|
|
1127
|
+
const distFromBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight;
|
|
1128
|
+
let stickToBottom = true;
|
|
1129
|
+
let lastFeedTop = 0;
|
|
1130
|
+
let feedScrollRaf = 0;
|
|
1118
1131
|
let unseen = 0;
|
|
1119
1132
|
const updateDown = () => {
|
|
1120
|
-
|
|
1121
|
-
feedDown.
|
|
1133
|
+
const show = !stickToBottom && distFromBottom() > BOTTOM_EPS;
|
|
1134
|
+
feedDown.hidden = !show;
|
|
1135
|
+
feedDown.textContent = unseen ? `↓ ${unseen} нов.` : '↓';
|
|
1122
1136
|
};
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1137
|
+
const scrollDown = (force = false, smooth = false) => {
|
|
1138
|
+
if (force) stickToBottom = true;
|
|
1139
|
+
if (!stickToBottom) { updateDown(); return; }
|
|
1140
|
+
cancelAnimationFrame(feedScrollRaf);
|
|
1141
|
+
feedScrollRaf = requestAnimationFrame(() => {
|
|
1142
|
+
if (!stickToBottom) return;
|
|
1143
|
+
if (smooth) feed.scrollTo({ top: feed.scrollHeight, behavior: 'smooth' });
|
|
1144
|
+
else feed.scrollTop = feed.scrollHeight;
|
|
1145
|
+
lastFeedTop = feed.scrollTop;
|
|
1146
|
+
unseen = 0;
|
|
1147
|
+
updateDown();
|
|
1148
|
+
});
|
|
1149
|
+
};
|
|
1150
|
+
const toBottom = (smooth = false) => { unseen = 0; scrollDown(true, smooth); };
|
|
1151
|
+
const releaseStick = () => { if (distFromBottom() > 2) { stickToBottom = false; updateDown(); } };
|
|
1152
|
+
feed.addEventListener('wheel', (e) => {
|
|
1153
|
+
if (e.deltaY >= 0) return;
|
|
1154
|
+
// прокрутка внутри блока размышлений или таблицы, которые сами ещё листаются вверх, ленту не трогает
|
|
1155
|
+
const inner = e.target.closest?.('.think-body, .md-c pre, .md-c .tbl-wrap');
|
|
1156
|
+
if (inner && inner.scrollTop > 0) return;
|
|
1157
|
+
stickToBottom = false;
|
|
1158
|
+
updateDown();
|
|
1159
|
+
}, { passive: true });
|
|
1160
|
+
feed.addEventListener('touchmove', releaseStick, { passive: true });
|
|
1161
|
+
feed.addEventListener('keydown', (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) { stickToBottom = false; updateDown(); } });
|
|
1162
|
+
feed.addEventListener('scroll', () => {
|
|
1163
|
+
const top = feed.scrollTop;
|
|
1164
|
+
// прокрутка вверх (в том числе мышью за полосу прокрутки) — пользователь читает историю
|
|
1165
|
+
if (top < lastFeedTop - 4 && distFromBottom() > BOTTOM_EPS) stickToBottom = false;
|
|
1166
|
+
// пользователь сам вернулся вниз — снова следим за новыми сообщениями
|
|
1167
|
+
if (distFromBottom() <= BOTTOM_EPS) { stickToBottom = true; unseen = 0; }
|
|
1168
|
+
lastFeedTop = top;
|
|
1169
|
+
updateDown();
|
|
1170
|
+
}, { passive: true });
|
|
1171
|
+
feedDown.addEventListener('click', () => toBottom(true));
|
|
1126
1172
|
const msgEls = new Map();
|
|
1127
1173
|
const liveEls = new Map();
|
|
1128
1174
|
|
|
@@ -1195,7 +1241,7 @@ function renderMsg(m) {
|
|
|
1195
1241
|
function upsertMsg(m) {
|
|
1196
1242
|
state.chat.msgs.set(m.id, m);
|
|
1197
1243
|
const old = msgEls.get(m.id);
|
|
1198
|
-
const stick =
|
|
1244
|
+
const stick = stickToBottom;
|
|
1199
1245
|
const el = renderMsg(m);
|
|
1200
1246
|
if (!el) { old?.remove(); msgEls.delete(m.id); return; }
|
|
1201
1247
|
if (old) {
|
|
@@ -1228,7 +1274,7 @@ function onDelta(d) {
|
|
|
1228
1274
|
|
|
1229
1275
|
function liveLine(key, html) {
|
|
1230
1276
|
let el = liveEls.get(key);
|
|
1231
|
-
const stick =
|
|
1277
|
+
const stick = stickToBottom;
|
|
1232
1278
|
if (!el) { el = document.createElement('div'); el.className = 'live'; liveEls.set(key, el); feed.append(el); }
|
|
1233
1279
|
el.innerHTML = html;
|
|
1234
1280
|
if (stick) scrollDown(true);
|
|
@@ -1343,6 +1389,8 @@ async function sendChat(text) {
|
|
|
1343
1389
|
if (speechRec && speechActive) stopVoiceInput(false);
|
|
1344
1390
|
text = (text ?? $('#chat-input').value).trim();
|
|
1345
1391
|
if (!text) return;
|
|
1392
|
+
// новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
|
|
1393
|
+
toBottom(true);
|
|
1346
1394
|
try {
|
|
1347
1395
|
await post('/api/chat/send', { text, mode: 'advisor' });
|
|
1348
1396
|
$('#chat-input').value = '';
|
|
@@ -3309,10 +3357,16 @@ $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#he
|
|
|
3309
3357
|
let stockModalChart = null;
|
|
3310
3358
|
let stockChartRo = null;
|
|
3311
3359
|
let currentStockData = null;
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3360
|
+
// Выбранный период и масштаб графика в досье акции сохраняются в браузере (масштаб — отдельно для каждого периода)
|
|
3361
|
+
const STOCK_CHART_KEY = 'na_stock_chart_v1';
|
|
3362
|
+
const loadStockChartPrefs = () => { try { return JSON.parse(localStorage.getItem(STOCK_CHART_KEY) || '{}') || {}; } catch { return {}; } };
|
|
3363
|
+
const saveStockChartPrefs = (patch) => { try { localStorage.setItem(STOCK_CHART_KEY, JSON.stringify({ ...loadStockChartPrefs(), ...patch })); } catch { /* ignore */ } };
|
|
3364
|
+
const STOCK_RANGES = ['1d', '1w', '1m', '1y', 'all'];
|
|
3365
|
+
let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1m';
|
|
3366
|
+
|
|
3367
|
+
function renderStockChart(d, range = currentStockRange) {
|
|
3315
3368
|
currentStockRange = range;
|
|
3369
|
+
saveStockChartPrefs({ range });
|
|
3316
3370
|
const box = $('#stock-chart-box');
|
|
3317
3371
|
if (!box) return;
|
|
3318
3372
|
if (stockChartRo) {
|
|
@@ -3424,7 +3478,27 @@ function renderStockChart(d, range = '1m') {
|
|
|
3424
3478
|
try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
|
|
3425
3479
|
}
|
|
3426
3480
|
}
|
|
3427
|
-
stockModalChart.timeScale()
|
|
3481
|
+
const ts = stockModalChart.timeScale();
|
|
3482
|
+
ts.fitContent();
|
|
3483
|
+
// Масштаб храним как «сколько свечей видно» и «отступ от правого края», чтобы он подходил к любой акции
|
|
3484
|
+
const lastIdx = seriesData.length - 1;
|
|
3485
|
+
const zoom = loadStockChartPrefs().zoom?.[range];
|
|
3486
|
+
if (zoom && lastIdx > 5 && zoom.span >= 5) {
|
|
3487
|
+
const to = lastIdx - Math.max(-20, Math.min(lastIdx, zoom.rightGap || 0));
|
|
3488
|
+
ts.setVisibleLogicalRange({ from: to - Math.min(zoom.span, lastIdx + 20), to });
|
|
3489
|
+
}
|
|
3490
|
+
const chartRef = stockModalChart;
|
|
3491
|
+
const renderedAt = Date.now();
|
|
3492
|
+
let saveTimer = null;
|
|
3493
|
+
ts.subscribeVisibleLogicalRangeChange((lr) => {
|
|
3494
|
+
// изменения при построении графика не считаются выбором пользователя
|
|
3495
|
+
if (!lr || stockModalChart !== chartRef || Date.now() - renderedAt < 400) return;
|
|
3496
|
+
clearTimeout(saveTimer);
|
|
3497
|
+
saveTimer = setTimeout(() => {
|
|
3498
|
+
const prefs = loadStockChartPrefs();
|
|
3499
|
+
saveStockChartPrefs({ zoom: { ...(prefs.zoom || {}), [range]: { span: Math.round((lr.to - lr.from) * 10) / 10, rightGap: Math.round((lastIdx - lr.to) * 10) / 10 } } });
|
|
3500
|
+
}, 400);
|
|
3501
|
+
});
|
|
3428
3502
|
}
|
|
3429
3503
|
|
|
3430
3504
|
async function openStockModal(symbol) {
|
|
@@ -3477,11 +3551,7 @@ async function openStockModal(symbol) {
|
|
|
3477
3551
|
<div class="stitle bar" style="margin:0 0 8px">
|
|
3478
3552
|
<span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
|
|
3479
3553
|
<div class="seg" id="stock-chart-range" style="margin:0">
|
|
3480
|
-
|
|
3481
|
-
<button type="button" data-srange="1w">1Н</button>
|
|
3482
|
-
<button type="button" data-srange="1m" class="active">1М</button>
|
|
3483
|
-
<button type="button" data-srange="1y">1Г</button>
|
|
3484
|
-
<button type="button" data-srange="all">Все</button>
|
|
3554
|
+
${[['1d', '1Д'], ['1w', '1Н'], ['1m', '1М'], ['1y', '1Г'], ['all', 'Все']].map(([k, l]) => `<button type="button" data-srange="${k}"${k === currentStockRange ? ' class="active"' : ''}>${l}</button>`).join('')}
|
|
3485
3555
|
</div>
|
|
3486
3556
|
</div>
|
|
3487
3557
|
<div id="stock-chart-box" class="chart"></div>
|
|
@@ -3586,7 +3656,7 @@ async function openStockModal(symbol) {
|
|
|
3586
3656
|
${newsHtml}
|
|
3587
3657
|
`;
|
|
3588
3658
|
|
|
3589
|
-
renderStockChart(d,
|
|
3659
|
+
renderStockChart(d, currentStockRange);
|
|
3590
3660
|
} catch (err) {
|
|
3591
3661
|
bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
|
|
3592
3662
|
}
|
package/public/styles.css
CHANGED
|
@@ -96,17 +96,26 @@ body.dragging iframe, body.dragging canvas { pointer-events: none; }
|
|
|
96
96
|
.feed, .tab, .think-body, .modal .md { scrollbar-width: thin; scrollbar-color: #2a3140 transparent; }
|
|
97
97
|
|
|
98
98
|
/* ---------- чат ---------- */
|
|
99
|
-
.chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
99
|
+
.chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
|
100
100
|
.chat-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
|
101
101
|
.chat-head select { font-size: 12.5px; padding: 3px 6px; }
|
|
102
102
|
.chat-head label { font-size: 12px; color: var(--muted); }
|
|
103
103
|
.chat-head label input { width: 46px; padding: 2px 4px; }
|
|
104
104
|
.feed-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
|
105
|
-
|
|
106
|
-
.feed
|
|
107
|
-
.
|
|
105
|
+
/* Лента чата: только вертикальная прокрутка; широкое содержимое (таблицы, код) листается внутри своего блока */
|
|
106
|
+
.feed { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; min-width: 0; }
|
|
107
|
+
.feed > * { min-width: 0; max-width: 100%; }
|
|
108
|
+
.feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; z-index: 2; }
|
|
109
|
+
.msg { max-width: 92%; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
|
108
110
|
.msg.user { align-self: flex-end; background: #1d3359; border-radius: 12px 12px 3px 12px; padding: 7px 12px; white-space: pre-wrap; }
|
|
109
|
-
.msg.ai { align-self: stretch; }
|
|
111
|
+
.msg.ai { align-self: stretch; max-width: 100%; }
|
|
112
|
+
.md-c { min-width: 0; max-width: 100%; }
|
|
113
|
+
.md-c pre { max-width: 100%; overflow-x: auto; white-space: pre; background: var(--panel); padding: 8px 10px; border-radius: 6px; }
|
|
114
|
+
.md-c pre code { background: none; padding: 0; }
|
|
115
|
+
.md-c .tbl-wrap { max-width: 100%; overflow-x: auto; margin: 6px 0; }
|
|
116
|
+
.md-c .tbl-wrap table { margin: 0; }
|
|
117
|
+
.md-c img { max-width: 100%; height: auto; }
|
|
118
|
+
.ev, .live, .act { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
|
|
110
119
|
.md-c > :first-child { margin-top: 0; }
|
|
111
120
|
.md-c > :last-child { margin-bottom: 0; }
|
|
112
121
|
.md-c p { margin: 6px 0; }
|
|
@@ -518,3 +527,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
|
|
|
518
527
|
background: var(--accent);
|
|
519
528
|
border-radius: 4px;
|
|
520
529
|
}
|
|
530
|
+
|
|
531
|
+
/* Прогноз: две таблицы с общей сеткой колонок */
|
|
532
|
+
.fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
533
|
+
|
package/server/brokers/index.js
CHANGED
package/server/model/dataset.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
|
|
3
|
-
import { ensureVocab } from './vocab.js';
|
|
3
|
+
import { ensureVocab, ensureIndexHistory } from './vocab.js';
|
|
4
4
|
import { kvGet, kvSet } from '../db.js';
|
|
5
5
|
|
|
6
6
|
const MSK = 3 * 3600000;
|
|
@@ -230,7 +230,17 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
230
230
|
return v.ds;
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
|
-
|
|
233
|
+
// Универсум = нынешний состав индекса + все бумаги, входившие в него за период (без ошибки выжившего).
|
|
234
|
+
// Для каждой свечи ниже отмечается, входила ли бумага в индекс на эту дату (tk.inIndex).
|
|
235
|
+
const curVocab = await ensureVocab();
|
|
236
|
+
const indexHistory = await ensureIndexHistory(fromTs, tillTs, { localOnly }).catch(() => []);
|
|
237
|
+
const vocab = [...curVocab];
|
|
238
|
+
const seenSym = new Set(vocab.map((v) => v.symbol));
|
|
239
|
+
for (const snap of indexHistory) {
|
|
240
|
+
for (const v of snap.list) {
|
|
241
|
+
if (!seenSym.has(v.symbol)) { seenSym.add(v.symbol); vocab.push({ symbol: v.symbol, name: v.name, weight: 0 }); }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
234
244
|
const symbols = vocab.map((v) => v.symbol);
|
|
235
245
|
const raw = {};
|
|
236
246
|
let done = 0;
|
|
@@ -281,12 +291,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
281
291
|
if (!raw.IMOEX?.n) throw new Error('Нет данных индекса МосБиржи');
|
|
282
292
|
|
|
283
293
|
// временная шкала: будни, где торговалось хотя бы 40% акций
|
|
294
|
+
// (считается по нынешнему составу, чтобы шкала не зависела от расширения универсума историческими бумагами)
|
|
284
295
|
const count = new Map();
|
|
285
|
-
for (const
|
|
286
|
-
const c = raw[
|
|
296
|
+
for (const v of curVocab) {
|
|
297
|
+
const c = raw[v.symbol];
|
|
298
|
+
if (!c) continue;
|
|
287
299
|
for (let i = 0; i < c.n; i++) if (dow(c.ts[i]) % 6 !== 0) count.set(c.ts[i], (count.get(c.ts[i]) || 0) + 1);
|
|
288
300
|
}
|
|
289
|
-
const timeline = [...count.entries()].filter(([, n]) => n >=
|
|
301
|
+
const timeline = [...count.entries()].filter(([, n]) => n >= curVocab.length * 0.4).map(([t]) => t).sort((a, b) => a - b);
|
|
290
302
|
const T = timeline.length;
|
|
291
303
|
const index = new Map(timeline.map((t, i) => [t, i]));
|
|
292
304
|
|
|
@@ -322,9 +334,26 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
322
334
|
const s = build(c);
|
|
323
335
|
let cov = 0;
|
|
324
336
|
for (let i = 0; i < T; i++) cov += s.has[i];
|
|
325
|
-
|
|
337
|
+
// Раньше бумаги с торгами меньше 60% периода отбрасывались — это само по себе ошибка выжившего
|
|
338
|
+
// (выпадали вышедшие на биржу позже и ушедшие с неё). Теперь отсекаем только почти неторгуемые (< 15%),
|
|
339
|
+
// а участие в сделках ограничивает tk.inIndex и фильтр ликвидности.
|
|
340
|
+
if (cov < T * 0.15) continue;
|
|
326
341
|
tokens.push({ symbol: v.symbol, name: v.name, weight: v.weight, ...s });
|
|
327
342
|
}
|
|
343
|
+
// Входила ли бумага в индекс на дату свечи (снимок месяца действует с 1-го числа до следующего снимка)
|
|
344
|
+
const snaps = indexHistory.map((sn) => ({ ts: sn.ts, set: new Set(sn.list.map((x) => x.symbol)) })).sort((a, b) => a.ts - b.ts);
|
|
345
|
+
for (const tk of tokens) {
|
|
346
|
+
const mask = new Uint8Array(T);
|
|
347
|
+
if (!snaps.length) mask.fill(1);
|
|
348
|
+
else {
|
|
349
|
+
let si = 0;
|
|
350
|
+
for (let i = 0; i < T; i++) {
|
|
351
|
+
while (si + 1 < snaps.length && snaps[si + 1].ts <= timeline[i]) si++;
|
|
352
|
+
mask[i] = snaps[si].set.has(tk.symbol) ? 1 : 0;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
tk.inIndex = mask;
|
|
356
|
+
}
|
|
328
357
|
const mk = build(raw.IMOEX);
|
|
329
358
|
// макро-ряды: выровненные цены с переносом (доходности посчитаем после lret)
|
|
330
359
|
const macroRaw = [];
|
package/server/model/forecast.js
CHANGED
|
@@ -321,7 +321,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
321
321
|
// и не даём сигнал на шорт, если на текущей живой свече акция взлетает вверх сильнее чем на +0.35%!
|
|
322
322
|
const liveBuyOk = !(liveSnap?.realtime && lastMovePct < -0.35);
|
|
323
323
|
const liveShortOk = !(liveSnap?.realtime && lastMovePct > 0.35);
|
|
324
|
-
|
|
324
|
+
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
325
|
+
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
326
|
+
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
325
327
|
const shortBlockedByBroker = Boolean(rawShortHit && shortableMap && shortableMap[tk.symbol] === false);
|
|
326
328
|
const row = {
|
|
327
329
|
i, symbol: tk.symbol, name: tk.name,
|
|
@@ -331,7 +333,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
331
333
|
expectedPct: round(exp * 100, 3),
|
|
332
334
|
lastMovePct,
|
|
333
335
|
weight: round(weight, 3),
|
|
334
|
-
signal: tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
336
|
+
signal: inIdx && tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
|
|
337
|
+
...(inIdx ? {} : { inIndex: false }),
|
|
335
338
|
};
|
|
336
339
|
if (dir !== 'long') {
|
|
337
340
|
row.shortSignal = rawShortHit && !shortBlockedByBroker;
|
package/server/model/research.js
CHANGED
|
@@ -37,7 +37,8 @@ export function isMarketDown(ds, t) {
|
|
|
37
37
|
|
|
38
38
|
// Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
|
|
39
39
|
// 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
|
|
40
|
-
|
|
40
|
+
// 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
|
|
41
|
+
const DATA_VERSION = 9;
|
|
41
42
|
|
|
42
43
|
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
43
44
|
function reuseParamsKey(params) {
|
|
@@ -300,6 +301,8 @@ async function run(runId, p, state) {
|
|
|
300
301
|
if (n < (T - firstTest) * 0.5) futOpen = null;
|
|
301
302
|
} catch { futOpen = null; }
|
|
302
303
|
const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
|
|
304
|
+
// новые позиции — только по бумагам, входившим в индекс на дату решения (без ошибки выжившего)
|
|
305
|
+
const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
|
|
303
306
|
// только покупки: на позицию капитал / K; с продажами: капитал / 2K (покупки и продажи вместе не больше капитала)
|
|
304
307
|
const slots = dir === 'long' ? K : 2 * K;
|
|
305
308
|
|
|
@@ -322,7 +325,7 @@ async function run(runId, p, state) {
|
|
|
322
325
|
}
|
|
323
326
|
const free = K - hMap.size;
|
|
324
327
|
if (free <= 0) continue;
|
|
325
|
-
const cand = [...pr.keys()].filter((i) => !hMap.has(i) && stockOk(i) &&
|
|
328
|
+
const cand = [...pr.keys()].filter((i) => !hMap.has(i) && stockOk(i) && canEnter(i, t) && edge(i) > need).sort((a, b) => pr[b] - pr[a]).slice(0, free);
|
|
326
329
|
for (const i of cand) hMap.set(i, tokens[i].open[tIn]);
|
|
327
330
|
}
|
|
328
331
|
return { net: w - l, pf: l > 0 ? w / l : (w > 0 ? 3 : 0), trades: n };
|
|
@@ -455,8 +458,8 @@ async function run(runId, p, state) {
|
|
|
455
458
|
}
|
|
456
459
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
457
460
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
458
|
-
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) &&
|
|
459
|
-
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) &&
|
|
461
|
+
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
|
|
462
|
+
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
|
|
460
463
|
// Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
|
|
461
464
|
// если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
|
|
462
465
|
// освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
|
|
@@ -497,11 +500,11 @@ async function run(runId, p, state) {
|
|
|
497
500
|
}
|
|
498
501
|
const free = K - held.size;
|
|
499
502
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
500
|
-
const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) &&
|
|
503
|
+
const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
|
|
501
504
|
const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
|
|
502
505
|
order = [...held.keys(), ...cand];
|
|
503
506
|
} else {
|
|
504
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 &&
|
|
507
|
+
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
505
508
|
order = cand;
|
|
506
509
|
const next = new Set(order);
|
|
507
510
|
for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
|
|
@@ -974,6 +977,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
974
977
|
const exitRatio = Number(p.modelExitRatio ?? 0.5);
|
|
975
978
|
const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
|
|
976
979
|
const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
|
|
980
|
+
const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
|
|
977
981
|
const tsOf = (t) => timeline[Math.min(t, T - 1)];
|
|
978
982
|
|
|
979
983
|
const runs = [];
|
|
@@ -1061,8 +1065,8 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1061
1065
|
}
|
|
1062
1066
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1063
1067
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
1064
|
-
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) &&
|
|
1065
|
-
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) &&
|
|
1068
|
+
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
|
|
1069
|
+
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
|
|
1066
1070
|
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
1067
1071
|
entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
|
|
1068
1072
|
} else if (effMinEdge > 0) {
|
|
@@ -1072,10 +1076,10 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1072
1076
|
if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1073
1077
|
}
|
|
1074
1078
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1075
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) &&
|
|
1079
|
+
const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && canEnter(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
|
|
1076
1080
|
order = [...held.keys(), ...cand];
|
|
1077
1081
|
} else {
|
|
1078
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 &&
|
|
1082
|
+
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
1079
1083
|
order = cand;
|
|
1080
1084
|
const next = new Set(order);
|
|
1081
1085
|
for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);
|
package/server/model/vocab.js
CHANGED
|
@@ -5,6 +5,55 @@ import { kvGet, kvSet } from '../db.js';
|
|
|
5
5
|
const URL = 'https://iss.moex.com/iss/statistics/engines/stock/markets/index/analytics/MOEXBMI.json?iss.meta=off&limit=100';
|
|
6
6
|
const MAX_AGE = 30 * 86400000;
|
|
7
7
|
|
|
8
|
+
// Исторический состав индекса MOEXBMI: снимок на середину каждого месяца (кэшируется навсегда — прошлое не меняется).
|
|
9
|
+
// Нужен, чтобы бэктест не страдал ошибкой выжившего: торговать только бумаги, входившие в индекс на дату сделки.
|
|
10
|
+
async function fetchComposition(date) {
|
|
11
|
+
const list = [];
|
|
12
|
+
for (let start = 0; start < 500; start += 100) {
|
|
13
|
+
const res = await fetch(`${URL}&date=${date}&start=${start}`, { signal: AbortSignal.timeout(30000) });
|
|
14
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
15
|
+
const j = await res.json();
|
|
16
|
+
const cols = j.analytics.columns;
|
|
17
|
+
const ix = (c) => cols.indexOf(c);
|
|
18
|
+
for (const r of j.analytics.data) list.push({ symbol: r[ix('ticker')], name: r[ix('shortnames')], weight: r[ix('weight')] });
|
|
19
|
+
if (j.analytics.data.length < 100) break;
|
|
20
|
+
}
|
|
21
|
+
return list;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ensureIndexHistory(fromTs, tillTs = Date.now(), { localOnly = false } = {}) {
|
|
25
|
+
const months = [];
|
|
26
|
+
const d = new Date(fromTs);
|
|
27
|
+
d.setUTCDate(1);
|
|
28
|
+
const end = new Date(tillTs);
|
|
29
|
+
while (d <= end) {
|
|
30
|
+
months.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`);
|
|
31
|
+
d.setUTCMonth(d.getUTCMonth() + 1);
|
|
32
|
+
}
|
|
33
|
+
const curMonth = new Date(Date.now()).toISOString().slice(0, 7);
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const m of months) {
|
|
36
|
+
const key = `bmiComp:${m}`;
|
|
37
|
+
let snap = kvGet(key);
|
|
38
|
+
const stale = m === curMonth && snap && Date.now() - (snap.at || 0) > 7 * 86400000;
|
|
39
|
+
if ((!snap || stale) && !localOnly) {
|
|
40
|
+
try {
|
|
41
|
+
let list = [];
|
|
42
|
+
for (const day of ['15', '16', '17', '18', '14', '13']) {
|
|
43
|
+
list = await fetchComposition(`${m}-${day}`);
|
|
44
|
+
if (list.length >= 20) break;
|
|
45
|
+
}
|
|
46
|
+
if (list.length >= 20) {
|
|
47
|
+
snap = { at: Date.now(), list };
|
|
48
|
+
kvSet(key, snap);
|
|
49
|
+
}
|
|
50
|
+
} catch { /* нет сети — используем то, что есть */ }
|
|
51
|
+
}
|
|
52
|
+
if (snap?.list?.length) out.push({ ts: Date.parse(`${m}-01T00:00:00+03:00`), list: snap.list });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
8
57
|
export async function ensureVocab({ refresh = false } = {}) {
|
|
9
58
|
const cached = kvGet('vocab');
|
|
10
59
|
if (cached && !refresh && Date.now() - cached.ts < MAX_AGE) return cached.list;
|