neuroalpha 0.5.3 → 0.5.5

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.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -565,13 +565,13 @@ function renderStress(sx) {
565
565
  const cell = (v, d = 1, pct = true) => (v === null || v === undefined ? '<span class="muted">—</span>' : `<span class="${pct ? cls(v) : ''}">${pct ? fmtPct(v, d) : fmtNum(v, d)}</span>`);
566
566
  const rows = sx.scenarios.map((s) => `<tr${s.key === 'realistic' ? ' style="font-weight:600"' : ''}>
567
567
  <td title="${esc(s.note || '')}">${esc(s.label)}</td>
568
- <td class="num">${cell(s.full.returnPct)}</td><td class="num">${cell(s.full.cagrPct)}</td>
568
+ <td class="num">${cell(s.full.returnPct)}</td><td class="num">${cell(s.full.cagrPct)}</td><td class="num">${cell(s.full.afterTaxCagrPct)}</td>
569
569
  <td class="num">${cell(s.full.profitFactor, 2, false)}</td><td class="num">${cell(s.full.maxDrawdownPct === null ? null : -s.full.maxDrawdownPct)}</td>
570
570
  <td class="num">${fmtNum(s.full.trades, 0)}</td><td class="num">${cell(s.full.avgTradePct, 3)}</td>
571
571
  <td class="num">${cell(s.last12.returnPct)}</td><td class="num">${cell(s.last6.returnPct)}</td></tr>`).join('');
572
572
  box.innerHTML = `<div class="stab-head">${verdict}<span><b>Стресс-тест исполнения</b>${base && real ? ` · итог ${fmtPct(base.full.returnPct)} → <b class="${cls(real.full.returnPct)}">${fmtPct(real.full.returnPct)}</b> при реалистичном исполнении` : ''} · рынок за период ${fmtPct(sx.market?.full)}</span></div>
573
- <div style="overflow-x:auto"><table class="cmp"><thead><tr><th>Сценарий</th><th class="num">Итог</th><th class="num">Годовых</th><th class="num">PF</th><th class="num">Просадка</th><th class="num">Сделок</th><th class="num">Ср. сделка</th><th class="num">12 мес.</th><th class="num">6 мес.</th></tr></thead><tbody>${rows}
574
- <tr class="muted"><td>Рынок (индекс МосБиржи)</td><td class="num">${cell(sx.market?.full)}</td><td></td><td></td><td></td><td></td><td></td><td class="num">${cell(sx.market?.last12)}</td><td class="num">${cell(sx.market?.last6)}</td></tr></tbody></table></div>
573
+ <div style="overflow-x:auto"><table class="cmp"><thead><tr><th>Сценарий</th><th class="num">Итог</th><th class="num">Годовых</th><th class="num" title="Грубая оценка: НДФЛ 13% с прибыли">после НДФЛ</th><th class="num">PF</th><th class="num">Просадка</th><th class="num">Сделок</th><th class="num">Ср. сделка</th><th class="num">12 мес.</th><th class="num">6 мес.</th></tr></thead><tbody>${rows}
574
+ <tr class="muted"><td>Рынок (индекс МосБиржи)</td><td class="num">${cell(sx.market?.full)}</td><td></td><td></td><td></td><td></td><td></td><td></td><td class="num">${cell(sx.market?.last12)}</td><td class="num">${cell(sx.market?.last6)}</td></tr></tbody></table></div>
575
575
  <p class="muted small note">${(sx.caveats || []).map(esc).join(' ')}</p>`;
576
576
  }
577
577
 
@@ -9,6 +9,7 @@ import { latestBarTs } from '../data/moex.js';
9
9
  import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
10
10
  import { forecastNow } from '../model/forecast.js';
11
11
  import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/edge.js';
12
+ import { liveDividendGuard } from '../model/dividends.js';
12
13
  import { MODELS } from '../model/models.js';
13
14
  import { blockingNews } from '../news/index.js';
