Skip to content

How-to guides

Task-oriented recipes. For explanations of why things work this way, see Concepts.

Call indicators as pl.col(...).ta.<name>()

Importing polars_ta registers a .ta namespace on every Polars expression, so indicators read like native Polars methods instead of free functions:

import polars as pl
import polars_ta  # importing registers the .ta namespace

df = pl.read_csv("ohlcv.csv")

out = df.with_columns(
    pl.col("close").ta.rsi(14).alias("rsi"),
    pl.col("close").ta.macd().alias("macd"),
    pl.col("high").ta.average_true_range("low", "close").alias("atr"),
    pl.col("close").ta.on_balance_volume("volume").alias("obv"),
)

The expression you call .ta on is bound to the indicator's first inputclose for most indicators, high for the high-anchored ones (average_true_range, stoch, aroon_up, ...). Every remaining input column is passed as an ordinary argument, in the same order the free function takes it. Because each .ta method returns a plain pl.Expr, everything else in this guide — .over("symbol"), streaming, fillna — works through the namespace unchanged:

out = df.with_columns(
    pl.col("close").ta.rsi(14).over("symbol").alias("rsi")
)

The .ta methods and the module-level functions (momentum.rsi("close", 14)) are the same code — a byte-for-byte-identical dispatch layer — so pick whichever reads better; there is no behavioural difference. A few functions that don't take a single price series (cross_sectional_zscore, cross_sectional_rank, regime_conditional_signal) stay free-function-only. polars_ta.TA_INDICATORS lists every name reachable via .ta.

Compute an indicator on a LazyFrame

import polars as pl
from polars_ta import momentum

lf = pl.scan_csv("ohlcv.csv")
out = lf.with_columns(momentum.rsi("close")).collect()

Compute indicators per symbol on a multi-asset frame

