Skip to content

Quant

Price, Yang-Zhang volatility with rolling drawdown, and the Hurst regime ribbon on BTCUSDT 5m

The professional-desk layer: volatility forecasting, risk sizing, regime detection, and factor construction. Where the retail indicators help you read a chart, these help you size a position, decide which strategy family fits the current regime, and build a cross-sectional book. Most are validated against real BTCUSDT market data rather than synthetic noise.

Volatility estimators

More accurate volatility than close-to-close, by using the whole OHLC bar. Each squeezes more information from the same data, trading off different assumptions:

  • historical_volatility — close-to-close baseline.
  • parkinson_volatility — uses the high-low range; far more efficient, but assumes no drift or gaps.
  • garman_klass_volatility — adds open/close to the range.
  • rogers_satchell_volatility — unbiased under drift.
  • yang_zhang_volatility — the desk favourite: handles both overnight gaps and drift. Reach for this when you have OHLC bars.
  • ewma_volatility — RiskMetrics-style exponential weighting, so a vol spike shows immediately and fades smoothly instead of dropping off a cliff window bars later.

Risk-adjusted performance & tail risk

For position sizing — lean out as the tail fattens, not just as symmetric vol rises:

  • rolling_sharpe_ratio / rolling_sortino_ratio — return per unit of total / downside volatility.
  • rolling_cvar — Conditional VaR / expected shortfall: the average of the worst α losses. A coherent risk measure (unlike VaR), and the natural sizing denominator.
  • cornish_fisher_var — VaR corrected for skew and fat tails, so it doesn't understate crash risk the way Gaussian VaR does.
  • rolling_max_drawdown / calmar_ratio — worst peak-to-trough pain, and return earned per unit of it.

Distribution shape (regime fragility)

Higher moments turn before volatility does when a market becomes crash-prone:

  • rolling_skew / rolling_kurtosis — asymmetry and fat-tailedness.
  • jarque_bera — one number summarising how non-Gaussian returns have been (rises when either tail asymmetry or fat tails appear).
  • gain_to_pain — net move per unit of downside suffered; a robustness/smoothness screen.

Regime detection & signal conditioning

Decide which strategy family fits now, and make features usable:

  • hurst_ribbon — multi-scale Hurst exponent: trending (>0.5) vs. mean-reverting (<0.5) regime, at several horizons at once.
  • regime_conditional_signal — a hard switch between two pre-built signals based on any regime score (e.g. trend-follow when Hurst says persistent, mean-revert otherwise).
  • frac_diff — fractional differentiation (López de Prado): make a series stationary while keeping most of its memory — often the difference between a feature that predicts and a return series that doesn't.
  • rolling_autocorr / rolling_ic — serial structure, and a feature's decaying predictive power (the IC is forward-looking — a monitoring diagnostic, never a live input).
  • rolling_z_score / vol_adjusted_momentum / volatility_z_score / relative_volume — standardised / vol-scaled building blocks for gating and sizing.

Cross-sectional / factor plumbing

For a multi-asset book: compare symbols against each other, and against a benchmark. Apply the cross-sectional ones with .over("timestamp") on a long-format frame.

  • cross_sectional_zscore / cross_sectional_rank — rank symbols at each instant (the core of a factor strategy).
  • momentum_12_1 — the canonical Jegadeesh-Titman momentum factor (skip the last month to drop short-term reversal).
  • rolling_beta_to / downside_beta — sensitivity to a benchmark, overall and in down markets (tail hedging).
  • idiosyncratic_vol — the asset-specific risk a beta hedge leaves behind.
  • amihud_illiquidity / micro_price_proxy — price impact per dollar traded, and a volume-tilted fair-price proxy.

polars_ta.quant

rolling_sharpe_ratio

rolling_sharpe_ratio(close: str, window: int = 63, risk_free_rate: float = 0.0) -> Expr

Annualized Rolling Sharpe Ratio (Assuming 252 trading days)

Source code in polars_ta/quant.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def rolling_sharpe_ratio(
    close: str, window: int = 63, risk_free_rate: float = 0.0
) -> pl.Expr:
    """Annualized Rolling Sharpe Ratio (Assuming 252 trading days)"""
    ret = as_expr(close).pct_change()

    # Assuming daily data, we annualize by multiplying by sqrt(252)
    rolling_mean = ret.rolling_mean(window_size=window) - (risk_free_rate / 252)
    rolling_std = ret.rolling_std(window_size=window)

    safe_std = pl.when(rolling_std == 0).then(1.0).otherwise(rolling_std)
    sharpe = (rolling_mean / safe_std) * math.sqrt(252)

    return sharpe.alias(f"sharpe_{window}")

rolling_sortino_ratio

rolling_sortino_ratio(close: str, window: int = 63, risk_free_rate: float = 0.0) -> Expr

Annualized Rolling Sortino Ratio (Penalizes only downside volatility)

Source code in polars_ta/quant.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def rolling_sortino_ratio(
    close: str, window: int = 63, risk_free_rate: float = 0.0
) -> pl.Expr:
    """Annualized Rolling Sortino Ratio (Penalizes only downside volatility)"""
    ret = as_expr(close).pct_change()

    downside_ret = pl.when(ret < 0).then(ret).otherwise(0.0)

    rolling_mean = ret.rolling_mean(window_size=window) - (risk_free_rate / 252)
    downside_std = downside_ret.rolling_std(window_size=window)

    safe_down_std = pl.when(downside_std == 0).then(1.0).otherwise(downside_std)
    sortino = (rolling_mean / safe_down_std) * math.sqrt(252)

    return sortino.alias(f"sortino_{window}")

historical_volatility

historical_volatility(close: str, window: int = 21) -> Expr

Annualized Historical Volatility (Close-to-Close)

Source code in polars_ta/quant.py
150
151
152
153
154
155
156
def historical_volatility(close: str, window: int = 21) -> pl.Expr:
    """Annualized Historical Volatility (Close-to-Close)"""
    log_ret = log_return(as_expr(close))

    # Standard deviation of log returns * sqrt(252 trading days)
    hv = log_ret.rolling_std(window_size=window) * math.sqrt(252)
    return hv.alias(f"hist_vol_{window}")

ewma_volatility

ewma_volatility(close: str, window: int = 21, lambda_: float = 0.94) -> Expr

Annualized EWMA (RiskMetrics-style) volatility of log returns.

Unlike historical_volatility's flat rolling window, older squared returns decay geometrically (weight lambda_ ** k), so a volatility spike shows up immediately and fades out smoothly instead of dropping off a cliff window bars later. lambda_=0.94 is the RiskMetrics daily default; min_samples=window keeps the same warm-up convention as every other volatility estimator here even though the EWM itself has infinite memory.

