Skip to content

Examples

Regenerating the figures

Every figure on this page is produced by a script in examples/ and committed to docs/assets/. Regenerate them all at once with uv run python examples/generate_all_figures.py after changing an indicator.

Quickstart: a full indicator bundle

The runnable version of this lives at examples/quickstart.py:

"""Quickstart: compute a full set of indicators on synthetic OHLCV data."""

import numpy as np
import polars as pl

from polars_ta import momentum, others, quant, trend, volatility, volume

# Build a synthetic OHLCV frame (swap in your own data here)
rng = np.random.default_rng(0)
n = 500
close = 100 + np.cumsum(rng.normal(0, 1, n))
df = pl.DataFrame(
    {
        "open": close + rng.normal(0, 0.5, n),
        "high": close + rng.uniform(0.1, 1.5, n),
        "low": close - rng.uniform(0.1, 1.5, n),
        "close": close,
        "volume": rng.uniform(1e4, 1e6, n),
    }
)

# All indicators are Polars expressions — compose them in one with_columns call
out = (
    df.lazy()
    .with_columns(
        momentum.rsi("close").alias("rsi_14"),
        momentum.stoch("high", "low", "close").alias("stoch_k"),
        trend.macd("close").alias("macd"),
        trend.macd_signal("close").alias("macd_signal"),
        trend.adx("high", "low", "close").alias("adx"),
        volatility.average_true_range("high", "low", "close").alias("atr_14"),
        volatility.bollinger_hband("close").alias("bb_high"),
        volatility.bollinger_lband("close").alias("bb_low"),
        volume.on_balance_volume("close", "volume").alias("obv"),
        volume.money_flow_index("high", "low", "close", "volume").alias("mfi"),
        quant.rolling_sharpe_ratio("close").alias("sharpe_63"),
        others.daily_return("close").alias("ret_pct"),
    )
    .collect()
)

print(out.tail(10))

Run it with:

uv run python examples/quickstart.py

Classic indicators on real BTCUSDT data

examples/plot_classic_indicators.py plots the well-known retail toolkit — Bollinger Bands, RSI, MACD and ATR — on the same 5,000-bar Binance BTCUSDT 5-minute fixture, so you can see the everyday indicators before the professional-desk ones below.

uv run python examples/plot_classic_indicators.py

BTCUSDT classic indicators: price with Bollinger Bands and SMA, RSI, MACD, and ATR

Reading the classic panels

Panel 1 — Price, Bollinger Bands (20, 2) and a 50-bar SMA. volatility.bollinger_hband/bollinger_lband draw the ±2σ envelope around the 20-bar moving average; price tagging or piercing a band flags a stretched move relative to recent volatility. The orange trend.sma_indicator is the slower trend reference.

Panel 2 — RSI (14). momentum.rsi with the conventional 30/70 guides. Excursions above 70 (overbought) and below 30 (oversold) are the classic mean-reversion cues.

Panel 3 — MACD (12/26/9). trend.macd, its signal line, and the histogram (macd_diff). Histogram bars are colored by sign and positioned above/below zero, so the momentum cross reads without relying on color.

Panel 4 — ATR (14). volatility.average_true_range, the standard volatility-of-range measure used for stop placement and position sizing.

Trend & volume toolkit on real BTCUSDT data

examples/plot_trend_volume.py plots the trend/volume family on the same fixture.

uv run python examples/plot_trend_volume.py

BTCUSDT trend and volume: price with Ichimoku cloud, ADX with +DI/-DI, Aroon oscillator, and OBV

Reading the trend & volume panels

Panel 1 — Price with the Ichimoku cloud. trend.ichimoku_a/ichimoku_b form the "kumo" cloud; price above the cloud is bullish context, below is bearish, and the cloud is shaded green/red by which span leads.

Panel 2 — ADX (14) with directional indicators. trend.adx measures trend strength (values above the dashed 25 line mark a trending market), while adx_pos/adx_neg (+DI / -DI) give its direction.

Panel 3 — Aroon oscillator (25). The difference of aroon_up and aroon_down, shaded by sign — positive (green) when up-trend momentum dominates, negative (purple) when down-trend does. (The raw up/down lines whip too fast to read over 5,000 bars, so the oscillator is shown instead.)

Panel 4 — On-Balance Volume. volume.on_balance_volume accumulates signed volume; its slope confirms or diverges from price moves.