Every indicator is a plain expression, so per-symbol computation is just .over("symbol") — one expression, grouped execution, no state leaking across symbol boundaries (the first bars of one symbol never see another symbol's tail). This holds for all indicators, including the sequential map_batches-based ones (KAMA, PSAR, VPIN, Hurst), and is enforced by tests/test_multi_asset.py.

import polars as pl
from polars_ta import momentum, trend

df = pl.read_parquet("all_symbols.parquet")  # columns: symbol, open, high, low, close, volume
out = df.with_columns(
    momentum.rsi("close").over("symbol").alias("rsi"),
    trend.macd("close").over("symbol").alias("macd"),
)

Warm-up nulls restart at each symbol boundary, exactly as if each symbol had been computed on its own frame.

Rank symbols cross-sectionally at each timestamp

Every indicator above computes a rolling statistic through time for one symbol via .over("symbol"). quant.cross_sectional_zscore and quant.cross_sectional_rank do the opposite: they compare symbols against each other at the same instant, the building block of a factor/ranking strategy. Group by the timestamp column instead of the symbol column:

import polars as pl
from polars_ta import quant

# Long format: one row per (timestamp, symbol).
df = pl.DataFrame({
    "timestamp": [1, 1, 1, 2, 2, 2],
    "symbol": ["A", "B", "C", "A", "B", "C"],
    "momentum": [1.0, 2.0, 3.0, -1.0, 0.0, 5.0],
})

out = df.with_columns(
    quant.cross_sectional_zscore("momentum").over("timestamp").alias("z"),
    quant.cross_sectional_rank("momentum").over("timestamp").alias("rank_pct"),
)

A cross-section with zero spread (every symbol tied) yields null rather than a divide-by-zero, and cross_sectional_rank(..., pct=False) returns a dense integer rank instead of a [0, 1] percentile.

Add calendar/seasonality features to a feature pipeline

polars_ta.calendar is the one module that takes a timestamp column instead of price/volume. Convert an epoch column to a real pl.Datetime first with pl.from_epoch, then derive day-of-week, intraday position, and month-end features:

import polars as pl
from polars_ta import calendar

df = pl.read_parquet("ohlcv.parquet")  # has an integer epoch-ms column
df = df.with_columns(
    pl.from_epoch("timestamp_open", time_unit="ms").alias("ts")
)

out = df.with_columns(
    calendar.day_of_week("ts").alias("dow"),
    calendar.hour_of_day("ts").alias("hour"),
    calendar.is_month_end("ts").alias("is_month_end"),
)

calendar.bars_since_session_open needs a session-boundary column (a date column for daily sessions, or your own session-id for intraday sessions with gaps) and is applied with .over(...) so the bar count restarts at zero for every session:

out = df.with_columns(pl.col("ts").dt.date().alias("date")).with_columns(
    calendar.bars_since_session_open("date").over("date").alias("bar_in_session")
)

This module deliberately does not hardcode market-specific trading-session windows (e.g. FX Asian/London/NY hours) — those are UTC-hour conventions that vary by instrument. hour_of_day / minute_of_day give you the raw building blocks to define your own session boundaries per instrument.

Regime-conditional trend/mean-reversion switch

quant.regime_conditional_signal switches between two already-computed signal expressions based on a regime score, row by row — a hard threshold switch, not a smooth blend. Wire it to quant.hurst_ribbon to trend-follow when the market is persistent and mean-revert when it isn't:

from polars_ta import quant, trend, volatility

trend_signal = trend.ema_indicator("close", window=10) - trend.ema_indicator(
    "close", window=30
)
reversion_signal = volatility.bollinger_pband("close") - 0.5

out = df.with_columns(
    **quant.hurst_ribbon("close", scales=(16, 32, 64))
).with_columns(
    quant.regime_conditional_signal(
        "h_ribbon_avg", 0.5, trend_signal, reversion_signal
    ).alias("composite_signal")
)

regime doesn't have to be Hurst — any expression works (ADX, Shannon entropy, a volatility z-score), and signal_above/signal_below can be any two pre-computed indicator expressions. A null regime value produces a null output rather than silently falling back to either branch. See the "Regime-conditional composite signal" example for a full runnable version plotted on real BTCUSDT data.

Size positions by tail risk, not just volatility

Volatility is symmetric; the left tail and the equity-curve path are what actually hurt. The quant risk block gives you those directly, as rolling causal expressions:

from polars_ta import quant

out = df.with_columns(
    # Expected shortfall: mean of the worst 5% of returns (a *coherent* risk
    # measure — unlike VaR, it's sub-additive), reported as a positive loss.
    quant.rolling_cvar("close", window=100, alpha=0.05).alias("cvar_5"),
    # Modified (Cornish-Fisher) VaR: the Gaussian VaR quantile corrected for
    # the window's skewness and excess kurtosis, so it doesn't understate
    # crash risk the way symmetric vol does.
    quant.cornish_fisher_var("close", window=100, alpha=0.05).alias("cf_var_5"),
    # Worst peak-to-trough decline over the trailing window (a positive
    # fraction), and return per unit of that pain.
    quant.rolling_max_drawdown("close", window=252).alias("mdd"),
    quant.calmar_ratio("close", window=252).alias("calmar"),
)

A common pattern is inverse-risk sizing: scale each bar's target exposure by 1 / cvar_5 (clipped), so the book leans out exactly as the tail fattens. calmar_ratio and gain_to_pain are null in windows where the metric is undefined (no drawdown, no losing bars) rather than reporting a fabricated infinity — filter those out before ranking.

Distribution-shape features are leading indicators of regime fragility: persistent negative rolling_skew and rising rolling_kurtosis flag a market becoming crash-prone before realized volatility moves.

Whiten a feature and monitor its alpha decay

A raw price series is non-stationary (it carries all the memory); its return series is stationary but has thrown that memory away. quant.frac_diff sits between the two — fractional differentiation (López de Prado, Advances in Financial ML, ch. 5) makes a series (approximately) stationary while retaining most of its long memory:

from polars_ta import quant

out = df.with_columns(
    # d in (0, 1): d->1 is an ordinary log return (stationary, memoryless),
    # d->0 keeps almost all memory. d~0.3-0.5 often passes an ADF test while
    # still predicting.
    quant.frac_diff("close", d=0.4, window=100).alias("fd_close"),
)

To check whether a signal still works, track its information coefficient — the rolling correlation between the signal and the realized forward return. Build the forward return explicitly, then correlate:

out = df.with_columns(
    my_signal.alias("signal"),
    pl.col("close").pct_change().shift(-5).alias("fwd_ret_5"),
).with_columns(
    quant.rolling_ic("signal", "fwd_ret_5", window=100).alias("ic"),
)

The IC series is forward-looking by construction

rolling_ic correlates against a future return, so it is a research / monitoring diagnostic only — a decaying IC is the earliest sign the alpha is dying — and must never be fed back in as a live trading input. That would be look-ahead leakage.

Cut 200 indicators down to the handful that are actually different

You can compute every indicator in this library in one with_columns. You should not then hand all of them to a model. They are nowhere near independent — rsi(14), rsi(21), cmo and roc are one quantity read four ways — and collinearity does not merely waste compute, it destroys feature-importance scores: two near-identical features split the credit and both look useless while a mediocre unique third outranks them (the substitution effect).

selection removes the redundancy first. It never looks at a target, so it cannot leak a label.

This module is not an expression API

Unlike everything else in polars_ta, these functions take a pl.DataFrame and return NumPy arrays and plain Python objects — choosing features needs the whole materialized matrix at once. Nothing here is on the .ta namespace.

Step 1: check stationarity before you correlate anything

Correlations between integrated series are spurious — two independent random walks correlate strongly by construction. Screen first:

from polars_ta import quant, selection

selection.nonstationary_features(feats, FEATURES, threshold=0.999)
# ['obv']

on_balance_volume, adi, volume_price_trend, cumulative_return and every price-level moving average are integrated by design. Fix a flagged feature and re-run:

feats = feats.with_columns(obv=quant.rolling_z_score("obv", 200))
selection.nonstationary_features(feats, FEATURES, threshold=0.999)
# []

The threshold is resolution-dependent

The screen is lag-1 autocorrelation of the level — a cheap heuristic, not a unit-root test. On 5-minute bars a 14-period ATR barely moves bar to bar and scores ~0.997 without being integrated at all, so the default 0.99 over-flags badly; 0.999 cleanly isolates OBV (0.9997). On daily bars 0.99 is about right. Look at the numbers before trusting the list.

frac_diff needs a strictly positive series

quant.frac_diff differentiates the log price, so passing it a signed cumulative feature like OBV yields all-NaN, and screen then reports that no rows survived warm-up. Reach for rolling_z_score on anything that can go negative.

Step 2: run the pipeline

result = selection.select_features(feats, FEATURES)

print(result.signal_rank, "of", len(result.names))  # 4 of 16
print(round(result.effective_rank, 2))              # 5.66
print(result.selected)                              # ['rsi_21', 'vol_21']

Sixteen indicators, but only a handful of genuinely distinct dimensions — an effective_rank of 5.66. That is the answer to "how do I manage all these features": mostly, you don't have as many as you think.

Treat the signal_rank of 4 as a working figure rather than a measurement. On autocorrelated features the Marchenko-Pastur edge sits too low, and a null that preserves each feature's autocorrelation clears it 5.1 times by chance alone — so the existence of shared structure is solid, the exact count is not. See step 4 below.

Step 3: cut at the dimensionality you actually measured

By default the number of clusters maximises the silhouette score, which asks "which cut is tidiest?" — on a smooth correlation structure that often settles on a very coarse 2. Pass k to cut at the dimensionality signal_rank measured instead:

result = selection.select_features(feats, FEATURES, k=result.signal_rank)

for cluster_id, members in sorted(result.clusters.items()):
    print(cluster_id, members)
# 0 ['rsi_14', 'rsi_21', 'cmo_14', 'roc_10', 'macd', 'trix', 'obv', 'mfi']
# 1 ['stochrsi']
# 2 ['adx_14', 'hurst']
# 3 ['atr_14', 'natr_14', 'ulcer', 'vol_21', 'yz_vol']

print(result.selected)
# ['rsi_21', 'stochrsi', 'adx_14', 'vol_21']

The clusters are readable: directional momentum, a fast oscillator, trend persistence, and volatility. Eight momentum indicators contributed one feature between them.

Step 3b: which features can you actually drop?

selected says what survived. report() says why, and which drops are unsafe:

report = result.report()

report.filter(pl.col("decision") == "review")  # risky drops — do not discard
report.filter(pl.col("kept"))                  # the final feature set
decision Meaning What to do
keep The cluster's representative Model on it
drop Representative correlates ≥ 0.7 with it Discard — nothing lost
review Representative correlates < 0.7 with it Partition too coarse

A representative only stands in for a member as well as it correlates with it. Dropping something at 0.97 costs nothing; dropping something at 0.62 throws away real information. selected alone cannot tell those apart.

So the rule for choosing k is: raise it until no row says review.

for k in (4, 6, 8):
    n = (
        selection.select_features(feats, FEATURES, k=k)
        .report()["decision"] == "review"
    ).sum()
    print(k, n)
# 4 → 4 flagged
# 6 → 0 flagged   ← smallest safe cut
# 8 → 0 flagged

k=6 agrees with effective_rank (5.66) — an independent check. signal_rank said 4, which would have discarded three features nothing left behind could reconstruct.

By default each cluster elects its medoid — the member closest to all the others, and a choice that never touches a target. Pass scores to elect the most predictive member instead:

scored = feats.with_columns(fwd=pl.col("close").pct_change().shift(-12))
ics = {name: abs(scored.select(pl.corr(name, "fwd")).item()) for name in FEATURES}

result = selection.select_features(feats, FEATURES, k=4, scores=ics)
print(result.selected)
# ['trix', 'stochrsi', 'hurst', 'vol_21']

scores looks at the future — treat the result as research output

A forward return is look-ahead by construction. Computing an IC over your whole sample and selecting on it leaks that sample's outcome into the feature set. Compute the ICs on a training window only, or stay with the medoid default.

Optional: strip the dominant mode

For technical indicators on a single asset, the largest eigenvector is essentially "trend/level" and swamps everything else. detone=1 removes it, leaving features that say something beyond direction:

result = selection.select_features(feats, FEATURES, detone=1, k=4)

Step 4: check the structure is real before believing it

Everything so far is target-free, but it is not assumption-free. The Marchenko-Pastur edge assumes i.i.d. rows, and rolling indicators are ~0.99 autocorrelated — so n_obs overstates the effective sample size and signal_rank reads optimistically. Test it against a null that keeps each feature's own autocorrelation and destroys only the cross-feature alignment:

import numpy as np

screened = selection.screen(feats, FEATURES)
q = screened.q

count = selection.permutation_test(
    screened.values,
    lambda v: float(selection.signal_rank(selection.corr_matrix(v), q)),
    block_size=250,   # comfortably longer than the longest indicator window
)
top = selection.permutation_test(
    screened.values,
    lambda v: float(np.linalg.eigvalsh(selection.corr_matrix(v))[-1]),
    block_size=250,
)

print(count.observed, count.null_mean, count.p_value)  # 4     5.1   0.99
print(top.observed, top.null_mean, top.p_value)        # 6.07  1.54  0.008

The count at the edge is not a measurement

Chance alone clears the edge 5.1 times here, so the observed 4 is not significant — while the top eigenvalue and effective_rank are overwhelmingly so. The shared structure is real; the precise number of dimensions is not. Quote effective_rank, and treat signal_rank as a ceiling unless you have run this test.

Pick the right tail

Use alternative="less" for statistics that fall when structure is present, such as effective_rank — it is maximal for independent features, so real dependence pushes it down, not up.

Step 5: rank what is left against an actual target

Now, and only now, bring in a label. Two things make this different from calling any importance routine on the raw features:

scored = feats.with_columns(fwd=pl.col("close").pct_change().shift(-12))
sel = selection.select_features(feats, FEATURES, k=4)

importance = selection.clustered_mda(
    scored, sel.names, "fwd",
    labels=sel.labels,     # shuffle whole clusters, not single features
    label_horizon=12,      # must match how `fwd` was built
    embargo=100,           # >= the longest indicator window
)

for cluster in importance.ranked:
    print(importance.clusters[cluster], round(importance.importance[cluster], 4))
  • purged_kfold under the hood. Bars t and t+1 share w-1 observations of any w-window indicator, and the label spans 12 more. Plain K-fold trains on data overlapping its own test fold and measures memorization. Purging drops the contaminated training rows; the embargo handles the serial correlation left over.
  • Clusters, not features. Shuffle rsi_14 alone and the model leans on rsi_21, so both score ~0. Shuffled jointly, the truth appears — in the library's own tests, three interchangeable copies of a driver score 0.17 / 0.04 / 0.13 individually but 0.90 as a cluster, more than their sum.

importance.t_stats() gives a crude across-fold stability check. An importance whose sign flips fold to fold is not an importance.

Step 6: cross-check the ranking with the rest of the toolkit

MDA alone is one number you cannot verify. The other measures cost little and each catches a different failure:

# Free triage — no model, so no model to blame.
screen = selection.target_screen(scored, sel.names, "fwd", **common)
screen.head(10)

# Each cluster alone: immune to substitution, blind to interaction.
sfi = selection.single_feature_importance(
    scored, sel.names, "fwd", labels=sel.labels, **common
)

# What the model *used*, in-sample. Needs a tree.
from sklearn.ensemble import RandomForestRegressor
mdi = selection.clustered_mdi(
    scored, sel.names, "fwd",
    model_factory=lambda: RandomForestRegressor(n_estimators=200, max_depth=4),
    labels=sel.labels, **common,
)

# The one that adds up: values sum to the full model's score.
shapley = selection.clustered_shapley(
    scored, sel.names, "fwd", labels=sel.labels, **common
)

Read them against each other, not in isolation:

Pattern Diagnosis
High SFI, low MDA Redundant — something else already carries it
Low SFI, high MDA Only works in combination; never model it alone
High MDI, low MDA The model memorized it in-sample
Top Shapley, negative MDA Contributes only in some orderings — check the null

MDI is a cross-check, never the ranking

In the full-library study a RandomForestRegressor spends 47% of its impurity budget on the one cluster whose out-of-sample MDA is 0.0001, and 0.001 on the three candlestick clusters — one of which has the best MDA in the study. MDI is in-sample and biased toward high-cardinality features: a continuous feature offers a tree hundreds of split points, a 0/±100 pattern offers two. Use it to detect memorization by comparing against MDA, not to pick features.

Shapley is the one to quote when you need a share: because the values sum exactly to the full model's out-of-sample score, "cluster 0 is 40% of the edge" is defensible. It costs \(2^k\) fits per fold, so keep k small.

Step 7: check the whole ranking against a shuffled label

The step people skip, and the one that decides whether any of the above meant anything. Break the feature-to-label link, keep everything else, and see what the method manufactures from nothing:

null = selection.null_importance(
    scored, sel.names, "fwd", labels=sel.labels,
    n_draws=100, **common,
)
null.report()                  # observed vs chance, with z-scores
null.significant(alpha=0.05)   # ids surviving Bonferroni

The label is scrambled with block_permute, not shuffle — a forward return is autocorrelated, and destroying that too would hand you a null far too easy to beat. significant() corrects for multiple testing by default, because testing 20 clusters at 5% yields a false positive about 64% of the time.

Expect this to come back empty, and believe it when it does

Run over the entire library — 183 screened indicators in 8 clusters, 5000 BTCUSDT 5m bars, 12-bar forward return — the best p-value is 0.118 and the full-model out-of-sample IC is −0.0079. Nothing survives. The univariate screen had looked fine at \(|IC| = 0.047\) with a t-stat of 3.8.

That gap between step 6 and step 7 is the entire reason this half of the module exists. See the worked example for the full table, and examples/plot_feature_importance.py to reproduce it.

Optional: cluster on non-linear dependence instead

Correlation sees monotone association only, and it is close to useless on the 61 candlestick patterns — sparse 0/±100 spikes rather than continuous series. Mutual information has neither limitation:

dist = selection.variation_of_information(screened.values)
labels, silhouette = selection.cluster_by_distance(dist, k=4)

variation_of_information is a true metric (Meila, 2007), so hierarchical clustering on it is as legitimate as on the correlation distance — and it often groups things differently, which is the point.

Optional: catch collinearity that pairs cannot see

scores = dict(zip(screened.names, selection.vif(corr)))
# atr_14: 1465, natr_14: 1471 — NATR *is* ATR divided by price

A feature can correlate modestly with every other feature individually and still be an exact linear combination of several of them. VIF regresses each feature on all the others, so it catches what a pairwise threshold misses.

Build a factor book: beta, idiosyncratic vol, downside beta, momentum

For a cross-sectional/factor strategy you need each asset's relationship to a benchmark through time, carried on the same frame as a benchmark price column:

from polars_ta import quant

out = df.with_columns(
    # Market beta of the asset's returns on the benchmark's.
    quant.rolling_beta_to("close", "benchmark", window=60).alias("beta"),
    # The vol a beta hedge leaves behind — the tradeable, asset-specific risk.
    quant.idiosyncratic_vol("close", "benchmark", window=60).alias("idio_vol"),
    # Beta estimated only on bars where the benchmark fell (Ang-Chen): the
    # regime that matters for tail hedging, which symmetric beta averages away.
    quant.downside_beta("close", "benchmark", window=60).alias("down_beta"),
    # Jegadeesh-Titman "12-1" momentum: return over the lookback but skipping
    # the most recent month, to drop short-term reversal.
    quant.momentum_12_1("close", lookback=252, skip=21).alias("mom_12_1"),
)

Every one of these is a per-symbol rolling expression, so on a long-format multi-asset frame apply it with .over("symbol"), then rank the momentum factor across symbols at each timestamp by chaining the cross-sectional helpers above:

out = df.with_columns(
    quant.momentum_12_1("close").over("symbol").alias("mom")
).with_columns(
    quant.cross_sectional_rank("mom").over("timestamp").alias("mom_rank")
)

Run on data larger than memory (streaming)

Pass engine="streaming" to .collect() — no changes to the indicator calls themselves:

out = lf.with_columns(momentum.rsi("close")).collect(engine="streaming")

Clean invalid values before computing indicators

polars_ta.utils.DataCleaner detects and repairs NaN/null/inf/excessively large values in numeric columns before they reach an indicator:

from polars_ta.utils import DataCleaner

# Drop any row containing an invalid numeric value
clean_df = DataCleaner.dropna(df)

# Or find which rows are bad, for logging
bad_rows = DataCleaner.get_invalid_indices(df)

# Or repair in place via linear interpolation + forward-fill
healed_df = DataCleaner.approximate_invalid_values(df)

Fill the warm-up period of an indicator

Pass fillna=True to any indicator:

from polars_ta import momentum

out = df.with_columns(momentum.rsi("close", fillna=True))

See Concepts → the fillna convention for what default value each indicator falls back to.

Use the namespaced class API instead of top-level functions

Every top-level function (momentum.rsi, trend.macd, ...) has an equivalent staticmethod on a *Indicators class, if you prefer explicit namespacing:

from polars_ta.momentum import MomentumIndicators

out = df.with_columns(MomentumIndicators.rsi("close"))

Build a custom indicator on top of existing ones

Because everything is a pl.Expr, you can freely combine library indicators with your own logic:

from polars_ta import momentum, volatility

out = df.with_columns(
    (momentum.rsi("close") - 50).alias("rsi_centered"),
    (volatility.average_true_range("high", "low", "close") / pl.col("close") * 100)
    .alias("atr_pct"),
)

Benchmark indicator throughput

uv run python benchmarks/bench_indicators.py

Runs a bundle of ~12 indicators across eager, lazy, and streaming engines at 10K/100K/1M rows.