Source code in polars_ta/quant.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def ewma_volatility(close: str, window: int = 21, lambda_: float = 0.94) -> pl.Expr:
    """Annualized EWMA (RiskMetrics-style) volatility of log returns.

    Unlike `historical_volatility`'s flat rolling window, older squared
    returns decay geometrically (weight `lambda_ ** k`), so a volatility
    spike shows up immediately and fades out smoothly instead of dropping off
    a cliff `window` bars later. `lambda_=0.94` is the RiskMetrics daily
    default; `min_samples=window` keeps the same warm-up convention as every
    other volatility estimator here even though the EWM itself has infinite
    memory.
    """
    log_ret = log_return(as_expr(close))
    sq_ret = log_ret.pow(2)
    variance = sq_ret.ewm_mean(alpha=1 - lambda_, adjust=False, min_samples=window)
    ewma_vol = variance.sqrt() * math.sqrt(252)
    return ewma_vol.alias(f"ewma_vol_{window}")

parkinson_volatility

parkinson_volatility(high: str, low: str, window: int = 20, trading_periods: int = 252) -> Expr

Parkinson (1980) high-low range volatility estimator, annualized.

Uses only the intraday high-low range, which makes it far more efficient than close-to-close historical volatility (it exploits the whole bar, not just the endpoints). It assumes no drift and no overnight jumps, so it complements Garman-Klass and Yang-Zhang rather than replacing them.

Source code in polars_ta/quant.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def parkinson_volatility(
    high: str, low: str, window: int = 20, trading_periods: int = 252
) -> pl.Expr:
    """Parkinson (1980) high-low range volatility estimator, annualized.

    Uses only the intraday high-low range, which makes it far more efficient
    than close-to-close historical volatility (it exploits the whole bar, not
    just the endpoints). It assumes no drift and no overnight jumps, so it
    complements Garman-Klass and Yang-Zhang rather than replacing them.
    """
    h, lo = (
        as_expr(high),
        as_expr(low),
    )
    factor = 1.0 / (4.0 * math.log(2.0))
    park_var = (factor * (h / lo).log().pow(2)).rolling_mean(window_size=window)
    return (park_var.sqrt() * math.sqrt(trading_periods)).alias(
        f"parkinson_vol_{window}"
    )

rogers_satchell_volatility

rogers_satchell_volatility(open_price: str, high: str, low: str, close: str, window: int = 20, trading_periods: int = 252) -> Expr

Rogers-Satchell (1991) volatility estimator, annualized.

Unlike Parkinson and Garman-Klass, this estimator is unbiased in the presence of a non-zero drift, using all four OHLC prices. It does not account for overnight gaps (that is what Yang-Zhang adds on top), so it is the natural mid-point of the OHLC-volatility family.

Source code in polars_ta/quant.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def rogers_satchell_volatility(
    open_price: str,
    high: str,
    low: str,
    close: str,
    window: int = 20,
    trading_periods: int = 252,
) -> pl.Expr:
    """Rogers-Satchell (1991) volatility estimator, annualized.

    Unlike Parkinson and Garman-Klass, this estimator is unbiased in the
    presence of a non-zero drift, using all four OHLC prices. It does not
    account for overnight gaps (that is what Yang-Zhang adds on top), so it is
    the natural mid-point of the OHLC-volatility family.
    """
    o, h, lo, c = (
        as_expr(open_price),
        as_expr(high),
        as_expr(low),
        as_expr(close),
    )
    log_ho = (h / o).log()
    log_hc = (h / c).log()
    log_lo = (lo / o).log()
    log_lc = (lo / c).log()
    rs = log_ho * log_hc + log_lo * log_lc
    rs_var = rs.rolling_mean(window_size=window)
    return (rs_var.sqrt() * math.sqrt(trading_periods)).alias(f"rs_vol_{window}")

yang_zhang_volatility

yang_zhang_volatility(open_price: str, high: str, low: str, close: str, window: int = 20, trading_periods: int = 252) -> Expr

Yang-Zhang (2000) volatility estimator, annualized.

Combines overnight (open-to-prev-close), open-to-close drift, and a Rogers-Satchell high/low term into a single minimum-variance estimator that (unlike close-to-close historical volatility or Garman-Klass) is unbiased in the presence of both overnight jumps and intraday drift. This is the volatility estimator of choice on professional vol desks when only OHLC bars (no tick data) are available.

Source code in polars_ta/quant.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def yang_zhang_volatility(
    open_price: str,
    high: str,
    low: str,
    close: str,
    window: int = 20,
    trading_periods: int = 252,
) -> pl.Expr:
    """Yang-Zhang (2000) volatility estimator, annualized.

    Combines overnight (open-to-prev-close), open-to-close drift, and a
    Rogers-Satchell high/low term into a single minimum-variance estimator
    that (unlike close-to-close historical volatility or Garman-Klass) is
    unbiased in the presence of both overnight jumps and intraday drift.
    This is the volatility estimator of choice on professional vol desks
    when only OHLC bars (no tick data) are available.
    """
    o = as_expr(open_price)
    h = as_expr(high)
    lo = as_expr(low)
    c = as_expr(close)

    log_ho = (h / o.shift(1)).log()
    log_lo_ = (lo / o).log()
    log_co = (c / o).log()
    log_oc = (o / c.shift(1)).log()
    log_cc = (c / c.shift(1)).log()

    rs_term = log_ho * (log_ho - log_co) + log_lo_ * (log_lo_ - log_co)

    open_vol = (log_oc**2).rolling_sum(window_size=window) / (window - 1)
    close_vol = (log_cc**2).rolling_sum(window_size=window) / (window - 1)
    window_rs = rs_term.rolling_sum(window_size=window) / (window - 1)

    k = 0.34 / (1.34 + (window + 1) / (window - 1))
    yz_variance = open_vol + k * close_vol + (1 - k) * window_rs

    return (yz_variance.sqrt() * math.sqrt(trading_periods)).alias(
        f"yz_volatility_{window}"
    )

hurst_ribbon

hurst_ribbon(close: str, scales: tuple[int, ...] = (16, 32, 64)) -> dict[str, Expr]

Multi-scale Hurst ribbon: rescaled-range Hurst exponent computed at several window scales simultaneously, plus two derived regime features.