Liquidity & microstructure toolkit on real BTCUSDT data

examples/plot_liquidity.py visualizes the professional-desk liquidity features on the same fixture.

uv run python examples/plot_liquidity.py

BTCUSDT liquidity: price, Roll vs Corwin-Schultz spread, Kyle's lambda, and mean-reversion half-life

Reading the liquidity panels

Panel 1 — Price. Reference for the three microstructure panels below.

Panel 2 — Spread estimators. microstructure.roll_spread (from serial covariance of price changes) versus corwin_schultz_spread (from consecutive high-low ranges), both in USDT — Corwin-Schultz is the smoother, more robust estimate on bar data while Roll is noisier and drops out when its covariance premise fails.

Panel 3 — Kyle's lambda. microstructure.kyle_lambda is price impact per unit of signed order flow — higher means thinner, less liquid conditions where a given order moves price more.

Panel 4 — Half-life of mean reversion. microstructure.half_life from a rolling Ornstein-Uhlenbeck fit: single-digit bars mean fast reversion, and the tall spikes are windows where reversion breaks down and the half-life diverges (the axis is capped at 300 bars so the fast-reverting structure stays legible).

Professional-desk regime dashboard on real BTCUSDT data

examples/plot_regime_dashboard.py is a runnable matplotlib example that plots polars_ta indicators on real market data — a 5,000-row slice of Binance BTCUSDT 5-minute OHLCV bars (tests/fixtures/btcusdt_5m_sample.arrow), the same fixture used by tests/test_quant.py and tests/test_microstructure.py.

uv run python examples/plot_regime_dashboard.py

BTCUSDT regime dashboard: price, Hurst-ribbon regime shading, Yang-Zhang volatility and VPIN

Reading the three panels

Panel 1 — Price. The raw BTCUSDT close price over the 5,000-bar window, for visual reference against the two panels below.

Panel 2 — Regime (multi-scale Hurst ribbon). quant.hurst_ribbon("close") computes the Hurst exponent at three window scales (16, 32, 64 bars) and averages them into h_ribbon_avg. The chart shades the background green where the (smoothed) average sits above 0.5 — a trending/persistent regime where momentum strategies have an edge — and purple where it sits below 0.5 — a mean-reverting regime where fading extremes tends to work better. The dashed red line at 0.5 marks the random-walk boundary from the underlying rescaled-range (R/S) theory. Notice how the shading flips back and forth rather than committing to one regime for long stretches — that instability is itself informative: it's a signal that this instrument, at this timeframe, doesn't reward a single fixed strategy family and instead needs regime-adaptive logic (this is exactly why professional desks compute this in real time rather than assuming one regime holds).

Panel 3 — Volatility and order-flow toxicity. The orange fill is quant.yang_zhang_volatility(...), an annualized OHLC volatility estimator that accounts for both overnight gaps and intraday drift (the standard choice on professional vol desks over plain close-to-close historical volatility). The red line is microstructure.vpin(...) — Volume-Synchronized Probability of Informed Trading — computed on 500-unit volume buckets, averaged over a rolling window of 20 buckets. VPIN spikes tend to lead or coincide with the sharpest volatility spikes (e.g. around bar ~2050 in the plot above), which is the whole point of the metric: it is a flow-toxicity early-warning signal, not a directional one — high VPIN says "informed traders are active and liquidity is thin right now," not "price will go up" or "down."

Why this example uses real data, not synthetic noise

Every other example and most unit tests in this repo use synthetic random-walk OHLCV data, which is fine for checking that an indicator's math is internally consistent (e.g. RSI stays in [0, 100]). But microstructure/regime features like VPIN and the Hurst ribbon are specifically about detecting structure that isn't present in i.i.d. random-walk noise — a synthetic series would make the regime panel look meaningless (Hurst hovering uselessly close to 0.5 everywhere, VPIN with no real spikes to speak of). Validating and visualizing them against real BTCUSDT data is what actually demonstrates they work as intended.

New indicators on real BTCUSDT data

examples/plot_new_indicators.py plots the newest indicator batch — both retail-standard trend/momentum additions and microstructure/quant additions — on the same 5,000-bar BTCUSDT fixture used above.

uv run python examples/plot_new_indicators.py

BTCUSDT new indicators: SuperTrend and Hull MA overlay, Elder Ray vs CMO, Fisher Transform vs Klinger Volume Oscillator, EWMA vs historical volatility

