engine.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. from __future__ import annotations
  2. from typing import Any
  3. import numpy as np
  4. import pandas as pd
  5. def _max_drawdown(equity: pd.Series) -> float:
  6. peak = equity.cummax()
  7. drawdown = equity / peak - 1.0
  8. return float(drawdown.min())
  9. def compute_metrics(
  10. strategy_returns: pd.Series,
  11. benchmark_returns: pd.Series,
  12. turnover: pd.Series | None = None,
  13. tracking_difference: pd.Series | None = None,
  14. annualization: int = 252,
  15. ) -> dict[str, float]:
  16. strategy_returns = strategy_returns.dropna()
  17. benchmark_returns = benchmark_returns.reindex(strategy_returns.index).fillna(0.0)
  18. turnover = turnover.reindex(strategy_returns.index).fillna(0.0) if turnover is not None else pd.Series(0.0, index=strategy_returns.index)
  19. tracking_difference = (
  20. tracking_difference.reindex(strategy_returns.index).fillna(0.0)
  21. if tracking_difference is not None
  22. else strategy_returns - benchmark_returns
  23. )
  24. if strategy_returns.empty:
  25. return {
  26. 'annual_return': 0.0,
  27. 'annual_vol': 0.0,
  28. 'sharpe': 0.0,
  29. 'max_drawdown': 0.0,
  30. 'calmar': 0.0,
  31. 'benchmark_sharpe': 0.0,
  32. 'sharpe_delta': 0.0,
  33. 'benchmark_max_drawdown': 0.0,
  34. 'drawdown_improvement_ratio': 0.0,
  35. 'upside_capture': 0.0,
  36. 'downside_capture': 0.0,
  37. 'annual_turnover': 0.0,
  38. 'tracking_diff_mean': 0.0,
  39. 'tracking_diff_abs_mean': 0.0,
  40. 'tracking_error_20_p95': 0.0,
  41. }
  42. def annual_return(returns: pd.Series) -> float:
  43. total = float((1.0 + returns).prod())
  44. n = len(returns)
  45. return total ** (annualization / max(n, 1)) - 1.0
  46. def annual_vol(returns: pd.Series) -> float:
  47. return float(returns.std(ddof=0) * np.sqrt(annualization))
  48. strategy_ann = annual_return(strategy_returns)
  49. strategy_vol = annual_vol(strategy_returns)
  50. strategy_sharpe = strategy_ann / strategy_vol if strategy_vol > 0 else 0.0
  51. strategy_equity = (1.0 + strategy_returns).cumprod()
  52. strategy_mdd = abs(_max_drawdown(strategy_equity))
  53. strategy_calmar = strategy_ann / strategy_mdd if strategy_mdd > 0 else 0.0
  54. bench_ann = annual_return(benchmark_returns)
  55. bench_vol = annual_vol(benchmark_returns)
  56. bench_sharpe = bench_ann / bench_vol if bench_vol > 0 else 0.0
  57. bench_equity = (1.0 + benchmark_returns).cumprod()
  58. bench_mdd = abs(_max_drawdown(bench_equity))
  59. up_mask = benchmark_returns > 0
  60. down_mask = benchmark_returns < 0
  61. upside_capture = (strategy_returns[up_mask].mean() / benchmark_returns[up_mask].mean()) if up_mask.any() else 0.0
  62. downside_capture = (strategy_returns[down_mask].mean() / benchmark_returns[down_mask].mean()) if down_mask.any() else 0.0
  63. drawdown_improvement = (bench_mdd - strategy_mdd) / bench_mdd if bench_mdd > 0 else 0.0
  64. annual_turnover = float(turnover.mean() * annualization)
  65. tracking_diff_mean = float(tracking_difference.mean())
  66. tracking_diff_abs_mean = float(tracking_difference.abs().mean())
  67. tracking_error_20 = tracking_difference.rolling(20).std().dropna()
  68. tracking_error_20_p95 = float(tracking_error_20.quantile(0.95)) if not tracking_error_20.empty else 0.0
  69. return {
  70. 'annual_return': float(strategy_ann),
  71. 'annual_vol': float(strategy_vol),
  72. 'sharpe': float(strategy_sharpe),
  73. 'max_drawdown': float(strategy_mdd),
  74. 'calmar': float(strategy_calmar),
  75. 'benchmark_return': float(bench_ann),
  76. 'benchmark_vol': float(bench_vol),
  77. 'benchmark_sharpe': float(bench_sharpe),
  78. 'benchmark_max_drawdown': float(bench_mdd),
  79. 'sharpe_delta': float(strategy_sharpe - bench_sharpe),
  80. 'drawdown_improvement_ratio': float(drawdown_improvement),
  81. 'upside_capture': float(upside_capture),
  82. 'downside_capture': float(downside_capture),
  83. 'annual_turnover': annual_turnover,
  84. 'tracking_diff_mean': tracking_diff_mean,
  85. 'tracking_diff_abs_mean': tracking_diff_abs_mean,
  86. 'tracking_error_20_p95': tracking_error_20_p95,
  87. }
  88. def run_backtest(df: pd.DataFrame, config: dict[str, Any] | None = None) -> tuple[pd.DataFrame, dict[str, float]]:
  89. out = df.copy()
  90. trading_cfg = (config or {}).get('trading', {})
  91. annualization = int(trading_cfg.get('annualization', 252))
  92. if 'target_exposure' not in out.columns:
  93. raise ValueError('target_exposure column is required for backtest.')
  94. if 'open' in out.columns:
  95. asset_exec_return = out['open'].shift(-1) / out['open'] - 1.0
  96. else:
  97. asset_exec_return = out['close'].pct_change().shift(-1)
  98. executed_exposure = out['target_exposure'].shift(1).fillna(0.0)
  99. previous_executed = executed_exposure.shift(1).fillna(0.0)
  100. turnover = (executed_exposure - previous_executed).abs()
  101. one_way_cost_bps = float(trading_cfg.get('fee_bps_roundtrip', 8)) / 2.0 + float(trading_cfg.get('slippage_bps_oneway', 4))
  102. cost_rate = one_way_cost_bps / 10000.0
  103. extreme_move_threshold = float(trading_cfg.get('extreme_day_move_threshold', 0.03))
  104. extreme_day_cost_multiplier = float(trading_cfg.get('extreme_day_cost_multiplier', 1.0))
  105. gap_slippage_factor = float(trading_cfg.get('gap_slippage_factor', 0.0))
  106. extreme_day_flag = asset_exec_return.abs() >= extreme_move_threshold
  107. effective_multiplier = pd.Series(1.0, index=out.index)
  108. effective_multiplier.loc[extreme_day_flag.fillna(False)] = extreme_day_cost_multiplier
  109. trading_cost_base = turnover * cost_rate * effective_multiplier
  110. if 'gap_open' in out.columns:
  111. gap_open = out['gap_open'].fillna(0.0)
  112. else:
  113. gap_open = (out['open'] / out['close'].shift(1) - 1.0).fillna(0.0) if {'open', 'close'}.issubset(out.columns) else pd.Series(0.0, index=out.index)
  114. gap_shock_cost = turnover * gap_open.abs() * gap_slippage_factor
  115. trading_cost = trading_cost_base + gap_shock_cost
  116. out['asset_exec_return'] = asset_exec_return
  117. out['executed_exposure'] = executed_exposure
  118. out['turnover'] = turnover
  119. out['extreme_day_flag'] = extreme_day_flag.fillna(False)
  120. out['execution_cost_multiplier'] = effective_multiplier
  121. out['trading_cost_base'] = trading_cost_base
  122. out['gap_shock_cost'] = gap_shock_cost
  123. out['trading_cost'] = trading_cost
  124. out['strategy_return_gross'] = executed_exposure * asset_exec_return
  125. out['strategy_return_net'] = out['strategy_return_gross'] - trading_cost
  126. out['tracking_difference'] = out['strategy_return_net'] - out['strategy_return_gross']
  127. out['tracking_error_20'] = out['tracking_difference'].rolling(20).std()
  128. out['strategy_equity'] = (1.0 + out['strategy_return_net'].fillna(0.0)).cumprod()
  129. out['benchmark_equity'] = (1.0 + out['asset_exec_return'].fillna(0.0)).cumprod()
  130. metrics = compute_metrics(
  131. strategy_returns=out['strategy_return_net'],
  132. benchmark_returns=out['asset_exec_return'],
  133. turnover=out['turnover'],
  134. tracking_difference=out['tracking_difference'],
  135. annualization=annualization,
  136. )
  137. return out, metrics