Files
2026-07-13 13:26:28 +08:00

495 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (AE), 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 (AF) 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,
)