Returns a dict of expressions rather than a single one — pass its values straight into with_columns(**hurst_ribbon("close").values()) or unpack individual keys. Keys are h_{scale} for each scale, plus:

  • h_ribbon_avg: mean Hurst across scales — overall trending (>0.5) vs mean-reverting (<0.5) regime.
  • h_ribbon_tilt: shortest-scale H minus longest-scale H — positive means short-term trend is stronger than long-term (breakout potential), negative means short-term is exhausting relative to the longer trend.

Unlike :func:hurst_exponent's full R/S analysis (accurate but O(window) per row), this uses the cheap log(range/std)/log(window) approximation, which is fast enough to run at several scales at once and is the form used in production multi-scale regime detectors.

Source code in polars_ta/quant.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def hurst_ribbon(
    close: str, scales: tuple[int, ...] = (16, 32, 64)
) -> dict[str, pl.Expr]:
    """Multi-scale Hurst ribbon: rescaled-range Hurst exponent computed at
    several window scales simultaneously, plus two derived regime features.

    Returns a dict of expressions rather than a single one — pass its values
    straight into ``with_columns(**hurst_ribbon("close").values())`` or
    unpack individual keys. Keys are ``h_{scale}`` for each scale, plus:

    - ``h_ribbon_avg``: mean Hurst across scales — overall trending
      (>0.5) vs mean-reverting (<0.5) regime.
    - ``h_ribbon_tilt``: shortest-scale H minus longest-scale H — positive
      means short-term trend is stronger than long-term (breakout
      potential), negative means short-term is exhausting relative to the
      longer trend.

    Unlike :func:`hurst_exponent`'s full R/S analysis (accurate but
    O(window) per row), this uses the cheap log(range/std)/log(window)
    approximation, which is fast enough to run at several scales at once
    and is the form used in production multi-scale regime detectors.
    """
    ln_p = as_expr(close).log()
    log_ret = ln_p.diff(1)

    h_exprs: dict[str, pl.Expr] = {}
    for w in scales:
        rng = ln_p.rolling_max(window_size=w) - ln_p.rolling_min(window_size=w)
        std = log_ret.rolling_std(window_size=w)
        safe_std = pl.when(std == 0).then(None).otherwise(std)
        h_exprs[f"h_{w}"] = ((rng / safe_std).log() / math.log(w)).alias(f"h_{w}")

    avg_expr = sum(h_exprs[f"h_{w}"] for w in scales) / len(scales)
    tilt_expr = h_exprs[f"h_{scales[0]}"] - h_exprs[f"h_{scales[-1]}"]

    h_exprs["h_ribbon_avg"] = avg_expr.alias("h_ribbon_avg")
    h_exprs["h_ribbon_tilt"] = tilt_expr.alias("h_ribbon_tilt")
    return h_exprs

relative_volume

relative_volume(volume: str, window: int = 100) -> Expr

Relative volume (RVol): current volume vs its rolling mean.

Spikes in RVol often mark the start or end of a regime — a standard "is something happening right now" gate on execution/monitoring desks.

Source code in polars_ta/quant.py
310
311
312
313
314
315
316
317
def relative_volume(volume: str, window: int = 100) -> pl.Expr:
    """Relative volume (RVol): current volume vs its rolling mean.

    Spikes in RVol often mark the start or end of a regime — a standard
    "is something happening right now" gate on execution/monitoring desks.
    """
    v = as_expr(volume)
    return (v / v.rolling_mean(window_size=window)).alias(f"rvol_{window}")

volatility_z_score

volatility_z_score(high: str, low: str, window: int = 100) -> Expr

Z-score of the rolling high-low range against its own recent history.

Flags volatility expansion/contraction relative to the recent norm, independent of the absolute price level — used to gate position sizing or strategy switching on a volatility-regime shift.

Source code in polars_ta/quant.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def volatility_z_score(high: str, low: str, window: int = 100) -> pl.Expr:
    """Z-score of the rolling high-low range against its own recent history.

    Flags volatility expansion/contraction relative to the recent norm,
    independent of the absolute price level — used to gate position sizing
    or strategy switching on a volatility-regime shift.
    """
    hl_range = (
        as_expr(high)
        - as_expr(low)
    ).rolling_mean(window_size=window)
    mean = hl_range.rolling_mean(window_size=window)
    std = hl_range.rolling_std(window_size=window)
    safe_std = pl.when(std == 0).then(None).otherwise(std)
    return ((hl_range - mean) / safe_std).alias(f"vol_z_score_{window}")

cross_sectional_zscore

cross_sectional_zscore(value: str) -> Expr

Cross-sectional z-score of value at each timestamp.

Unlike every other indicator in this library, which computes a rolling statistic through time for one symbol, this compares symbols against each other at the same instant — the core building block of a factor/ranking strategy. It is a per-symbol expression like any other, but is only meaningful applied with .over(timestamp_column) on a long-format multi-asset frame (columns: timestamp, symbol, value, ...), grouping across symbols rather than across time:

df.with_columns(
    quant.cross_sectional_zscore("momentum").over("timestamp")
)

A cross-section with zero spread (all symbols tied) yields null rather than a divide-by-zero.

Source code in polars_ta/quant.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def cross_sectional_zscore(value: str) -> pl.Expr:
    """Cross-sectional z-score of `value` at each timestamp.

    Unlike every other indicator in this library, which computes a rolling
    statistic *through time* for one symbol, this compares symbols *against
    each other at the same instant* — the core building block of a
    factor/ranking strategy. It is a per-symbol expression like any other,
    but is only meaningful applied with `.over(timestamp_column)` on a
    long-format multi-asset frame (columns: timestamp, symbol, value, ...),
    grouping across symbols rather than across time:

        df.with_columns(
            quant.cross_sectional_zscore("momentum").over("timestamp")
        )

    A cross-section with zero spread (all symbols tied) yields null rather
    than a divide-by-zero.
    """
    v = as_expr(value)
    mean = v.mean()
    std = v.std()
    safe_std = pl.when(std == 0).then(None).otherwise(std)
    return (v - mean) / safe_std

cross_sectional_rank

cross_sectional_rank(value: str, pct: bool = True) -> Expr

Cross-sectional rank of value at each timestamp, in [0, 1] by default (pct=True) or as a dense integer rank (pct=False).

Same usage as :func:cross_sectional_zscore: apply with .over(timestamp_column) on a long-format multi-asset frame to rank symbols against each other at each instant, not through time.

