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.""" """Performance analysis and reporting for backtest results."""
import os
from typing import Any 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]: 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.""" """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) print("=" * 50)
return report 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 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) stamp_duty: float = 0.001 # 0.1% stamp duty on sells only (handled in strategy)
adjust: str = "qfq" 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.set_index("date")
df = df[["open", "high", "low", "close", "volume"]] df = df[["open", "high", "low", "close", "volume"]]
return bt.feeds.PandasData(dataname=df) 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 typing import Optional
from backtest.config import BacktestConfig 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 from data.downloader import download_daily
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,33 +30,49 @@ class BacktestRunner:
self.cerebro.adddata(feed, name=symbol) self.cerebro.adddata(feed, name=symbol)
logger.info(f"Added {symbol}: {len(df)} bars") 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: def add_strategy(self, strategy_cls, **kwargs) -> None:
"""Add a strategy class to cerebro.""" """Add a strategy class to cerebro."""
self.cerebro.addstrategy(strategy_cls, **kwargs) self.cerebro.addstrategy(strategy_cls, **kwargs)
def setup(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> None: def _configure(self) -> None:
"""Full setup: load data for all symbols, configure cerebro, add strategy.""" """Configure broker, sizer, and analyzers (independent of data feeds)."""
# Load data for all symbols
for sym in self.config.symbols:
self.add_data(sym)
# Configure cerebro
self.cerebro.broker.setcash(self.config.initial_cash) self.cerebro.broker.setcash(self.config.initial_cash)
self.cerebro.broker.setcommission(commission=self.config.commission) 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.SharpeRatio, _name="sharpe", riskfreerate=0.02)
self.cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown") self.cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
self.cerebro.addanalyzer(bt.analyzers.Returns, _name="returns") self.cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
self.cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades") self.cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
self.cerebro.addanalyzer(
bt.analyzers.TimeReturn, _name="timereturn", timeframe=bt.TimeFrame.Days
)
# Add strategy def setup(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> None:
strategy_kwargs = strategy_kwargs or {} """Full setup: load data for all symbols, configure cerebro, add strategy."""
self.cerebro.addstrategy(strategy_cls, **strategy_kwargs) 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: 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) 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() start_val = self.cerebro.broker.getvalue()
logger.info(f"Starting portfolio value: {start_val:,.2f}") logger.info(f"Starting portfolio value: {start_val:,.2f}")
self._results = self.cerebro.run() 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 akshare>=1.14.0
baostock>=0.8.8 baostock>=0.8.8
pandas>=2.0.0 pandas>=2.0.0
matplotlib>=3.7.0
pytest>=7.0.0 pytest>=7.0.0
+74 -10
View File
@@ -1,25 +1,89 @@
#!/usr/bin/env python3 #!/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 logging
import pandas as pd
from analysis.report import generate_report
from backtest.config import BacktestConfig from backtest.config import BacktestConfig
from backtest.runner import BacktestRunner from backtest.runner import BacktestRunner
from strategies.reversal import FiveDayReversal from data.downloader import download_batch
from analysis.report import print_results 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def main(): 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( config = BacktestConfig(
symbols=["sh600000", "sz000001", "sh600519"], # 浦发银行, 平安银行, 贵州茅台 symbols=list(data.keys()),
start_date="2023-01-01", start_date=start,
end_date="2024-12-31", end_date=end,
initial_cash=1_000_000, 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) # 8. Print summary.
results = runner.run(FiveDayReversal) print("\nSIGNAL IC")
print_results(results, config.initial_cash) 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__": 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)