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 input —
close 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_kfoldunder the hood. Barstandt+1sharew-1observations of anyw-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_14alone and the model leans onrsi_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.