Source code in polars_ta/quant.py
362
363
364
365
366
367
368
369
370
371
372
373
def cross_sectional_rank(value: str, pct: bool = True) -> pl.Expr:
    """Cross-sectional rank of `value` at each timestamp, in `[0, 1]` by
    default (`pct=True`) or as a dense integer rank (`pct=False`).

    Same usage as :func:`cross_sectional_zscore`: apply with
    `.over(timestamp_column)` on a long-format multi-asset frame to rank
    symbols against each other at each instant, not through time.
    """
    v = as_expr(value)
    if pct:
        return v.rank(method="average") / v.count()
    return v.rank(method="dense")

amihud_illiquidity

amihud_illiquidity(close: str, volume: str, window: int = 21) -> Expr

Rolling Amihud Illiquidity (Price impact per dollar traded)

Source code in polars_ta/quant.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def amihud_illiquidity(close: str, volume: str, window: int = 21) -> pl.Expr:
    """Rolling Amihud Illiquidity (Price impact per dollar traded)"""
    c = as_expr(close)
    v = as_expr(volume)

    daily_ret_abs = c.pct_change().abs()
    dollar_volume = c * v

    safe_dollar_vol = pl.when(dollar_volume == 0).then(None).otherwise(dollar_volume)

    # Ratio of absolute return to dollar volume, smoothed over a window
    amihud = (daily_ret_abs / safe_dollar_vol).rolling_mean(window_size=window)

    # Multiply by 10^6 to make the numbers human-readable (standard practice)
    return (amihud * 1_000_000).alias(f"amihud_illiq_{window}")

regime_conditional_signal

regime_conditional_signal(regime: str | Expr, threshold: float, signal_above: str | Expr, signal_below: str | Expr, above_or_equal: bool = True) -> Expr

Switch between two pre-computed signal expressions based on a regime score, row by row: signal_above where regime >= threshold (or > if above_or_equal=False), signal_below otherwise.

This is a hard switch, not a smooth blend — the output jumps discretely at the threshold rather than fading between the two signals, which keeps the composite as interpretable as its inputs (no intermediate "40% trend-following" values to explain) at the cost of a discontinuity exactly at the boundary. This is a compositional building block, not a Hurst-specific helper: regime can be any expression — quant.hurst_ribbon(...)["h_ribbon_avg"], an ADX reading, a Shannon entropy score — and signal_above/signal_below can be any two already-computed indicator expressions you want the regime to arbitrate between (they are typically named differently, e.g. a fast EMA-cross trend signal vs. a Bollinger %B mean-reversion signal — see the "Regime-conditional trend/mean-reversion switch" how-to guide for a complete example built on hurst_ribbon).

A null regime value produces a null output (arbitration is undefined without a regime reading), rather than silently falling back to either branch.

Source code in polars_ta/quant.py
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def regime_conditional_signal(
    regime: str | pl.Expr,
    threshold: float,
    signal_above: str | pl.Expr,
    signal_below: str | pl.Expr,
    above_or_equal: bool = True,
) -> pl.Expr:
    """Switch between two pre-computed signal expressions based on a
    regime score, row by row: `signal_above` where `regime >= threshold`
    (or `>` if `above_or_equal=False`), `signal_below` otherwise.

    This is a hard switch, not a smooth blend — the output jumps discretely
    at the threshold rather than fading between the two signals, which
    keeps the composite as interpretable as its inputs (no intermediate
    "40% trend-following" values to explain) at the cost of a
    discontinuity exactly at the boundary. This is a compositional building
    block, not a Hurst-specific helper: `regime` can be any expression —
    `quant.hurst_ribbon(...)["h_ribbon_avg"]`, an ADX reading, a Shannon
    entropy score — and `signal_above`/`signal_below` can be any two
    already-computed indicator expressions you want the regime to arbitrate
    between (they are typically named differently, e.g. a fast EMA-cross
    trend signal vs. a Bollinger %B mean-reversion signal — see the
    "Regime-conditional trend/mean-reversion switch" how-to guide for a
    complete example built on `hurst_ribbon`).

    A null `regime` value produces a null output (arbitration is undefined
    without a regime reading), rather than silently falling back to either
    branch.
    """
    regime_expr = as_expr(regime)
    above = as_expr(signal_above)
    below = as_expr(signal_below)

    condition = regime_expr >= threshold if above_or_equal else regime_expr > threshold
    return (
        pl.when(regime_expr.is_null())
        .then(None)
        .when(condition)
        .then(above)
        .otherwise(below)
        .alias("regime_conditional_signal")
    )

rolling_cvar

rolling_cvar(close: str | Expr, window: int = 100, alpha: float = 0.05) -> Expr

Rolling Conditional Value-at-Risk (Expected Shortfall) of simple returns.

CVaR at level alpha is the mean of the worst alpha fraction of returns in the window — the expected loss given that the VaR threshold is breached. Unlike VaR (a quantile), CVaR is a coherent risk measure (sub-additive), which is why it is the tail metric of choice for position sizing and the objective in Rockafellar-Uryasev portfolio optimization.

Reported as a positive loss magnitude (the sign is flipped), so larger means more tail risk. A window with fewer than ceil(1/alpha) returns can't resolve the alpha tail and reports null rather than a single-point "expected shortfall".

Source code in polars_ta/quant.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def rolling_cvar(
    close: str | pl.Expr, window: int = 100, alpha: float = 0.05
) -> pl.Expr:
    """Rolling Conditional Value-at-Risk (Expected Shortfall) of simple returns.

    CVaR at level ``alpha`` is the mean of the worst ``alpha`` fraction of
    returns in the window — the expected loss *given* that the VaR threshold is
    breached. Unlike VaR (a quantile), CVaR is a coherent risk measure
    (sub-additive), which is why it is the tail metric of choice for position
    sizing and the objective in Rockafellar-Uryasev portfolio optimization.

    Reported as a **positive** loss magnitude (the sign is flipped), so larger
    means more tail risk. A window with fewer than ``ceil(1/alpha)`` returns
    can't resolve the ``alpha`` tail and reports null rather than a single-point
    "expected shortfall".
    """
    close = as_expr(close)
    ret = close.pct_change()
    # Number of tail observations averaged; at least 1, and the window must hold
    # enough returns to actually populate that tail.
    k = max(1, math.ceil(alpha * window))

    def _cvar(s: pl.Series) -> pl.Series:
        x = s.to_numpy()
        n = len(x)
        out = [None] * n
        min_needed = max(window, k + 1)
        for i in range(min_needed - 1, n):
            w = x[i - window + 1 : i + 1]
            w = w[~np.isnan(w)]
            if len(w) < k:
                continue
            worst = np.sort(w)[:k]
            out[i] = float(-worst.mean())
        return pl.Series(out, dtype=pl.Float64)

    return ret.map_batches(_cvar, returns_scalar=False).alias(
        f"cvar_{int(alpha * 100)}_{window}"
    )

