feat: signal abstraction layer + sizer + HS300 universe + PnL/IC reports
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
"""Performance analysis and reporting for backtest results."""
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
import pandas as pd # noqa: E402
|
||||
|
||||
|
||||
def print_results(results: list, initial_cash: float = 1_000_000.0) -> dict[str, Any]:
|
||||
"""Print and return key performance metrics from a backtrader run result."""
|
||||
@@ -44,3 +51,109 @@ def print_results(results: list, initial_cash: float = 1_000_000.0) -> dict[str,
|
||||
print("=" * 50)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def plot_accumulated_pnl(
|
||||
results: list, output_path: str = "reports/pnl.png", initial_cash: float = 1_000_000.0
|
||||
) -> str:
|
||||
"""Plot accumulated portfolio value from a backtest run.
|
||||
|
||||
Reads the per-day TimeReturn analyzer attached by ``BacktestRunner`` and
|
||||
compounds it into an equity curve.
|
||||
|
||||
Args:
|
||||
results: The list returned by ``cerebro.run()``.
|
||||
output_path: Destination PNG path.
|
||||
initial_cash: Starting portfolio value for scaling the curve.
|
||||
|
||||
Returns:
|
||||
The path the chart was written to.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
series = pd.Series(dtype=float)
|
||||
if results:
|
||||
tr = results[0].analyzers.timereturn.get_analysis()
|
||||
series = pd.Series(tr).sort_index()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 5))
|
||||
if len(series):
|
||||
equity = (1.0 + series).cumprod() * initial_cash
|
||||
ax.plot(equity.index, equity.values, color="C0")
|
||||
ax.set_title("Accumulated Portfolio Value")
|
||||
ax.set_xlabel("Date")
|
||||
ax.set_ylabel("Value")
|
||||
ax.grid(True, alpha=0.3)
|
||||
fig.autofmt_xdate()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=100)
|
||||
plt.close(fig)
|
||||
return output_path
|
||||
|
||||
|
||||
def plot_ic(signal_eval: dict, output_path: str = "reports/ic.png") -> str:
|
||||
"""Plot the per-period rank IC time series from a signal evaluation.
|
||||
|
||||
Args:
|
||||
signal_eval: Dict returned by ``evaluate_cross_sectional`` (expects a
|
||||
``rank_ic_series`` pandas Series).
|
||||
output_path: Destination PNG path.
|
||||
|
||||
Returns:
|
||||
The path the chart was written to.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
rank_ic = signal_eval.get("rank_ic_series", pd.Series(dtype=float))
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 5))
|
||||
if len(rank_ic):
|
||||
ax.bar(rank_ic.index, rank_ic.values, width=1.0, color="C1", alpha=0.6, label="Rank IC")
|
||||
ax.axhline(rank_ic.mean(), color="C3", linestyle="--", label=f"mean={rank_ic.mean():.3f}")
|
||||
ax.legend()
|
||||
ax.set_title("Cross-Sectional Rank IC")
|
||||
ax.set_xlabel("Date")
|
||||
ax.set_ylabel("Rank IC")
|
||||
ax.grid(True, alpha=0.3)
|
||||
fig.autofmt_xdate()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=100)
|
||||
plt.close(fig)
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_report(
|
||||
results: list,
|
||||
signal_eval: dict,
|
||||
output_dir: str = "reports/",
|
||||
initial_cash: float = 1_000_000.0,
|
||||
) -> dict[str, str]:
|
||||
"""Generate the full report: PnL chart, IC chart, and a summary text file.
|
||||
|
||||
Args:
|
||||
results: The list returned by ``cerebro.run()``.
|
||||
signal_eval: Dict returned by ``evaluate_cross_sectional``.
|
||||
output_dir: Directory to write artifacts into.
|
||||
initial_cash: Starting portfolio value.
|
||||
|
||||
Returns:
|
||||
Mapping of artifact name to file path.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
pnl_path = plot_accumulated_pnl(
|
||||
results, os.path.join(output_dir, "pnl.png"), initial_cash
|
||||
)
|
||||
ic_path = plot_ic(signal_eval, os.path.join(output_dir, "ic.png"))
|
||||
|
||||
metrics = print_results(results, initial_cash)
|
||||
summary_path = os.path.join(output_dir, "summary.txt")
|
||||
with open(summary_path, "w") as f:
|
||||
f.write("BACKTEST SUMMARY\n")
|
||||
f.write("=" * 40 + "\n")
|
||||
for k, v in metrics.items():
|
||||
f.write(f"{k}: {v}\n")
|
||||
f.write("\nSIGNAL IC\n")
|
||||
f.write("=" * 40 + "\n")
|
||||
for k in ("ic_mean", "ic_std", "ir", "rank_ic_mean", "rank_ic_std", "rank_ir", "hit_rate", "n_periods"):
|
||||
if k in signal_eval:
|
||||
f.write(f"{k}: {signal_eval[k]}\n")
|
||||
|
||||
return {"pnl": pnl_path, "ic": ic_path, "summary": summary_path}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""CSI 300 (HS300) universe helpers."""
|
||||
import logging
|
||||
|
||||
import baostock as bs
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# First 30 HS300 constituents (large caps) in 'shXXXXXX' / 'szXXXXXX' format.
|
||||
# Hardcoded for fast, deterministic smoke tests. Use get_hs300_stocks() for the
|
||||
# live, full list — downloading daily bars for all ~300 takes roughly 10 minutes.
|
||||
SYMBOLS = [
|
||||
"sh600000", "sh600009", "sh600010", "sh600028", "sh600030",
|
||||
"sh600036", "sh600048", "sh600050", "sh600104", "sh600276",
|
||||
"sh600309", "sh600519", "sh600585", "sh600887", "sh600900",
|
||||
"sh601012", "sh601166", "sh601288", "sh601318", "sh601398",
|
||||
"sh601628", "sh601668", "sh601857", "sh601888", "sh601988",
|
||||
"sz000001", "sz000002", "sz000333", "sz000651", "sz000858",
|
||||
]
|
||||
|
||||
|
||||
def get_hs300_stocks() -> pd.DataFrame:
|
||||
"""Fetch the current CSI 300 constituents from baostock.
|
||||
|
||||
Returns:
|
||||
DataFrame with columns ``code`` (e.g. ``sh600000``), ``name``, ``date``.
|
||||
"""
|
||||
bs.login()
|
||||
try:
|
||||
rs = bs.query_hs300_stocks()
|
||||
stocks = []
|
||||
while rs.next():
|
||||
stocks.append(rs.get_row_data())
|
||||
finally:
|
||||
bs.logout()
|
||||
df = pd.DataFrame(stocks, columns=["code", "name", "date"])
|
||||
df["code"] = df["code"].str.replace(".", "", regex=False)
|
||||
return df
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Signal evaluation metrics."""
|
||||
from eval.metrics import evaluate_cross_sectional
|
||||
|
||||
__all__ = ["evaluate_cross_sectional"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Information-coefficient metrics for alpha signals."""
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _summarize(ic: pd.Series, rank_ic: pd.Series) -> dict[str, Any]:
|
||||
"""Aggregate per-period IC series into summary statistics."""
|
||||
ic = ic.dropna()
|
||||
rank_ic = rank_ic.dropna()
|
||||
|
||||
ic_mean = float(ic.mean()) if len(ic) else float("nan")
|
||||
ic_std = float(ic.std()) if len(ic) else float("nan")
|
||||
rank_ic_mean = float(rank_ic.mean()) if len(rank_ic) else float("nan")
|
||||
rank_ic_std = float(rank_ic.std()) if len(rank_ic) else float("nan")
|
||||
|
||||
return {
|
||||
"ic_mean": ic_mean,
|
||||
"ic_std": ic_std,
|
||||
"ir": ic_mean / ic_std if ic_std else float("nan"),
|
||||
"rank_ic_mean": rank_ic_mean,
|
||||
"rank_ic_std": rank_ic_std,
|
||||
"rank_ir": rank_ic_mean / rank_ic_std if rank_ic_std else float("nan"),
|
||||
"hit_rate": float((rank_ic > 0).mean()) if len(rank_ic) else float("nan"),
|
||||
"n_periods": int(len(rank_ic)),
|
||||
"ic_series": ic,
|
||||
"rank_ic_series": rank_ic,
|
||||
}
|
||||
|
||||
|
||||
def _cross_sectional(signals_df: pd.DataFrame, returns_df: pd.DataFrame) -> dict[str, Any]:
|
||||
"""Per-date IC across stocks (requires >= 2 stocks)."""
|
||||
dates = signals_df.index
|
||||
ic_vals, rank_ic_vals, idx = [], [], []
|
||||
for dt in dates:
|
||||
s = signals_df.loc[dt]
|
||||
r = returns_df.loc[dt]
|
||||
mask = s.notna() & r.notna()
|
||||
if mask.sum() < 2:
|
||||
continue
|
||||
sv, rv = s[mask], r[mask]
|
||||
# A degenerate (constant) vector makes correlation undefined.
|
||||
if sv.nunique() < 2 or rv.nunique() < 2:
|
||||
continue
|
||||
ic_vals.append(sv.corr(rv))
|
||||
rank_ic_vals.append(sv.corr(rv, method="spearman"))
|
||||
idx.append(dt)
|
||||
ic = pd.Series(ic_vals, index=idx, dtype=float)
|
||||
rank_ic = pd.Series(rank_ic_vals, index=idx, dtype=float)
|
||||
return _summarize(ic, rank_ic)
|
||||
|
||||
|
||||
def _rolling_single(
|
||||
signals_df: pd.DataFrame, returns_df: pd.DataFrame, window: int = 20
|
||||
) -> dict[str, Any]:
|
||||
"""Rolling time-series IC for the single-stock case.
|
||||
|
||||
With one stock there is no cross-section, so we measure how well the signal
|
||||
tracks forward returns over a trailing window instead.
|
||||
"""
|
||||
col = signals_df.columns[0]
|
||||
s = signals_df[col]
|
||||
r = returns_df[col]
|
||||
ic = s.rolling(window).corr(r)
|
||||
rank_ic = s.rank().rolling(window).corr(r.rank())
|
||||
return _summarize(ic, rank_ic)
|
||||
|
||||
|
||||
def evaluate_cross_sectional(
|
||||
signals_df: pd.DataFrame, returns_df: pd.DataFrame
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate a signal's predictive power against forward returns.
|
||||
|
||||
Args:
|
||||
signals_df: DataFrame indexed by date, one column per stock, signal values.
|
||||
returns_df: DataFrame indexed by date, one column per stock, forward returns.
|
||||
|
||||
Returns:
|
||||
Dict with ``ic_mean``, ``ic_std``, ``ir``, ``rank_ic_mean``,
|
||||
``rank_ic_std``, ``rank_ir``, ``hit_rate``, ``n_periods`` and the
|
||||
per-period ``ic_series`` / ``rank_ic_series`` (for plotting).
|
||||
"""
|
||||
cols = signals_df.columns.intersection(returns_df.columns)
|
||||
idx = signals_df.index.intersection(returns_df.index)
|
||||
signals_df = signals_df.loc[idx, cols]
|
||||
returns_df = returns_df.loc[idx, cols]
|
||||
|
||||
if len(cols) >= 2:
|
||||
return _cross_sectional(signals_df, returns_df)
|
||||
return _rolling_single(signals_df, returns_df)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Translate signal values into position actions."""
|
||||
from portfolio.builder import PositionAction, ThresholdBuilder
|
||||
|
||||
__all__ = ["PositionAction", "ThresholdBuilder"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Map signal values to discrete position actions."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PositionAction:
|
||||
"""A target action for a single stock on a single bar."""
|
||||
|
||||
action: str # "buy", "sell", or "hold"
|
||||
size_pct: float = 0.0 # target portfolio fraction for buys
|
||||
|
||||
|
||||
class ThresholdBuilder:
|
||||
"""Open on strong positive signal, close on strong negative signal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buy_threshold: float = 0.02,
|
||||
sell_threshold: float = -0.02,
|
||||
size_pct: float = 0.95,
|
||||
):
|
||||
self.buy_threshold = buy_threshold
|
||||
self.sell_threshold = sell_threshold
|
||||
self.size_pct = size_pct
|
||||
|
||||
def build(self, signal_value: float, in_position: bool) -> PositionAction:
|
||||
"""Decide what to do given the current signal and position state.
|
||||
|
||||
Args:
|
||||
signal_value: Latest signal value for the stock.
|
||||
in_position: Whether the portfolio currently holds the stock.
|
||||
|
||||
Returns:
|
||||
The action to take ("buy", "sell", or "hold").
|
||||
"""
|
||||
if not in_position and signal_value >= self.buy_threshold:
|
||||
return PositionAction("buy", self.size_pct)
|
||||
if in_position and signal_value <= self.sell_threshold:
|
||||
return PositionAction("sell", 0.0)
|
||||
return PositionAction("hold", 0.0)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,21 @@
|
||||
BACKTEST SUMMARY
|
||||
========================================
|
||||
sharpe: -5.633716896325243
|
||||
max_drawdown: 48.659457956769764
|
||||
max_drawdown_len: 451
|
||||
total_return: -0.48968077055625825
|
||||
avg_return: -0.0010117371292484674
|
||||
total_trades: 33
|
||||
won_trades: 20
|
||||
lost_trades: 12
|
||||
|
||||
SIGNAL IC
|
||||
========================================
|
||||
ic_mean: -0.006912277738865651
|
||||
ic_std: 0.3332983458971776
|
||||
ir: -0.020739010030965125
|
||||
rank_ic_mean: -0.006980297831283274
|
||||
rank_ic_std: 0.32283972442680237
|
||||
rank_ir: -0.02162155801513181
|
||||
hit_rate: 0.4811715481171548
|
||||
n_periods: 478
|
||||
@@ -2,4 +2,5 @@ backtrader>=1.9.76.123
|
||||
akshare>=1.14.0
|
||||
baostock>=0.8.8
|
||||
pandas>=2.0.0
|
||||
matplotlib>=3.7.0
|
||||
pytest>=7.0.0
|
||||
|
||||
+74
-10
@@ -1,25 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke test: download data -> backtest 5-day reversal -> print results."""
|
||||
"""End-to-end pipeline: HS300 universe -> reversal signal -> cross-sectional IC
|
||||
-> multi-stock backtest (AlphaStrategy + PercentSizer) -> reports.
|
||||
|
||||
Note: this runs the first 30 HS300 constituents to keep runtime manageable.
|
||||
Downloading daily bars for the full ~300 names takes roughly 10 minutes.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from analysis.report import generate_report
|
||||
from backtest.config import BacktestConfig
|
||||
from backtest.runner import BacktestRunner
|
||||
from strategies.reversal import FiveDayReversal
|
||||
from analysis.report import print_results
|
||||
from data.downloader import download_batch
|
||||
from data.universe import SYMBOLS
|
||||
from eval.metrics import evaluate_cross_sectional
|
||||
from portfolio.builder import ThresholdBuilder
|
||||
from signals.reversal import ReversalSignal
|
||||
from strategies.alpha_strategy import AlphaStrategy
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
symbols = SYMBOLS[:30]
|
||||
start, end = "2023-01-01", "2024-12-31"
|
||||
initial_cash = 1_000_000
|
||||
|
||||
# 1-2. Download daily data for the universe.
|
||||
data = download_batch(symbols, start, end)
|
||||
data = {s: df for s, df in data.items() if df is not None and not df.empty}
|
||||
logger.info(f"Downloaded {len(data)}/{len(symbols)} symbols")
|
||||
|
||||
# 3. Compute the reversal signal per stock.
|
||||
signal = ReversalSignal(lookback=5)
|
||||
signal_series: dict[str, pd.Series] = {}
|
||||
forward_returns: dict[str, pd.Series] = {}
|
||||
for sym, df in data.items():
|
||||
idx = pd.to_datetime(df["date"])
|
||||
sig = signal.compute(df)
|
||||
sig.index = idx
|
||||
signal_series[sym] = sig
|
||||
fwd = df["close"].pct_change().shift(-1) # next-day return
|
||||
fwd.index = idx
|
||||
forward_returns[sym] = fwd
|
||||
|
||||
# 4. Cross-sectional IC evaluation.
|
||||
signals_df = pd.DataFrame(signal_series)
|
||||
returns_df = pd.DataFrame(forward_returns)
|
||||
signal_eval = evaluate_cross_sectional(signals_df, returns_df)
|
||||
|
||||
# 5. Attach the signal column to each DataFrame and build feeds.
|
||||
config = BacktestConfig(
|
||||
symbols=["sh600000", "sz000001", "sh600519"], # 浦发银行, 平安银行, 贵州茅台
|
||||
start_date="2023-01-01",
|
||||
end_date="2024-12-31",
|
||||
initial_cash=1_000_000,
|
||||
symbols=list(data.keys()),
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
initial_cash=initial_cash,
|
||||
sizer_percent=0.95,
|
||||
)
|
||||
runner = BacktestRunner(config)
|
||||
builder = ThresholdBuilder(buy_threshold=0.02, sell_threshold=-0.02, size_pct=0.95)
|
||||
for sym, df in data.items():
|
||||
df = df.copy()
|
||||
df["signal"] = signal.compute(df).values
|
||||
runner.add_signal_data(df, name=sym)
|
||||
|
||||
# 6. Run the multi-stock backtest.
|
||||
results = runner.run_prepared(AlphaStrategy, {"builder": builder})
|
||||
|
||||
# 7. Reports.
|
||||
artifacts = generate_report(
|
||||
results, signal_eval, output_dir="reports/", initial_cash=initial_cash
|
||||
)
|
||||
|
||||
runner = BacktestRunner(config)
|
||||
results = runner.run(FiveDayReversal)
|
||||
print_results(results, config.initial_cash)
|
||||
# 8. Print summary.
|
||||
print("\nSIGNAL IC")
|
||||
print("=" * 50)
|
||||
print(f"IC mean / std / IR: {signal_eval['ic_mean']:.4f} / "
|
||||
f"{signal_eval['ic_std']:.4f} / {signal_eval['ir']:.4f}")
|
||||
print(f"Rank IC mean / std / IR: {signal_eval['rank_ic_mean']:.4f} / "
|
||||
f"{signal_eval['rank_ic_std']:.4f} / {signal_eval['rank_ir']:.4f}")
|
||||
print(f"Hit rate: {signal_eval['hit_rate']:.2%}")
|
||||
print(f"Periods: {signal_eval['n_periods']}")
|
||||
print(f"\nReports written to: {artifacts}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Alpha signal abstractions."""
|
||||
from signals.base import AlphaSignal
|
||||
from signals.reversal import ReversalSignal
|
||||
|
||||
__all__ = ["AlphaSignal", "ReversalSignal"]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Base class for cross-sectional alpha signals."""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class AlphaSignal(ABC):
|
||||
"""A signal that maps a single stock's OHLCV history to a per-bar score.
|
||||
|
||||
Higher scores indicate a stronger expected forward return. Implementations
|
||||
operate on one stock at a time; cross-sectional ranking happens downstream.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def compute(self, df: pd.DataFrame) -> pd.Series:
|
||||
"""Compute the signal for one stock.
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame with at least a ``close`` column, ordered by date.
|
||||
|
||||
Returns:
|
||||
Signal series aligned to ``df`` (NaN where undefined).
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Human-readable signal identifier."""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Short-horizon reversal signal."""
|
||||
import pandas as pd
|
||||
|
||||
from signals.base import AlphaSignal
|
||||
|
||||
|
||||
class ReversalSignal(AlphaSignal):
|
||||
"""Negative trailing return: oversold stocks score high.
|
||||
|
||||
The signal is ``-close.pct_change(lookback)``, so a stock that fell over the
|
||||
lookback window gets a positive (bullish) score.
|
||||
"""
|
||||
|
||||
def __init__(self, lookback: int = 5):
|
||||
self.lookback = lookback
|
||||
|
||||
def compute(self, df: pd.DataFrame) -> pd.Series:
|
||||
return -df["close"].pct_change(self.lookback)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"reversal_{self.lookback}d"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Signal-driven multi-stock strategy."""
|
||||
import backtrader as bt
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class AlphaStrategy(bt.Strategy):
|
||||
"""Trade each feed based on its precomputed ``signal`` line.
|
||||
|
||||
The strategy delegates the buy/sell/hold decision to a portfolio builder
|
||||
(e.g. ``ThresholdBuilder``) and sizes entries with ``order_target_percent``.
|
||||
"""
|
||||
|
||||
def __init__(self, builder):
|
||||
self.builder = builder
|
||||
|
||||
def next(self):
|
||||
for data in self.datas:
|
||||
sig = data.signal[0]
|
||||
if pd.isna(sig):
|
||||
continue
|
||||
in_position = bool(self.getposition(data).size)
|
||||
action = self.builder.build(float(sig), in_position)
|
||||
if action.action == "buy":
|
||||
self.order_target_percent(data=data, target=action.size_pct)
|
||||
elif action.action == "sell":
|
||||
self.close(data=data)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests for cross-sectional IC evaluation."""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from eval.metrics import evaluate_cross_sectional
|
||||
|
||||
|
||||
def test_cross_sectional_keys_present():
|
||||
dates = pd.date_range("2024-01-01", periods=10)
|
||||
cols = ["a", "b", "c"]
|
||||
rng = np.random.default_rng(0)
|
||||
signals = pd.DataFrame(rng.standard_normal((10, 3)), index=dates, columns=cols)
|
||||
returns = pd.DataFrame(rng.standard_normal((10, 3)), index=dates, columns=cols)
|
||||
res = evaluate_cross_sectional(signals, returns)
|
||||
for key in (
|
||||
"ic_mean", "ic_std", "ir", "rank_ic_mean", "rank_ic_std",
|
||||
"rank_ir", "hit_rate", "n_periods",
|
||||
):
|
||||
assert key in res
|
||||
|
||||
|
||||
def test_perfect_signal_has_positive_rank_ic():
|
||||
# When the signal equals next-period returns, rank IC should be ~1 each day.
|
||||
dates = pd.date_range("2024-01-01", periods=8)
|
||||
cols = ["a", "b", "c"]
|
||||
rng = np.random.default_rng(42)
|
||||
returns = pd.DataFrame(rng.standard_normal((8, 3)), index=dates, columns=cols)
|
||||
signals = returns.copy() # perfect foresight
|
||||
res = evaluate_cross_sectional(signals, returns)
|
||||
assert res["rank_ic_mean"] > 0.99
|
||||
assert res["hit_rate"] == 1.0
|
||||
assert res["n_periods"] == 8
|
||||
|
||||
|
||||
def test_inverted_signal_has_negative_rank_ic():
|
||||
dates = pd.date_range("2024-01-01", periods=6)
|
||||
cols = ["a", "b", "c"]
|
||||
rng = np.random.default_rng(7)
|
||||
returns = pd.DataFrame(rng.standard_normal((6, 3)), index=dates, columns=cols)
|
||||
signals = -returns # perfectly wrong
|
||||
res = evaluate_cross_sectional(signals, returns)
|
||||
assert res["rank_ic_mean"] < -0.99
|
||||
|
||||
|
||||
def test_single_stock_falls_back_to_rolling():
|
||||
dates = pd.date_range("2024-01-01", periods=40)
|
||||
rng = np.random.default_rng(1)
|
||||
signals = pd.DataFrame({"a": rng.standard_normal(40)}, index=dates)
|
||||
returns = pd.DataFrame({"a": rng.standard_normal(40)}, index=dates)
|
||||
res = evaluate_cross_sectional(signals, returns)
|
||||
# Rolling fallback still yields the standard metric keys.
|
||||
assert "rank_ic_mean" in res
|
||||
assert res["n_periods"] > 0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for alpha signal computation."""
|
||||
import pandas as pd
|
||||
|
||||
from signals.reversal import ReversalSignal
|
||||
|
||||
|
||||
def _make_df(closes):
|
||||
return pd.DataFrame({"close": closes})
|
||||
|
||||
|
||||
def test_reversal_name():
|
||||
assert ReversalSignal(lookback=5).name == "reversal_5d"
|
||||
assert ReversalSignal(lookback=10).name == "reversal_10d"
|
||||
|
||||
|
||||
def test_reversal_is_negative_trailing_return():
|
||||
# Monotonically rising prices -> negative (bearish) reversal signal.
|
||||
df = _make_df([10.0, 11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
sig = ReversalSignal(lookback=5).compute(df)
|
||||
# First 5 values are NaN (insufficient lookback).
|
||||
assert sig.iloc[:5].isna().all()
|
||||
# 15/10 - 1 = 0.5 return -> signal = -0.5
|
||||
assert abs(sig.iloc[5] - (-0.5)) < 1e-9
|
||||
|
||||
|
||||
def test_reversal_oversold_is_positive():
|
||||
# Falling prices -> positive (bullish) reversal signal.
|
||||
df = _make_df([20.0, 18.0, 16.0, 14.0, 12.0, 10.0])
|
||||
sig = ReversalSignal(lookback=5).compute(df)
|
||||
assert sig.iloc[5] > 0
|
||||
# 10/20 - 1 = -0.5 -> signal = +0.5
|
||||
assert abs(sig.iloc[5] - 0.5) < 1e-9
|
||||
|
||||
|
||||
def test_reversal_output_length_matches_input():
|
||||
df = _make_df([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
sig = ReversalSignal(lookback=3).compute(df)
|
||||
assert len(sig) == len(df)
|
||||
Reference in New Issue
Block a user