diff --git a/analysis/report.py b/analysis/report.py index d3106b0..368d7a9 100644 --- a/analysis/report.py +++ b/analysis/report.py @@ -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} diff --git a/backtest/config.py b/backtest/config.py index 30d35c1..ccae95b 100644 --- a/backtest/config.py +++ b/backtest/config.py @@ -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 diff --git a/backtest/feed.py b/backtest/feed.py index a19e0ee..8cff07a 100644 --- a/backtest/feed.py +++ b/backtest/feed.py @@ -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) diff --git a/backtest/runner.py b/backtest/runner.py index fc5704e..07ba90f 100644 --- a/backtest/runner.py +++ b/backtest/runner.py @@ -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() diff --git a/data/universe.py b/data/universe.py new file mode 100644 index 0000000..9116467 --- /dev/null +++ b/data/universe.py @@ -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 diff --git a/eval/__init__.py b/eval/__init__.py new file mode 100644 index 0000000..20cb146 --- /dev/null +++ b/eval/__init__.py @@ -0,0 +1,4 @@ +"""Signal evaluation metrics.""" +from eval.metrics import evaluate_cross_sectional + +__all__ = ["evaluate_cross_sectional"] diff --git a/eval/metrics.py b/eval/metrics.py new file mode 100644 index 0000000..3dda218 --- /dev/null +++ b/eval/metrics.py @@ -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) diff --git a/portfolio/__init__.py b/portfolio/__init__.py new file mode 100644 index 0000000..2b063cb --- /dev/null +++ b/portfolio/__init__.py @@ -0,0 +1,4 @@ +"""Translate signal values into position actions.""" +from portfolio.builder import PositionAction, ThresholdBuilder + +__all__ = ["PositionAction", "ThresholdBuilder"] diff --git a/portfolio/builder.py b/portfolio/builder.py new file mode 100644 index 0000000..22a37d6 --- /dev/null +++ b/portfolio/builder.py @@ -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) diff --git a/reports/ic.png b/reports/ic.png new file mode 100644 index 0000000..b4b2e69 Binary files /dev/null and b/reports/ic.png differ diff --git a/reports/pnl.png b/reports/pnl.png new file mode 100644 index 0000000..c981320 Binary files /dev/null and b/reports/pnl.png differ diff --git a/reports/summary.txt b/reports/summary.txt new file mode 100644 index 0000000..6e837d8 --- /dev/null +++ b/reports/summary.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index b01bb2b..b018091 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/run_example.py b/run_example.py index 2ff2ef8..ce11b48 100644 --- a/run_example.py +++ b/run_example.py @@ -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__": diff --git a/signals/__init__.py b/signals/__init__.py new file mode 100644 index 0000000..4fc8c97 --- /dev/null +++ b/signals/__init__.py @@ -0,0 +1,5 @@ +"""Alpha signal abstractions.""" +from signals.base import AlphaSignal +from signals.reversal import ReversalSignal + +__all__ = ["AlphaSignal", "ReversalSignal"] diff --git a/signals/base.py b/signals/base.py new file mode 100644 index 0000000..1beeab0 --- /dev/null +++ b/signals/base.py @@ -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.""" diff --git a/signals/reversal.py b/signals/reversal.py new file mode 100644 index 0000000..b516a76 --- /dev/null +++ b/signals/reversal.py @@ -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" diff --git a/strategies/alpha_strategy.py b/strategies/alpha_strategy.py new file mode 100644 index 0000000..dcceb29 --- /dev/null +++ b/strategies/alpha_strategy.py @@ -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) diff --git a/tests/test_eval.py b/tests/test_eval.py new file mode 100644 index 0000000..df049ab --- /dev/null +++ b/tests/test_eval.py @@ -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 diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 0000000..fe61aff --- /dev/null +++ b/tests/test_signals.py @@ -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)