Reading the four panels

Panel 1 — Price with SuperTrend (10, 3.0) and Hull Moving Average (20). trend.supertrend is an ATR-banded stop-and-reverse line — green while price trades above the band (uptrend/long bias), red while below (downtrend/short bias) — computed with the same stateful map_batches band-flip logic as psar. trend.hull_moving_average (purple) tracks price more tightly than a plain SMA/EMA of the same length, trading a little more whipsaw for much less lag.

Panel 2 — Elder Ray (Bull/Bear Power) vs. Chande Momentum Oscillator. trend.elder_bull_power and trend.elder_bear_power measure how far the high/low reach above/below a 13-bar EMA of close — a positive Bull Power with a rising EMA is the classic Elder buy setup. momentum.cmo (blue, right axis) is RSI's less-smoothed cousin: it sums raw up/down moves over the window instead of using Wilder's EMA, so it swings faster and reaches further into the ±100 band.

Panel 3 — Fisher Transform vs. Klinger Volume Oscillator. momentum.fisher_transform (orange) maps a bounded stochastic-style price position through atanh, sharpening turning points into more distinct spikes than a plain oscillator — note it uses Ehlers' original double-EMA-damped recursion, not a one-shot atanh, which is what keeps it from saturating at its clip boundary on noisy real data. volume.klinger_volume_oscillator (purple, right axis) is a volume-force oscillator that flips sign with the typical-price trend direction — used to confirm whether a price move has real volume backing it.

Panel 4 — EWMA volatility vs. historical volatility. quant.historical_volatility (blue) uses a flat rolling window; quant.ewma_volatility (red) uses RiskMetrics-style exponential decay (λ=0.94), so it reacts to a volatility spike immediately and fades out smoothly instead of dropping off a cliff exactly window bars later — visible at every spike in the chart, where the red line leads the blue line up and trails it back down.

Two indicators from this batch don't appear in the figure because they aren't single time-series lines: microstructure.lee_ready_trade_sign classifies each bar as buy/sell/unclassifiable (+1/-1/0) rather than producing a continuous line, and quant.cross_sectional_zscore / quant.cross_sectional_rank rank symbols against each other at each timestamp on a multi-asset frame — see How-to guides for a runnable example of both.

Entropy-based regime indicators on real BTCUSDT data

examples/plot_entropy.py plots the two entropy-based complexity indicators against the multi-scale Hurst ribbon on the same 5,000-bar BTCUSDT fixture.

uv run python examples/plot_entropy.py

BTCUSDT entropy indicators: Shannon entropy vs Hurst ribbon, and approximate entropy

Reading the three panels

Panel 1 — Price. Raw BTCUSDT close, for visual reference against the two panels below.

Panel 2 — Shannon entropy vs. Hurst ribbon. microstructure.shannon_entropy (orange) bins the window's log returns and measures how uniformly they're spread across bins, normalized to [0, 1]. quant.hurst_ribbon's average (blue, right axis) measures directional persistence instead. The two disagree on purpose: Shannon entropy stays consistently high here (returns are well-spread across bins most of the time — this is what noisy real crypto data looks like) while the Hurst ribbon still swings between trending and mean-reverting regimes underneath that noise. A high-entropy, high-Hurst bar means "noisy but trending"; a high-entropy, low-Hurst bar means "noisy and choppy" — two very different trading conditions that either signal alone would blur together.

Panel 3 — Approximate entropy. microstructure.approximate_entropy measures how often short return patterns repeat — low values mean the recent path is more self-similar/predictable, high values mean it isn't. Watch bar ~2050: it spikes sharply, coinciding with the same sharp volatility event visible in the price panel and in the VPIN spike from the regime dashboard above — a sudden break in pattern regularity is itself a marker of a regime change, not just elevated volatility.

A cost note on approximate_entropy. Its rolling window uses the textbook O(window²) pairwise-distance algorithm (no known faster exact form), so it's deliberately run here with a small window=30 — see its docstring for the tradeoff before increasing it on a large frame.

Regime-conditional composite signal on real BTCUSDT data

examples/plot_regime_conditional_signal.py is a capstone example: it wires quant.regime_conditional_signal to the existing Hurst ribbon to switch between a trend-following signal and a mean-reversion signal, on the same 5,000-bar BTCUSDT fixture.

uv run python examples/plot_regime_conditional_signal.py