cornish_fisher_var

cornish_fisher_var(close: str | Expr, window: int = 100, alpha: float = 0.05) -> Expr

Rolling Cornish-Fisher (modified) Value-at-Risk of simple returns.

Standard Gaussian VaR uses mu + z_alpha * sigma, which understates tail risk for the negatively-skewed, fat-tailed return distributions typical of risk assets. The Cornish-Fisher expansion corrects the Gaussian quantile z for the sample skewness S and excess kurtosis K::

z_cf = z + (z^2-1)/6 * S + (z^3-3z)/24 * K - (2z^3-5z)/36 * S^2

and VaR = -(mu + z_cf * sigma), reported as a positive loss magnitude. This is the RiskMetrics "modified VaR" and captures crash risk that symmetric vol misses, without assuming a parametric fat-tailed family.

Source code in polars_ta/quant.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
def cornish_fisher_var(
    close: str | pl.Expr, window: int = 100, alpha: float = 0.05
) -> pl.Expr:
    """Rolling Cornish-Fisher (modified) Value-at-Risk of simple returns.

    Standard Gaussian VaR uses ``mu + z_alpha * sigma``, which understates tail
    risk for the negatively-skewed, fat-tailed return distributions typical of
    risk assets. The Cornish-Fisher expansion corrects the Gaussian quantile
    ``z`` for the sample skewness ``S`` and excess kurtosis ``K``::

        z_cf = z + (z^2-1)/6 * S + (z^3-3z)/24 * K - (2z^3-5z)/36 * S^2

    and VaR = ``-(mu + z_cf * sigma)``, reported as a positive loss magnitude.
    This is the RiskMetrics "modified VaR" and captures crash risk that
    symmetric vol misses, without assuming a parametric fat-tailed family.
    """
    close = as_expr(close)
    ret = close.pct_change()

    z = _norm_ppf(alpha)
    mu = ret.rolling_mean(window_size=window)
    sigma = ret.rolling_std(window_size=window)
    s = ret.rolling_skew(window_size=window)
    # Polars' rolling_kurtosis returns excess kurtosis (normal -> 0).
    k = ret.rolling_kurtosis(window_size=window)

    z_cf = (
        z
        + (z**2 - 1) / 6.0 * s
        + (z**3 - 3 * z) / 24.0 * k
        - (2 * z**3 - 5 * z) / 36.0 * s.pow(2)
    )
    # A zero-dispersion window is a degenerate point mass: skewness/kurtosis are
    # undefined (NaN), and VaR is just -mu (no tail beyond the point). Guard so
    # that constant-return windows yield -mu rather than leaking NaN.
    var = pl.when(sigma == 0).then(-mu).otherwise(-(mu + z_cf * sigma))
    return var.alias(f"cf_var_{int(alpha * 100)}_{window}")

rolling_max_drawdown

rolling_max_drawdown(close: str | Expr, window: int = 100) -> Expr

Rolling maximum drawdown over a trailing window of prices.

Drawdown at each bar is price / running_peak - 1; the maximum drawdown over the window is the most negative such value, reported as a positive fraction (e.g. 0.18 for an 18% peak-to-trough decline). Uses the trailing rolling peak (rolling_max), so it is fully causal — no look-ahead to a future high — and answers "how bad has the worst dip been over the last window bars".

Source code in polars_ta/quant.py
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def rolling_max_drawdown(close: str | pl.Expr, window: int = 100) -> pl.Expr:
    """Rolling maximum drawdown over a trailing ``window`` of prices.

    Drawdown at each bar is ``price / running_peak - 1``; the maximum drawdown
    over the window is the most negative such value, reported as a **positive**
    fraction (e.g. ``0.18`` for an 18% peak-to-trough decline). Uses the
    trailing rolling peak (``rolling_max``), so it is fully causal — no
    look-ahead to a future high — and answers "how bad has the worst dip been
    over the last ``window`` bars".
    """
    close = as_expr(close)
    peak = close.rolling_max(window_size=window)
    safe_peak = pl.when(peak == 0).then(None).otherwise(peak)
    drawdown = close / safe_peak - 1.0
    return (-drawdown.rolling_min(window_size=window)).alias(f"max_drawdown_{window}")

calmar_ratio

calmar_ratio(close: str | Expr, window: int = 252) -> Expr

Rolling Calmar ratio: annualized return divided by rolling max drawdown.

Calmar rewards return per unit of worst-case path pain rather than per unit of volatility (Sharpe) — the metric managed-futures/CTA desks are judged on, since it punishes a strategy that makes steady money then gives it all back in one drawdown. Numerator is the annualized simple return over the window ((P_t / P_{t-window})^{252/window} - 1); denominator is :func:rolling_max_drawdown. A window with zero drawdown yields null (the ratio is undefined, not infinite).

Source code in polars_ta/quant.py
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
def calmar_ratio(close: str | pl.Expr, window: int = 252) -> pl.Expr:
    """Rolling Calmar ratio: annualized return divided by rolling max drawdown.

    Calmar rewards return per unit of *worst-case path pain* rather than per
    unit of volatility (Sharpe) — the metric managed-futures/CTA desks are
    judged on, since it punishes a strategy that makes steady money then gives
    it all back in one drawdown. Numerator is the annualized simple return over
    the window (``(P_t / P_{t-window})^{252/window} - 1``); denominator is
    :func:`rolling_max_drawdown`. A window with zero drawdown yields null (the
    ratio is undefined, not infinite).
    """
    close = as_expr(close)
    total_ret = close / close.shift(window)
    ann_ret = total_ret.pow(252.0 / window) - 1.0

    peak = close.rolling_max(window_size=window)
    safe_peak = pl.when(peak == 0).then(None).otherwise(peak)
    mdd = -(close / safe_peak - 1.0).rolling_min(window_size=window)
    safe_mdd = pl.when(mdd <= 0).then(None).otherwise(mdd)
    return (ann_ret / safe_mdd).alias(f"calmar_{window}")

rolling_skew

rolling_skew(close: str | Expr, window: int = 60) -> Expr

Rolling skewness of simple returns over window bars.

Persistent negative skew ("picks up pennies in front of a steamroller") is the classic signature of a strategy or regime carrying hidden crash risk; rising positive skew often accompanies momentum/blow-off phases. A zero-dispersion window (constant returns) has undefined skew and yields null rather than leaking NaN.

