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
+113
View File
@@ -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}
+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()
+38
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""Signal evaluation metrics."""
from eval.metrics import evaluate_cross_sectional
__all__ = ["evaluate_cross_sectional"]
+90
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
"""Translate signal values into position actions."""
from portfolio.builder import PositionAction, ThresholdBuilder
__all__ = ["PositionAction", "ThresholdBuilder"]
+40
View File
@@ -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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+21
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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__":
+5
View File
@@ -0,0 +1,5 @@
"""Alpha signal abstractions."""
from signals.base import AlphaSignal
from signals.reversal import ReversalSignal
__all__ = ["AlphaSignal", "ReversalSignal"]
+28
View File
@@ -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."""
+22
View File
@@ -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"
+26
View File
@@ -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)
+53
View File
@@ -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
+38
View File
@@ -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)