BTCUSDT regime-conditional signal: price shaded by active regime, Hurst ribbon, and the composite signal switching between a trend-following and mean-reversion signal

Reading the three panels

Panel 1 — Price, shaded by which branch is active. Green shading marks bars where the Hurst ribbon average is ≥ 0.5 (trend-following signal active); orange marks < 0.5 (mean-reversion signal active). Notice how often the shading flips — this is the same instability observed in the regime dashboard example, and it's exactly the situation this helper is built for: a single fixed strategy would fight itself through these flips, so the composite switches instead.

Panel 2 — Regime score. quant.hurst_ribbon's h_ribbon_avg, the same regime score used to shade panel 1.

Panel 3 — The two candidate signals and the composite. The trend signal (green, mostly hidden under the composite) is an EMA(10)-EMA(30) cross, normalized by ATR so it lands on a comparable scale to the reversion signal (tan) — a Bollinger %B deviation from 0.5, scaled by 4. quant.regime_conditional_signal (red) is the hard row-by-row switch between them: it visibly tracks the trend signal during green-shaded regions and the reversion signal during orange-shaded ones, with a discrete jump exactly at each regime flip rather than a smooth blend.

regime_conditional_signal is a compositional building block, not a Hurst-specific helper — swap in any regime score (ADX, Shannon entropy, a volatility z-score) and any two pre-computed signal expressions. See the "Regime-conditional trend/mean-reversion switch" how-to guide for the minimal version of this pattern.

Case study: which of my 16 indicators are actually different?

Every other example on this page adds a feature. This one asks the question that follows, and it is the only example whose conclusion is to delete most of what was computed.

examples/plot_feature_selection.py runs the full selection pipeline on 16 indicators spanning momentum, trend, volatility and volume — the same 5,000-bar BTCUSDT 5-minute fixture as everything above.

uv run python examples/plot_feature_selection.py

Feature selection on BTCUSDT 5m: raw correlation matrix, eigenvalue spectrum against the Marchenko-Pastur edge, denoised matrix reordered by cluster, and cumulative variance explained

The problem

Sixteen indicators, computed in one with_columns, look like sixteen inputs to a model. They are not. Measured on 4,801 usable bars:

Diagnostic Value What it means
Mean |ρ| off-diagonal 0.32 the average pair is substantially correlated
Smallest eigenvalue 0.0003 one direction is essentially flat — the matrix is near-singular
Condition number 17,748 anything that inverts this matrix is numerically meaningless

Panel A shows it directly: dense red blocks where whole families move together. That near-singular direction is not a curiosity. A condition number of ~18,000 means a linear model, a mean-variance optimizer, or any GLS step operating on these features is amplifying floating-point noise by four orders of magnitude.

And the damage is not only numerical. Collinearity destroys feature-importance scores: two near-identical features split the credit and both look unimportant, while a mediocre but unique third feature outranks them. This is the substitution effect, and it breaks MDI and MDA alike. Ranking features before removing redundancy answers the wrong question.

The methodology

Four stages, none of which touches a target variable — so none of them can leak a label:

  1. Stationarity screen. Correlations between integrated series are spurious (Granger & Newbold, 1974). nonstationary_features flags them by lag-1 autocorrelation.
  2. Spearman correlation. Rank-based, because bounded oscillators and heavy-tailed volatility features break Pearson's linearity assumption.
  3. Marchenko-Pastur denoising. For \(T\) observations of \(N\) independent variables, noise eigenvalues fall below \(\lambda_+ = \sigma^2(1+\sqrt{1/q})^2\) with \(q = T/N\). Everything under that edge is clipped to the bulk average, which preserves the trace and lifts the near-singular directions away from zero.
  4. Cluster and elect. Hierarchical clustering on the Mantegna distance \(d_{ij}=\sqrt{(1-\rho_{ij})/2}\), cut at the dimensionality that stage 3 measured, then one representative per cluster — the medoid, which is chosen without reference to any target.

The code

from polars_ta import quant, selection

# Stage 0 — screen before correlating anything.
selection.nonstationary_features(feats, FEATURES, threshold=0.999)
# ['obv']

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

# Stages 1-2 — how many dimensions are actually here?
screened = selection.screen(feats, FEATURES)
corr = selection.corr_matrix(screened.values)
rank = selection.signal_rank(corr, screened.q)   # 4

