项目文件夹

文件
2026-07-13 13:26:28 +08:00

495 行
21 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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,
)