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 +1 -1
- package/public/app.js +3 -3
- package/server/brokers/index.js +29 -20
- package/server/data/moex.js +20 -6
- package/server/model/dataset.js +19 -0
- package/server/model/dividends.js +117 -0
- package/server/model/models.js +4 -2
- package/server/model/research.js +93 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
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
|
|
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';
|
|
@@ -353,7 +354,7 @@ function computeExecutionAudit(a) {
|
|
|
353
354
|
slipTier1Pct,
|
|
354
355
|
slipTier2Pct,
|
|
355
356
|
totalCostPerSidePct,
|
|
356
|
-
breakEvenTargetPct: 0.
|
|
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
|
-
|
|
1501
|
+
// Отбор до K кандидатов, прошедших абсолютный порог выгоды (единый с бэктестом в research.js)
|
|
1502
|
+
const filterBrokerDynamicTopK = (cands, _needVal, maxCount) => {
|
|
1501
1503
|
if (!cands.length || maxCount <= 0) return [];
|
|
1502
|
-
|
|
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
|
-
|
|
1601
|
-
const
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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
|
-
:
|
|
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();
|
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/models.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
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);
|
|
@@ -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
|
-
//
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
784
|
-
const
|
|
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: {
|
|
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
|
});
|