# Stages 3-4 — cut at that dimensionality and elect a representative each.
result = selection.select_features(feats, FEATURES, k=rank)
print(result.selected)
# ['rsi_21', 'stochrsi', 'adx_14', 'vol_21']

What we observe

Only 4 of 16 eigenvalues clear the noise edge. With \(q = T/N = 300\) the edge sits at \(\lambda_+ = 1.12\) — and the average eigenvalue of any correlation matrix is exactly 1, so the bar is low and twelve features still fail to clear it (panel B). Those four components carry 85% of the total variance (panel D).

But that count does not survive a null (panel E). This is the most important result on the page, and it cuts against the headline. Marchenko-Pastur assumes i.i.d. rows; rolling indicators are ~0.99 autocorrelated, so n_obs grossly overstates the effective sample size and the edge sits too low. Test it with a block_permute null that keeps each feature's own autocorrelation and destroys only the cross-feature alignment:

Statistic Observed Null (mean ± sd) p
Eigenvalues above the edge 4 5.1 ± 1.2 0.99 — not significant
Top eigenvalue 6.07 1.54 ± 0.08 0.008 — highly significant
Effective rank 5.66 15.5 0.008 — highly significant

Read that carefully. The existence of strong shared structure is beyond any doubt: the top eigenvalue is 4× what chance produces, and the effective rank is a third of it. What is not established is the precise number 4 — chance alone clears the edge 5.1 times on average. So "4 dimensions" is a reasonable working figure, not a measurement, and effective_rank (5.66) is the more honest headline. Anyone quoting signal_rank on autocorrelated features without this test is over-claiming.

VIF finds collinearity that pairwise correlation understates (panel F). atr_14 and natr_14 score ~1,470 — NATR is ATR divided by price, so each is almost perfectly predictable from the rest of the set. A pairwise screen at a 0.95 threshold would not necessarily catch it; a feature can correlate modestly with every other feature individually and still be an exact linear combination of several.

Denoising fixes the conditioning. Clipping the noise bulk takes the condition number from 17,748 to 34, and the smallest eigenvalue from 0.0003 to 0.18 (panel C).

The clusters are interpretable — which is the real check that this is finding structure rather than fitting noise:

Cluster Members Kept Reads as
0 rsi_14, rsi_21, cmo_14, roc_10, macd, trix, obv, mfi rsi_21 directional momentum
1 stochrsi stochrsi a fast oscillator, genuinely its own thing
2 adx_14, hurst adx_14 trend persistence, independent of direction
3 atr_14, natr_14, ulcer, vol_21, yz_vol vol_21 volatility / dispersion

Eight momentum indicators contributed one feature between them. Five volatility estimators — including three that differ only in which parts of the OHLC bar they read — contributed one. Meanwhile ADX lands with the Hurst exponent rather than with the other trend indicators, which is correct and not obvious: both measure how persistent a move is, not which way it points.

Which of those drops are actually safe?

selected says what survived. result.report() says which drops you can trust — a representative stands in for a member only as well as it correlates with it. At k=4:

feature representative similarity decision
rsi_14 rsi_21 0.80 drop
mfi rsi_21 0.77 drop
cmo_14 rsi_21 0.75 drop
roc_10 rsi_21 0.66 review
trix rsi_21 0.65 review
obv rsi_21 0.62 review
ulcer vol_21 0.69 review

Dropping rsi_14 at 0.80 costs nothing. Dropping obv at 0.62 throws away real information — rsi_21 cannot reconstruct it. Those two cases are indistinguishable in selected alone.

The rule: raise k until nothing says review. Here that is k=6, which independently agrees with effective_rank (5.66) — while signal_rank's 4 was too aggressive by three features.

k flagged review
4 4
6 0 ← smallest safe cut
8 0

What we conclude

  • The feature count was never the constraint. Going from 16 indicators to 205 would not have produced 205 dimensions; it would have produced a longer tail below the same noise edge. Adding indicators from the same family adds estimation noise, not information.
  • Always test the dimensionality against a null. signal_rank reads like a measurement and is not one on autocorrelated data — here the observed count is below what chance produces. Report effective_rank and the top eigenvalue, which do survive the null, and treat the count as a ceiling.
  • Decide with report(), not with selected. The list of survivors hides the difference between a free drop and a lossy one. Raise k until no row is flagged review; that is the smallest feature set you can take without losing information.
  • Run this before any importance ranking, not after. With the redundancy removed, an MDA score on four decorrelated features means something. On the original sixteen it would mostly have measured which member of each family the shuffle happened to hit first.
  • The medoid default is the safe one. Electing representatives by information coefficient looks appealing and is how leakage gets in — an IC computed over the whole sample encodes that sample's outcome. Use scores= only with ICs computed on a training window.
  • This is redundancy analysis, not alpha. Nothing here says the four survivors predict anything. It says the other twelve had nothing to add that these four did not already carry.

