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

615 lines
22 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.
"""Signal conversion and weight construction for backtesting.
Converts ML model predictions (probabilities or scores) into trading signals
and portfolio weights. Used by Ch16-20 notebooks.
Signal conversion approaches:
1. Fixed threshold: signal when score exceeds cutoff
2. Rolling percentile: signal when score exceeds recent distribution quantile
3. Cross-sectional percentile: signal for top-N% of assets at each rebalance
Weight construction:
- Equal-weight top-K: rank and select, uniform allocation
- Score-weighted top-K: rank and select, weight proportional to score
- Inverse volatility: equal-weight placeholder (full impl in Ch17)
"""
from __future__ import annotations
from typing import Literal
import polars as pl
# ---------------------------------------------------------------------------
# Signal conversion
# ---------------------------------------------------------------------------
def fixed_threshold_signal(
predictions: pl.DataFrame,
threshold: float = 0.5,
score_col: str = "y_score",
signal_type: Literal["long_only", "long_short"] = "long_only",
) -> pl.DataFrame:
"""Convert predictions to signals using a fixed threshold.
For classification predictions, the score is typically a probability [0, 1].
For regression predictions, the score may need normalization first.
Args:
predictions: DataFrame with at least [timestamp, symbol, y_score]
threshold: Score threshold for entry signal
score_col: Column containing prediction scores
signal_type: "long_only" (signal=1 when above threshold) or
"long_short" (signal=1 above, signal=-1 below mirror threshold)
Returns:
DataFrame with added 'signal' column (-1, 0, or 1)
"""
if signal_type == "long_only":
return predictions.with_columns(
signal=pl.when(pl.col(score_col) > threshold).then(1).otherwise(0).cast(pl.Int8)
)
else: # long_short
lower_threshold = 1.0 - threshold
return predictions.with_columns(
signal=pl.when(pl.col(score_col) > threshold)
.then(1)
.when(pl.col(score_col) < lower_threshold)
.then(-1)
.otherwise(0)
.cast(pl.Int8)
)
def rolling_percentile_signal(
predictions: pl.DataFrame,
window: int = 63,
percentile: float = 90.0,
score_col: str = "y_score",
time_col: str = "timestamp",
asset_col: str = "symbol",
signal_type: Literal["long_only", "long_short"] = "long_only",
) -> pl.DataFrame:
"""Convert predictions to signals using rolling percentile threshold.
Computes a rolling percentile of recent scores per asset and generates
entry signals when the current score exceeds this adaptive threshold.
Args:
predictions: DataFrame with at least [timestamp, symbol, y_score]
window: Rolling window size (e.g., 63 for ~3 months of daily data)
percentile: Percentile threshold (e.g., 90 for top 10%)
score_col: Column containing prediction scores
time_col: Column containing timestamps
asset_col: Column containing asset identifiers
signal_type: "long_only" or "long_short"
Returns:
DataFrame with added 'signal' column and 'rolling_threshold' column
"""
df = predictions.sort(time_col)
df = df.with_columns(
rolling_threshold=pl.col(score_col)
.rolling_quantile(quantile=percentile / 100.0, window_size=window)
.over(asset_col)
)
if signal_type == "long_only":
df = df.with_columns(
signal=pl.when(pl.col(score_col) > pl.col("rolling_threshold"))
.then(1)
.otherwise(0)
.cast(pl.Int8)
)
else: # long_short
lower_percentile = 100.0 - percentile
df = df.with_columns(
rolling_lower_threshold=pl.col(score_col)
.rolling_quantile(quantile=lower_percentile / 100.0, window_size=window)
.over(asset_col)
)
df = df.with_columns(
signal=pl.when(pl.col(score_col) > pl.col("rolling_threshold"))
.then(1)
.when(pl.col(score_col) < pl.col("rolling_lower_threshold"))
.then(-1)
.otherwise(0)
.cast(pl.Int8)
)
return df
def cross_sectional_percentile_signal(
predictions: pl.DataFrame,
percentile: float = 90.0,
score_col: str = "y_score",
time_col: str = "timestamp",
signal_type: Literal["long_only", "long_short"] = "long_only",
) -> pl.DataFrame:
"""Convert predictions to signals using cross-sectional percentile.
At each timestamp, selects assets in the top N% by score. Controls
position count regardless of absolute score levels.
Args:
predictions: DataFrame with at least [timestamp, symbol, y_score]
percentile: Percentile cutoff (e.g., 90 for top 10% of assets)
score_col: Column containing prediction scores
time_col: Column containing timestamps
signal_type: "long_only" or "long_short"
Returns:
DataFrame with added 'signal' column and 'cs_threshold' column
"""
df = predictions.with_columns(
cs_threshold=pl.col(score_col).quantile(percentile / 100.0).over(time_col)
)
if signal_type == "long_only":
df = df.with_columns(
signal=pl.when(pl.col(score_col) >= pl.col("cs_threshold"))
.then(1)
.otherwise(0)
.cast(pl.Int8)
)
else: # long_short
lower_percentile = 100.0 - percentile
df = df.with_columns(
cs_lower_threshold=pl.col(score_col).quantile(lower_percentile / 100.0).over(time_col)
)
df = df.with_columns(
signal=pl.when(pl.col(score_col) >= pl.col("cs_threshold"))
.then(1)
.when(pl.col(score_col) <= pl.col("cs_lower_threshold"))
.then(-1)
.otherwise(0)
.cast(pl.Int8)
)
return df
# ---------------------------------------------------------------------------
# Weight construction
# ---------------------------------------------------------------------------
def build_target_weights(
predictions: pl.DataFrame,
method: Literal[
"equal_weight_top_k",
"score_weighted_top_k",
"inverse_vol",
] = "equal_weight_top_k",
top_k: int = 10,
long_short: bool = False,
score_col: str = "y_score",
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> pl.DataFrame:
"""Convert predictions to portfolio target weights.
Args:
predictions: DataFrame with at least [timestamp, asset, y_score]
method: Weight construction method
top_k: Number of assets to select per rebalance
long_short: If True, go long top_k and short bottom_k
score_col: Column with prediction scores
time_col: Timestamp column
asset_col: Asset identifier column
Returns:
DataFrame with [timestamp, asset, weight] — weights sum to ~1.0 per timestamp
"""
df = predictions.sort(time_col)
# Rank assets cross-sectionally at each timestamp
df = df.with_columns(
cs_rank=pl.col(score_col).rank(method="ordinal", descending=True).over(time_col),
n_assets=pl.col(score_col).count().over(time_col),
)
# Effective top_k (can't select more assets than available)
df = df.with_columns(
eff_k=pl.min_horizontal(pl.lit(top_k), pl.col("n_assets")),
)
if method == "equal_weight_top_k":
if long_short:
df = df.with_columns(
weight=pl.when(pl.col("cs_rank") <= pl.col("eff_k"))
.then(1.0 / pl.col("eff_k"))
.when(pl.col("cs_rank") > pl.col("n_assets") - pl.col("eff_k"))
.then(-1.0 / pl.col("eff_k"))
.otherwise(0.0)
)
else:
df = df.with_columns(
weight=pl.when(pl.col("cs_rank") <= pl.col("eff_k"))
.then(1.0 / pl.col("eff_k"))
.otherwise(0.0)
)
elif method == "score_weighted_top_k":
# When the top-K absolute-score sum is 0 at a timestamp (all top-K
# predictions exactly zero), score-proportional weighting would
# divide by zero. Fall back to equal-weight within the top-K for
# those timestamps so the rebalance is well-defined.
if long_short:
top = df.filter(pl.col("cs_rank") <= pl.col("eff_k"))
bottom = df.filter(pl.col("cs_rank") > pl.col("n_assets") - pl.col("eff_k"))
top_denom = pl.col(score_col).abs().sum().over(time_col)
top = top.with_columns(
weight=pl.when(top_denom > 0)
.then(pl.col(score_col).abs() / top_denom)
.otherwise(1.0 / pl.col("eff_k"))
)
bottom_denom = pl.col(score_col).abs().sum().over(time_col)
bottom = bottom.with_columns(
weight=pl.when(bottom_denom > 0)
.then(-pl.col(score_col).abs() / bottom_denom)
.otherwise(-1.0 / pl.col("eff_k"))
)
mid = df.filter(
(pl.col("cs_rank") > pl.col("eff_k"))
& (pl.col("cs_rank") <= pl.col("n_assets") - pl.col("eff_k"))
).with_columns(weight=pl.lit(0.0))
df = pl.concat([top, mid, bottom], how="diagonal_relaxed")
else:
top = df.filter(pl.col("cs_rank") <= pl.col("eff_k"))
top_denom = pl.col(score_col).abs().sum().over(time_col)
top = top.with_columns(
weight=pl.when(top_denom > 0)
.then(pl.col(score_col).abs() / top_denom)
.otherwise(1.0 / pl.col("eff_k"))
)
rest = df.filter(pl.col("cs_rank") > pl.col("eff_k")).with_columns(weight=pl.lit(0.0))
df = pl.concat([top, rest], how="diagonal_relaxed")
elif method == "inverse_vol":
# Placeholder — requires historical returns; full impl in Ch17
df = df.with_columns(
weight=pl.when(pl.col("cs_rank") <= pl.col("eff_k"))
.then(1.0 / pl.col("eff_k"))
.otherwise(0.0)
)
# Clean up helper columns
result = df.select([time_col, asset_col, "weight"]).filter(pl.col("weight") != 0.0)
return result.sort(time_col, asset_col)
# ---------------------------------------------------------------------------
# Config-driven dispatcher
# ---------------------------------------------------------------------------
def _signals_to_equal_weights(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> pl.DataFrame:
"""Convert a signal column ({-1, 0, 1}) to equal weights within each group.
Long signals get +1/N_long, short signals get -1/N_short, zero signals excluded.
"""
# Count longs and shorts per timestamp
df = df.with_columns(
n_long=pl.col("signal").filter(pl.col("signal") > 0).count().over(time_col),
n_short=pl.col("signal").filter(pl.col("signal") < 0).count().over(time_col),
)
df = df.with_columns(
weight=pl.when(pl.col("signal") > 0)
.then(1.0 / pl.col("n_long"))
.when(pl.col("signal") < 0)
.then(-1.0 / pl.col("n_short"))
.otherwise(0.0)
)
return (
df.select([time_col, asset_col, "weight"])
.filter(pl.col("weight") != 0.0)
.sort(time_col, asset_col)
)
def per_symbol_rolling_percentile_signal(
predictions: pl.DataFrame,
long_q: float = 0.80,
lookback_days: int = 20,
bars_per_day: int = 390,
score_col: str = "y_score",
time_col: str = "timestamp",
asset_col: str = "symbol",
signal_type: Literal["long_only", "long_short"] = "long_only",
stay_q: float | None = None,
) -> pl.DataFrame:
"""Per-symbol time-series rolling-percentile entry — ranks within own history.
At the first bar of each session, computes the trailing rolling quantile
of `y_score` over the past `lookback_days × bars_per_day` rows per symbol
(causal: shifted by 1 before rolling). The day's threshold is held
constant for that session's later bars via forward-fill within (symbol, date).
A bar enters when `y_score` crosses its symbol-specific session threshold:
long_only: signal=+1 if y_score > p_long
long_short: signal=+1 if y_score > p_long, -1 if y_score < (1 - long_q)
The short tail is the symmetric complement of `long_q` (so `long_q=0.85`
sets `p_short` at the 0.15 quantile). The function asserts `long_q > 0.5`
in `long_short` mode to keep `p_long > p_short`.
Warm-up: `min_samples=W // 2` means roughly the first `lookback_days / 2`
sessions per symbol have a null rolling quantile and therefore a null
threshold; the signal is coerced to 0 for those bars (no entry).
When ``stay_q`` is provided (must be < ``long_q``), a second rolling
quantile is computed at that lower level using identical windowing and
daily anchoring, exposed as a ``stay_thresh`` column on the output. The
stay threshold is used by ``slot_strategy.build_persistent_slot_weights_hybrid``
for signal-based slot exits; the entry signal column is unchanged.
Mirrors the polars aggregator at
`agents/.agents/work/nasdaq100_v3/scripts/sweep_daily_thresh_v2.py::add_daily_pct`,
which is the canonical reference for the nasdaq100 v3 strategy.
"""
if signal_type == "long_short" and long_q <= 0.5:
msg = (
f"per_symbol_rolling_percentile_signal long_short requires long_q > 0.5 "
f"to keep p_long > p_short; got long_q={long_q}"
)
raise ValueError(msg)
if stay_q is not None and stay_q >= long_q:
msg = (
f"per_symbol_rolling_percentile_signal stay_q must be < long_q so the "
f"stay threshold sits below the entry threshold; got stay_q={stay_q}, "
f"long_q={long_q}"
)
raise ValueError(msg)
W = int(lookback_days * bars_per_day)
df = predictions.sort([asset_col, time_col]).with_columns(
_date=pl.col(time_col).dt.date(),
)
y_lag = pl.col(score_col).shift(1).over(asset_col)
df = df.with_columns(
_raw_p_long=y_lag.rolling_quantile(
quantile=long_q,
window_size=W,
min_samples=W // 2,
).over(asset_col),
)
if signal_type == "long_short":
df = df.with_columns(
_raw_p_short=y_lag.rolling_quantile(
quantile=1 - long_q,
window_size=W,
min_samples=W // 2,
).over(asset_col),
)
if stay_q is not None:
df = df.with_columns(
_raw_p_stay=y_lag.rolling_quantile(
quantile=stay_q,
window_size=W,
min_samples=W // 2,
).over(asset_col),
)
df = (
df.with_columns(
_is_first=(pl.col(time_col) == pl.col(time_col).min().over([asset_col, "_date"])),
)
.with_columns(
_p_long_seed=pl.when(pl.col("_is_first")).then(pl.col("_raw_p_long")).otherwise(None),
)
.with_columns(
p_long=pl.col("_p_long_seed").forward_fill().over([asset_col, "_date"]),
)
)
if signal_type == "long_short":
df = (
df.with_columns(
_p_short_seed=pl.when(pl.col("_is_first"))
.then(pl.col("_raw_p_short"))
.otherwise(None),
)
.with_columns(
p_short=pl.col("_p_short_seed").forward_fill().over([asset_col, "_date"]),
)
.with_columns(
signal=pl.when(
pl.col("p_long").is_not_null() & (pl.col(score_col) > pl.col("p_long"))
)
.then(pl.lit(1).cast(pl.Int8))
.when(pl.col("p_short").is_not_null() & (pl.col(score_col) < pl.col("p_short")))
.then(pl.lit(-1).cast(pl.Int8))
.otherwise(pl.lit(0).cast(pl.Int8))
)
)
drop_cols = [
"_date",
"_raw_p_long",
"_raw_p_short",
"_is_first",
"_p_long_seed",
"_p_short_seed",
"p_long",
"p_short",
]
else:
df = df.with_columns(
signal=pl.when(pl.col("p_long").is_not_null() & (pl.col(score_col) > pl.col("p_long")))
.then(pl.lit(1).cast(pl.Int8))
.otherwise(pl.lit(0).cast(pl.Int8))
)
drop_cols = ["_date", "_raw_p_long", "_is_first", "_p_long_seed", "p_long"]
if stay_q is not None:
df = df.with_columns(
_p_stay_seed=pl.when(pl.col("_is_first")).then(pl.col("_raw_p_stay")).otherwise(None),
).with_columns(
stay_thresh=pl.col("_p_stay_seed").forward_fill().over([asset_col, "_date"]),
)
drop_cols = [*drop_cols, "_raw_p_stay", "_p_stay_seed"]
return df.drop(drop_cols)
def _decile_long_short(
predictions: pl.DataFrame,
n_quantiles: int = 10,
score_col: str = "y_score",
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> pl.DataFrame:
"""Academic factor portfolio: long top decile, short bottom decile.
Args:
predictions: DataFrame with [timestamp, asset, y_score]
n_quantiles: Number of quantile bins (10=decile, 5=quintile)
score_col: Score column
time_col: Timestamp column
asset_col: Asset column
Returns:
DataFrame with [timestamp, asset, weight]
"""
df = predictions.sort(time_col)
# Cross-sectional quantile rank per timestamp
df = df.with_columns(
cs_rank=pl.col(score_col).rank(method="ordinal", descending=True).over(time_col),
n_assets=pl.col(score_col).count().over(time_col),
)
# Determine top and bottom quantile thresholds
df = df.with_columns(
top_cutoff=(pl.col("n_assets") / n_quantiles).floor().cast(pl.Int64).clip(lower_bound=1),
)
# Top quantile = long, bottom quantile = short
df = df.with_columns(
signal=pl.when(pl.col("cs_rank") <= pl.col("top_cutoff"))
.then(pl.lit(1).cast(pl.Int8))
.when(pl.col("cs_rank") > pl.col("n_assets") - pl.col("top_cutoff"))
.then(pl.lit(-1).cast(pl.Int8))
.otherwise(pl.lit(0).cast(pl.Int8))
)
return _signals_to_equal_weights(df, time_col, asset_col)
def build_target_weights_from_config(
predictions: pl.DataFrame,
config: dict,
score_col: str = "y_score",
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> pl.DataFrame:
"""Config-dict dispatcher for signal conversion and weight construction.
Dispatches to existing methods based on config["method"]:
- "equal_weight_top_k": top_k assets, equal weight
- "score_weighted_top_k": top_k assets, score-proportional weight
- "cross_sectional_percentile": percentile-based selection, equal weight
- "per_symbol_rolling_percentile": per-symbol time-series rolling quantile,
daily-anchored; long_q + lookback_days + bars_per_day
- "fixed_threshold": threshold-based selection, equal weight
- "decile_long_short": top/bottom decile, equal weight (academic factor)
- "quintile_long_short": top/bottom quintile, equal weight
Config dict keys:
method (str): One of the methods above
top_k (int): For top-k methods
long_short (bool): For top-k methods
percentile (float): For cross-sectional percentile (e.g., 90.0)
threshold (float): For fixed threshold
n_quantiles (int): For decile/quintile methods (default 10)
Returns:
DataFrame with [timestamp, asset, weight]
"""
method = config["method"]
long_short = config.get("long_short", False)
direction = str(config.get("direction", "long_only")).strip().lower()
def _apply_direction(weights: pl.DataFrame) -> pl.DataFrame:
if direction == "long_only":
return weights
if direction == "short_only":
return weights.with_columns((-pl.col("weight")).alias("weight"))
msg = f"Unknown signal direction: {direction}"
raise ValueError(msg)
if method in ("equal_weight_top_k", "score_weighted_top_k", "inverse_vol"):
return _apply_direction(
build_target_weights(
predictions,
method=method,
top_k=config.get("top_k", 10),
long_short=long_short,
score_col=score_col,
time_col=time_col,
asset_col=asset_col,
)
)
elif method == "cross_sectional_percentile":
percentile = config.get("percentile", 90.0)
signal_type = "long_short" if long_short else "long_only"
df_with_signal = cross_sectional_percentile_signal(
predictions,
percentile=percentile,
score_col=score_col,
time_col=time_col,
signal_type=signal_type,
)
return _apply_direction(_signals_to_equal_weights(df_with_signal, time_col, asset_col))
elif method == "per_symbol_rolling_percentile":
long_q = float(config.get("long_q", 0.80))
lookback_days = int(config.get("lookback_days", 20))
bars_per_day = int(config.get("bars_per_day", 390))
signal_type = "long_short" if long_short else "long_only"
df_with_signal = per_symbol_rolling_percentile_signal(
predictions,
long_q=long_q,
lookback_days=lookback_days,
bars_per_day=bars_per_day,
score_col=score_col,
time_col=time_col,
asset_col=asset_col,
signal_type=signal_type,
)
return _apply_direction(_signals_to_equal_weights(df_with_signal, time_col, asset_col))
elif method == "fixed_threshold":
threshold = config.get("threshold", 0.0)
signal_type = "long_short" if long_short else "long_only"
df_with_signal = fixed_threshold_signal(
predictions,
threshold=threshold,
score_col=score_col,
signal_type=signal_type,
)
return _apply_direction(_signals_to_equal_weights(df_with_signal, time_col, asset_col))
elif method in ("decile_long_short", "quintile_long_short"):
n_q = config.get("n_quantiles", 10 if method == "decile_long_short" else 5)
return _apply_direction(
_decile_long_short(
predictions,
n_quantiles=n_q,
score_col=score_col,
time_col=time_col,
asset_col=asset_col,
)
)
else:
msg = f"Unknown signal method: {method}"
raise ValueError(msg)