Source code for finance_calcs.quantile

"""Quantile and signal-quantile analysis as polars expressions.

Most functions are intended to be evaluated cross-sectionally inside
``group_by("date").agg(...)`` or ``with_columns(... .over("date"))``.
"""

from __future__ import annotations

import polars as pl

__all__ = [
    "assign_quantile",
    "rank_normalize",
    "zscore",
    "winsorize",
    "long_short_spread",
    "mean_return_by_quantile",
    "quantile_changed",
    "quantile_turnover",
]


[docs] def assign_quantile(signal: pl.Expr, n_quantiles: int = 5) -> pl.Expr: """Assign integer quantile labels ``0..n_quantiles-1`` to ``signal``. Args: signal: Signal series. Nulls produce null labels. n_quantiles: Number of quantile buckets. Returns: Integer expression in ``[0, n_quantiles - 1]``. Higher signal values map to higher labels. """ rank = signal.rank(method="ordinal") n = signal.count() q = ((rank - 1) * n_quantiles / n).floor().cast(pl.Int32) return q.clip(0, n_quantiles - 1)
[docs] def rank_normalize(signal: pl.Expr) -> pl.Expr: """Cross-sectional rank scaled to ``[-0.5, 0.5]``. Args: signal: Signal series. Returns: Expression with mean zero and bounded support. """ rank = signal.rank(method="average") return (rank - 0.5) / signal.count() - 0.5
[docs] def zscore(signal: pl.Expr) -> pl.Expr: """Cross-sectional z-score: ``(x - mean) / std``. Args: signal: Signal series. Returns: Z-score expression. """ return (signal - signal.mean()) / signal.std()
[docs] def winsorize(signal: pl.Expr, cutoff: float = 3.0) -> pl.Expr: """Clip values to ``mean ± cutoff * std``. Args: signal: Signal series. cutoff: Number of standard deviations. Must be positive. Returns: Clipped expression. """ mu = signal.mean() sd = signal.std() return signal.clip(mu - cutoff * sd, mu + cutoff * sd)
[docs] def long_short_spread( returns: pl.Expr, quantile: pl.Expr, upper: int, lower: int, ) -> pl.Expr: """Top-quantile mean return minus bottom-quantile mean return. Use inside ``group_by("date").agg(...)``:: df.group_by("date").agg( long_short_spread(pl.col("ret"), pl.col("q"), upper=4, lower=0) .alias("ls"), ) Args: returns: Forward return series. quantile: Integer quantile label series. upper: Long quantile label. lower: Short quantile label. Returns: Scalar expression. """ long_leg = returns.filter(quantile == upper).mean() short_leg = returns.filter(quantile == lower).mean() return long_leg - short_leg
[docs] def mean_return_by_quantile( returns: pl.Expr, quantile: pl.Expr, *, n_quantiles: int = 5, prefix: str = "q", ) -> list[pl.Expr]: """Build mean-return expressions for quantile labels ``0..n-1``.""" return [returns.filter(quantile == label).mean().alias(f"{prefix}{label}") for label in range(n_quantiles)]
[docs] def quantile_changed(quantile: pl.Expr) -> pl.Expr: """Boolean expression: ``quantile != quantile.shift(1)``. Use inside ``... .over("asset")`` to compute per-asset turnover flags. Aggregate by date to get the fraction of names that changed quantile. Args: quantile: Integer quantile label series. Returns: Boolean expression. The first observation is null/false. """ return quantile != quantile.shift(1)
[docs] def quantile_turnover(changed: pl.Expr) -> pl.Expr: """Fraction of names whose quantile assignment changed.""" return changed.fill_null(False).cast(pl.Float64).mean()