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 putsNaNthrough 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 withkchosen by silhouette (or forced viak=, the natural argument beingsignal_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). Barstandt+1sharew-1observations of anyw-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-suppliedfit/predictcallable, keeping scikit-learn out of this library's dependencies;RidgeRegressoris the zero-dependency default.- More redundancy screens:
vif(multicollinearity a pairwise screen cannot see — it flagsatr_14/natr_14at ~1470, since NATR is ATR over price),mutual_informationandvariation_of_information(non-linear dependence, and the only sane lens on the 61 sparse0/±100candlestick patterns), andcluster_by_distanceto cluster on any metric. -
screen(max_missing=...)withScreenResult.worst_missing(). Row removal is driven by the worst column: on the full libraryroll_spreadis 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_mdaalone is one number with nothing to check it against. These complete the standard quant-finance toolkit, all sharingImportanceResultso 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 exposingfeature_importances_, summed per cluster (López de Prado, 2020, ch. 6). No default model:RidgeRegressorhas 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, hencemax_clusters=12as 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 withlabel_horizon=12andembargo=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_rankis a ceiling, not a measurement. The previous entry's headline — "16 indicators, 4 real dimensions" — was over-claimed, and the newpermutation_testis what caught it. Marchenko-Pastur assumes i.i.d. rows; rolling indicators are ~0.99 autocorrelated, son_obsgrossly 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.
-
vifno longer usespinv. 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.tanamespace 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,.tanamespace), 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 av_factordialing 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_componentshelper that already backedadx. The raw ±DM keep the magnitude of directional movement that ±DI normalizes away. - Momentum:
mom(difference in price units),apo(the absolute-units counterpart ofppo), andbop(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) andnormalized_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_priceis 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 vectorizedpl.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), asInt32— with one deliberate divergence: warm-up bars arenullrather than0, 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/closecolumn names, acceptsstrorpl.Expron all four inputs, and is exposed on the.tanamespace (pl.col("open").ta.cdl_doji()).ALL_PATTERNSmaps every name to its function for computing the family in one pass. - Seven patterns take a
penetrationargument (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. -
.taexpression namespace. Importingpolars_tanow registers a.taaccessor on every Polars expression, so all 126 single-price-series indicators are callable aspl.col("close").ta.rsi(14)in addition to the existingmomentum.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 andfillnawork 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_INDICATORSlists every exposed name. New "Call indicators aspl.col(...).ta.<name>()" how-to guide. -
New
quantportfolio/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), andcalmar_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), andjarque_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, androlling_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), andmomentum_12_1(Jegadeesh-Titman skip-a-month momentum factor). Combine the cross-sectional ones with.over(timestamp)and the existingcross_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 whenhurst_ribbon'sh_ribbon_avg >= 0.5, mean-revert otherwise. Not wired specifically to Hurst:regimeand the two signal branches can be any expressions. A null regime value produces null output rather than silently falling back to either branch. Newexamples/plot_regime_conditional_signal.pycapstone 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.calendarmodule:day_of_week,is_weekend,hour_of_day,minute_of_day,time_since_midnight,month_of_year,is_month_end, andbars_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) andmicrostructure.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). Newexamples/plot_entropy.pyfigure 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), andtrend.elder_bull_power/trend.elder_bear_power(Elder Ray). - New momentum indicators:
momentum.cmo(Chande Momentum Oscillator) andmomentum.fisher_transform(Ehlers' Fisher Transform, using the original double-EMA-damped recursion rather than a one-shotatanh, 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) andquant.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 existingpl.Expr— previously ~1/3 of functions coerced their first argument with an unconditionalpl.col(...)and rejected anExpr. This is what makes the new.tanamespace work uniformly across the whole library, and is enforced by the test suite.
Fixed¶
momentum.kamaand thetrend.adxfamily reported fabricated values during their warm-up window. KAMA's efficiency-ratio guard turned the null ("not enough data yet") denominator into0.0, starting the recursion at bar 0 with no history; the ADX zero-denominator guard added in 0.2.0 usedfill_null(0.0), which also filled warm-up nulls, soadx/adx_pos/adx_negreported from bar 0. All four now surface the firstwindow - 1rows as nulls, like every other indicator. Post-warm-up values are unchanged for KAMA/adx_pos/adx_neg;adxvalues shift slightly because the Wilder recursion is now seeded from the first post-warm-up DX value instead of bar 1.-
microstructure.vpincrashed withIndexErrorwhen total volume exceededbucket_size × n_bars. The per-bucket accumulators were sized by bar count, but a single bar can fill several buckets. Buffers are now sized bytotal volume / bucket_size. -
momentum.kamareturned floatNaNinstead of null for its warm-up rows, violating the library-wide "not enough data yet is null" convention. quant.log_return,microstructure.rolling_beta, andmicrostructure.rolling_covrejected column-name strings (they only acceptedpl.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 newtests/test_multi_asset.pyand 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.typedmarker, 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.adxreturned all-NaN on real data.adx,adx_pos, andadx_negdivided by the true range and by(+DI + -DI)without guarding a zero denominator, so a flat or low-range bar producedinf/NaN. Because those values feed a Wilderewm_mean(infinite memory), a singleNaNpoisoned the entire column — on the real BTCUSDT fixtureadx()was NaN for all 5000 rows. The zero denominators are now nulled and the result filled with0(no directional movement).adx_pos/adx_negoutput is unchanged on data without flat bars;adxnow 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/NaNon non-positive prices. A price<= 0now 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_volatilityandquant.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) andmicrostructure.half_life(Ornstein-Uhlenbeck half-life of mean reversion). - Optional
speedextra (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_exponentrewritten from a per-rowrolling_mapto a single vectorizedmap_batchespass 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._internalmodule 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
quantmodule, amicrostructuremodule (VPIN, Kyle's and Hasbrouck's lambda, Roll's spread, multi-scale Hurst ribbon), packaging with uv, tests, benchmarks, and an MkDocs documentation site.