Skip to content

Changelog

All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • polars_ta.selection: feature selection for a 200-indicator library. Computing more indicators is easy; knowing which of them are actually different from each other is the hard part, and collinearity does not merely waste compute — it destroys per-feature importance scores, because two near-identical features split the credit and both look useless (the substitution effect). This module removes the redundancy first, and never looks at a target, so it cannot leak a label:
  • screen — materialize the matrix, drop warm-up rows and constant columns (a real hazard here: on a short sample many candlestick patterns never fire, and a zero-variance column puts NaN through the whole eigendecomposition).
  • nonstationary_features — flag integrated features before correlating anything, since correlations between random walks are spurious. A lag-1 autocorrelation heuristic, not a unit-root test; the right threshold depends on bar resolution.
  • corr_matrix — Spearman (default, since bounded oscillators and heavy-tailed microstructure features break Pearson's assumptions) or Pearson.
  • denoise — Marchenko-Pastur eigenvalue clipping with optional detoning of the dominant "trend/level" mode. The noise variance is estimated by fitting the theoretical MP density to a KDE of the observed eigenvalues (López de Prado, 2020, ch. 2) rather than by the tempting iterate-the-bulk-mean shortcut, which spirals downward whenever real signal is present.
  • signal_rank / effective_rank — how many dimensions the feature set really has. On 16 indicators over real BTCUSDT 5m data: four.
  • cluster_features / select_representatives / select_features — average-linkage clustering on the Mantegna correlation distance with k chosen by silhouette (or forced via k=, the natural argument being signal_rank), then one feature per cluster — the medoid by default, or the highest-|score| member when scores are supplied.

This is the one deliberate exception to the library's expression convention: it takes a DataFrame and returns NumPy arrays and plain Python objects, is not lazy or streaming-safe, and is not on the .ta namespace. Model-based importance is deferred to the two entries below, because on rolling features it requires purged K-fold with an embargo — a naive implementation would measure memorization of overlapping windows rather than prediction.

  • examples/plot_feature_selection.py: a worked case study. Runs the whole pipeline on 16 indicators over the real BTCUSDT 5m fixture and prints the narrative, with a 4-panel figure (raw correlation → eigenvalue spectrum vs. the MP edge → denoised matrix reordered by cluster → cumulative variance). The headline result: 16 indicators, 4 eigenvalues above the noise edge carrying 85% of the variance, and a condition number cut from 17,748 to 34. Written up under "Case study: which of my 16 indicators are actually different?" in the examples docs.

  • polars_ta.selection, part 2: significance and relevance. The first pass removed redundancy without ever touching a target. These add the two things that follow — is the structure real, and does it predict?

  • block_permute / permutation_test — a null distribution for any statistic of the feature matrix. Blocks, not a plain shuffle: shuffling would destroy the autocorrelation every rolling indicator has by construction and produce a null far too easy to beat.
  • purged_kfold — K-fold with purging and an embargo (López de Prado, 2018, ch. 7). Bars t and t+1 share w-1 observations of any w-window indicator, so a plain K-fold measures memorization, not prediction.
  • clustered_mda — importance that shuffles whole clusters jointly, so the substitution effect cannot scramble the scores (López de Prado, 2020, ch. 6). The model is a user-supplied fit/predict callable, keeping scikit-learn out of this library's dependencies; RidgeRegressor is the zero-dependency default.
  • More redundancy screens: vif (multicollinearity a pairwise screen cannot see — it flags atr_14/natr_14 at ~1470, since NATR is ATR over price), mutual_information and variation_of_information (non-linear dependence, and the only sane lens on the 61 sparse 0/±100 candlestick patterns), and cluster_by_distance to cluster on any metric.
  • screen(max_missing=...) with ScreenResult.worst_missing(). Row removal is driven by the worst column: on the full library roll_spread is null wherever its covariance premise fails — 42% of bars, scattered — and alone cut a 5,000-bar sample to 2,610 rows for all 184 features.

  • polars_ta.selection, part 3: the rest of the importance literature. clustered_mda alone is one number with nothing to check it against. These complete the standard quant-finance toolkit, all sharing ImportanceResult so they are directly comparable:

  • target_screen — model-free univariate triage, out of sample on the purged folds: rank information coefficient (Grinold & Kahn, 1999) and mutual information, because a U-shaped relationship scores an IC of zero and an MI well above it. Reported with a per-fold t-stat, which measures consistency and not size: a pure-noise feature reaches a t-stat near 5 on an |IC| of 0.008, so the two columns must be read together.
  • clustered_mdi — in-sample impurity shares from any model exposing feature_importances_, summed per cluster (López de Prado, 2020, ch. 6). No default model: RidgeRegressor has no impurity to decrease, and quietly substituting coefficients would be a different measure wearing this one's name. High MDI with zero MDA is the signature of memorization.
  • single_feature_importance (SFI) — fits each cluster alone, so it is structurally immune to substitution and equally blind to interaction (2018, ch. 8). Its disagreement with MDA is the diagnostic: high SFI / low MDA means redundant, low SFI / high MDA means it only works in combination.
  • clustered_shapley — exact Shapley values over clusters, which reconciles MDA (remove last) and SFI (add first) by averaging over every ordering. The only measure here that is additive: the values sum exactly to the full model's out-of-sample score, so a share of the edge is a defensible claim. Costs 2^k fits per fold, hence max_clusters=12 as a guard.
  • null_importance — refits against a block-permuted label to give any of the above a p-value (Altmann et al., 2010). significant() applies a Bonferroni correction by default, since testing 20 clusters at 5% yields a false positive about 64% of the time.

  • examples/plot_feature_importance.py: the honest negative result. Runs all six over the whole library — 205 indicators, 183 after screening, 8 clusters — against a 12-bar forward return with label_horizon=12 and embargo=300. The univariate screen looks promising at |IC| 0.047 with a t-stat of 3.8. The full model's out-of-sample IC is −0.0079, the best cluster p-value against the shuffled-label null is 0.118, and nothing survives Bonferroni at 5%.

That is the correct answer: 200 technical indicators on 5,000 bars of one asset do not predict its 12-bar forward return, and a pipeline reporting otherwise would be measuring its own leakage. Panel A doubles as a redundancy finding — the top 20 features by |IC| are VWAP, Donchian, Bollinger, Ichimoku and six moving averages, all within 0.004 of each other, because they are all the price level.

The MDI column (a RandomForestRegressor, 120 trees, depth 4) demonstrates both of that measure's documented failure modes at once: the forest spends 47% of its impurity budget on the cluster whose out-of-sample MDA is 0.0001 and whose Shapley value is −0.021, and 0.001 on the three candlestick clusters — one of which has the best MDA and the lowest p-value in the study. Read MDI alone and you would keep the 109 trend/momentum features and discard the only cluster that came close to significance. scikit-learn is a dev dependency for this script alone; the library never imports it.

Changed

  • Corrected: signal_rank is a ceiling, not a measurement. The previous entry's headline — "16 indicators, 4 real dimensions" — was over-claimed, and the new permutation_test is what caught it. 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:
Statistic Observed Null p
Eigenvalues above the edge 4 5.1 ± 1.2 0.99 — not significant
Top eigenvalue 6.07 1.54 ± 0.08 0.008
effective_rank 5.66 15.5 0.008

The shared structure is unambiguously real; the precise count at the edge is not. The README, the case study, the figure and signal_rank's own docstring now say so, and effective_rank is the headline figure instead.

  • vif no longer uses pinv. The pseudo-inverse discards the null space, so exactly-collinear features came back with a reassuring VIF of 1.0 — hiding precisely what the diagnostic exists to expose. It now uses a ridged eigendecomposition, which keeps the blow-up visible and finite.

  • examples/plot_full_library_selection.py. Builds all 205 indicators programmatically from the .ta namespace registry (199 automatic, 6 special-cased with documented reasons) and runs the pipeline over the lot: 205 → 183 non-constant features → 19 above the noise edge, effective rank 32, 40 clusters, the largest holding 34 indicators. The direct answer to "how do you manage all of these features".

  • Docs: MathJax (pymdownx.arithmatex + docs/javascripts/mathjax.js), so the random-matrix and distance-metric formulas render.

  • TA-Lib parity: 18 indicators filling gaps against TA-Lib's function list. All follow the library's existing conventions (str-or-Expr inputs, null warm-up, .over("symbol") isolation, streaming-safe, .ta namespace), and each is cross-checked in the tests against an independent NumPy reference rather than against its own implementation:

  • Moving averages (trend): dema, tema — lag-cancelling averages that track a constant-slope move exactly, where an EMA never catches up; t3 (Tillson, with a v_factor dialing smoothness against responsiveness); trima, which trades the other way for a smoother baseline. TRIMA follows TA-Lib's odd/even window split.
  • Directional movement (trend): dx, adxr, plus_dm, minus_dm — the rest of Wilder's ladder, built on the _adx_components helper that already backed adx. The raw ±DM keep the magnitude of directional movement that ±DI normalizes away.
  • Momentum: mom (difference in price units), apo (the absolute-units counterpart of ppo), and bop (Balance of Power) — the last being the only momentum indicator here that reads the open, which pairs with the candlestick patterns.
  • Volatility: true_range (exposed on its own, not just inside ATR) and normalized_average_true_range (NATR) — ATR as a percent of price, which is what makes volatility comparable across symbols on multi-asset frames.
  • Volume: chaikin_ad_oscillator (ADOSC). The A/D line is a cumulative sum whose level depends on where the data starts, so only its slope is informative; ADOSC extracts that slope.
  • Price transforms (others): average_price, median_price, typical_price, weighted_close_price. One-liners, but conventional named ones — typical_price is what CCI and the Money Flow Index are defined on.

Deliberately not ported: TA-Lib's Math Transform (SIN, SQRT, LN, …) and Math Operators (ADD, SUM, MAX, …). Those exist because C has no expression language; in Polars they are already a + b, expr.sqrt() and expr.rolling_max(), so wrapping them would add ~40 namespace entries that are strictly worse than what users can write natively. STOCHF is likewise omitted — the existing stoch / stoch_signal already are TA-Lib's fast %K / %D.

  • polars_ta.candles — 61 candlestick pattern detectors. The full classic set (Doji, Hammer, Engulfing, Harami, Morning/Evening Star, Three White Soldiers, Three Black Crows, Marubozu, Hikkake, Abandoned Baby, …), each a single vectorized pl.Expr. Pattern definitions follow TA-Lib, the de-facto reference for this family, but the implementation is a Polars rewrite rather than a port: TA-Lib walks bar-by-bar carrying running sums and mutable state, whereas these compile to shifts and rolling windows, so .over("symbol") and the streaming engine work unchanged.
  • Output is TA-Lib-compatible 100 (bullish) / -100 (bearish) / 0 (no pattern), as Int32 — with one deliberate divergence: warm-up bars are null rather than 0, matching this library's "warm-up is null, never faked" rule so an undecidable bar can't be read as a genuine negative.
  • Thresholds come from TA-Lib's candle-settings table (_candle_internal.CANDLE_SETTINGS), which defines "long body" / "short shadow" relative to recent candles — so a pattern means the same thing across price scales and volatility regimes, and one table edit retunes every pattern coherently. The rolling average deliberately excludes the candle being judged, matching TA-Lib's trailing-sum semantics.
  • Every pattern defaults to the conventional open/high/low/close column names, accepts str or pl.Expr on all four inputs, and is exposed on the .ta namespace (pl.col("open").ta.cdl_doji()). ALL_PATTERNS maps every name to its function for computing the family in one pass.
  • Seven patterns take a penetration argument (morning/evening star and doji-star variants, abandoned baby, mat hold, dark cloud cover).
  • New "Candlestick patterns" API reference page with detections plotted on real BTCUSDT 5m data and a pattern-frequency chart, generated by examples/plot_candlestick_patterns.py.

  • .ta expression namespace. Importing polars_ta now registers a .ta accessor on every Polars expression, so all 126 single-price-series indicators are callable as pl.col("close").ta.rsi(14) in addition to the existing momentum.rsi("close", 14) free-function form. The calling expression is bound to the indicator's first input; remaining columns are passed as arguments (pl.col("high").ta.average_true_range("low", "close")). It is a thin, byte-for-byte-identical dispatch layer over the same functions — .over(...), streaming and fillna work through it unchanged — generated from the module functions at import time so it can't drift. Cross-sectional and regime-composite helpers (which don't take a single price series) stay free-function-only. polars_ta.TA_INDICATORS lists every exposed name. New "Call indicators as pl.col(...).ta.<name>()" how-to guide.

  • New quant portfolio/risk features across four families that retail TA libraries typically omit — all pure Polars expressions with the library's standard null warm-up and .over("symbol") guarantees:

  • Tail risk & drawdown: rolling_cvar (Conditional VaR / expected shortfall — a coherent risk measure, unlike VaR), cornish_fisher_var (skew/kurtosis-adjusted "modified VaR" that captures crash risk symmetric vol misses), rolling_max_drawdown (causal, trailing peak), and calmar_ratio (annualized return per unit of worst-case path pain).
  • Distribution shape: rolling_skew, rolling_kurtosis (excess), gain_to_pain (net move per unit of downside suffered), and jarque_bera (a single normality-test statistic that rises when either tail asymmetry or fat-tailedness appears) — leading indicators of regime fragility that complement the entropy tools.
  • Signal conditioning: frac_diff (fixed-width fractional differentiation, López de Prado AFML ch. 5 — stationarity while retaining long memory), rolling_autocorr, and rolling_ic (rolling information coefficient for alpha-decay monitoring; documented as a forward-looking research diagnostic, never a live input).
  • Cross-sectional / factor plumbing: rolling_beta_to, idiosyncratic_vol (residual vol a beta hedge leaves behind), downside_beta (Ang-Chen / Bawa-Lindenberg, estimated only on down-benchmark bars), and momentum_12_1 (Jegadeesh-Titman skip-a-month momentum factor). Combine the cross-sectional ones with .over(timestamp) and the existing cross_sectional_rank / zscore.

  • New quant.regime_conditional_signal: a compositional building block that switches between two pre-computed signal expressions based on a regime score (a hard threshold switch, not a smooth blend) — e.g. trend-follow when hurst_ribbon's h_ribbon_avg >= 0.5, mean-revert otherwise. Not wired specifically to Hurst: regime and the two signal branches can be any expressions. A null regime value produces null output rather than silently falling back to either branch. New examples/plot_regime_conditional_signal.py capstone figure wiring it to the existing Hurst ribbon on real BTCUSDT data, plus a "Regime-conditional trend/mean-reversion switch" how-to guide.

  • New polars_ta.calendar module: day_of_week, is_weekend, hour_of_day, minute_of_day, time_since_midnight, month_of_year, is_month_end, and bars_since_session_open. The first module whose input is a timestamp column rather than price/volume — deliberately does not hardcode market-specific session windows (e.g. FX Asian/London/NY hours), since those are UTC-hour conventions that vary by instrument. New "Add calendar/seasonality features" how-to guide.

  • New microstructure/regime indicators: microstructure.shannon_entropy (normalized entropy of the binned return distribution — distributional concentration, not directional persistence) and microstructure.approximate_entropy (pattern predictability; documented as O(window²) per row, so it's meant for small windows as a slow-moving regime gate, not a per-bar signal over a large lookback). New examples/plot_entropy.py figure and docs section showing both against the existing Hurst ribbon on real BTCUSDT data.

  • New trend indicators: trend.supertrend (ATR-banded stop-and-reverse line), trend.hull_moving_average (lower-lag alternative to SMA/EMA), and trend.elder_bull_power / trend.elder_bear_power (Elder Ray).

  • New momentum indicators: momentum.cmo (Chande Momentum Oscillator) and momentum.fisher_transform (Ehlers' Fisher Transform, using the original double-EMA-damped recursion rather than a one-shot atanh, which would saturate at the clip boundary on noisy real data).
  • New volume indicator: volume.klinger_volume_oscillator.
  • New quant indicators: quant.ewma_volatility (RiskMetrics-style exponentially-weighted volatility) and quant.cross_sectional_zscore / quant.cross_sectional_rank — the library's first cross-sectional indicators, comparing symbols against each other at each timestamp (.over(timestamp_column)) rather than through time for one symbol.
  • New microstructure indicator: microstructure.lee_ready_trade_sign (Lee-Ready buy/sell trade classification, falling back to the tick test on bar data without quotes).
  • A new documentation example (examples/plot_new_indicators.py) and "New indicators" section in the docs covering all of the above on real BTCUSDT data, plus a "Rank symbols cross-sectionally" how-to guide.
  • Reference, warm-up, smoke, and multi-asset (.over("symbol")) test coverage for every new indicator, keeping the suite at 100% line coverage.

Changed

  • The str-or-Expr argument convention is now universal. Every indicator's column arguments accept either a column name (str) or an existing pl.Expr — previously ~1/3 of functions coerced their first argument with an unconditional pl.col(...) and rejected an Expr. This is what makes the new .ta namespace work uniformly across the whole library, and is enforced by the test suite.

Fixed

  • momentum.kama and the trend.adx family reported fabricated values during their warm-up window. KAMA's efficiency-ratio guard turned the null ("not enough data yet") denominator into 0.0, starting the recursion at bar 0 with no history; the ADX zero-denominator guard added in 0.2.0 used fill_null(0.0), which also filled warm-up nulls, so adx/adx_pos/ adx_neg reported from bar 0. All four now surface the first window - 1 rows as nulls, like every other indicator. Post-warm-up values are unchanged for KAMA/adx_pos/adx_neg; adx values shift slightly because the Wilder recursion is now seeded from the first post-warm-up DX value instead of bar 1.
  • microstructure.vpin crashed with IndexError when total volume exceeded bucket_size × n_bars. The per-bucket accumulators were sized by bar count, but a single bar can fill several buckets. Buffers are now sized by total volume / bucket_size.

  • momentum.kama returned float NaN instead of null for its warm-up rows, violating the library-wide "not enough data yet is null" convention.

  • quant.log_return, microstructure.rolling_beta, and microstructure.rolling_cov rejected column-name strings (they only accepted pl.Expr, unlike every other indicator). They now coerce strings like the rest of the API.

Added

  • 100% line coverage, enforced in CI (--cov-fail-under=100): a smoke suite (tests/test_smoke.py) exercises every public indicator in both fillna modes, plus edge-case tests for class-only combination indicators, the Keltner EMA variant, Ichimoku visual mode, smoothed VPT, and degenerate inputs (flat/short series, never-filling VPIN buckets).
  • Multi-asset guarantee: every indicator (including the sequential map_batches-based KAMA, PSAR, VPIN, and Hurst) works per-symbol via .over("symbol") with no state leaking across symbols, enforced by a new tests/test_multi_asset.py and documented in the how-to guides.
  • Warm-up policy tests (tests/test_warmup.py) pinning each indicator's exact first-valid row: warm-up rows are null, never fabricated numbers, and no nulls appear after warm-up on clean data.
  • Reference cross-checks for EMA, MACD, OBV, MFI, Stochastic %K, Williams %R, ROC, and CCI against independent NumPy implementations.
  • The package now ships a py.typed marker, so type checkers see the library's annotations; PyPI classifiers declare Python 3.10–3.13 support.
  • CI reports line coverage (pytest-cov).

[0.2.0] - 2026-07-19

Fixed

  • trend.adx returned all-NaN on real data. adx, adx_pos, and adx_neg divided by the true range and by (+DI + -DI) without guarding a zero denominator, so a flat or low-range bar produced inf/NaN. Because those values feed a Wilder ewm_mean (infinite memory), a single NaN poisoned the entire column — on the real BTCUSDT fixture adx() was NaN for all 5000 rows. The zero denominators are now nulled and the result filled with 0 (no directional movement). adx_pos/adx_neg output is unchanged on data without flat bars; adx now returns valid values in [0, 100]. Regression tests added (test_adx_matches_reference, test_adx_flat_market_is_finite).
  • Log-return indicators no longer leak -inf/NaN on non-positive prices. A price <= 0 now yields a null return instead of poisoning downstream rolling windows (hasbrouck_lambda, hurst_exponent, variance_ratio, quant.historical_volatility).

Added

  • New volatility estimators: quant.parkinson_volatility and quant.rogers_satchell_volatility, completing the OHLC realized-volatility family alongside Garman-Klass and Yang-Zhang.
  • New microstructure indicators: microstructure.corwin_schultz_spread (high-low bid-ask spread estimator) and microstructure.half_life (Ornstein-Uhlenbeck half-life of mean reversion).
  • Optional speed extra (pip install polars-ta-lib[speed]) that JIT-compiles the sequential VPIN volume-bucketing loop with Numba; falls back to an identical pure-Python loop when absent (output is byte-for-byte the same).
  • Documentation figures rendered on the real BTCUSDT fixture — classic indicators, trend & volume, and liquidity & microstructure — plus a one-command regeneration script (examples/generate_all_figures.py).
  • Hypothesis property tests (tests/test_properties.py) covering length preservation, no NaN/inf leakage, and causality (no lookahead).

Changed

  • microstructure.hurst_exponent rewritten from a per-row rolling_map to a single vectorized map_batches pass over a sliding-window view (roughly minutes → seconds on large inputs); output unchanged.
  • Shared rolling-covariance / OLS-slope / log-return logic factored into an internal polars_ta._internal module and reused across the microstructure and quant indicators; output verified byte-identical.

[0.1.0]

  • Initial release: Polars-native technical-analysis indicators (momentum, trend, volatility, volume), a quant module, a microstructure module (VPIN, Kyle's and Hasbrouck's lambda, Roll's spread, multi-scale Hurst ribbon), packaging with uv, tests, benchmarks, and an MkDocs documentation site.