The stationarity threshold is resolution-dependent

At 5-minute resolution a 14-period ATR scores ~0.997 lag-1 autocorrelation without being integrated at all, so the default 0.99 flags eight of the sixteen. 0.999 cleanly isolates OBV (0.9997), the one feature that really is a cumulative sum. On daily bars 0.99 is about right. The screen is a heuristic, not a unit-root test — look at the numbers before trusting the list.

Case study: how many dimensions does the whole library span?

The study above uses a hand-picked sixteen. This one answers the question directly — compute every indicator polars_ta exposes and ask how many dimensions they actually span.

examples/plot_full_library_selection.py builds the indicators programmatically from the .ta namespace registry, so it never goes stale: add an indicator to the library and it appears here automatically. 199 of 205 build from the registry alone; the six exceptions are listed with their reasons in the script (three need a benchmark asset, one returns a dict, one needs a non-column argument, and rolling_ic needs a forward return — look-ahead by construction).

uv run python examples/plot_full_library_selection.py

All 205 polars_ta indicators: the screening funnel, the eigenvalue spectrum on a log scale, cluster sizes, and the non-finite rate per column

The answer

Stage Count
Indicators computed 205
Dense enough (max_missing=0.2) 204
Non-constant 183
Eigenvalues above the MP edge 19
Clusters / features selected 40

205 indicators, 19 dimensions above the noise edge, effective rank 32. The top 19 components carry 73% of the variance. This is the concrete answer to "how do you manage all of these features": you do not have as many as the column count suggests, and no amount of adding indicators from the same families will change that.

Three things the full sweep exposes that sixteen features did not

One sparse column can cost you half the sample. roll_spread is null wherever its serial-covariance premise fails — 42% of bars, scattered throughout, entirely by design. Because screen drops any row with a non-finite anywhere, that single column took the sample from 5,000 rows to 2,610 for all 184 features. max_missing=0.2 drops the column instead and recovers 4,272 rows (panel D). ScreenResult.worst_missing() is how you find the culprit.

21 of the 61 candlestick patterns never fire once in 5,000 bars, leaving all-zero columns. A zero-variance feature has undefined correlation and puts NaN through the entire eigendecomposition, so screen removes them — but the deeper point is that correlation is the wrong lens for sparse 0/±100 spikes in the first place. Use mutual_information or cluster on variation_of_information when candles are in the matrix.

The condition number reaches 2.2 × 10¹⁸. The full library is singular to machine precision. Any model that inverts this matrix — mean-variance, GLS, OLS normal equations — is returning noise, not an answer.

The clusters are recognisable

The largest cluster holds 34 indicators; 20 clusters are singletons. The big groups are exactly the families you would draw by hand — a price-level group (moving averages, Bollinger bands, cumulative return, A/D line), an oscillator group (CCI, CMO, money-flow, Chaikin), a range/volatility group (ATR, NATR, Donchian width, spread estimators), and a directional group. That the method rediscovers the library's own taxonomy from the data alone is the best available evidence it is finding structure rather than fitting noise.

Does any of it actually predict?

The sweep above stops at redundancy: it says the 205 indicators span about 19 dimensions, but says nothing about whether those dimensions are useful. examples/plot_feature_importance.py finishes the job — it takes the 183 screened indicators, cuts them into 8 clusters, and ranks them against a 12-bar forward return with every importance measure the library exposes, then checks the whole ranking against a shuffled-label null.

uv run python examples/plot_feature_importance.py

The full importance toolkit over 183 indicators: the univariate IC screen, four importance measures side by side, the shuffled-label null, and Shapley attribution

The answer: no

Measure Result
Best univariate |IC| 0.047 (volume_weighted_average_price), t-stat 3.8
Full-model out-of-sample IC −0.0079
Best cluster p-value vs the null 0.118
Clusters surviving Bonferroni at 5% none

