Spaces:
Running
Running
| # backend/app/simulator.py | |
| import pandas as pd | |
| import numpy as np | |
| import datetime | |
| from app.config import INITIAL_CASH, FORCE_LIQUIDATION_TIME, SOFT_DRAWDOWN_LIMIT, HARD_DRAWDOWN_LIMIT, MAX_CONSECUTIVE_LOSSES, FORCE_LIQUIDATION_OPEN_FOCUS | |
| from app.trading_engine import Portfolio | |
| from app.strategy import evaluate_market_state | |
| def run_backtest_sim(df, ticker, strategy_params, risk_params, is_intraday=True): | |
| """ | |
| 通用回测执行引擎,支持日内 (is_intraday=True) 和日线 (is_intraday=False) 级别回测。 | |
| 增强输出:regime_breakdown, drawdown_curve, Sharpe, Calmar, CAGR, Profit Factor | |
| """ | |
| df = df.copy() | |
| if is_intraday: | |
| # Precompute ORB (Opening Range Breakout) High/Low for each day | |
| # Range is defined between 9:30 and 9:35 (first 5 minutes of regular trading hours) | |
| df['Time'] = df.index.time | |
| df['Date'] = df.index.date | |
| import datetime as dt_mod | |
| open_mask = (df['Time'] >= dt_mod.time(9, 30)) & (df['Time'] <= dt_mod.time(9, 35)) | |
| opening_df = df[open_mask] | |
| if not opening_df.empty: | |
| orb_high = opening_df.groupby('Date')['High'].max().to_dict() | |
| orb_low = opening_df.groupby('Date')['Low'].min().to_dict() | |
| df['ORB_High'] = df['Date'].map(orb_high).fillna(0.0) | |
| df['ORB_Low'] = df['Date'].map(orb_low).fillna(0.0) | |
| else: | |
| df['ORB_High'] = 0.0 | |
| df['ORB_Low'] = 0.0 | |
| portfolio = Portfolio( | |
| initial_cash=INITIAL_CASH, | |
| slippage_rate=risk_params.get("slippage_rate", 0.0003), | |
| commission_per_share=risk_params.get("commission_per_share", 0.005), | |
| min_commission_per_order=risk_params.get("min_commission_per_order", 1.0) | |
| ) | |
| # 提取账户风控限制 | |
| soft_dd = risk_params.get("soft_dd", SOFT_DRAWDOWN_LIMIT) | |
| hard_dd = risk_params.get("hard_dd", HARD_DRAWDOWN_LIMIT) | |
| max_consecutive_losses = risk_params.get("max_consecutive_losses", MAX_CONSECUTIVE_LOSSES) | |
| # 提取开盘突击模式参数,默认是 True | |
| market_open_focus = strategy_params.get("market_open_focus", True) | |
| equity_curve = [] | |
| # 记录每笔交易所处的 regime(用于 regime breakdown) | |
| trade_regime_map = {} # buy_timestamp -> regime | |
| # 我们从第2行开始,因为策略需要 prev_row 进行对比 | |
| for i in range(1, len(df)): | |
| row = df.iloc[i] | |
| prev_row = df.iloc[i-1] | |
| timestamp = df.index[i] | |
| time_str = timestamp.strftime("%H:%M") | |
| close_price = float(row['Close']) | |
| current_prices = {ticker: close_price} | |
| shares = portfolio.get_position_shares(ticker) | |
| avg_cost = portfolio.get_position_avg_cost(ticker) | |
| # 1. 账户级风控指标更新 (含最高权益及回撤计算) | |
| equity = portfolio.get_equity(current_prices) | |
| portfolio.peak_equity = max(portfolio.peak_equity, equity) | |
| drawdown_pct = (portfolio.peak_equity - equity) / portfolio.peak_equity if portfolio.peak_equity > 0 else 0.0 | |
| # 统计最近 SELL 操作的 realized_pnl | |
| sell_trades = [t for t in portfolio.ledger if t['action'] == 'SELL'] | |
| consecutive_losses = 0 | |
| for t in reversed(sell_trades): | |
| if t.get('realized_pnl', 0.0) < 0: | |
| consecutive_losses += 1 | |
| else: | |
| break | |
| portfolio.consecutive_losses = consecutive_losses | |
| # 2. 判定硬/软熔断并更新 risk_multiplier | |
| if drawdown_pct >= hard_dd: | |
| portfolio.risk_multiplier = 0.0 | |
| elif drawdown_pct >= soft_dd or consecutive_losses >= max_consecutive_losses: | |
| portfolio.risk_multiplier = 0.5 | |
| else: | |
| portfolio.risk_multiplier = 1.0 | |
| # 3. 更新追踪止损的最高价 | |
| if shares > 0: | |
| portfolio.update_highest_price(ticker, close_price) | |
| highest_price = portfolio.get_position_highest_price(ticker) | |
| # 记录资产曲线数据 | |
| equity_curve.append({ | |
| "time": int(timestamp.timestamp()), | |
| "value": round(equity, 2) | |
| }) | |
| # 4. 日内清仓检测 (仅限 1分钟、5分钟等日内级别) | |
| liq_time = FORCE_LIQUIDATION_OPEN_FOCUS if market_open_focus else FORCE_LIQUIDATION_TIME | |
| if is_intraday and time_str == liq_time and shares > 0: | |
| portfolio.sell(timestamp, ticker, close_price, shares) | |
| continue | |
| # 5. 执行常规策略评估 | |
| is_trading_window = True | |
| if is_intraday: | |
| if market_open_focus: | |
| # 开盘突击:只在 9:35 - 10:15 之间开新仓 | |
| import datetime as dt_mod | |
| is_trading_window = dt_mod.time(9, 35) <= timestamp.time() <= dt_mod.time(10, 15) | |
| else: | |
| # 日内交易时间窗口:上午 9:35 到 下午 15:54 | |
| is_trading_window = datetime.time(9, 35) <= timestamp.time() < datetime.time(15, 54) | |
| # 如果我们已经持有仓位,为了检查止损止盈等,任何时间(直到强制平仓前)都是交易窗口 | |
| if shares > 0: | |
| is_trading_window = True | |
| if is_trading_window: | |
| current_regime = row.get('Regime', 'range_bound') | |
| action, explanation = evaluate_market_state( | |
| row, prev_row, shares, avg_cost, ticker, highest_price, strategy_params | |
| ) | |
| if action == "BUY" and shares == 0: | |
| # 仓位计算 | |
| if risk_params.get("position_sizing_mode", "atr") == "atr": | |
| shares_to_buy = portfolio.calculate_position_size( | |
| ticker, close_price, row['ATR'], | |
| risk_pct=risk_params.get("risk_per_trade_pct", 0.01), | |
| atr_multiplier=strategy_params.get("trailing_stop_atr_mult", 2.0), | |
| max_size_pct=risk_params.get("max_position_size_pct", 0.50) | |
| ) | |
| else: | |
| target_allocation = equity * risk_params.get("max_position_size_pct", 0.50) | |
| shares_to_buy = int(target_allocation / close_price) | |
| if shares_to_buy > 0: | |
| portfolio.buy(timestamp, ticker, close_price, shares_to_buy) | |
| # 记录买入时的 regime | |
| trade_regime_map[str(timestamp)] = current_regime | |
| elif action == "SELL" and shares > 0: | |
| portfolio.sell(timestamp, ticker, close_price, shares) | |
| # 6. 计算回测指标 | |
| total_trades = len(portfolio.ledger) | |
| final_equity = portfolio.get_equity({ticker: df.iloc[-1]['Close']}) | |
| net_pnl = final_equity - INITIAL_CASH | |
| pnl_pct = (net_pnl / INITIAL_CASH) * 100 | |
| sell_trades = [t for t in portfolio.ledger if t['action'] == 'SELL'] | |
| winning_trades = [t for t in sell_trades if t.get('realized_pnl', 0.0) > 0] | |
| losing_trades = [t for t in sell_trades if t.get('realized_pnl', 0.0) < 0] | |
| win_rate = (len(winning_trades) / len(sell_trades)) * 100 if sell_trades else 0.0 | |
| total_commission = sum(t['commission'] for t in portfolio.ledger) | |
| # 统计最大回撤与 drawdown curve | |
| eq_vals = [e["value"] for e in equity_curve] | |
| max_drawdown = 0.0 | |
| drawdown_curve = [] | |
| if eq_vals: | |
| peaks = np.maximum.accumulate(eq_vals) | |
| drawdowns = (peaks - eq_vals) / peaks | |
| max_drawdown = float(np.max(drawdowns)) | |
| for idx_ec, ec in enumerate(equity_curve): | |
| drawdown_curve.append({ | |
| "time": ec["time"], | |
| "value": round(float(drawdowns[idx_ec]) * -100, 2) # 负百分比 | |
| }) | |
| # 7. 高级指标计算 | |
| # Profit Factor = 总盈利 / 总亏损 | |
| gross_profit = sum(t.get('realized_pnl', 0.0) for t in winning_trades) | |
| gross_loss = abs(sum(t.get('realized_pnl', 0.0) for t in losing_trades)) | |
| profit_factor = round(gross_profit / max(gross_loss, 1e-8), 2) | |
| # 计算回测跨度的交易日数 | |
| if len(df) >= 2: | |
| start_date = df.index[0] | |
| end_date = df.index[-1] | |
| total_days = (end_date - start_date).days | |
| total_years = total_days / 365.25 if total_days > 0 else 1.0 / 365.25 | |
| else: | |
| total_years = 1.0 / 365.25 | |
| # CAGR = (Final/Initial)^(1/years) - 1 | |
| if final_equity > 0 and INITIAL_CASH > 0 and total_years > 0: | |
| cagr = (final_equity / INITIAL_CASH) ** (1.0 / max(total_years, 0.01)) - 1.0 | |
| else: | |
| cagr = 0.0 | |
| # Sharpe Ratio (基于日/bar收益序列,假设 risk-free = 0) | |
| if len(eq_vals) > 1: | |
| returns = np.diff(eq_vals) / np.array(eq_vals[:-1]) | |
| if np.std(returns) > 0: | |
| # 年化 Sharpe(按交易日数年化) | |
| bars_per_year = len(eq_vals) / max(total_years, 0.01) | |
| sharpe = (np.mean(returns) / np.std(returns)) * np.sqrt(bars_per_year) | |
| else: | |
| sharpe = 0.0 | |
| else: | |
| sharpe = 0.0 | |
| # Calmar Ratio = CAGR / Max Drawdown | |
| calmar = round(cagr / max(max_drawdown, 1e-8), 2) if max_drawdown > 0.001 else 0.0 | |
| # 8. Regime Breakdown — 统计不同市场状态下交易表现 | |
| regime_breakdown = _compute_regime_breakdown(portfolio.ledger, trade_regime_map) | |
| # 9. Regime 分布统计 (每个 regime 的 bar 数量占比) | |
| regime_distribution = {} | |
| if 'Regime' in df.columns: | |
| regime_counts = df['Regime'].value_counts() | |
| total_bars = len(df) | |
| for regime_name, count in regime_counts.items(): | |
| regime_distribution[regime_name] = round(count / total_bars * 100, 1) | |
| return { | |
| "final_equity": round(final_equity, 2), | |
| "net_pnl": round(net_pnl, 2), | |
| "pnl_pct": round(pnl_pct, 2), | |
| "total_trades": total_trades, | |
| "round_trips": len(sell_trades), | |
| "win_rate": round(win_rate, 2), | |
| "commission": round(total_commission, 2), | |
| "max_drawdown": round(max_drawdown, 4), | |
| # 新增高级指标 | |
| "sharpe": round(sharpe, 2), | |
| "calmar": calmar, | |
| "cagr": round(cagr * 100, 2), # 百分比 | |
| "profit_factor": profit_factor, | |
| "gross_profit": round(gross_profit, 2), | |
| "gross_loss": round(gross_loss, 2), | |
| # 数据序列 | |
| "ledger": portfolio.ledger, | |
| "equity_curve": equity_curve, | |
| "drawdown_curve": drawdown_curve, | |
| # Regime 统计 | |
| "regime_breakdown": regime_breakdown, | |
| "regime_distribution": regime_distribution, | |
| } | |
| def _compute_regime_breakdown(ledger, trade_regime_map): | |
| """ | |
| 按 regime 汇总交易表现:pnl, win_rate, trade_count | |
| """ | |
| regime_stats = {} | |
| # 将 BUY/SELL 配对,并查找每对交易的 regime | |
| buy_trades = [t for t in ledger if t['action'] == 'BUY'] | |
| sell_trades = [t for t in ledger if t['action'] == 'SELL'] | |
| for i, sell in enumerate(sell_trades): | |
| # 每个 sell 与对应的 buy 配对 | |
| if i < len(buy_trades): | |
| buy_ts = buy_trades[i]['timestamp'] | |
| regime = trade_regime_map.get(buy_ts, 'unknown') | |
| else: | |
| regime = 'unknown' | |
| pnl = sell.get('realized_pnl', 0.0) | |
| if regime not in regime_stats: | |
| regime_stats[regime] = { | |
| 'total_pnl': 0.0, | |
| 'trade_count': 0, | |
| 'wins': 0, | |
| 'losses': 0, | |
| 'total_commission': 0.0 | |
| } | |
| regime_stats[regime]['total_pnl'] += pnl | |
| regime_stats[regime]['trade_count'] += 1 | |
| regime_stats[regime]['total_commission'] += sell.get('commission', 0.0) | |
| if pnl > 0: | |
| regime_stats[regime]['wins'] += 1 | |
| else: | |
| regime_stats[regime]['losses'] += 1 | |
| # 转为前端友好格式 | |
| result = [] | |
| for regime, stats in regime_stats.items(): | |
| win_rate = (stats['wins'] / stats['trade_count'] * 100) if stats['trade_count'] > 0 else 0.0 | |
| result.append({ | |
| "regime": regime, | |
| "total_pnl": round(stats['total_pnl'], 2), | |
| "trade_count": stats['trade_count'], | |
| "win_rate": round(win_rate, 1), | |
| "wins": stats['wins'], | |
| "losses": stats['losses'], | |
| "commission": round(stats['total_commission'], 2) | |
| }) | |
| # 按 trade_count 降序 | |
| result.sort(key=lambda x: x['trade_count'], reverse=True) | |
| return result | |