neuroalpha 0.5.2 → 0.5.4

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.2",
3
+ "version": "0.5.4",
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';
@@ -280,11 +281,15 @@ export function brokerAccountsSummary(uid = 1) {
280
281
  }
281
282
 
282
283
  const candlePrevStmt = db.prepare('SELECT ts, open, close FROM candles WHERE symbol = ? AND interval = 10 AND ts <= ? ORDER BY ts DESC LIMIT 3');
284
+ const executionAuditCache = new Map(); // accountId -> { sig, val }
283
285
 
284
- // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста
286
+ // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста (с кешированием по срезу истории)
285
287
  function computeExecutionAudit(a) {
286
288
  const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
287
289
  if (!ops.length) return null;
290
+ const sig = `${a.lastHistory?.at || 0}:${ops.length}:${ops[0]?.id || ''}:${ops[ops.length - 1]?.id || ''}`;
291
+ const hit = executionAuditCache.get(a.id);
292
+ if (hit && hit.sig === sig) return hit.val;
288
293
  const sorted = [...ops].sort((x, y) => x.date - y.date);
289
294
  // отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
290
295
  const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
@@ -339,7 +344,7 @@ function computeExecutionAudit(a) {
339
344
  const netRub = round(grossRub - feesRub, 0);
340
345
  const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
341
346
 
342
- return {
347
+ const val = {
343
348
  fromTs: algoOps[0]?.date || null,
344
349
  tradesCount: trades.length,
345
350
  closedCount,
@@ -349,12 +354,14 @@ function computeExecutionAudit(a) {
349
354
  slipTier1Pct,
350
355
  slipTier2Pct,
351
356
  totalCostPerSidePct,
352
- breakEvenTargetPct: 0.255,
357
+ breakEvenTargetPct: 0.229,
353
358
  grossRub,
354
359
  feesRub: round(feesRub, 0),
355
360
  netRub,
356
361
  profitFactorGross,
357
362
  };
363
+ executionAuditCache.set(a.id, { sig, val });
364
+ return val;
358
365
  }
359
366
 
360
367
  function publicAccount(a) {
@@ -866,7 +873,7 @@ async function syncTbank(acc) {
866
873
  // 2) если лимитная заявка висит неисполненной дольше 12 минут (прошла свеча, цена ушла) — автоматически снимаем её и разблокируем рубли!
867
874
  if (acc.mode !== 'readonly' && rawOrders.length > 0) {
868
875
  const seenInstDir = new Set();
869
- const toCancelIds = [];
876
+ const toCancelOrders = [];
870
877
  const keptOrders = [];
871
878
  const sortedByDate = [...rawOrders].sort((a, b) => Date.parse(b.orderDate || 0) - Date.parse(a.orderDate || 0));
872
879
  const nowMs = Date.now();
@@ -874,19 +881,21 @@ async function syncTbank(acc) {
874
881
  const k = `${o.instrumentUid || o.figi || o.ticker}:${o.direction}`;
875
882
  const ageMs = o.orderDate ? (nowMs - Date.parse(o.orderDate)) : 0;
876
883
  if (seenInstDir.has(k) || ageMs > 12 * 60000) {
877
- if (o.orderId) toCancelIds.push(o.orderId);
884
+ if (o.orderId) toCancelOrders.push({ o, reason: ageMs > 12 * 60000 ? '>12 мин' : 'дубль' });
878
885
  } else {
879
886
  seenInstDir.add(k);
880
887
  keptOrders.push(o);
881
888
  }
882
889
  }
883
- if (toCancelIds.length > 0) {
884
- await Promise.all(toCancelIds.map((orderId) => (
890
+ if (toCancelOrders.length > 0) {
891
+ await Promise.all(toCancelOrders.map(({ o }) => (
885
892
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
886
- method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
893
+ method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 8000,
887
894
  }).catch(() => null)
888
895
  )));
889
- logBrokerEvent(acc, 'order_expired', `авто-снятие ${toCancelIds.length} неисполненных/дублирующих заявок (>12 мин в стакане)`);
896
+ for (const { o, reason } of toCancelOrders) {
897
+ logBrokerEvent(acc, 'order_expired', `${o.ticker || o.figi || 'заявка'}: снята (${reason}) · ${o.orderId}`);
898
+ }
890
899
  rawOrders = keptOrders;
891
900
  }
892
901
  }
@@ -1589,8 +1598,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1589
1598
  const nb = params.newsFilter ? blockingNews(6) : null;
1590
1599
 
1591
1600
  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));
1592
- 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);
1593
- 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) : [];
1601
+ // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1602
+ const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1603
+ const divBlocked = [];
1604
+ 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)
1605
+ .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1606
+ .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1607
+ 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)
1608
+ .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1609
+ .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1594
1610
  const nonShortableSkipped = [];
1595
1611
  const rawShortCands = [];
1596
1612
  for (const r of allShortSignals) {
@@ -1646,12 +1662,16 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1646
1662
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1647
1663
  const hitHardStopLoss = curGainPct <= -2.6;
1648
1664
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1649
- const keep = keepByForecast && !hitHardStopLoss;
1665
+ const dg = divGuardOf(sym, curPx);
1666
+ const divExit = Boolean(dg?.exitLong);
1667
+ const keep = keepByForecast && !hitHardStopLoss && !divExit;
1650
1668
  if (lots >= 1 && !keep) {
1651
1669
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1652
1670
  const exitReason = hitHardStopLoss
1653
1671
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1654
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1672
+ : divExit
1673
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1674
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1655
1675
  orders.push({
1656
1676
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1657
1677
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1664,12 +1684,16 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1664
1684
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1665
1685
  const hitHardStopLoss = curGainPct <= -2.2;
1666
1686
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1667
- const keep = keepByForecast && !hitHardStopLoss;
1687
+ const dg = divGuardOf(sym, curPx);
1688
+ const divExit = Boolean(dg?.exitShort);
1689
+ const keep = keepByForecast && !hitHardStopLoss && !divExit;
1668
1690
  if (lots >= 1 && !keep) {
1669
1691
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1670
1692
  const exitReason = hitHardStopLoss
1671
1693
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1672
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1694
+ : divExit
1695
+ ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1696
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1673
1697
  orders.push({
1674
1698
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1675
1699
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -1948,6 +1972,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1948
1972
  : `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
1949
1973
  }
1950
1974
  }
1975
+ if (divBlocked.length) {
1976
+ diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
1977
+ }
1951
1978
 
1952
1979
  if (onlyEntries && !orders.length) {
1953
1980
  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
+ }
@@ -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);
@@ -301,6 +304,15 @@ async function run(runId, p, state) {
301
304
  const invVol = p.modelAllocMode === 'invvol';
302
305
  const realCost = (p.commissionPct + p.slippagePct) / 100;
303
306
  const symMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
307
+ const divCal = buildDividendCalendar(ds);
308
+ // реальные цены вечного фьючерса на индекс МосБиржи (IMOEXF) — для честной оценки защиты фьючерсом
309
+ let futOpen = null;
310
+ try {
311
+ futOpen = await loadAlignedOpen(ds, 'IMOEXF', { fromTs: timeline[Math.max(0, firstTest - 10)], tillTs: timeline[T - 1] + 600000, signal: state.ctrl.signal });
312
+ let n = 0;
313
+ for (let t = firstTest; t < T; t++) if (futOpen[t] > 0) n++;
314
+ if (n < (T - firstTest) * 0.5) futOpen = null;
315
+ } catch { futOpen = null; }
304
316
  const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
305
317
  // только покупки: на позицию капитал / K; с продажами: капитал / 2K (покупки и продажи вместе не больше капитала)
306
318
  const slots = dir === 'long' ? K : 2 * K;
@@ -350,6 +362,21 @@ async function run(runId, p, state) {
350
362
  const needMult = Number(ex.needMult) > 0 ? Number(ex.needMult) : ENTRY_EDGE_MULT;
351
363
  const cashRateAnnual = Number(ex.cashRateAnnual) > 0 ? Number(ex.cashRateAnnual) : 0;
352
364
  const costOf = (i) => (ex.tierCost ? (symMult[i] === 1 ? 0.00065 : 0.00125) : cost);
365
+ // дивидендный предохранитель (правило стратегии, как в живом контуре; выключается только для сравнения)
366
+ const divGuard = ex.divGuard !== false;
367
+ // налог 13% с дивиденда по покупкам, удержанным через отсечку (цены в бэктесте скорректированы на полный дивиденд)
368
+ const divTax = Boolean(ex.divTax);
369
+ // защита от движения рынка: короткий/длинный фьючерс на индекс на чистую бету портфеля
370
+ const betaHedge = Boolean(ex.betaHedge);
371
+ const hedgeCarry = Number(ex.hedgeCarry ?? 0.08); // разница фьючерса и индекса, годовых (шорт фьючерса её получает)
372
+ const hedgeCost = Number(ex.hedgeCost ?? 0.0002); // издержки на сторону при перестройке защиты
373
+ let hedgeRub = 0;
374
+ const stats = { borrowRub: 0, divTaxRub: 0, feesRub: 0, hedgePnlRub: 0, hedgeMktRub: 0, hedgeCarryRub: 0, hedgeCostRub: 0, netBetaSum: 0, netBetaN: 0, divExits: 0 };
375
+ const divRule = (i, t) => {
376
+ if (!divGuard) return null;
377
+ const nd = nextDividendAt(divCal, i, t);
378
+ return nd ? dividendRules(nd.daysToEx, nd.y) : null;
379
+ };
353
380
  let equity = p.capital;
354
381
  let idleShareSum = 0, idleSteps = 0, prevStepTs = tsOf(testTs[startIdx] ?? testTs[0]);
355
382
  const curve = [{ ts: tsOf(testTs[startIdx] ?? testTs[0]), equity }];
@@ -390,8 +417,16 @@ async function run(runId, p, state) {
390
417
  gross = h.alloc * (exitPrice / h.entryPrice - 1);
391
418
  fee = (h.alloc + gross) * c;
392
419
  }
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));
420
+ // покупка удержана через отсечку: на счёт пришёл дивиденд за вычетом НДФЛ, а скорректированная цена учла его целиком
421
+ let divLoss = 0;
422
+ if (divTax && h.side === 'long') {
423
+ for (const e of divCal.events[i]) if (e.exT > h.entryT && e.exT <= t) divLoss += DIV_TAX * e.y * h.alloc;
424
+ }
425
+ equity += gross - fee - borrow - divLoss;
426
+ stats.borrowRub += borrow;
427
+ stats.divTaxRub += divLoss;
428
+ stats.feesRub += fee + h.entryCost;
429
+ trades.push(tradeRec(name, i, h, t, exitPrice, gross - fee - borrow - divLoss - h.entryCost, h.entryCost + fee, borrow, end, why));
395
430
  held.delete(i);
396
431
  if (autoAdapt) {
397
432
  const realGross = h.side === 'short' ? (1 - exitPrice / h.entryPrice) : (exitPrice / h.entryPrice - 1);
@@ -410,6 +445,15 @@ async function run(runId, p, state) {
410
445
  const sg = sigmaAt.get(t);
411
446
  const bear = isMarketDown(ds, t);
412
447
  const mktDown = mktFilter && bear;
448
+ // дивидендный предохранитель: закрываем позиции в последний день с правом на дивиденд и не входим перед отсечкой
449
+ if (divGuard) {
450
+ for (const [i, h] of [...held]) {
451
+ const r = divRule(i, t);
452
+ if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) { closePos(i, h, tIn, false, 'Дивидендная отсечка'); stats.divExits++; }
453
+ }
454
+ }
455
+ const okLong = (i) => !divRule(i, t)?.blockLong;
456
+ const okShort = (i) => !divRule(i, t)?.blockShort;
413
457
  let order, entries;
414
458
  if (dir !== 'long') {
415
459
  const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
@@ -425,8 +469,8 @@ async function run(runId, p, state) {
425
469
  }
426
470
  const needL = autoAdapt && bear ? need * 1.25 : need;
427
471
  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]);
472
+ 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]);
473
+ 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
474
  // Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
431
475
  // если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
432
476
  // освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
@@ -467,7 +511,7 @@ async function run(runId, p, state) {
467
511
  }
468
512
  const free = K - held.size;
469
513
  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]);
514
+ 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
515
  const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
472
516
  order = [...held.keys(), ...cand];
473
517
  } else {
@@ -539,6 +583,27 @@ async function run(runId, p, state) {
539
583
  const dtYears = Math.min(4 / 365, (curStepTs - prevStepTs) / (365.25 * 86400000));
540
584
  equity += idleCash * cashRateAnnual * dtYears;
541
585
  }
586
+ // защита фьючерсом на индекс: держим позицию против чистой беты портфеля на отрезке [tIn, tOut]
587
+ if (betaHedge) {
588
+ const bt = betaAt.get(t);
589
+ let netBetaRub = 0;
590
+ for (const [i, h] of held) netBetaRub += (h.side === 'short' ? -1 : 1) * h.alloc * (bt ? bt[i] : 1);
591
+ const target = -netBetaRub;
592
+ const rebal = Math.abs(target - hedgeRub) * hedgeCost;
593
+ hedgeRub = target;
594
+ const src = ex.hedgeSource === 'index' || !futOpen ? ds.mopen : futOpen;
595
+ const m0 = src[tIn], m1 = src[tOut];
596
+ const mRet = m0 > 0 && m1 > 0 ? m1 / m0 - 1 : 0;
597
+ const dtYears = Math.max(0, (tsOf(tOut) - tsOf(tIn)) / (365.25 * 86400000));
598
+ const hp = hedgeRub * mRet - hedgeRub * hedgeCarry * dtYears - rebal;
599
+ equity += hp;
600
+ stats.hedgePnlRub += hp;
601
+ stats.hedgeMktRub += hedgeRub * mRet;
602
+ stats.hedgeCarryRub += -hedgeRub * hedgeCarry * dtYears;
603
+ stats.hedgeCostRub += rebal;
604
+ stats.netBetaSum += equity > 0 ? netBetaRub / equity : 0;
605
+ stats.netBetaN++;
606
+ }
542
607
  prevStepTs = curStepTs;
543
608
  let mtm = 0;
544
609
  for (const [i, h] of held) {
@@ -553,7 +618,7 @@ async function run(runId, p, state) {
553
618
  exitRatio: curExitRatio,
554
619
  quarantined: tokens.map((tk, i) => (!stockAllowed(i) ? tk.symbol : null)).filter(Boolean),
555
620
  } : null;
556
- return { trades, curve, adaptation, idleCashPct: round((idleShareSum / Math.max(1, idleSteps)) * 100, 1) };
621
+ return { trades, curve, adaptation, idleCashPct: round((idleShareSum / Math.max(1, idleSteps)) * 100, 1), stats };
557
622
  }
558
623
  function tradeRec(name, i, h, t, exitPrice, pnl, commission, borrow, end = false, why = '') {
559
624
  const heldMin = (t - h.entryT) * BAR_INTERVAL; // минуты
@@ -780,8 +845,9 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
780
845
  return c.length > 1 ? round((c.at(-1).equity / c[0].equity - 1) * 100, 2) : null;
781
846
  };
782
847
  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 };
848
+ // реалистичные сценарии учитывают и налог 13% с дивидендов по покупкам, удержанным через отсечку
849
+ const REAL = { lag: 2, needTrade: true, partCap: 0.05, lots: true, shortable, divTax: true };
850
+ const LIVE = { midBar: true, needTrade: true, partCap: 0.05, lots: true, shortable, divTax: true };
785
851
  const scenarios = [
786
852
  { key: 'base', label: 'Как в основном отчёте', note: `издержки ${round(base * 100, 3)}% на сторону, вход по open следующей свечи`, cost: base, ex: {} },
787
853
  { key: 'cost15', label: 'Издержки 0,15% на сторону', note: 'комиссия + спред + проскальзывание для второго-третьего эшелона', cost: 0.0015, ex: {} },
@@ -792,6 +858,8 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
792
858
  { key: 'shorts', label: 'Шорт только разрешённых бумаг', note: shortable ? `по текущему списку Т-Банка (${shortable.size} бумаг)` : 'список Т-Банка не загружен — проверка не выполнена', cost: base, ex: { shortable } },
793
859
  { key: 'liveLike', label: 'Как живой контур (внутри свечи), издержки как в отчёте', note: 'живые цены брокера: вход внутри следующей свечи + сделки, оборот, лоты, шорты', cost: base, ex: LIVE },
794
860
  { key: 'liveLike10', label: 'Как живой контур, издержки 0,10%', note: 'то же при издержках 0,10% на сторону (комиссия 0,05% + спред/проскальзывание 0,05%)', cost: 0.001, ex: LIVE },
861
+ { key: 'liveNoDivGuard', label: 'Живой контур, 0,10%, без дивидендного предохранителя', note: 'для сравнения: позиции держатся через дивидендные отсечки (с налогом 13% по покупкам)', cost: 0.001, ex: { ...LIVE, divGuard: false } },
862
+ { key: 'liveHedge', label: 'Живой контур, 0,10% + защита фьючерсом на индекс', note: 'реальные цены вечного фьючерса IMOEXF против чистой беты портфеля: перестройка каждые 30 мин, издержки 0,02% на сторону, разница фьючерса и индекса 8% годовых', cost: 0.001, ex: { ...LIVE, betaHedge: true } },
795
863
  { 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
864
  { key: 'execOnly', label: 'Всё вместе, издержки как в отчёте', note: 'задержка, сделки, оборот, лоты и шорты — без роста издержек', cost: base, ex: REAL },
797
865
  { key: 'realistic', label: 'Всё вместе, издержки 0,15%', note: 'реалистичная оценка', cost: 0.0015, ex: REAL },
@@ -804,9 +872,21 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
804
872
  const yrs = yearsOf(sim.curve);
805
873
  const longPnl = sim.trades.filter((t) => t.side === 'long').reduce((a, t) => a + t.pnl, 0);
806
874
  const shortPnl = sim.trades.filter((t) => t.side === 'short').reduce((a, t) => a + t.pnl, 0);
875
+ const cg = cagr(full.returnPct, yrs);
876
+ const perYearPct = (rub) => round((rub / p.capital / yrs) * 100, 2);
807
877
  out.push({
808
878
  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) },
879
+ full: {
880
+ 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),
881
+ // грубая оценка после НДФЛ 13% с годовой прибыли (убыточные годы налогом не облагаются)
882
+ afterTaxCagrPct: cg === null ? null : round(cg > 0 ? cg * (1 - 0.13) : cg, 1),
883
+ },
884
+ costs: sim.stats ? {
885
+ 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,
886
+ hedgeMktRub: round(sim.stats.hedgeMktRub, 0), hedgeCarryRub: round(sim.stats.hedgeCarryRub, 0), hedgeCostRub: round(sim.stats.hedgeCostRub, 0),
887
+ avgNetBetaPct: sim.stats.netBetaN ? round((sim.stats.netBetaSum / sim.stats.netBetaN) * 100, 1) : null,
888
+ borrowPctPerYear: perYearPct(sim.stats.borrowRub), divTaxPctPerYear: perYearPct(sim.stats.divTaxRub),
889
+ } : null,
810
890
  last12: segment(sim, lastTs - 365 * 86400000),
811
891
  last6: segment(sim, lastTs - 182 * 86400000),
812
892
  });