feat: signal abstraction layer + sizer + HS300 universe + PnL/IC reports
This commit is contained in:
@@ -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