feat: 组合级资产对比 — portfolio_sim(100万/10等分仓/24月),v7.1以+60.8%总收益夺冠(周转率胜过单笔收益),研究Tab新增总收益/最终资产/年化/资产回撤四列

This commit is contained in:
hmo
2026-07-29 02:03:50 +08:00
parent c711f6ff80
commit 1a5951c13c
2 changed files with 93 additions and 10 deletions
+19 -10
View File
@@ -1956,14 +1956,18 @@ function renderStrategyTable(strategies) {
const el = document.getElementById('strategyList');
if (!strategies.length) { el.innerHTML = '<div class="text-slate-500">暂无策略版本</div>'; return; }
// 找每列最优值用于高亮
const best = { win_rate: -999, sharpe_ratio: -999, profit_factor: -999, avg_profit_pct: -999, max_drawdown_pct: 999 };
const best = { win_rate: -999, sharpe_ratio: -999, profit_factor: -999, avg_profit_pct: -999,
total_return_pct: -999, cagr_pct: -999, portfolio_max_dd_pct: 999 };
for (const s of strategies) {
const st = s.summary_stats || {};
const pf = st.portfolio || {};
if (st.win_rate != null) best.win_rate = Math.max(best.win_rate, st.win_rate);
if (st.sharpe_ratio != null) best.sharpe_ratio = Math.max(best.sharpe_ratio, st.sharpe_ratio);
if (st.profit_factor != null) best.profit_factor = Math.max(best.profit_factor, st.profit_factor);
if (st.avg_profit_pct != null) best.avg_profit_pct = Math.max(best.avg_profit_pct, st.avg_profit_pct);
if (st.max_drawdown_pct != null) best.max_drawdown_pct = Math.min(best.max_drawdown_pct, st.max_drawdown_pct);
if (pf.total_return_pct != null) best.total_return_pct = Math.max(best.total_return_pct, pf.total_return_pct);
if (pf.cagr_pct != null) best.cagr_pct = Math.max(best.cagr_pct, pf.cagr_pct);
if (pf.portfolio_max_dd_pct != null) best.portfolio_max_dd_pct = Math.min(best.portfolio_max_dd_pct, pf.portfolio_max_dd_pct);
}
const hl = (val, bestVal, invert) => {
if (val == null) return '';
@@ -1972,31 +1976,36 @@ function renderStrategyTable(strategies) {
};
let html = '<table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-700">' +
'<th class="text-left px-2 py-1.5">版本</th><th class="text-left px-2 py-1.5">名称</th>' +
'<th class="text-left px-2 py-1.5">改进假设</th>' +
'<th class="text-right px-2 py-1.5">交易数</th><th class="text-right px-2 py-1.5">胜率</th>' +
'<th class="text-right px-2 py-1.5 text-amber-400">总收益</th>' +
'<th class="text-right px-2 py-1.5 text-amber-400">最终资产</th>' +
'<th class="text-right px-2 py-1.5">年化</th>' +
'<th class="text-right px-2 py-1.5">胜率</th>' +
'<th class="text-right px-2 py-1.5">平均收益</th><th class="text-right px-2 py-1.5">夏普</th>' +
'<th class="text-right px-2 py-1.5">盈亏比</th><th class="text-right px-2 py-1.5">最大回撤</th>' +
'<th class="text-right px-2 py-1.5">盈亏比</th><th class="text-right px-2 py-1.5">资产回撤</th>' +
'<th class="text-center px-2 py-1.5">操作</th></tr></thead><tbody>';
for (const s of strategies) {
const st = s.summary_stats || {};
const pf = st.portfolio || {};
const hasResult = st.total_trades != null;
const smallSample = hasResult && st.total_trades < 40;
const retClass = pf.total_return_pct == null ? '' : (pf.total_return_pct >= 0 ? 'text-green-400' : 'text-red-400');
html += '<tr class="border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer" onclick="showStrategyDetail(\'' + s.version + '\')">' +
'<td class="px-2 py-1.5 font-mono font-bold text-blue-400">' + s.version + '</td>' +
'<td class="px-2 py-1.5">' + (s.name || '') + '</td>' +
'<td class="px-2 py-1.5 text-slate-400 max-w-xs truncate" title="' + (s.hypothesis || '').replace(/"/g, '&quot;') + '">' + (s.hypothesis || s.summary || '') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono">' + (hasResult ? st.total_trades + (smallSample ? '⚠️' : '') : '—') + '</td>' +
'<td class="px-2 py-1.5" title="' + (s.hypothesis || s.summary || '').replace(/"/g, '&quot;') + '">' + (s.name || '') + (smallSample ? ' <span class="text-amber-500" title="样本<40笔">⚠️</span>' : '') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono font-bold ' + retClass + ' ' + hl(pf.total_return_pct, best.total_return_pct) + '">' + (pf.total_return_pct != null ? pf.total_return_pct + '%' : '') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + (pf.capital_final != null ? '' : 'text-slate-600') + '">' + (pf.capital_final != null ? '¥' + (pf.capital_final/10000).toFixed(0) + '' : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + hl(pf.cagr_pct, best.cagr_pct) + '">' + (pf.cagr_pct != null ? pf.cagr_pct + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.win_rate, best.win_rate) + '">' + (st.win_rate != null ? st.win_rate + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + ((st.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') + '">' + (st.avg_profit_pct != null ? st.avg_profit_pct + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.sharpe_ratio, best.sharpe_ratio) + '">' + (st.sharpe_ratio != null ? st.sharpe_ratio : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.profit_factor, best.profit_factor) + '">' + (st.profit_factor != null ? st.profit_factor : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.max_drawdown_pct, best.max_drawdown_pct, true) + '">' + (st.max_drawdown_pct != null ? st.max_drawdown_pct + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1.5 font-mono ' + hl(pf.portfolio_max_dd_pct, best.portfolio_max_dd_pct, true) + '">' + (pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct + '%' : '—') + '</td>' +
'<td class="text-center px-2 py-1.5" onclick="event.stopPropagation()">' +
'<button onclick="runStrategyBacktest(\'' + s.version + '\')" class="px-2 py-0.5 bg-blue-600/60 hover:bg-blue-500 rounded text-xs">▶回测</button>' +
'</td></tr>';
}
html += '</tbody></table>' +
'<div class="text-xs text-slate-600 mt-2">⚠️ = 样本&lt;40笔,统计意义有限 · 绿色 = 该列最优 · 回测期间: 2026-01-21 ~ 2026-07-24</div>';
'<div class="text-xs text-slate-600 mt-2">总收益/最终资产 = 100万本金·最多10等分仓位·24个月组合模拟 · ⚠️样本&lt;40笔 · 绿色=该列最优 · 悬停名称查看改进假设</div>';
el.innerHTML = html;
}
+74
View File
@@ -736,6 +736,9 @@ def run_backtest(strategy_version, start_date, end_date, capital=1000000, save=T
i += step
summary = calc_summary(trades, capital)
# 组合级资产模拟(统一标准:等额仓位、最多10仓)
if summary:
summary['portfolio'] = portfolio_sim(trades, capital, 10)
result = {
'strategy': strat['version'],
'strategy_name': strat['name'],
@@ -751,6 +754,77 @@ def run_backtest(strategy_version, start_date, end_date, capital=1000000, save=T
return result
def portfolio_sim(trades, capital=1000000, max_positions=10):
"""组合级模拟:固定等分仓位,按交易日历执行,返回最终资产/总收益/资产曲线回撤
规则:每日先结算到期仓位 → 再执行当日入场(仓位满跳过)→ 持仓按成本估值"""
if not trades:
return {}
# 交易日历(用大盘指数日期)
cal = sorted(_MKT_CTX.keys())
if not cal:
return {}
cal_idx = {d: i for i, d in enumerate(cal)}
def add_days(d, n):
i = cal_idx.get(d)
if i is None:
return d
return cal[min(i + n, len(cal) - 1)]
# 按入场日组织(同日高分优先)
entries = {}
for t in trades:
entries.setdefault(t['entry_date'], []).append(t)
for d in entries:
entries[d].sort(key=lambda x: -x.get('score', 0))
cash = capital
open_pos = [] # {'exit_date','alloc','pnl'}
skipped = 0
peak = capital
max_dd = 0
for day in cal:
# 结算到期
still = []
for p in open_pos:
if p['exit_date'] <= day:
cash += p['alloc'] * (1 + p['pnl'] / 100)
else:
still.append(p)
open_pos = still
# 当日入场
for t in entries.get(day, []):
if len(open_pos) >= max_positions:
skipped += 1
continue
equity = cash + sum(p['alloc'] for p in open_pos)
alloc = min(equity / max_positions, cash)
if alloc <= 0:
skipped += 1
continue
cash -= alloc
open_pos.append({
'exit_date': add_days(t['entry_date'], t['hold_days']),
'alloc': alloc, 'pnl': t['profit_pct'],
})
equity = cash + sum(p['alloc'] for p in open_pos)
peak = max(peak, equity)
max_dd = max(max_dd, (peak - equity) / peak * 100)
# 期末结算全部
final = cash + sum(p['alloc'] * (1 + p['pnl'] / 100) for p in open_pos)
total_ret = (final - capital) / capital * 100
# 年化
days = len(cal)
cagr = ((final / capital) ** (250 / days) - 1) * 100 if days > 0 and final > 0 else 0
return {
'capital_final': round(final, 0),
'total_return_pct': round(total_ret, 1),
'cagr_pct': round(cagr, 1),
'portfolio_max_dd_pct': round(max_dd, 1),
'positions_taken': len(trades) - skipped,
'positions_skipped': skipped,
}
def calc_summary(trades, capital):
if not trades:
return {}