Source code in polars_ta/quant.py
578
579
580
581
582
583
584
585
586
587
588
def rolling_skew(close: str | pl.Expr, window: int = 60) -> pl.Expr:
    """Rolling skewness of simple returns over ``window`` bars.

    Persistent negative skew ("picks up pennies in front of a steamroller") is
    the classic signature of a strategy or regime carrying hidden crash risk;
    rising positive skew often accompanies momentum/blow-off phases. A
    zero-dispersion window (constant returns) has undefined skew and yields
    null rather than leaking NaN.
    """
    ret = as_expr(close).pct_change()
    return ret.rolling_skew(window_size=window).fill_nan(None).alias(f"skew_{window}")

rolling_kurtosis

rolling_kurtosis(close: str | Expr, window: int = 60) -> Expr

Rolling excess kurtosis of simple returns over window bars.

Excess kurtosis (normal distribution -> 0) rising well above 0 flags fat tails / clustered extreme moves — a warning that Gaussian VaR and any downstream mean-variance sizing are understating tail risk. Pairs naturally with :func:cornish_fisher_var, which uses exactly this moment. A zero-dispersion window (constant returns) has undefined kurtosis and yields null rather than leaking NaN.

Source code in polars_ta/quant.py
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
def rolling_kurtosis(close: str | pl.Expr, window: int = 60) -> pl.Expr:
    """Rolling excess kurtosis of simple returns over ``window`` bars.

    Excess kurtosis (normal distribution -> 0) rising well above 0 flags
    fat tails / clustered extreme moves — a warning that Gaussian VaR and any
    downstream mean-variance sizing are understating tail risk. Pairs naturally
    with :func:`cornish_fisher_var`, which uses exactly this moment. A
    zero-dispersion window (constant returns) has undefined kurtosis and yields
    null rather than leaking NaN.
    """
    ret = as_expr(close).pct_change()
    return (
        ret.rolling_kurtosis(window_size=window)
        .fill_nan(None)
        .alias(f"kurtosis_{window}")
    )

gain_to_pain

gain_to_pain(close: str | Expr, window: int = 60) -> Expr

Rolling gain-to-pain ratio: sum of returns divided by sum of losses.

