ChozhanMurugan
Initial deployment
0d7b049
Raw
History Blame Contribute Delete
4.81 kB
"""Vectorized event-driven backtest.
Strategy
--------
For each labelled call we have:
- ``prob_up`` from the classifier (out-of-fold for honesty)
- ``ret_excess`` realised T+1 excess return vs SPY
A position is taken at the close of day T (call day) and unwound at the close
of day T+1:
- long if prob_up >= 0.5 + threshold
- short if prob_up <= 0.5 - threshold
- flat otherwise
We sum per-day PnL across all triggered positions (equal-weighted) and report
hit-rate, mean trade return, annualised Sharpe and max drawdown. The benchmark
is buy-and-hold SPY over the same date range.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
TRADING_DAYS = 252
@dataclass
class BacktestResult:
n_trades: int
hit_rate: float
mean_trade_return: float
total_return: float
annualised_sharpe: float
max_drawdown: float
benchmark_total_return: float
threshold: float
equity_curve: pd.DataFrame # cols: date, strategy_equity, benchmark_equity
def summary(self) -> dict[str, float | int]:
return {
"n_trades": self.n_trades,
"hit_rate": self.hit_rate,
"mean_trade_return": self.mean_trade_return,
"total_return": self.total_return,
"annualised_sharpe": self.annualised_sharpe,
"max_drawdown": self.max_drawdown,
"benchmark_total_return": self.benchmark_total_return,
"threshold": self.threshold,
}
def run_backtest(
df: pd.DataFrame,
*,
threshold: float = 0.0,
prob_col: str = "prob_up",
ret_col: str = "ret_excess",
bench_col: str = "ret_bench",
date_col: str = "call_date",
) -> BacktestResult:
"""Run the long/short rule on a prediction dataframe.
``df`` must contain ``prob_col``, ``ret_col``, ``bench_col`` and a date column.
"""
required = {prob_col, ret_col, date_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"backtest input missing columns: {sorted(missing)}")
work = df.dropna(subset=[prob_col, ret_col]).copy()
work[date_col] = pd.to_datetime(work[date_col])
work = work.sort_values(date_col).reset_index(drop=True)
signal = np.where(
work[prob_col] >= 0.5 + threshold, 1,
np.where(work[prob_col] <= 0.5 - threshold, -1, 0),
)
work["signal"] = signal
work["trade_ret"] = work["signal"] * work[ret_col]
trades = work[work["signal"] != 0]
n_trades = int(len(trades))
if n_trades == 0:
return BacktestResult(
n_trades=0, hit_rate=float("nan"), mean_trade_return=float("nan"),
total_return=0.0, annualised_sharpe=float("nan"), max_drawdown=0.0,
benchmark_total_return=_safe_compound(work.get(bench_col, pd.Series(dtype=float))),
threshold=threshold, equity_curve=pd.DataFrame(columns=["date", "strategy_equity", "benchmark_equity"]),
)
hit_rate = float((trades["trade_ret"] > 0).mean())
mean_trade_return = float(trades["trade_ret"].mean())
# equity: aggregate trades by date (equal-weight intraday-of-day average)
daily = trades.groupby(work[date_col].dt.normalize())["trade_ret"].mean()
eq = (1 + daily).cumprod()
total_return = float(eq.iloc[-1] - 1) if len(eq) else 0.0
# annualised Sharpe on daily strategy returns (assume rf=0)
if daily.std(ddof=0) > 0:
sharpe = float(daily.mean() / daily.std(ddof=0) * np.sqrt(TRADING_DAYS))
else:
sharpe = float("nan")
drawdown = float(_max_drawdown(eq)) if len(eq) else 0.0
# benchmark over the same dates
if bench_col in work.columns:
bench_daily = work.groupby(work[date_col].dt.normalize())[bench_col].mean().reindex(eq.index).fillna(0.0)
bench_eq = (1 + bench_daily).cumprod()
bench_total = float(bench_eq.iloc[-1] - 1) if len(bench_eq) else 0.0
else:
bench_eq = pd.Series(1.0, index=eq.index)
bench_total = 0.0
curve = pd.DataFrame(
{"date": eq.index, "strategy_equity": eq.values, "benchmark_equity": bench_eq.values}
).reset_index(drop=True)
return BacktestResult(
n_trades=n_trades,
hit_rate=hit_rate,
mean_trade_return=mean_trade_return,
total_return=total_return,
annualised_sharpe=sharpe,
max_drawdown=drawdown,
benchmark_total_return=bench_total,
threshold=threshold,
equity_curve=curve,
)
def _max_drawdown(equity: pd.Series) -> float:
peak = equity.cummax()
dd = (equity - peak) / peak
return float(dd.min())
def _safe_compound(s: pd.Series) -> float:
if s.empty:
return 0.0
s = s.dropna()
if s.empty:
return 0.0
return float((1 + s).prod() - 1)