neuroalpha 0.5.6 → 0.5.8
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 +40 -14
- package/public/styles.css +4 -0
- package/server/brokers/index.js +8 -10
- 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.8",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1026,12 +1026,16 @@ async function loadForecast(force, silent = false) {
|
|
|
1026
1026
|
</tr>`;
|
|
1027
1027
|
const buyThr = f.buyThresholdPct ?? f.thresholdPct;
|
|
1028
1028
|
const shortThr = f.shortThresholdPct ?? f.thresholdPct;
|
|
1029
|
+
// бумаги, выбывшие из индекса, остаются в модели как контекст, но в таблицах не показываются
|
|
1030
|
+
const shownRows = f.rows.filter((r) => r.inIndex !== false);
|
|
1029
1031
|
const shortRows = [
|
|
1030
|
-
...
|
|
1031
|
-
...
|
|
1032
|
+
...shownRows.filter((r) => r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
1033
|
+
...shownRows.filter((r) => !r.shortSignal).sort((a, b) => a.expectedPct - b.expectedPct),
|
|
1032
1034
|
].slice(0, 4);
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
+
// общая сетка колонок для обеих таблиц: столбцы выровнены по всей высоте блока
|
|
1036
|
+
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>`;
|
|
1037
|
+
box.innerHTML = `<div style="overflow-x:auto">${fcTable('Сильнее рынка', shownRows.slice(0, 6))}
|
|
1038
|
+
${fcTable('Слабее рынка', shortRows, ';margin-top:14px')}</div>
|
|
1035
1039
|
<p class="muted small">Ожидание — движение относительно рынка в целом (авто-пересчёт каждые 15 сек по живым ценам Мосбиржи). «Купить» — ожидание выше порога <b>${fmtPct(buyThr, 2)}</b>.${f.direction && f.direction !== 'long' ? ` «Шорт» — ожидание ниже <b>−${fmtNum(shortThr, 2)}%</b> (с учётом тренда индекса и доступности шорта у брокера).` : ''}</p>`;
|
|
1036
1040
|
if (state.brokers?.accounts?.length) renderBrokers();
|
|
1037
1041
|
} catch (e) {
|
|
@@ -3309,10 +3313,16 @@ $('#help').addEventListener('click', (e) => { if (e.target.id === 'help') $('#he
|
|
|
3309
3313
|
let stockModalChart = null;
|
|
3310
3314
|
let stockChartRo = null;
|
|
3311
3315
|
let currentStockData = null;
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3316
|
+
// Выбранный период и масштаб графика в досье акции сохраняются в браузере (масштаб — отдельно для каждого периода)
|
|
3317
|
+
const STOCK_CHART_KEY = 'na_stock_chart_v1';
|
|
3318
|
+
const loadStockChartPrefs = () => { try { return JSON.parse(localStorage.getItem(STOCK_CHART_KEY) || '{}') || {}; } catch { return {}; } };
|
|
3319
|
+
const saveStockChartPrefs = (patch) => { try { localStorage.setItem(STOCK_CHART_KEY, JSON.stringify({ ...loadStockChartPrefs(), ...patch })); } catch { /* ignore */ } };
|
|
3320
|
+
const STOCK_RANGES = ['1d', '1w', '1m', '1y', 'all'];
|
|
3321
|
+
let currentStockRange = STOCK_RANGES.includes(loadStockChartPrefs().range) ? loadStockChartPrefs().range : '1m';
|
|
3322
|
+
|
|
3323
|
+
function renderStockChart(d, range = currentStockRange) {
|
|
3315
3324
|
currentStockRange = range;
|
|
3325
|
+
saveStockChartPrefs({ range });
|
|
3316
3326
|
const box = $('#stock-chart-box');
|
|
3317
3327
|
if (!box) return;
|
|
3318
3328
|
if (stockChartRo) {
|
|
@@ -3424,7 +3434,27 @@ function renderStockChart(d, range = '1m') {
|
|
|
3424
3434
|
try { areaSeries.setMarkers(markers.slice(-40)); } catch { /* ignore */ }
|
|
3425
3435
|
}
|
|
3426
3436
|
}
|
|
3427
|
-
stockModalChart.timeScale()
|
|
3437
|
+
const ts = stockModalChart.timeScale();
|
|
3438
|
+
ts.fitContent();
|
|
3439
|
+
// Масштаб храним как «сколько свечей видно» и «отступ от правого края», чтобы он подходил к любой акции
|
|
3440
|
+
const lastIdx = seriesData.length - 1;
|
|
3441
|
+
const zoom = loadStockChartPrefs().zoom?.[range];
|
|
3442
|
+
if (zoom && lastIdx > 5 && zoom.span >= 5) {
|
|
3443
|
+
const to = lastIdx - Math.max(-20, Math.min(lastIdx, zoom.rightGap || 0));
|
|
3444
|
+
ts.setVisibleLogicalRange({ from: to - Math.min(zoom.span, lastIdx + 20), to });
|
|
3445
|
+
}
|
|
3446
|
+
const chartRef = stockModalChart;
|
|
3447
|
+
const renderedAt = Date.now();
|
|
3448
|
+
let saveTimer = null;
|
|
3449
|
+
ts.subscribeVisibleLogicalRangeChange((lr) => {
|
|
3450
|
+
// изменения при построении графика не считаются выбором пользователя
|
|
3451
|
+
if (!lr || stockModalChart !== chartRef || Date.now() - renderedAt < 400) return;
|
|
3452
|
+
clearTimeout(saveTimer);
|
|
3453
|
+
saveTimer = setTimeout(() => {
|
|
3454
|
+
const prefs = loadStockChartPrefs();
|
|
3455
|
+
saveStockChartPrefs({ zoom: { ...(prefs.zoom || {}), [range]: { span: Math.round((lr.to - lr.from) * 10) / 10, rightGap: Math.round((lastIdx - lr.to) * 10) / 10 } } });
|
|
3456
|
+
}, 400);
|
|
3457
|
+
});
|
|
3428
3458
|
}
|
|
3429
3459
|
|
|
3430
3460
|
async function openStockModal(symbol) {
|
|
@@ -3477,11 +3507,7 @@ async function openStockModal(symbol) {
|
|
|
3477
3507
|
<div class="stitle bar" style="margin:0 0 8px">
|
|
3478
3508
|
<span>График цены ${esc(d.symbol)} и ваши сделки на счёте</span>
|
|
3479
3509
|
<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>
|
|
3510
|
+
${[['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
3511
|
</div>
|
|
3486
3512
|
</div>
|
|
3487
3513
|
<div id="stock-chart-box" class="chart"></div>
|
|
@@ -3586,7 +3612,7 @@ async function openStockModal(symbol) {
|
|
|
3586
3612
|
${newsHtml}
|
|
3587
3613
|
`;
|
|
3588
3614
|
|
|
3589
|
-
renderStockChart(d,
|
|
3615
|
+
renderStockChart(d, currentStockRange);
|
|
3590
3616
|
} catch (err) {
|
|
3591
3617
|
bodyEl.innerHTML = `<p class="neg">Не удалось загрузить досье ${esc(sym)}: ${esc(err.message)}</p>`;
|
|
3592
3618
|
}
|
package/public/styles.css
CHANGED
|
@@ -518,3 +518,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
|
|
|
518
518
|
background: var(--accent);
|
|
519
519
|
border-radius: 4px;
|
|
520
520
|
}
|
|
521
|
+
|
|
522
|
+
/* Прогноз: две таблицы с общей сеткой колонок */
|
|
523
|
+
.fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
524
|
+
|
package/server/brokers/index.js
CHANGED
|
@@ -354,7 +354,7 @@ function computeExecutionAudit(a) {
|
|
|
354
354
|
slipTier1Pct,
|
|
355
355
|
slipTier2Pct,
|
|
356
356
|
totalCostPerSidePct,
|
|
357
|
-
breakEvenTargetPct: 0.
|
|
357
|
+
breakEvenTargetPct: 0.214,
|
|
358
358
|
grossRub,
|
|
359
359
|
feesRub: round(feesRub, 0),
|
|
360
360
|
netRub,
|
|
@@ -1517,17 +1517,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1517
1517
|
const nowTs = Date.now();
|
|
1518
1518
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1519
1519
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
// Спред-фильтр реального стакана по эшелонам ликвидности:
|
|
1524
|
-
// для 1-го эшелона требуем минимум базовый порог (≥ 0.50%), а для 2–3 эшелона (широкий спред стакана) — в 1.35× выше (≥ 0.675%)
|
|
1520
|
+
// Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
|
|
1521
|
+
// Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
|
|
1522
|
+
// что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
|
|
1525
1523
|
const baseBuyNeedPct = Number(fc.buyThresholdPct ?? fc.thresholdPct ?? 0.50);
|
|
1526
1524
|
const baseShortNeedPct = Number(fc.shortThresholdPct ?? fc.thresholdPct ?? 0.50);
|
|
1527
|
-
const minBuyEdgePct = round(baseBuyNeedPct
|
|
1528
|
-
const minShortEdgePct = round(baseShortNeedPct
|
|
1529
|
-
const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30
|
|
1530
|
-
const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31
|
|
1525
|
+
const minBuyEdgePct = round(baseBuyNeedPct, 3);
|
|
1526
|
+
const minShortEdgePct = round(baseShortNeedPct, 3);
|
|
1527
|
+
const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
|
|
1528
|
+
const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
|
|
1531
1529
|
const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
|
|
1532
1530
|
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, как в research.js):
|
|
1533
1531
|
// если за последние 14 дней по бумаге было >= 3 закрытых сделок на счёте и их суммарный итог отрицательный (< -1.0%),
|
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;
|