neuroalpha 0.5.3 → 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 +1 -1
- package/public/app.js +3 -3
- package/server/brokers/index.js +25 -6
- package/server/data/moex.js +20 -6
- package/server/model/dataset.js +19 -0
- package/server/model/dividends.js +117 -0
- package/server/model/research.js +90 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
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
|
|
package/server/brokers/index.js
CHANGED
|
@@ -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';
|
|
@@ -1597,8 +1598,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1597
1598
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
1598
1599
|
|
|
1599
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));
|
|
1600
|
-
|
|
1601
|
-
const
|
|
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) : [];
|
|
1602
1610
|
const nonShortableSkipped = [];
|
|
1603
1611
|
const rawShortCands = [];
|
|
1604
1612
|
for (const r of allShortSignals) {
|
|
@@ -1654,12 +1662,16 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1654
1662
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1655
1663
|
const hitHardStopLoss = curGainPct <= -2.6;
|
|
1656
1664
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1657
|
-
const
|
|
1665
|
+
const dg = divGuardOf(sym, curPx);
|
|
1666
|
+
const divExit = Boolean(dg?.exitLong);
|
|
1667
|
+
const keep = keepByForecast && !hitHardStopLoss && !divExit;
|
|
1658
1668
|
if (lots >= 1 && !keep) {
|
|
1659
1669
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
1660
1670
|
const exitReason = hitHardStopLoss
|
|
1661
1671
|
? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
|
|
1662
|
-
:
|
|
1672
|
+
: divExit
|
|
1673
|
+
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
|
|
1674
|
+
: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
|
|
1663
1675
|
orders.push({
|
|
1664
1676
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1665
1677
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -1672,12 +1684,16 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1672
1684
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1673
1685
|
const hitHardStopLoss = curGainPct <= -2.2;
|
|
1674
1686
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1675
|
-
const
|
|
1687
|
+
const dg = divGuardOf(sym, curPx);
|
|
1688
|
+
const divExit = Boolean(dg?.exitShort);
|
|
1689
|
+
const keep = keepByForecast && !hitHardStopLoss && !divExit;
|
|
1676
1690
|
if (lots >= 1 && !keep) {
|
|
1677
1691
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
1678
1692
|
const exitReason = hitHardStopLoss
|
|
1679
1693
|
? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
|
|
1680
|
-
:
|
|
1694
|
+
: divExit
|
|
1695
|
+
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
|
|
1696
|
+
: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
|
|
1681
1697
|
orders.push({
|
|
1682
1698
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1683
1699
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -1956,6 +1972,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1956
1972
|
: `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
|
|
1957
1973
|
}
|
|
1958
1974
|
}
|
|
1975
|
+
if (divBlocked.length) {
|
|
1976
|
+
diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
|
|
1977
|
+
}
|
|
1959
1978
|
|
|
1960
1979
|
if (onlyEntries && !orders.length) {
|
|
1961
1980
|
return brokersState();
|
package/server/data/moex.js
CHANGED
|
@@ -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
|
|
179
|
-
const url = `https://smart-lab.ru/q/${
|
|
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
|
-
|
|
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
|
}
|
package/server/model/dataset.js
CHANGED
|
@@ -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
|
+
}
|
package/server/model/research.js
CHANGED
|
@@ -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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
784
|
-
const
|
|
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: {
|
|
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
|
});
|