"""Tests for case_studies/utils/signals.py — prediction → weight contracts. Signal construction sits on the critical path between every model and every backtest. A silent behavior change here would corrupt every Ch16-20 strategy result. These tests pin the observable contracts: - threshold / percentile cutoffs are applied with the documented inequality semantics (``>`` for fixed threshold, ``>=`` for cross- sectional percentile, ``>`` for rolling) - long-short variants produce symmetric signals and weights - equal-weight top-K weights sum to 1 (or 0 for excluded assets), and score-weighted weights sum to 1 with score-proportional magnitudes - the config dispatcher routes every documented method and raises on unknowns - ``direction=short_only`` is a pure sign flip of the weight column - zero-weight rows are filtered from the output - outputs are deterministic across repeated calls on the same input """ from __future__ import annotations import numpy as np import polars as pl import pytest from polars.testing import assert_frame_equal from case_studies.utils.signals import ( build_target_weights, build_target_weights_from_config, cross_sectional_percentile_signal, fixed_threshold_signal, per_symbol_rolling_percentile_signal, rolling_percentile_signal, ) # ----------------------------------------------------------------------------- # Fixtures # ----------------------------------------------------------------------------- @pytest.fixture def predictions_2d5s() -> pl.DataFrame: """2 timestamps × 5 symbols (A–E), y_score ascending per date.""" return pl.DataFrame( { "timestamp": ["2024-01-01"] * 5 + ["2024-01-02"] * 5, "symbol": list("ABCDE") * 2, "y_score": [0.1, 0.3, 0.5, 0.7, 0.9, 0.2, 0.4, 0.6, 0.8, 1.0], } ).with_columns(pl.col("timestamp").str.to_date()) @pytest.fixture def predictions_2d6s() -> pl.DataFrame: """2 timestamps × 6 symbols (A–F) for even-split top/bottom tests.""" return pl.DataFrame( { "timestamp": ["2024-01-01"] * 6 + ["2024-01-02"] * 6, "symbol": list("ABCDEF") * 2, "y_score": [0.1, 0.2, 0.3, 0.7, 0.8, 0.9, 0.1, 0.3, 0.5, 0.6, 0.8, 1.0], } ).with_columns(pl.col("timestamp").str.to_date()) @pytest.fixture def predictions_rolling() -> pl.DataFrame: """50 timestamps × 2 symbols for rolling-window tests.""" rng = np.random.default_rng(42) ts = pl.date_range(pl.date(2024, 1, 1), pl.date(2024, 2, 19), "1d", eager=True) rows = [(t, s, float(rng.random())) for s in ("A", "B") for t in ts] return pl.DataFrame(rows, schema=["timestamp", "symbol", "y_score"], orient="row").sort( "timestamp", "symbol" ) # ----------------------------------------------------------------------------- # fixed_threshold_signal # ----------------------------------------------------------------------------- def test_fixed_threshold_long_only_strict_greater_than(predictions_2d5s) -> None: """signal=1 iff score > threshold (strict). At-threshold scores get 0.""" out = fixed_threshold_signal(predictions_2d5s, threshold=0.5, signal_type="long_only") # 0.5 → 0 (not strictly >); 0.7/0.9/0.6/0.8/1.0 → 1 expected = [0, 0, 0, 1, 1, 0, 0, 1, 1, 1] assert out["signal"].to_list() == expected def test_fixed_threshold_signal_is_int8(predictions_2d5s) -> None: out = fixed_threshold_signal(predictions_2d5s, threshold=0.5) assert out["signal"].dtype == pl.Int8 def test_fixed_threshold_preserves_row_count_and_columns(predictions_2d5s) -> None: out = fixed_threshold_signal(predictions_2d5s, threshold=0.5) assert out.height == predictions_2d5s.height assert set(predictions_2d5s.columns) <= set(out.columns) def test_fixed_threshold_long_short_uses_symmetric_mirror() -> None: """long_short with threshold=0.7 → above 0.7 → 1, below (1-0.7)=0.3 → -1.""" df = pl.DataFrame({"y_score": [0.1, 0.4, 0.5, 0.6, 0.9]}) out = fixed_threshold_signal(df, threshold=0.7, signal_type="long_short") assert out["signal"].to_list() == [-1, 0, 0, 0, 1] def test_fixed_threshold_deterministic_across_calls(predictions_2d5s) -> None: a = fixed_threshold_signal(predictions_2d5s, threshold=0.5) b = fixed_threshold_signal(predictions_2d5s, threshold=0.5) assert_frame_equal(a, b) # ----------------------------------------------------------------------------- # rolling_percentile_signal # ----------------------------------------------------------------------------- def test_rolling_percentile_adds_threshold_column(predictions_rolling) -> None: out = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0) assert "rolling_threshold" in out.columns def test_rolling_percentile_early_window_has_null_threshold(predictions_rolling) -> None: """First window-1 rows per asset have insufficient history → null threshold.""" out = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0) # 2 symbols × (window-1=9) early rows = 18 nulls assert out["rolling_threshold"].null_count() == 18 def test_rolling_percentile_long_short_adds_both_thresholds(predictions_rolling) -> None: out = rolling_percentile_signal( predictions_rolling, window=10, percentile=80.0, signal_type="long_short" ) assert "rolling_threshold" in out.columns assert "rolling_lower_threshold" in out.columns # Must produce at least one long and one short signal with random data counts = dict(out.group_by("signal").len().iter_rows()) assert counts.get(1, 0) > 0 assert counts.get(-1, 0) > 0 def test_rolling_percentile_per_asset_independence() -> None: """Each asset computes its own rolling quantile — asset ordering shouldn't change its own signal sequence.""" ts = pl.date_range(pl.date(2024, 1, 1), pl.date(2024, 1, 20), "1d", eager=True) rows_a = [(t, "A", float(i)) for i, t in enumerate(ts)] rows_b = [(t, "B", float(-i)) for i, t in enumerate(ts)] df = pl.DataFrame(rows_a + rows_b, schema=["timestamp", "symbol", "y_score"], orient="row") a_only_thresholds = rolling_percentile_signal( df.filter(pl.col("symbol") == "A"), window=5, percentile=80.0 )["rolling_threshold"] with_both = rolling_percentile_signal(df, window=5, percentile=80.0).filter( pl.col("symbol") == "A" )["rolling_threshold"] assert a_only_thresholds.to_list() == with_both.to_list() # ----------------------------------------------------------------------------- # cross_sectional_percentile_signal # ----------------------------------------------------------------------------- def test_cs_percentile_long_only_at_or_above_cutoff(predictions_2d5s) -> None: """cs_percentile uses ``>=`` — score equal to the threshold gets a signal. At percentile=80 with 5 symbols, the 80th percentile interpolates to the second-highest score. With ascending scores D=0.7, E=0.9 for date 1, cs_threshold=0.7 and both D and E get signal=1. """ out = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0).sort( "timestamp", "symbol" ) # Per date, top 2 by score should be selected assert out.filter(pl.col("signal") == 1).height == 4 # 2 dates × 2 winners def test_cs_percentile_threshold_differs_per_timestamp(predictions_2d5s) -> None: """Different dates have different score distributions → different thresholds.""" out = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0) per_date = out.group_by("timestamp").agg(pl.col("cs_threshold").first()).sort("timestamp") thresholds = per_date["cs_threshold"].to_list() assert thresholds[0] != thresholds[1] def test_cs_percentile_long_short_produces_both_signs(predictions_2d5s) -> None: out = cross_sectional_percentile_signal( predictions_2d5s, percentile=80.0, signal_type="long_short" ) signs = set(out["signal"].to_list()) assert 1 in signs assert -1 in signs # ----------------------------------------------------------------------------- # build_target_weights — equal_weight_top_k # ----------------------------------------------------------------------------- def test_equal_weight_top_k_long_only_weights_sum_to_1(predictions_2d5s) -> None: out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) per_date = out.group_by("timestamp").agg(pl.col("weight").sum()).sort("timestamp") for w in per_date["weight"].to_list(): assert abs(w - 1.0) < 1e-9 def test_equal_weight_top_k_selects_exactly_k_assets_per_date(predictions_2d5s) -> None: out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) per_date = out.group_by("timestamp").agg(pl.col("symbol").count().alias("n")).sort("timestamp") assert per_date["n"].to_list() == [2, 2] def test_equal_weight_top_k_picks_highest_scores(predictions_2d5s) -> None: """With ascending scores A..E, top 2 should always be D and E.""" out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) selected = set(out["symbol"].unique().to_list()) assert selected == {"D", "E"} def test_equal_weight_top_k_long_short_weights_are_symmetric(predictions_2d6s) -> None: """long_short top_k=2 with 6 symbols: 2 long @+0.5, 2 short @-0.5, 2 zero (dropped).""" out = build_target_weights( predictions_2d6s, method="equal_weight_top_k", top_k=2, long_short=True ) longs = out.filter(pl.col("weight") > 0) shorts = out.filter(pl.col("weight") < 0) assert longs.height == 4 # 2 dates × 2 longs assert shorts.height == 4 # Magnitudes equal assert all(abs(w - 0.5) < 1e-9 for w in longs["weight"]) assert all(abs(w + 0.5) < 1e-9 for w in shorts["weight"]) def test_equal_weight_top_k_clamps_when_k_exceeds_n_assets(predictions_2d5s) -> None: """Asking for top_k=100 with 5 assets per date → selects all 5, weights = 1/5.""" out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=100) assert out.height == 10 # all rows survive assert all(abs(w - 0.2) < 1e-9 for w in out["weight"]) def test_equal_weight_top_k_filters_zero_weights(predictions_2d5s) -> None: """The helper strips zero-weight rows from the output.""" out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) assert (out["weight"] == 0.0).sum() == 0 def test_equal_weight_top_k_output_sorted_by_time_then_asset(predictions_2d5s) -> None: out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) pairs = list(zip(out["timestamp"].to_list(), out["symbol"].to_list(), strict=True)) assert pairs == sorted(pairs) # ----------------------------------------------------------------------------- # build_target_weights — score_weighted_top_k # ----------------------------------------------------------------------------- def test_score_weighted_top_k_long_only_weights_sum_to_1(predictions_2d5s) -> None: out = build_target_weights(predictions_2d5s, method="score_weighted_top_k", top_k=2) per_date = out.group_by("timestamp").agg(pl.col("weight").sum()) for w in per_date["weight"].to_list(): assert abs(w - 1.0) < 1e-9 def test_score_weighted_top_k_weight_proportional_to_abs_score(predictions_2d6s) -> None: """Top 2 of [0.8, 0.9] → weights 0.8/1.7 ≈ 0.4706 and 0.9/1.7 ≈ 0.5294.""" out = build_target_weights(predictions_2d6s, method="score_weighted_top_k", top_k=2).sort( "timestamp", "symbol" ) date1 = out.filter(pl.col("timestamp") == pl.date(2024, 1, 1)).sort("symbol") weights = dict(zip(date1["symbol"].to_list(), date1["weight"].to_list(), strict=True)) assert abs(weights["E"] - 0.8 / 1.7) < 1e-9 assert abs(weights["F"] - 0.9 / 1.7) < 1e-9 def test_score_weighted_top_k_deterministic(predictions_2d6s) -> None: a = build_target_weights(predictions_2d6s, method="score_weighted_top_k", top_k=2) b = build_target_weights(predictions_2d6s, method="score_weighted_top_k", top_k=2) assert_frame_equal(a.sort("timestamp", "symbol"), b.sort("timestamp", "symbol")) # ----------------------------------------------------------------------------- # build_target_weights — inverse_vol (placeholder path: equal weight) # ----------------------------------------------------------------------------- def test_inverse_vol_placeholder_uses_equal_weight(predictions_2d5s) -> None: """inverse_vol is documented as a placeholder — same output as equal_weight_top_k.""" eq = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) iv = build_target_weights(predictions_2d5s, method="inverse_vol", top_k=2) assert_frame_equal( eq.sort("timestamp", "symbol"), iv.sort("timestamp", "symbol"), ) # ----------------------------------------------------------------------------- # build_target_weights_from_config — dispatcher # ----------------------------------------------------------------------------- def test_from_config_equal_weight_top_k_matches_direct_call(predictions_2d5s) -> None: direct = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) via = build_target_weights_from_config( predictions_2d5s, {"method": "equal_weight_top_k", "top_k": 2} ) assert_frame_equal(direct.sort("timestamp", "symbol"), via.sort("timestamp", "symbol")) def test_from_config_decile_long_short_on_small_universe(predictions_2d6s) -> None: """6 symbols, decile → top_cutoff=floor(6/10)=0 clipped to 1 → 1 long, 1 short.""" out = build_target_weights_from_config(predictions_2d6s, {"method": "decile_long_short"}).sort( "timestamp", "symbol" ) # Per date: 1 long @ +1.0 (top score), 1 short @ -1.0 (bottom score) assert out.height == 4 assert sorted(out["weight"].unique().to_list()) == [-1.0, 1.0] def test_from_config_cross_sectional_percentile(predictions_2d5s) -> None: out = build_target_weights_from_config( predictions_2d5s, {"method": "cross_sectional_percentile", "percentile": 80.0}, ) # Top 2 assets per date → weights sum to 1 per date per_date = out.group_by("timestamp").agg(pl.col("weight").sum()) for w in per_date["weight"].to_list(): assert abs(w - 1.0) < 1e-9 def test_from_config_fixed_threshold_selects_above_cutoff(predictions_2d5s) -> None: out = build_target_weights_from_config( predictions_2d5s, {"method": "fixed_threshold", "threshold": 0.5} ) # Per date: D (0.7), E (0.9) → 2 assets @ 0.5 each, sum to 1 on date 1. # Date 2: C (0.6), D (0.8), E (1.0) → 3 assets @ 1/3 each. date1 = out.filter(pl.col("timestamp") == pl.date(2024, 1, 1)) date2 = out.filter(pl.col("timestamp") == pl.date(2024, 1, 2)) assert date1.height == 2 and abs(date1["weight"].sum() - 1.0) < 1e-9 assert date2.height == 3 and abs(date2["weight"].sum() - 1.0) < 1e-9 def test_from_config_short_only_negates_weights(predictions_2d5s) -> None: """direction=short_only flips signs; magnitudes identical to long_only.""" long_w = build_target_weights_from_config( predictions_2d5s, {"method": "equal_weight_top_k", "top_k": 2} ) short_w = build_target_weights_from_config( predictions_2d5s, {"method": "equal_weight_top_k", "top_k": 2, "direction": "short_only"}, ) # Sort and pair up, then verify the negation contract long_sorted = long_w.sort("timestamp", "symbol") short_sorted = short_w.sort("timestamp", "symbol") assert long_sorted["symbol"].to_list() == short_sorted["symbol"].to_list() for lw, sw in zip( long_sorted["weight"].to_list(), short_sorted["weight"].to_list(), strict=True ): assert abs(lw + sw) < 1e-9 def test_from_config_rejects_unknown_method(predictions_2d5s) -> None: with pytest.raises(ValueError, match="Unknown signal method"): build_target_weights_from_config(predictions_2d5s, {"method": "bogus"}) def test_from_config_rejects_unknown_direction(predictions_2d5s) -> None: with pytest.raises(ValueError, match="Unknown signal direction"): build_target_weights_from_config( predictions_2d5s, {"method": "equal_weight_top_k", "top_k": 2, "direction": "bogus"}, ) def test_from_config_quintile_long_short_uses_5_buckets(predictions_2d5s) -> None: """quintile with 5 assets → top_cutoff=1 → 1 long, 1 short per date.""" out = build_target_weights_from_config(predictions_2d5s, {"method": "quintile_long_short"}) per_date = out.group_by("timestamp").agg(pl.col("symbol").count().alias("n")) assert per_date["n"].to_list() == [2, 2] # 1 long + 1 short each date # ----------------------------------------------------------------------------- # Determinism # ----------------------------------------------------------------------------- def test_cross_sectional_percentile_deterministic(predictions_2d5s) -> None: a = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0) b = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0) assert_frame_equal(a, b) def test_rolling_percentile_deterministic(predictions_rolling) -> None: a = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0) b = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0) assert_frame_equal(a, b) def test_build_target_weights_deterministic(predictions_2d5s) -> None: a = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) b = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2) assert_frame_equal(a, b) # ----------------------------------------------------------------------------- # per_symbol_rolling_percentile_signal — stay_q extension # ----------------------------------------------------------------------------- @pytest.fixture def per_symbol_intraday() -> pl.DataFrame: """30 days × 2 symbols × 14 bars/day; deterministic seeded scores.""" from datetime import datetime, timedelta rng = np.random.default_rng(11) rows = [] for d in range(30): for i in range(14): ts = datetime(2024, 1, 2, 9, 30) + timedelta(days=d, minutes=15 * i) for sym in ("AAA", "BBB"): rows.append((ts, sym, float(rng.standard_normal()))) return pl.DataFrame(rows, schema=["timestamp", "symbol", "y_score"], orient="row").sort( "symbol", "timestamp" ) def test_per_symbol_default_excludes_stay_thresh(per_symbol_intraday) -> None: """When stay_q is None, stay_thresh column is NOT present (back-compat).""" out = per_symbol_rolling_percentile_signal( per_symbol_intraday, long_q=0.80, lookback_days=10, bars_per_day=14, ) assert "stay_thresh" not in out.columns assert "signal" in out.columns def test_per_symbol_stay_q_adds_stay_thresh(per_symbol_intraday) -> None: """When stay_q is set, stay_thresh column is added; non-null after warm-up.""" out = per_symbol_rolling_percentile_signal( per_symbol_intraday, long_q=0.80, lookback_days=10, bars_per_day=14, stay_q=0.40, ) assert "stay_thresh" in out.columns # After warm-up (~5 sessions = 70 bars per symbol), stay_thresh should be non-null by_sym = out.group_by("symbol").agg(pl.col("stay_thresh").is_not_null().sum().alias("n")) for n in by_sym["n"].to_list(): assert n > 100 # well past warm-up of W//2 = 70 def test_per_symbol_stay_thresh_monotonic_in_stay_q(per_symbol_intraday) -> None: """stay_thresh must increase monotonically with stay_q, and thus stay below the entry threshold (long_q) for any stay_q < long_q. long_thresh is dropped from the output, and the function forbids ``stay_q == long_q``, so we cannot read long_thresh directly. Instead we verify the underlying invariant black-box: a lower stay_q must yield a stay_thresh at or below a higher stay_q's on the same row. Since the entry threshold is the long_q quantile, monotonicity transitively guarantees every ``stay_q < long_q`` threshold sits below it. This catches a sign flip between stay_thresh and long_thresh, unlike the previous tautological "score exceeds the threshold that fired it" check. """ kw = dict(long_q=0.80, lookback_days=10, bars_per_day=14) lo = per_symbol_rolling_percentile_signal(per_symbol_intraday, stay_q=0.40, **kw) hi = per_symbol_rolling_percentile_signal(per_symbol_intraday, stay_q=0.79, **kw) joined = ( lo.select(["symbol", "timestamp", "stay_thresh"]) .join( hi.select(["symbol", "timestamp", pl.col("stay_thresh").alias("stay_thresh_hi")]), on=["symbol", "timestamp"], how="inner", ) .filter(pl.col("stay_thresh").is_not_null() & pl.col("stay_thresh_hi").is_not_null()) ) assert joined.height > 100 # well past warm-up # q=0.40 quantile must never exceed the q=0.79 quantile (< the q=0.80 entry). assert (joined["stay_thresh"] - joined["stay_thresh_hi"]).max() <= 1e-9 def test_per_symbol_rejects_stay_q_at_or_above_long_q(per_symbol_intraday) -> None: with pytest.raises(ValueError, match="stay_q must be < long_q"): per_symbol_rolling_percentile_signal( per_symbol_intraday, long_q=0.60, lookback_days=10, bars_per_day=14, stay_q=0.60, )