| |
|
|
| 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): |
| """ |
| Generic backtest execution engine supporting intraday (is_intraday=True) and daily (is_intraday=False) backtesting. |
| Outputs: regime_breakdown, drawdown_curve, Sharpe, Calmar, CAGR, Profit Factor |
| """ |
| df = df.copy() |
| if is_intraday: |
| |
| |
| 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) |
| |
| |
| market_open_focus = strategy_params.get("market_open_focus", True) |
| |
| equity_curve = [] |
| |
| |
| trade_regime_map = {} |
| |
| |
| 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) |
| |
| |
| 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_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 |
| |
| |
| 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 |
| |
| |
| 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) |
| }) |
| |
| |
| 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 |
| |
| |
| is_trading_window = True |
| if is_intraday: |
| if market_open_focus: |
| import datetime as dt_mod |
| is_trading_window = dt_mod.time(9, 35) <= timestamp.time() <= dt_mod.time(10, 15) |
| else: |
| 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) |
| trade_regime_map[str(timestamp)] = current_regime |
| |
| elif action == "SELL" and shares > 0: |
| portfolio.sell(timestamp, ticker, close_price, shares) |
| |
| |
| 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) |
| |
| |
| 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) |
| }) |
| |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| if len(eq_vals) > 1: |
| returns = np.diff(eq_vals) / np.array(eq_vals[:-1]) |
| if np.std(returns) > 0: |
| |
| 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 = round(cagr / max(max_drawdown, 1e-8), 2) if max_drawdown > 0.001 else 0.0 |
| |
| |
| regime_breakdown = _compute_regime_breakdown(portfolio.ledger, trade_regime_map) |
| |
| |
| 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_breakdown": regime_breakdown, |
| "regime_distribution": regime_distribution, |
| } |
|
|
|
|
| def _compute_regime_breakdown(ledger, trade_regime_map): |
| """ |
| 按 regime 汇总交易表现:pnl, win_rate, trade_count |
| """ |
| regime_stats = {} |
| |
| |
| 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): |
| |
| 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) |
| }) |
| |
| |
| result.sort(key=lambda x: x['trade_count'], reverse=True) |
| return result |
|
|