feat: signal abstraction layer + sizer + HS300 universe + PnL/IC reports

This commit is contained in:
Yuxuan Yan
2026-06-07 09:44:33 +08:00
parent 085e51abf1
commit da01312292
20 changed files with 610 additions and 23 deletions
+1
View File
@@ -11,3 +11,4 @@ class BacktestConfig:
commission: float = 0.0003 # 0.03% for Chinese A-shares
stamp_duty: float = 0.001 # 0.1% stamp duty on sells only (handled in strategy)
adjust: str = "qfq"
sizer_percent: float = 0.95 # fraction of portfolio per trade
+23
View File
@@ -10,3 +10,26 @@ def df_to_bt_feed(df: pd.DataFrame) -> bt.feeds.PandasData:
df = df.set_index("date")
df = df[["open", "high", "low", "close", "volume"]]
return bt.feeds.PandasData(dataname=df)
class SignalPandasData(bt.feeds.PandasData):
"""PandasData feed carrying an extra ``signal`` line alongside OHLCV."""
lines = ("signal",)
params = (("signal", -1),) # -1 -> match by column name
def df_to_signal_feed(df: pd.DataFrame) -> "SignalPandasData":
"""Convert an OHLCV+signal DataFrame to a SignalPandasData feed.
Args:
df: DataFrame with ``date``, OHLCV columns, and a ``signal`` column.
Returns:
A SignalPandasData feed (NaN signals are preserved for the strategy to skip).
"""
df = df.copy()
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
df = df[["open", "high", "low", "close", "volume", "signal"]]
return SignalPandasData(dataname=df)
+29 -13
View File
@@ -4,7 +4,7 @@ import backtrader as bt
from typing import Optional
from backtest.config import BacktestConfig
from backtest.feed import df_to_bt_feed
from backtest.feed import df_to_bt_feed, df_to_signal_feed
from data.downloader import download_daily
logger = logging.getLogger(__name__)
@@ -30,33 +30,49 @@ class BacktestRunner:
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 setup(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> None:
"""Full setup: load data for all symbols, configure cerebro, add strategy."""
# Load data for all symbols
for sym in self.config.symbols:
self.add_data(sym)
# Configure cerebro
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)
# Add analyzers
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
)
# Add strategy
strategy_kwargs = strategy_kwargs or {}
self.cerebro.addstrategy(strategy_cls, **strategy_kwargs)
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 and run the backtest. Returns cerebro run results."""
"""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()