feat: signal abstraction layer + sizer + HS300 universe + PnL/IC reports
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user