diff --git a/static/index.html b/static/index.html index d9acf2ac..1a85774e 100644 --- a/static/index.html +++ b/static/index.html @@ -1956,14 +1956,18 @@ function renderStrategyTable(strategies) { const el = document.getElementById('strategyList'); if (!strategies.length) { el.innerHTML = '
暂无策略版本
'; 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 = '' + '' + - '' + - '' + + '' + + '' + + '' + + '' + '' + - '' + + '' + ''; 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 += '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + '' + '' + '' + '' + - '' + + '' + ''; } html += '
版本名称改进假设交易数胜率总收益最终资产年化胜率平均收益夏普盈亏比最大回撤盈亏比资产回撤操作
' + s.version + '' + (s.name || '') + '' + (s.hypothesis || s.summary || '') + '' + (hasResult ? st.total_trades + (smallSample ? '⚠️' : '') : '—') + '' + (s.name || '') + (smallSample ? ' ⚠️' : '') + '' + (pf.total_return_pct != null ? pf.total_return_pct + '%' : '—') + '' + (pf.capital_final != null ? '¥' + (pf.capital_final/10000).toFixed(0) + '万' : '—') + '' + (pf.cagr_pct != null ? pf.cagr_pct + '%' : '—') + '' + (st.win_rate != null ? st.win_rate + '%' : '—') + '' + (st.avg_profit_pct != null ? st.avg_profit_pct + '%' : '—') + '' + (st.sharpe_ratio != null ? st.sharpe_ratio : '—') + '' + (st.profit_factor != null ? st.profit_factor : '—') + '' + (st.max_drawdown_pct != null ? st.max_drawdown_pct + '%' : '—') + '' + (pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct + '%' : '—') + '' + '' + '
' + - '
⚠️ = 样本量<40笔,统计意义有限 · 绿色 = 该列最优 · 回测期间: 2026-01-21 ~ 2026-07-24
'; + '
总收益/最终资产 = 100万本金·最多10等分仓位·24个月组合模拟 · ⚠️样本<40笔 · 绿色=该列最优 · 悬停名称查看改进假设
'; el.innerHTML = html; } diff --git a/strategy_lab.py b/strategy_lab.py index 7a949113..9093b58e 100644 --- a/strategy_lab.py +++ b/strategy_lab.py @@ -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 {}