feat: 组合级资产对比 — portfolio_sim(100万/10等分仓/24月),v7.1以+60.8%总收益夺冠(周转率胜过单笔收益),研究Tab新增总收益/最终资产/年化/资产回撤四列
This commit is contained in:
+19
-10
@@ -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, '"') + '">' + (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, '"') + '">' + (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">⚠️ = 样本量<40笔,统计意义有限 · 绿色 = 该列最优 · 回测期间: 2026-01-21 ~ 2026-07-24</div>';
|
||||
'<div class="text-xs text-slate-600 mt-2">总收益/最终资产 = 100万本金·最多10等分仓位·24个月组合模拟 · ⚠️样本<40笔 · 绿色=该列最优 · 悬停名称查看改进假设</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user