14
15
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
@@ -353,7 +354,7 @@ function computeExecutionAudit(a) {
353
354
  slipTier1Pct,
354
355
  slipTier2Pct,
355
356
  totalCostPerSidePct,
356
- breakEvenTargetPct: 0.229,
357
+ breakEvenTargetPct: 0.237,
357
358
  grossRub,
358
359
  feesRub: round(feesRub, 0),
359
360
  netRub,
@@ -1497,20 +1498,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1497
1498
  const rawTopK = Number(params.modelTopK ?? 2);
1498
1499
  const autoK = rawTopK === 0 || autoAdapt;
1499
1500
  const K = rawTopK > 0 ? rawTopK : 4;
1500
- const filterBrokerDynamicTopK = (cands, needVal, maxCount) => {
1501
+ // Отбор до K кандидатов, прошедших абсолютный порог выгоды (единый с бэктестом в research.js)
1502
+ const filterBrokerDynamicTopK = (cands, _needVal, maxCount) => {
1501
1503
  if (!cands.length || maxCount <= 0) return [];
1502
- const lim = Math.min(cands.length, maxCount);
1503
- if (!autoK || lim <= 1) return cands.slice(0, lim);
1504
- const bestE = Math.abs(cands[0].expectedPct || 0);
1505
- const out = [cands[0]];
1506
- for (let m = 1; m < lim; m++) {
1507
- const curE = Math.abs(cands[m].expectedPct || 0);
1508
- const relMin = m === 1 ? 0.62 : 0.78;
1509
- const absMin = m === 1 ? needVal * 1.15 : needVal * 1.45;
1510
- if (curE >= bestE * relMin && curE >= absMin) out.push(cands[m]);
1511
- else break;
1512
- }
1513
- return out;
1504
+ return cands.slice(0, Math.min(cands.length, maxCount));
1514
1505
  };
1515
1506
  // Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
1516
1507
  const marginAllowed = acc.lastSync?.marginEnabled !== false;
@@ -1597,8 +1588,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1597
1588
  const nb = params.newsFilter ? blockingNews(6) : null;
1598
1589
 
1599
1590
  const cooldownSkipped = fc.rows.filter((r) => ((r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))) && isInCooldown(r.symbol));
1600
- const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1601
- const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
1591
+ // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1592
+ const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1593
+ const divBlocked = [];
1594
+ const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1595
+ .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1596
+ .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1597
+ const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1598
+ .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1599
+ .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1602
1600
  const nonShortableSkipped = [];
1603
1601
  const rawShortCands = [];
1604
1602
  for (const r of allShortSignals) {
@@ -1654,12 +1652,16 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1654
1652
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1655
1653
  const hitHardStopLoss = curGainPct <= -2.6;
1656
1654
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1657
- const keep = keepByForecast && !hitHardStopLoss;
1655
+ const dg = divGuardOf(sym, curPx);
1656
+ const divExit = Boolean(dg?.exitLong);
1657
+ const keep = keepByForecast && !hitHardStopLoss && !divExit;
1658
1658
  if (lots >= 1 && !keep) {
1659
1659
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1660
1660
  const exitReason = hitHardStopLoss
1661
1661
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1662
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1662
+ : divExit
1663
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1664
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1663
1665
  orders.push({
1664
1666
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1665
1667
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1672,12 +1674,16 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1672
1674
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1673
1675
  const hitHardStopLoss = curGainPct <= -2.2;
1674
1676
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1675
- const keep = keepByForecast && !hitHardStopLoss;
1677
+ const dg = divGuardOf(sym, curPx);
1678
+ const divExit = Boolean(dg?.exitShort);
1679
+ const keep = keepByForecast && !hitHardStopLoss && !divExit;
1676
1680
  if (lots >= 1 && !keep) {
1677
1681
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1678
1682
  const exitReason = hitHardStopLoss
1679
1683
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1680
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1684
+ : divExit
1685
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1686
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1681
1687
  orders.push({
1682
1688
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1683
1689
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1956,6 +1962,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1956
1962
  : `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
1957
1963
  }
1958
1964
  }
1965
+ if (divBlocked.length) {
1966
+ diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
1967
+ }
1959
1968
 
1960
1969
  if (onlyEntries && !orders.length) {
1961
1970
  return brokersState();
@@ -6,7 +6,7 @@ const ISS = 'https://iss.moex.com/iss';
6
6
  const INDEXES = new Set(['IMOEX', 'RGBI', 'MOEXBMI']);
7
7
  // макро-инструменты: валюта (CETS) и фьючерсы (FORTS/RFUD)
8
8
  const CURRENCY = new Set(['CNYRUB_TOM', 'USD000UTSTOM']);
9
- const FUTURES = /^GLDRUBF$|^BR[A-Z]\d$/;
9
+ const FUTURES = /^GLDRUBF$|^IMOEXF$|^BR[A-Z]\d$/;
10
10
 
11
11
  export const mskToTs = (s) => Date.parse(s.replace(' ', 'T') + '+03:00');
12
12
  export const tsToDate = (ts) => new Date(ts + MSK).toISOString().slice(0, 10);
@@ -175,29 +175,43 @@ async function fetchDividendsIss(symbol, signal) {
175
175
  }
176
176
 
177
177
  // Запасной источник: smart-lab.ru/q/{тикер}/dividend/ — таблица «дата отсечки» (= дата реестра) и сумма в рублях
178
- async function fetchDividendsSmartlab(symbol, signal) {
179
- const url = `https://smart-lab.ru/q/${symbol}/dividend/`;
178
+ async function fetchDividendsSmartlabPage(page, symbol, signal) {
179
+ const url = `https://smart-lab.ru/q/${page}/dividend/`;
180
180
  const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
181
181
  const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, signal: sig });
182
+ if (r.status === 404) return null;
182
183
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
183
184
  const t = await r.text();
184
185
  const m = t.match(/simple-little-table financials dividends[\s\S]*?<\/table>/);
185
186
  if (!m) return null;
186
187
  const list = [];
187
188
  for (const row of m[0].matchAll(/<tr[^>]*>\s*<td>([A-Z0-9]+)<\/td>\s*<td>[\d.]+<\/td>\s*<td>(\d\d)\.(\d\d)\.(\d{4})<\/td>\s*<td>[^<]*<\/td>\s*<td><strong[^>]*>([\d\s,]+)<\/strong>/g)) {
188
- if (row[1] !== symbol) continue; // на странице есть и префы
189
+ if (row[1] !== symbol) continue; // на странице есть и обыкновенные, и привилегированные акции
189
190
  const value = Number(row[5].replace(/\s/g, '').replace(',', '.'));
190
191
  if (value > 0) list.push({ registry: `${row[4]}-${row[3]}-${row[2]}`, value });
191
192
  }
192
193
  return list;
193
194
  }
194
195
 
196
+ // Запасной источник: smart-lab.ru/q/{тикер}/dividend/ — таблица «дата отсечки» (= дата реестра) и сумма в рублях.
197
+ // Дивиденды привилегированных акций (SBERP, SNGSP, TATNP…) публикуются на странице обыкновенной акции (SBER, SNGS, TATN).
198
+ async function fetchDividendsSmartlab(symbol, signal) {
199
+ const own = await fetchDividendsSmartlabPage(symbol, symbol, signal);
200
+ if (own?.length) return own;
201
+ if (/^[A-Z]{3,}P$/.test(symbol)) {
202
+ const viaCommon = await fetchDividendsSmartlabPage(symbol.slice(0, -1), symbol, signal);
203
+ if (viaCommon) return viaCommon;
204
+ }
205
+ return own;
206
+ }
207
+
195
208
  // Дивиденды акции с кэшем в kv на 7 суток (включая пустой список для бездивидендных бумаг); при ошибке сети — старый кэш
196
209
  export async function ensureDividends(symbol, { signal, localOnly = false } = {}) {
197
210
  const key = `dividends:${symbol}`;
198
211
  const cached = kvGet(key);
199
212
  if (localOnly) return cached?.list || [];
200
- if (cached && Array.isArray(cached.list) && Date.now() - cached.at < 7 * DAY) return cached.list;
213
+ // v2: дивиденды привилегированных акций со страницы обыкновенной (раньше у префов кешировался пустой список)
214
+ if (cached && cached.v === 2 && Array.isArray(cached.list) && Date.now() - cached.at < 7 * DAY) return cached.list;
201
215
  let list = null;
202
216
  let networkOk = false;
203
217
  try {
@@ -213,7 +227,7 @@ export async function ensureDividends(symbol, { signal, localOnly = false } = {}
213
227
  }
214
228
  const finalList = Array.isArray(list) ? list : (cached?.list || []);
215
229
  if (networkOk || Array.isArray(list)) {
216
- kvSet(key, { at: Date.now(), list: finalList });
230
+ kvSet(key, { v: 2, at: Date.now(), list: finalList });
217
231
  }
218
232
  return finalList;
219
233
  }
@@ -196,6 +196,25 @@ async function loadBrent(fromTs, tillTs, signal) {
196
196
  return { n, ts: Float64Array.from(ts), open: Float64Array.from(close), close: Float64Array.from(close), value: new Float64Array(n) };
197
197
  }
198
198
 
199
+ // Цены инструмента, выровненные по временной шкале датасета (цена открытия свечи; без сделок — последняя известная цена).
200
+ // Используется для расчёта защиты реальным фьючерсом на индекс (IMOEXF), а не самим значением индекса.
201
+ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {}) {
202
+ const rows = await ensureCandles(symbol, BAR_INTERVAL, fromTs ?? ds.timeline[0], tillTs ?? ds.timeline[ds.T - 1] + BAR_MS, { signal }).catch(() => []);
203
+ const out = new Float64Array(ds.T).fill(NaN);
204
+ let j = 0, last = NaN;
205
+ for (let i = 0; i < ds.T; i++) {
206
+ const t = ds.timeline[i];
207
+ let o = NaN;
208
+ while (j < rows.length && rows[j].ts <= t) {
209
+ if (rows[j].ts === t) o = rows[j].open;
210
+ last = rows[j].close;
211
+ j++;
212
+ }
213
+ out[i] = Number.isFinite(o) && o > 0 ? o : last;
214
+ }
215
+ return out;
216
+ }
217
+
199
218
  // Выровненная по времени «матрица рынка»: N акций × T свечей по 10 минут (только будни),
200
219
  // цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
201
220
  // Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
@@ -0,0 +1,117 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Календарь дивидендных отсечек и общий «дивидендный предохранитель» для бэктеста и живой торговли.
3
+ //
4
+ // Бэктест считает по ценам, скорректированным на дивиденды, а реальный счёт — нет:
5
+ // • покупка, удерживаемая через отсечку: цена падает на весь дивиденд, а на счёт приходит дивиденд за вычетом НДФЛ 13%
6
+ // → потеря ≈ 0,13 × дивидендная доходность от позиции (для бумаги с дивидендом 10% это −1,3%);
7
+ // • шорт через отсечку: брокер списывает компенсацию дивиденда; теоретически это покрывается падением цены,
8
+ // но брокеры нередко запрещают или принудительно закрывают шорты перед отсечкой — держать шорт через отсечку не нужно.
9
+ // Правила (последний день с правом на дивиденд = торговый день перед экс-датой):
10
+ // • за 1–2 торговых дня до экс-даты — не открываем шорт;
11
+ // • в последний день с правом — не открываем покупку при дивиденде ≥ 1% и закрываем позиции стратегии
12
+ // (покупки при дивиденде ≥ 1%, шорты при дивиденде ≥ 0,3%).
13
+ import { kvGet } from '../db.js';
14
+ import { exDate } from '../data/moex.js';
15
+
16
+ const MSK = 3 * 3600000;
17
+ const DAY = 86400000;
18
+ export const DIV_TAX = 0.13;
19
+ export const DIV_MIN_YIELD = 0.003; // меньше 0,3% — шум, не реагируем
20
+ export const LONG_EXIT_YIELD = 0.01;
21
+
22
+ export function dividendRules(daysToEx, y) {
23
+ if (!(y >= DIV_MIN_YIELD) || !(daysToEx >= 1)) return { blockLong: false, blockShort: false, exitLong: false, exitShort: false };
24
+ return {
25
+ blockShort: daysToEx <= 2,
26
+ blockLong: daysToEx === 1 && y >= LONG_EXIT_YIELD,
27
+ exitLong: daysToEx === 1 && y >= LONG_EXIT_YIELD,
28
+ exitShort: daysToEx === 1,
29
+ };
30
+ }
31
+
32
+ const uniqDivs = (list) => {
33
+ const seen = new Set();
34
+ return (Array.isArray(list) ? list : []).filter((d) => {
35
+ if (!d?.registry || !(d.value > 0)) return false;
36
+ const k = `${d.registry}|${d.value}`;
37
+ if (seen.has(k)) return false;
38
+ seen.add(k);
39
+ return true;
40
+ });
41
+ };
42
+
43
+ // ---------- бэктест: экс-даты на временной шкале датасета ----------
44
+ // Возвращает { dayIdx: Uint32Array(T), events: Array<Array<{ exDay, exT, y }>> } — по каждой акции список отсечек
45
+ export function buildDividendCalendar(ds) {
46
+ const { timeline, tokens, T } = ds;
47
+ const dayIdx = new Uint32Array(T);
48
+ const days = [], first = [];
49
+ let lastKey = -1;
50
+ for (let t = 0; t < T; t++) {
51
+ const k = Math.floor((timeline[t] + MSK) / DAY);
52
+ if (k !== lastKey) { lastKey = k; days.push(new Date(k * DAY).toISOString().slice(0, 10)); first.push(t); }
53
+ dayIdx[t] = days.length - 1;
54
+ }
55
+ const events = tokens.map((tk) => {
56
+ const out = [];
57
+ for (const d of uniqDivs(kvGet(`dividends:${tk.symbol}`)?.list)) {
58
+ const ex = exDate(d.registry, days);
59
+ const di = ex ? days.indexOf(ex) : -1;
60
+ if (di < 1) continue;
61
+ const exT = first[di];
62
+ const pc = tk.close[exT - 1];
63
+ const y = pc > 0 ? d.value / pc : 0;
64
+ if (y >= DIV_MIN_YIELD && y < 0.5) out.push({ exDay: di, exT, y });
65
+ }
66
+ return out.sort((a, b) => a.exT - b.exT);
67
+ });
68
+ return { dayIdx, events };
69
+ }
70
+
71
+ // Ближайшая отсечка акции i, ещё не наступившая к бару t: { daysToEx, y } или null
72
+ export function nextDividendAt(cal, i, t) {
73
+ const cur = cal.dayIdx[t];
74
+ for (const e of cal.events[i]) {
75
+ if (e.exDay > cur) return { daysToEx: e.exDay - cur, y: e.y, exT: e.exT };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // ---------- живая торговля: календарь по будням (праздники МосБиржи не учитываются — запас в 1 день) ----------
81
+ const isWeekend = (d) => d.getUTCDay() === 0 || d.getUTCDay() === 6;
82
+ const ymd = (d) => d.toISOString().slice(0, 10);
83
+ const prevWeekday = (d) => { const x = new Date(d); do x.setUTCDate(x.getUTCDate() - 1); while (isWeekend(x)); return x; };
84
+
85
+ // режим T+1: экс-дата = дата реестра, если это торговый день, иначе предыдущий торговый день
86
+ function liveExDate(registry) {
87
+ const r = new Date(`${registry}T00:00:00Z`);
88
+ return isWeekend(r) ? prevWeekday(r) : r;
89
+ }
90
+
91
+ function weekdaysBetween(fromYmd, toYmd) {
92
+ let n = 0;
93
+ const d = new Date(`${fromYmd}T00:00:00Z`), end = new Date(`${toYmd}T00:00:00Z`);
94
+ while (d < end) {
95
+ d.setUTCDate(d.getUTCDate() + 1);
96
+ if (!isWeekend(d)) n++;
97
+ }
98
+ return n;
99
+ }
100
+
101
+ // Предохранитель для живого контура: ближайшая отсечка бумаги и действующие правила
102
+ export function liveDividendGuard(symbol, price, now = Date.now()) {
103
+ if (!(price > 0)) return null;
104
+ const today = ymd(new Date(now + MSK));
105
+ let best = null;
106
+ for (const d of uniqDivs(kvGet(`dividends:${symbol}`)?.list)) {
107
+ const ex = ymd(liveExDate(d.registry));
108
+ if (ex <= today) continue;
109
+ if (!best || ex < best.ex) best = { ex, value: d.value };
110
+ }
111
+ if (!best) return null;
112
+ const daysToEx = weekdaysBetween(today, best.ex);
113
+ const y = best.value / price;
114
+ const rules = dividendRules(daysToEx, y);
115
+ if (!rules.blockLong && !rules.blockShort && !rules.exitLong && !rules.exitShort) return null;
116
+ return { ...rules, exDate: best.ex, daysToEx, yieldPct: Math.round(y * 10000) / 100, value: best.value };
117
+ }
@@ -480,11 +480,13 @@ function predictAttention(m, f, u) {
480
480
  s += ML[mi].b[i] * zz;
481
481
  }
482
482
  // Равный ансамбль 50/50 с базовой моделью своей инерции (при наличии весов inertiaCoef):
483
- // без подгонки весов объединяет переток по карте соседей и собственный импульс/возврат акции
483
+ // если внимание к соседям и собственная инерция смотрят в разные стороны (s * sIn < 0),
484
+ // ослабляем сигнал вдвое, чтобы не проходить порог входа на конфликте моделей, но не искажать ранговый порядок IC!
484
485
  if (m.inertiaCoef) {
485
486
  let sIn = 0;
486
487
  for (let q = 0; q < 3; q++) sIn += m.inertiaCoef[q] * f.X[0][q][k];
487
- s = 0.50 * s + 0.50 * sIn;
488
+ const mix = 0.50 * s + 0.50 * sIn;
489
+ s = s * sIn >= 0 ? mix : mix * 0.5;
488
490
  }
489
491
  out[i] = s;
490
492
  }
@@ -3,13 +3,14 @@ import { normalizeParams } from '../settings.js';
3
3
  import { computeReport, downsample, buildPeriods } from './metrics.js';
4
4
  import { round } from '../util.js';
5
5
  import { hub } from '../hub.js';
6
- import { loadDataset, barsFromHours, BAR_INTERVAL } from './dataset.js';
6
+ import { loadDataset, loadAlignedOpen, barsFromHours, BAR_INTERVAL } from './dataset.js';
7
7
  import { buildFold, MODELS, modelNames, HEADS, LIQ_MIN, FEATURE_WARMUP, mapSnapshot, alignTo, matchClusters } from './models.js';
8
8
  import { ensureProfiles } from './profiles.js';
9
9
  import { spearman } from './linalg.js';
10
10
  import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
11
11
  import { strategyDefaults } from '../config.js';
12
12
  import { ENTRY_EDGE_MULT, symbolEdgeMult } from './edge.js';
13
+ import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
13
14
 
14
15
  const DAY = 86400000;
15
16
  const MSK = 3 * 3600000;
@@ -191,6 +192,7 @@ async function run(runId, p, state) {
191
192
  const preds = Object.fromEntries(names.map((n) => [n, new Map()])); // t -> Float64Array(N)
192
193
  const yraw = new Map(); // t -> Float64Array(N)
193
194
  const sigmaAt = new Map(); // t -> обычный размах акций в этом окне обучения
195
+ const betaAt = new Map(); // t -> бета акций к индексу в этом окне обучения (для защиты от движения рынка)
194
196
  const snapshots = [];
195
197
  let prevPts = null, prevCl = null, lastFold = null, lastModels = null;
196
198
  const folds = Math.ceil((T - H - firstTest) / testLen);
@@ -220,6 +222,7 @@ async function run(runId, p, state) {
220
222
  for (const n of names) preds[n].set(t, MODELS[n].predict(fitted[n], f, u));
221
223
  yraw.set(t, Float64Array.from(f.yraw.subarray(u * N, u * N + N)));
222
224
  sigmaAt.set(t, f.sigma);
225
+ betaAt.set(t, f.beta);
223
226
  }
224
227
  // снимок карты рынка на начало месяца прогноза
225
228
  const snap = mapSnapshot(f);
@@ -276,24 +279,10 @@ async function run(runId, p, state) {
276
279
  const rawTopK = Number(p.modelTopK ?? 2);
277
280
  const autoK = rawTopK === 0 || autoAdapt;
278
281
  const K = rawTopK > 0 ? rawTopK : 4;
279
- // Адаптивный отбор числа акций (Dynamic Top-K):
280
- // лидер №1 берётся всегда при пробое порога; следующие бумаги (2…K) берутся только если их сигнал
281
- // достаточно силён относительно лидера (>= 68% от силы №1) и уверенно перекрывает порог (>= 1.25 * need),
282
- // чтобы не размывать капитал на слабые окраинные сигналы
283
- const filterDynamicTopK = (cands, edgeFn, needVal, maxCount) => {
282
+ // Отбор до K кандидатов, прошедших абсолютный порог выгоды (без отсева 2..K из-за сравнения с лидером №1 — проверено на IS и OOS)
283
+ const filterDynamicTopK = (cands, _edgeFn, _needVal, maxCount) => {
284
284
  if (!cands.length || maxCount <= 0) return [];
285
- const lim = Math.min(cands.length, maxCount);
286
- if (!autoK || lim <= 1) return cands.slice(0, lim);
287
- const bestE = Math.abs(edgeFn(cands[0]));
288
- const out = [cands[0]];
289
- for (let m = 1; m < lim; m++) {
290
- const curE = Math.abs(edgeFn(cands[m]));
291
- const relMin = m === 1 ? 0.62 : 0.78;
292
- const absMin = m === 1 ? needVal * 1.15 : needVal * 1.45;
293
- if (curE >= bestE * relMin && curE >= absMin) out.push(cands[m]);
294
- else break;
295
- }
296
- return out;
285
+ return cands.slice(0, Math.min(cands.length, maxCount));
297
286
  };
298
287
  const dir = p.modelDirection;
299
288
  const exitRatio = Number(p.modelExitRatio ?? 0.5);
@@ -301,6 +290,15 @@ async function run(runId, p, state) {
301
290
  const invVol = p.modelAllocMode === 'invvol';
302
291
  const realCost = (p.commissionPct + p.slippagePct) / 100;
303
292
  const symMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
293
+ const divCal = buildDividendCalendar(ds);
294
+ // реальные цены вечного фьючерса на индекс МосБиржи (IMOEXF) — для честной оценки защиты фьючерсом
295
+ let futOpen = null;
296
+ try {
297
+ futOpen = await loadAlignedOpen(ds, 'IMOEXF', { fromTs: timeline[Math.max(0, firstTest - 10)], tillTs: timeline[T - 1] + 600000, signal: state.ctrl.signal });
298
+ let n = 0;
299
+ for (let t = firstTest; t < T; t++) if (futOpen[t] > 0) n++;
300
+ if (n < (T - firstTest) * 0.5) futOpen = null;
301
+ } catch { futOpen = null; }
304
302
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
305
303
  // только покупки: на позицию капитал / K; с продажами: капитал / 2K (покупки и продажи вместе не больше капитала)
306
304
  const slots = dir === 'long' ? K : 2 * K;
@@ -350,6 +348,21 @@ async function run(runId, p, state) {
350
348
  const needMult = Number(ex.needMult) > 0 ? Number(ex.needMult) : ENTRY_EDGE_MULT;
351
349
  const cashRateAnnual = Number(ex.cashRateAnnual) > 0 ? Number(ex.cashRateAnnual) : 0;
352
350
  const costOf = (i) => (ex.tierCost ? (symMult[i] === 1 ? 0.00065 : 0.00125) : cost);
351
+ // дивидендный предохранитель (правило стратегии, как в живом контуре; выключается только для сравнения)
352
+ const divGuard = ex.divGuard !== false;
353
+ // налог 13% с дивиденда по покупкам, удержанным через отсечку (цены в бэктесте скорректированы на полный дивиденд)
354
+ const divTax = Boolean(ex.divTax);
355
+ // защита от движения рынка: короткий/длинный фьючерс на индекс на чистую бету портфеля
356
+ const betaHedge = Boolean(ex.betaHedge);
357
+ const hedgeCarry = Number(ex.hedgeCarry ?? 0.08); // разница фьючерса и индекса, годовых (шорт фьючерса её получает)
358
+ const hedgeCost = Number(ex.hedgeCost ?? 0.0002); // издержки на сторону при перестройке защиты
359
+ let hedgeRub = 0;
360
+ const stats = { borrowRub: 0, divTaxRub: 0, feesRub: 0, hedgePnlRub: 0, hedgeMktRub: 0, hedgeCarryRub: 0, hedgeCostRub: 0, netBetaSum: 0, netBetaN: 0, divExits: 0 };
361
+ const divRule = (i, t) => {
362
+ if (!divGuard) return null;
363
+ const nd = nextDividendAt(divCal, i, t);
364
+ return nd ? dividendRules(nd.daysToEx, nd.y) : null;
365
+ };
353
366
  let equity = p.capital;
354
367
  let idleShareSum = 0, idleSteps = 0, prevStepTs = tsOf(testTs[startIdx] ?? testTs[0]);
355
368
  const curve = [{ ts: tsOf(testTs[startIdx] ?? testTs[0]), equity }];
@@ -390,8 +403,16 @@ async function run(runId, p, state) {
390
403
  gross = h.alloc * (exitPrice / h.entryPrice - 1);
391
404
  fee = (h.alloc + gross) * c;
392
405
  }
393
- equity += gross - fee - borrow;
394
- trades.push(tradeRec(name, i, h, t, exitPrice, gross - fee - borrow - h.entryCost, h.entryCost + fee, borrow, end, why));
406
+ // покупка удержана через отсечку: на счёт пришёл дивиденд за вычетом НДФЛ, а скорректированная цена учла его целиком
407
+ let divLoss = 0;
408
+ if (divTax && h.side === 'long') {
409
+ for (const e of divCal.events[i]) if (e.exT > h.entryT && e.exT <= t) divLoss += DIV_TAX * e.y * h.alloc;
410
+ }
411
+ equity += gross - fee - borrow - divLoss;
412
+ stats.borrowRub += borrow;
413
+ stats.divTaxRub += divLoss;
414
+ stats.feesRub += fee + h.entryCost;
415
+ trades.push(tradeRec(name, i, h, t, exitPrice, gross - fee - borrow - divLoss - h.entryCost, h.entryCost + fee, borrow, end, why));
395
416
  held.delete(i);
396
417
  if (autoAdapt) {
397
418
  const realGross = h.side === 'short' ? (1 - exitPrice / h.entryPrice) : (exitPrice / h.entryPrice - 1);
@@ -410,6 +431,15 @@ async function run(runId, p, state) {
410
431
  const sg = sigmaAt.get(t);
411
432
  const bear = isMarketDown(ds, t);
412
433
  const mktDown = mktFilter && bear;
434
+ // дивидендный предохранитель: закрываем позиции в последний день с правом на дивиденд и не входим перед отсечкой
435
+ if (divGuard) {
436
+ for (const [i, h] of [...held]) {
437
+ const r = divRule(i, t);
438
+ if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) { closePos(i, h, tIn, false, 'Дивидендная отсечка'); stats.divExits++; }
439
+ }
440
+ }
441
+ const okLong = (i) => !divRule(i, t)?.blockLong;
442
+ const okShort = (i) => !divRule(i, t)?.blockShort;
413
443
  let order, entries;
414
444
  if (dir !== 'long') {
415
445
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
@@ -425,8 +455,8 @@ async function run(runId, p, state) {
425
455
  }
426
456
  const needL = autoAdapt && bear ? need * 1.25 : need;
427
457
  const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
428
- const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
429
- const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
458
+ const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
459
+ const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && okShort(i) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
430
460
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
431
461
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
432
462
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -467,7 +497,7 @@ async function run(runId, p, state) {
467
497
  }
468
498
  const free = K - held.size;
469
499
  const needL = autoAdapt && bear ? need * 1.25 : need;
470
- const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
500
+ const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && okLong(i) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
471
501
  const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
472
502
  order = [...held.keys(), ...cand];
473
503
  } else {
@@ -539,6 +569,27 @@ async function run(runId, p, state) {
539
569
  const dtYears = Math.min(4 / 365, (curStepTs - prevStepTs) / (365.25 * 86400000));
540
570
  equity += idleCash * cashRateAnnual * dtYears;
541
571
  }
572
+ // защита фьючерсом на индекс: держим позицию против чистой беты портфеля на отрезке [tIn, tOut]
573
+ if (betaHedge) {
574
+ const bt = betaAt.get(t);
575
+ let netBetaRub = 0;
576
+ for (const [i, h] of held) netBetaRub += (h.side === 'short' ? -1 : 1) * h.alloc * (bt ? bt[i] : 1);
577
+ const target = -netBetaRub;
578
+ const rebal = Math.abs(target - hedgeRub) * hedgeCost;
579
+ hedgeRub = target;
580
+ const src = ex.hedgeSource === 'index' || !futOpen ? ds.mopen : futOpen;
581
+ const m0 = src[tIn], m1 = src[tOut];
582
+ const mRet = m0 > 0 && m1 > 0 ? m1 / m0 - 1 : 0;
583
+ const dtYears = Math.max(0, (tsOf(tOut) - tsOf(tIn)) / (365.25 * 86400000));
584
+ const hp = hedgeRub * mRet - hedgeRub * hedgeCarry * dtYears - rebal;
585
+ equity += hp;
586
+ stats.hedgePnlRub += hp;
587
+ stats.hedgeMktRub += hedgeRub * mRet;
588
+ stats.hedgeCarryRub += -hedgeRub * hedgeCarry * dtYears;
589
+ stats.hedgeCostRub += rebal;
590
+ stats.netBetaSum += equity > 0 ? netBetaRub / equity : 0;
591
+ stats.netBetaN++;
592
+ }
542
593
  prevStepTs = curStepTs;
543
594
  let mtm = 0;
544
595
  for (const [i, h] of held) {
@@ -553,7 +604,7 @@ async function run(runId, p, state) {
553
604
  exitRatio: curExitRatio,
554
605
  quarantined: tokens.map((tk, i) => (!stockAllowed(i) ? tk.symbol : null)).filter(Boolean),
555
606
  } : null;
556
- return { trades, curve, adaptation, idleCashPct: round((idleShareSum / Math.max(1, idleSteps)) * 100, 1) };
607
+ return { trades, curve, adaptation, idleCashPct: round((idleShareSum / Math.max(1, idleSteps)) * 100, 1), stats };
557
608
  }
558
609
  function tradeRec(name, i, h, t, exitPrice, pnl, commission, borrow, end = false, why = '') {
559
610
  const heldMin = (t - h.entryT) * BAR_INTERVAL; // минуты
@@ -780,8 +831,9 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
780
831
  return c.length > 1 ? round((c.at(-1).equity / c[0].equity - 1) * 100, 2) : null;
781
832
  };
782
833
  const base = (p.commissionPct + p.slippagePct) / 100;
783
- const REAL = { lag: 2, needTrade: true, partCap: 0.05, lots: true, shortable };
784
- const LIVE = { midBar: true, needTrade: true, partCap: 0.05, lots: true, shortable };
834
+ // реалистичные сценарии учитывают и налог 13% с дивидендов по покупкам, удержанным через отсечку
835
+ const REAL = { lag: 2, needTrade: true, partCap: 0.05, lots: true, shortable, divTax: true };
836
+ const LIVE = { midBar: true, needTrade: true, partCap: 0.05, lots: true, shortable, divTax: true };
785
837
  const scenarios = [
786
838
  { key: 'base', label: 'Как в основном отчёте', note: `издержки ${round(base * 100, 3)}% на сторону, вход по open следующей свечи`, cost: base, ex: {} },
787
839
  { key: 'cost15', label: 'Издержки 0,15% на сторону', note: 'комиссия + спред + проскальзывание для второго-третьего эшелона', cost: 0.0015, ex: {} },
@@ -792,6 +844,8 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
792
844
  { key: 'shorts', label: 'Шорт только разрешённых бумаг', note: shortable ? `по текущему списку Т-Банка (${shortable.size} бумаг)` : 'список Т-Банка не загружен — проверка не выполнена', cost: base, ex: { shortable } },
793
845
  { key: 'liveLike', label: 'Как живой контур (внутри свечи), издержки как в отчёте', note: 'живые цены брокера: вход внутри следующей свечи + сделки, оборот, лоты, шорты', cost: base, ex: LIVE },
794
846
  { key: 'liveLike10', label: 'Как живой контур, издержки 0,10%', note: 'то же при издержках 0,10% на сторону (комиссия 0,05% + спред/проскальзывание 0,05%)', cost: 0.001, ex: LIVE },
847
+ { key: 'liveNoDivGuard', label: 'Живой контур, 0,10%, без дивидендного предохранителя', note: 'для сравнения: позиции держатся через дивидендные отсечки (с налогом 13% по покупкам)', cost: 0.001, ex: { ...LIVE, divGuard: false } },
848
+ { key: 'liveHedge', label: 'Живой контур, 0,10% + защита фьючерсом на индекс', note: 'реальные цены вечного фьючерса IMOEXF против чистой беты портфеля: перестройка каждые 30 мин, издержки 0,02% на сторону, разница фьючерса и индекса 8% годовых', cost: 0.001, ex: { ...LIVE, betaHedge: true } },
795
849
  { key: 'liveTierLqdt', label: 'Живой контур + издержки по эшелонам + свободный кэш в LQDT (16%/год)', note: 'издержки 0,065% 1-й эшелон и 0,125% 2–3-й эшелон + начисление ставки денежного рынка на незанятые рубли (~95% времени)', cost: 0.001, ex: { ...LIVE, tierCost: true, cashRateAnnual: 0.16 } },
796
850
  { key: 'execOnly', label: 'Всё вместе, издержки как в отчёте', note: 'задержка, сделки, оборот, лоты и шорты — без роста издержек', cost: base, ex: REAL },
797
851
  { key: 'realistic', label: 'Всё вместе, издержки 0,15%', note: 'реалистичная оценка', cost: 0.0015, ex: REAL },
@@ -804,9 +858,21 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
804
858
  const yrs = yearsOf(sim.curve);
805
859
  const longPnl = sim.trades.filter((t) => t.side === 'long').reduce((a, t) => a + t.pnl, 0);
806
860
  const shortPnl = sim.trades.filter((t) => t.side === 'short').reduce((a, t) => a + t.pnl, 0);
861
+ const cg = cagr(full.returnPct, yrs);
862
+ const perYearPct = (rub) => round((rub / p.capital / yrs) * 100, 2);
807
863
  out.push({
808
864
  key: s.key, label: s.label, note: s.note,
809
- full: { returnPct: full.returnPct, cagrPct: cagr(full.returnPct, yrs), profitFactor: full.profitFactor, maxDrawdownPct: full.maxDrawdownPct, trades: full.trades, winRate: full.winRate, avgTradePct: full.avgTradePct, longPnl: round(longPnl, 0), shortPnl: round(shortPnl, 0) },
865
+ full: {
866
+ returnPct: full.returnPct, cagrPct: cg, profitFactor: full.profitFactor, maxDrawdownPct: full.maxDrawdownPct, trades: full.trades, winRate: full.winRate, avgTradePct: full.avgTradePct, longPnl: round(longPnl, 0), shortPnl: round(shortPnl, 0),
867
+ // грубая оценка после НДФЛ 13% с годовой прибыли (убыточные годы налогом не облагаются)
868
+ afterTaxCagrPct: cg === null ? null : round(cg > 0 ? cg * (1 - 0.13) : cg, 1),
869
+ },
870
+ costs: sim.stats ? {
871
+ borrowRub: round(sim.stats.borrowRub, 0), divTaxRub: round(sim.stats.divTaxRub, 0), feesRub: round(sim.stats.feesRub, 0), hedgePnlRub: round(sim.stats.hedgePnlRub, 0), divExits: sim.stats.divExits,
872
+ hedgeMktRub: round(sim.stats.hedgeMktRub, 0), hedgeCarryRub: round(sim.stats.hedgeCarryRub, 0), hedgeCostRub: round(sim.stats.hedgeCostRub, 0),
873
+ avgNetBetaPct: sim.stats.netBetaN ? round((sim.stats.netBetaSum / sim.stats.netBetaN) * 100, 1) : null,
874
+ borrowPctPerYear: perYearPct(sim.stats.borrowRub), divTaxPctPerYear: perYearPct(sim.stats.divTaxRub),
875
+ } : null,
810
876
  last12: segment(sim, lastTs - 365 * 86400000),
811
877
  last6: segment(sim, lastTs - 182 * 86400000),
812
878
  });