Defined (Schwager) as sum(returns) / sum(|negative returns|) over the window — total net move per unit of downside suffered. It is more robust than Sharpe to a handful of outlier winners (it doesn't reward upside volatility at all) and is a standard discretionary-desk screen for "smoothness" of a return stream. A window with no losing bars yields null (infinite gain-to-pain is not a meaningful finite feature).

Source code in polars_ta/quant.py
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
def gain_to_pain(close: str | pl.Expr, window: int = 60) -> pl.Expr:
    """Rolling gain-to-pain ratio: sum of returns divided by sum of losses.

    Defined (Schwager) as ``sum(returns) / sum(|negative returns|)`` over the
    window — total net move per unit of downside suffered. It is more robust
    than Sharpe to a handful of outlier winners (it doesn't reward upside
    volatility at all) and is a standard discretionary-desk screen for
    "smoothness" of a return stream. A window with no losing bars yields null
    (infinite gain-to-pain is not a meaningful finite feature).
    """
    ret = as_expr(close).pct_change()
    total = ret.rolling_sum(window_size=window)
    pain = pl.when(ret < 0).then(-ret).otherwise(0.0).rolling_sum(window_size=window)
    safe_pain = pl.when(pain == 0).then(None).otherwise(pain)
    return (total / safe_pain).alias(f"gain_to_pain_{window}")

jarque_bera

jarque_bera(close: str | Expr, window: int = 60) -> Expr

Rolling Jarque-Bera normality test statistic of simple returns.

JB = (n / 6) * (S**2 + K**2 / 4) where S is the skewness and K the excess kurtosis of the returns in the window. Under the null of normally distributed returns JB is asymptotically chi-squared with 2 degrees of freedom, so a value above ~6 rejects normality at the 5% level (~9.2 at 1%). It is a single scalar that rises when either tail asymmetry or fat-tailedness appears — a compact "how non-Gaussian has this regime been" gate that subsumes :func:rolling_skew and :func:rolling_kurtosis into one number, which matters because any downstream Gaussian VaR / mean-variance sizing is only valid while JB stays small.

Reuses the same biased (population) moments Polars' rolling_skew / rolling_kurtosis report, so it is consistent with those two features rather than a separately-normalized estimate.

Source code in polars_ta/quant.py
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
def jarque_bera(close: str | pl.Expr, window: int = 60) -> pl.Expr:
    """Rolling Jarque-Bera normality test statistic of simple returns.

    ``JB = (n / 6) * (S**2 + K**2 / 4)`` where ``S`` is the skewness and ``K``
    the excess kurtosis of the returns in the window. Under the null of
    normally distributed returns JB is asymptotically chi-squared with 2
    degrees of freedom, so a value above ~6 rejects normality at the 5% level
    (~9.2 at 1%). It is a single scalar that rises when *either* tail asymmetry
    or fat-tailedness appears — a compact "how non-Gaussian has this regime
    been" gate that subsumes :func:`rolling_skew` and :func:`rolling_kurtosis`
    into one number, which matters because any downstream Gaussian VaR /
    mean-variance sizing is only valid while JB stays small.

    Reuses the same biased (population) moments Polars' ``rolling_skew`` /
    ``rolling_kurtosis`` report, so it is consistent with those two features
    rather than a separately-normalized estimate.
    """
    ret = as_expr(close).pct_change()
    s = ret.rolling_skew(window_size=window)
    k = ret.rolling_kurtosis(window_size=window)  # excess kurtosis
    jb = (window / 6.0) * (s.pow(2) + k.pow(2) / 4.0)
    # A zero-dispersion window has undefined moments; null rather than NaN.
    return jb.fill_nan(None).alias(f"jarque_bera_{window}")

frac_diff

frac_diff(close: str | Expr, d: float = 0.4, window: int = 100) -> Expr

Fixed-width-window fractional differentiation of the log price.

Applies the binomial fractional-difference operator (1-L)^d truncated to a fixed window (Lopez de Prado, Advances in Financial ML, ch. 5). For d in (0, 1) the output is (approximately) stationary while retaining far more of the original series' long memory than a full first difference (d = 1, ordinary log returns) would — often the difference between a feature that passes an ADF stationarity test and still predicts, versus a return series that is stationary but has thrown its memory away.

Weights w_k = -w_{k-1} (d-k+1)/k are applied to the trailing window log-prices. Larger d -> more differencing / less memory; d near 0 keeps almost all memory but may not fully stationarize.

Source code in polars_ta/quant.py
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
def frac_diff(close: str | pl.Expr, d: float = 0.4, window: int = 100) -> pl.Expr:
    """Fixed-width-window fractional differentiation of the log price.

    Applies the binomial fractional-difference operator ``(1-L)^d`` truncated
    to a fixed window (Lopez de Prado, *Advances in Financial ML*, ch. 5).
    For ``d in (0, 1)`` the output is (approximately) stationary while
    retaining far more of the original series' long memory than a full first
    difference (``d = 1``, ordinary log returns) would — often the difference
    between a feature that passes an ADF stationarity test *and* still predicts,
    versus a return series that is stationary but has thrown its memory away.

    Weights ``w_k = -w_{k-1} (d-k+1)/k`` are applied to the trailing ``window``
    log-prices. Larger ``d`` -> more differencing / less memory; ``d`` near 0
    keeps almost all memory but may not fully stationarize.
    """
    ln_p = as_expr(close).log()
    weights = _frac_diff_weights(d, window)
    # Dot the fixed weight vector against the trailing window: w_0*p_t +
    # w_1*p_{t-1} + ... Each shift is a lag, so this stays a pure vectorized
    # expression (no Python per-row loop) — the loop is only over `window`
    # constant coefficients, built once.
    terms = [weights[k] * ln_p.shift(k) for k in range(window)]
    return sum(terms[1:], terms[0]).alias(f"frac_diff_{d}_{window}")

rolling_autocorr

rolling_autocorr(close: str | Expr, lag: int = 1, window: int = 60) -> Expr

Rolling lag-lag autocorrelation of simple returns.

corr(r_t, r_{t-lag}) over a trailing window. Positive lag-1 autocorrelation is momentum/trending structure; negative is mean reversion (bid-ask bounce at short horizons, or genuine reversal at longer ones). A direct, sign-explicit complement to the variance ratio and Hurst tools. A flat window (zero variance) yields null.

Source code in polars_ta/quant.py
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def rolling_autocorr(close: str | pl.Expr, lag: int = 1, window: int = 60) -> pl.Expr:
    """Rolling lag-``lag`` autocorrelation of simple returns.

    ``corr(r_t, r_{t-lag})`` over a trailing window. Positive lag-1
    autocorrelation is momentum/trending structure; negative is mean reversion
    (bid-ask bounce at short horizons, or genuine reversal at longer ones). A
    direct, sign-explicit complement to the variance ratio and Hurst tools. A
    flat window (zero variance) yields null.
    """
    ret = as_expr(close).pct_change()
    lagged = ret.shift(lag)
    # corr = cov(r, r_lag) / (std(r) * std(r_lag)); use the shared primitives.
    num = rolling_cov(ret, lagged, window)
    var_a = rolling_var(ret, window)
    var_b = rolling_var(lagged, window)
    denom = (var_a * var_b).sqrt()
    safe_denom = pl.when(denom == 0).then(None).otherwise(denom)
    return (num / safe_denom).alias(f"autocorr_{lag}_{window}")

rolling_ic

rolling_ic(signal: str | Expr, forward_return: str | Expr, window: int = 60) -> Expr

Rolling information coefficient: correlation between a signal and the realized forward return.

The IC is the single most important diagnostic for a predictive feature: the rolling Pearson correlation between signal_t and the contemporaneous forward_return_t column (which you construct as a forward return, e.g. close.pct_change().shift(-h), so that at evaluation time the pair (signal_t, fwd_ret_t) is aligned). A decaying rolling IC is the earliest sign of alpha decay — the feature has stopped working — long before the equity curve rolls over.

Look-ahead warning: forward_return is forward-looking by construction; that is correct for measuring predictive power but means the IC series itself must never be used as a live trading input — it is a research/monitoring diagnostic. A flat window yields null.

Source code in polars_ta/quant.py
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
def rolling_ic(
    signal: str | pl.Expr, forward_return: str | pl.Expr, window: int = 60
) -> pl.Expr:
    """Rolling information coefficient: correlation between a signal and the
    realized forward return.

    The IC is the single most important diagnostic for a predictive feature:
    the rolling Pearson correlation between ``signal_t`` and the
    contemporaneous ``forward_return_t`` column (which you construct as a
    *forward* return, e.g. ``close.pct_change().shift(-h)``, so that at
    evaluation time the pair ``(signal_t, fwd_ret_t)`` is aligned). A decaying
    rolling IC is the earliest sign of alpha decay — the feature has stopped
    working — long before the equity curve rolls over.

    **Look-ahead warning:** ``forward_return`` is forward-looking *by
    construction*; that is correct for measuring predictive power but means the
    IC series itself must never be used as a live trading input — it is a
    research/monitoring diagnostic. A flat window yields null.
    """

    sig = as_expr(signal)
    fwd = as_expr(forward_return)
    num = rolling_cov(sig, fwd, window)
    denom = (rolling_var(sig, window) * rolling_var(fwd, window)).sqrt()
    safe_denom = pl.when(denom == 0).then(None).otherwise(denom)
    return (num / safe_denom).alias(f"ic_{window}")

rolling_beta_to

rolling_beta_to(close: str | Expr, benchmark: str | Expr, window: int = 60) -> Expr

Rolling market beta of an asset's returns against a benchmark's returns.

OLS slope of the asset's simple returns on the benchmark's simple returns over a trailing window — the sensitivity a factor-neutral book needs to hedge out. benchmark is a returns-bearing price column (e.g. BTC or an index level) carried alongside the asset on the same frame. A flat benchmark window yields null.

Source code in polars_ta/quant.py
754
755
756
757
758
759
760
761
762
763
764
765
766
767
def rolling_beta_to(
    close: str | pl.Expr, benchmark: str | pl.Expr, window: int = 60
) -> pl.Expr:
    """Rolling market beta of an asset's returns against a benchmark's returns.

    OLS slope of the asset's simple returns on the benchmark's simple returns
    over a trailing window — the sensitivity a factor-neutral book needs to
    hedge out. ``benchmark`` is a returns-bearing price column (e.g. BTC or an
    index level) carried alongside the asset on the same frame. A flat
    benchmark window yields null.
    """
    r_asset = as_expr(close).pct_change()
    r_bench = as_expr(benchmark).pct_change()
    return rolling_beta(r_asset, r_bench, window).alias(f"beta_{window}")

idiosyncratic_vol

idiosyncratic_vol(close: str | Expr, benchmark: str | Expr, window: int = 60) -> Expr

Rolling idiosyncratic (residual) volatility relative to a benchmark.

The part of an asset's return variance the benchmark does not explain: sqrt(var(r_asset) * (1 - rho^2)) over the window, where rho is the rolling correlation to the benchmark. This is the risk that survives a beta hedge — the tradeable, asset-specific component a stat-arb/relative- value book actually harvests, and a cleaner "specialness" measure than raw volatility. Annualized by sqrt(252).

Source code in polars_ta/quant.py
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
def idiosyncratic_vol(
    close: str | pl.Expr, benchmark: str | pl.Expr, window: int = 60
) -> pl.Expr:
    """Rolling idiosyncratic (residual) volatility relative to a benchmark.

    The part of an asset's return variance the benchmark does *not* explain:
    ``sqrt(var(r_asset) * (1 - rho^2))`` over the window, where ``rho`` is the
    rolling correlation to the benchmark. This is the risk that survives a
    beta hedge — the tradeable, asset-specific component a stat-arb/relative-
    value book actually harvests, and a cleaner "specialness" measure than raw
    volatility. Annualized by ``sqrt(252)``.
    """

    r_asset = as_expr(close).pct_change()
    r_bench = as_expr(benchmark).pct_change()
    var_a = rolling_var(r_asset, window)
    var_b = rolling_var(r_bench, window)
    cov = rolling_cov(r_asset, r_bench, window)
    denom = var_a * var_b
    rho2 = pl.when(denom == 0).then(0.0).otherwise(cov.pow(2) / denom)
    resid_var = var_a * (1.0 - rho2)
    # Clamp tiny negatives from floating error before the sqrt.
    resid_var = pl.when(resid_var < 0).then(0.0).otherwise(resid_var)
    return (resid_var.sqrt() * math.sqrt(252)).alias(f"idio_vol_{window}")

downside_beta

downside_beta(close: str | Expr, benchmark: str | Expr, window: int = 60) -> Expr

Rolling downside beta: beta estimated only on bars where the benchmark fell.

Bawa-Lindenberg / Ang-Chen downside beta captures how an asset behaves when the market is down — the regime that actually matters for tail hedging and that symmetric beta averages away. Computed as the OLS slope of asset returns on benchmark returns restricted to benchmark-negative bars within each trailing window. A window with fewer than two down-benchmark bars (or a flat down-benchmark subset) yields null.

Unlike the symmetric :func:rolling_beta_to, the down-bar subset can't use the shared rolling-covariance primitive (its windows would be riddled with the masked-out up-bars, and Polars' default rolling requires a full window of non-nulls), so the per-window OLS on the down subset runs in a single map_batches pass over a stacked [asset, bench] struct.