The univariate screen looks promising — an \(|IC|\) of 0.047 with a t-stat near 4 would pass for a signal on many desks. It does not survive contact with a model, purged cross-validation, and a null. The full model's out-of-sample information coefficient is negative, and every cluster sits comfortably inside what the same pipeline produces on a scrambled label (panel C).

This is the correct result, and the point of the exercise. 200 technical indicators on 5,000 bars of one asset do not predict its 12-bar forward return. A pipeline that reported otherwise would be measuring its own leakage. Every guard in the module — purging, embargoing, block permutation, Bonferroni — is there to make this the outcome rather than a confident, wrong number.

What the disagreement between measures tells you

Reading four measures together is what makes the negative result interpretable rather than merely disappointing:

Cluster n Example SFI MDA MDI Shapley Reading
0 44 acc_dist_index 0.0095 −0.0022 0.191 0.0171 Contributes only in combination — and only in some orderings
6 14 cdl_3_white_soldiers 0.0190 0.0077 0.001 0.0097 The most consistent of the eight, and still p = 0.118
2 63 adx_pos −0.0399 −0.0141 0.262 −0.0166 Actively harmful alone and jointly
1 46 adx −0.0409 0.0001 0.469 −0.0208 The forest's favourite; worthless out of sample

Clusters 1 and 2 are 109 of the 183 features — the trend and momentum families. They score badly on SFI and near zero on MDA: nothing is lost by dropping them. Cluster 0 is the case that needs all three measures to read at all — top Shapley value, negative MDA. MDA alone would have called it harmful, SFI alone mediocre; Shapley says its contribution is real but order-dependent. The null then says none of it clears chance anyway.

MDI ranks it exactly backwards — the memorization signature

The RandomForestRegressor spends 47% of its total impurity reduction on cluster 1, whose out-of-sample MDA is 0.0001 and whose Shapley value is −0.021. Clusters 1 and 2 take 73% of the MDI budget between them and contribute nothing out of sample.

The three candlestick clusters score 0.001 of MDI combined — despite cluster 6 having the best MDA and the lowest p-value in the study. That is MDI's cardinality bias in numbers: a continuous feature offers a tree hundreds of split points, a 0/±100 pattern offers two, and MDI rewards the opportunity rather than the content.

Read MDI alone and you would keep the 109 trend/momentum features and discard the only cluster that came close to significance. This is precisely why clustered_mdi ships as a cross-check on MDA rather than a replacement for it.

Panel A is a redundancy finding, not a ranking

The top 20 features by \(|IC|\) are VWAP, Donchian bands, Bollinger bands, Ichimoku lines and six moving averages — all within 0.004 of each other, because they are all the same quantity: the price level. That is what a univariate screen looks like when you skip the redundancy pass first.

More indicator combinations

Trend + volatility regime filter

Combine ADX (trend strength) with Bollinger Band width (volatility) to flag "trending and volatile" periods:

from polars_ta import trend, volatility

out = df.with_columns(
    trend.adx("high", "low", "close").alias("adx"),
    volatility.bollinger_wband("close").alias("bb_width"),
).with_columns(
    ((pl.col("adx") > 25) & (pl.col("bb_width") > pl.col("bb_width").rolling_mean(20)))
    .alias("trending_and_volatile")
)

Volume-confirmed momentum

Require both RSI momentum and Money Flow Index (volume-weighted RSI-analogue) to agree:

from polars_ta import momentum, volume

out = df.with_columns(
    momentum.rsi("close").alias("rsi"),
    volume.money_flow_index("high", "low", "close", "volume").alias("mfi"),
).with_columns(
    ((pl.col("rsi") > 70) & (pl.col("mfi") > 80)).alias("overbought_confirmed")
)

Liquidity-aware execution gate

Combine Kyle's lambda (price impact) with VPIN (flow toxicity) to flag conditions where a large order is likely to move the market and get adversely selected — a real pre-trade check used by execution desks before sizing an order:

from polars_ta import microstructure as ms

out = df.with_columns(
    ms.kyle_lambda("close", "volume").alias("kyle_lambda"),
    ms.vpin("close", "volume", bucket_size=500, window=20).alias("vpin"),
).with_columns(
    (
        (pl.col("kyle_lambda") > pl.col("kyle_lambda").rolling_mean(200) * 1.5)
        & (pl.col("vpin") > 0.4)
    ).alias("thin_and_toxic")
)