"""BacktestRunner: orchestrates data loading, cerebro setup, and execution.""" import logging import backtrader as bt from typing import Optional from backtest.config import BacktestConfig from backtest.feed import df_to_bt_feed, df_to_signal_feed from data.downloader import download_daily logger = logging.getLogger(__name__) class BacktestRunner: """Run backtrader backtests with Chinese equity data.""" def __init__(self, config: BacktestConfig): self.config = config self.cerebro = bt.Cerebro() self._results: Optional[list] = None def add_data(self, symbol: str) -> None: """Download data for a symbol and add to cerebro as a feed.""" df = download_daily( symbol=symbol, start=self.config.start_date, end=self.config.end_date, adjust=self.config.adjust, ) feed = df_to_bt_feed(df) self.cerebro.adddata(feed, name=symbol) logger.info(f"Added {symbol}: {len(df)} bars") def add_signal_data(self, df, name: str) -> None: """Add a pre-built OHLCV+signal DataFrame as a SignalPandasData feed.""" feed = df_to_signal_feed(df) self.cerebro.adddata(feed, name=name) logger.info(f"Added signal feed {name}: {len(df)} bars") def add_strategy(self, strategy_cls, **kwargs) -> None: """Add a strategy class to cerebro.""" self.cerebro.addstrategy(strategy_cls, **kwargs) def _configure(self) -> None: """Configure broker, sizer, and analyzers (independent of data feeds).""" self.cerebro.broker.setcash(self.config.initial_cash) self.cerebro.broker.setcommission(commission=self.config.commission) self.cerebro.addsizer(bt.sizers.PercentSizer, percents=self.config.sizer_percent * 100) self.cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", riskfreerate=0.02) self.cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown") self.cerebro.addanalyzer(bt.analyzers.Returns, _name="returns") self.cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades") self.cerebro.addanalyzer( bt.analyzers.TimeReturn, _name="timereturn", timeframe=bt.TimeFrame.Days ) def setup(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> None: """Full setup: load data for all symbols, configure cerebro, add strategy.""" for sym in self.config.symbols: self.add_data(sym) self._configure() self.cerebro.addstrategy(strategy_cls, **(strategy_kwargs or {})) def run(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> list: """Setup (downloading all symbols) and run the backtest.""" self.setup(strategy_cls, strategy_kwargs) return self._execute() def run_prepared(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> list: """Run a backtest using feeds already added via ``add_signal_data``.""" self._configure() self.cerebro.addstrategy(strategy_cls, **(strategy_kwargs or {})) return self._execute() def _execute(self) -> list: start_val = self.cerebro.broker.getvalue() logger.info(f"Starting portfolio value: {start_val:,.2f}") self._results = self.cerebro.run() end_val = self.cerebro.broker.getvalue() logger.info(f"Ending portfolio value: {end_val:,.2f}") return self._results def get_results(self) -> Optional[list]: return self._results