Source code in polars_ta/quant.py
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
def downside_beta(
    close: str | pl.Expr, benchmark: str | pl.Expr, window: int = 60
) -> pl.Expr:
    """Rolling downside beta: beta estimated only on bars where the benchmark
    fell.

    Bawa-Lindenberg / Ang-Chen downside beta captures how an asset behaves when
    the market is *down* — the regime that actually matters for tail hedging and
    that symmetric beta averages away. Computed as the OLS slope of asset
    returns on benchmark returns restricted to benchmark-negative bars within
    each trailing window. A window with fewer than two down-benchmark bars (or a
    flat down-benchmark subset) yields null.

    Unlike the symmetric :func:`rolling_beta_to`, the down-bar subset can't use
    the shared rolling-covariance primitive (its windows would be riddled with
    the masked-out up-bars, and Polars' default rolling requires a full window
    of non-nulls), so the per-window OLS on the down subset runs in a single
    ``map_batches`` pass over a stacked ``[asset, bench]`` struct.
    """
    r_asset = as_expr(close).pct_change()
    r_bench = as_expr(benchmark).pct_change()

    def _downside_beta(struct_s: pl.Series) -> pl.Series:
        sub = struct_s.struct.unnest()
        a = sub["a"].to_numpy().astype(float)
        b = sub["b"].to_numpy().astype(float)
        n = len(a)
        out = np.full(n, np.nan)
        for i in range(window - 1, n):
            aw = a[i - window + 1 : i + 1]
            bw = b[i - window + 1 : i + 1]
            mask = (~np.isnan(aw)) & (~np.isnan(bw)) & (bw < 0)
            if mask.sum() < 2:
                continue
            bd = bw[mask]
            ad = aw[mask]
            # A down subset with a single distinct benchmark return makes the
            # slope undefined; skip it rather than divide by (near-)zero
            # variance, mirroring how the shared rolling_beta helper guards its
            # windows. Test min == max rather than var() == 0.0, since the
            # variance of identical floats is a tiny non-zero rounding residual.
            if bd.min() == bd.max():
                continue
            out[i] = float(np.cov(ad, bd, bias=True)[0, 1] / bd.var())
        return pl.Series(out, dtype=pl.Float64).fill_nan(None)

    return (
        pl.struct([r_asset.alias("a"), r_bench.alias("b")])
        .map_batches(_downside_beta, returns_scalar=False)
        .alias(f"downside_beta_{window}")
    )

momentum_12_1

momentum_12_1(close: str | Expr, lookback: int = 252, skip: int = 21) -> Expr

Cross-sectional price momentum, skipping the most recent skip bars.

The canonical Jegadeesh-Titman / Fama-French "momentum 12-1" factor: the return from lookback bars ago up to skip bars ago (P_{t-skip} / P_{t-lookback} - 1), deliberately excluding the most recent month. The skip removes the well-documented short-term reversal (bid-ask bounce and 1-month mean reversion) that otherwise contaminates the momentum signal. Feed the result through :func:cross_sectional_rank or :func:cross_sectional_zscore .over(timestamp) to build the ranked factor.

Source code in polars_ta/quant.py
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
def momentum_12_1(close: str | pl.Expr, lookback: int = 252, skip: int = 21) -> pl.Expr:
    """Cross-sectional price momentum, skipping the most recent ``skip`` bars.

    The canonical Jegadeesh-Titman / Fama-French "momentum 12-1" factor: the
    return from ``lookback`` bars ago up to ``skip`` bars ago
    (``P_{t-skip} / P_{t-lookback} - 1``), deliberately *excluding* the most
    recent month. The skip removes the well-documented short-term reversal
    (bid-ask bounce and 1-month mean reversion) that otherwise contaminates the
    momentum signal. Feed the result through :func:`cross_sectional_rank` or
    :func:`cross_sectional_zscore` ``.over(timestamp)`` to build the ranked
    factor.
    """
    c = as_expr(close)
    return (c.shift(skip) / c.shift(lookback) - 1.0).alias(
        f"momentum_{lookback}_{skip}"
    )