Indicators

pomata.indicators is the technical-analysis layer — 75 classic studies, from simple moving averages to Ehlers’ cycle measures. Each is a pure pl.Expr factory over your OHLCV columns, verified to the float64 floor against an independent oracle, so any number of them fuse into a single Polars query with no glue code between the steps.

What you get

Seventy-five studies across eleven categories. Every name links to its full signature and formula in the API reference.

Moving average

The trend line under everything — from the simple mean to adaptive and lag-reduced variants.

sma() · ema() · wma() · hma() · dema() · tema() · trima() · kama() · t3() · rma() · vwma()

Momentum

Rate-of-change and overbought/oversold oscillators — how fast price is moving, and whether it is stretched.

rsi() · macd() · mom() · roc() · trix() · cci() · williams_r() · awesome_oscillator() · aroon() · aroon_oscillator() · absolute_price_oscillator() · percentage_price_oscillator() · balance_of_power() · chande_momentum_oscillator() · fisher_transform() · rsi_stochastic() · ultimate_oscillator()

Volatility

How much each bar moves — the True Range and its smoothed and banded forms.

true_range() · atr() · atr_normalized() · bollinger_bands()

Channel

Envelopes around price — rolling extremes, ATR bands, and the Ichimoku cloud.

midpoint() · midprice() · donchian_channels() · keltner_channels() · ichimoku()

Directional movement

Wilder’s trend-strength system — the directional indicators, their spread, and the smoothed ADX.

dm_plus() · dm_minus() · di_plus() · di_minus() · dx() · adx() · adxr() · vortex()

Stochastic

Where the close sits within its recent high-low range.

stochastic_fast() · stochastic_slow()

Trend

Trailing-stop trend followers that flip with the move.

parabolic_sar() · supertrend()

Price transform

Single-bar summaries — the representative price an indicator consumes.

price_average() · price_median() · price_typical() · price_weighted_close()

Statistic

Rolling regression and dispersion — slope, forecast, variance, and standard deviation.

linear_regression() · linear_regression_slope() · linear_regression_angle() · linear_regression_intercept() · time_series_forecast() · variance_rolling() · variance_ewma() · standard_deviation_rolling() · standard_deviation_ewma()

Volume

Flow indicators that weight price by traded size.

obv() · vwap() · accumulation_distribution() · accumulation_distribution_oscillator() · chaikin_money_flow() · money_flow_index()

Cycle

Ehlers’ Hilbert-transform measures — the dominant cycle, its phase, and the trend/cycle mode.

hilbert_phasor() · hilbert_trendline() · dominant_cycle_period() · dominant_cycle_phase() · sine_wave() · mama() · trend_mode()

Common pains, solved

One query, not five round-trips

A trend filter, a momentum gate, and a volatility read usually mean three passes over the frame and three intermediate DataFrames to stitch back together. Because every indicator is a pl.Expr, they all live in one lazy pipeline — the optimizer fuses the scan, and a single .collect() materializes the lot:

>>> import polars as pl
>>> from pomata.indicators import rsi, sma, atr
>>>
>>> prices = pl.LazyFrame(
...     {
...         "high":  [10.0, 11.0, 12.0, 11.5, 13.0, 14.0, 13.5, 15.0],
...         "low":   [ 9.0,  9.5, 10.5, 10.0, 11.0, 12.5, 12.0, 13.5],
...         "close": [ 9.5, 10.5, 11.5, 11.0, 12.5, 13.5, 13.0, 14.5],
...     }
... )
>>> signals = (
...     prices
...     .with_columns(
...         fast=sma(pl.col("close"), 2),
...         slow=sma(pl.col("close"), 4),
...         vol=atr(pl.col("high"), pl.col("low"), pl.col("close"), 3),
...     )
...     .with_columns(long=(pl.col("fast") > pl.col("slow")) & (rsi(pl.col("close"), 3) > 50.0))
...     .collect()
... )
>>> signals.select(pl.col("close"), pl.col("vol").round(4), pl.col("long"))
shape: (8, 3)
┌───────┬────────┬──────┐
│ close ┆ vol    ┆ long │
│ ---   ┆ ---    ┆ ---  │
│ f64   ┆ f64    ┆ bool │
╞═══════╪════════╪══════╡
│ 9.5   ┆ null   ┆ null │
│ 10.5  ┆ null   ┆ null │
│ 11.5  ┆ 1.3333 ┆ null │
│ 11.0  ┆ 1.3889 ┆ true │
│ 12.5  ┆ 1.5926 ┆ true │
│ 13.5  ┆ 1.5617 ┆ true │
│ 13.0  ┆ 1.5412 ┆ true │
│ 14.5  ┆ 1.6941 ┆ true │
└───────┴────────┴──────┘

The regime filter (fast > slow) and the momentum confirmation (rsi > 50) compose as ordinary boolean expressions; nothing leaves Polars until you ask for it.

A panel that can’t leak across assets

A stacked multi-ticker frame is the classic trap: a window that spills from one symbol’s tail into the next fabricates signals that never existed. Wrap the call in .over("ticker") and each group is reduced on its own — windows and recursions restart at every boundary:

>>> from pomata.indicators import ema
>>>
>>> panel = pl.DataFrame(
...     {
...         "ticker": ["A", "A", "A", "A", "B", "B", "B", "B"],
...         "close":  [10.0, 11.0, 12.0, 13.0, 100.0, 90.0, 95.0, 105.0],
...     }
... )
>>> panel.with_columns(
...     clean=ema(pl.col("close"), 3).over("ticker").round(4),
...     leaky=ema(pl.col("close"), 3).round(4),
... )
shape: (8, 4)
┌────────┬───────┬───────┬───────┐
│ ticker ┆ close ┆ clean ┆ leaky │
│ ---    ┆ ---   ┆ ---   ┆ ---   │
│ str    ┆ f64   ┆ f64   ┆ f64   │
╞════════╪═══════╪═══════╪═══════╡
│ A      ┆ 10.0  ┆ null  ┆ null  │
│ A      ┆ 11.0  ┆ null  ┆ null  │
│ A      ┆ 12.0  ┆ 11.0  ┆ 11.0  │
│ A      ┆ 13.0  ┆ 12.0  ┆ 12.0  │
│ B      ┆ 100.0 ┆ null  ┆ 56.0  │
│ B      ┆ 90.0  ┆ null  ┆ 73.0  │
│ B      ┆ 95.0  ┆ 95.0  ┆ 84.0  │
│ B      ┆ 105.0 ┆ 100.0 ┆ 94.5  │
└────────┴───────┴───────┴───────┘

With .over, ticker B opens its own warm-up (None, None) and prices in from 100.0. Without it, B’s first value is 56.0 — a number contaminated by A’s tail, the cross-asset leak made visible.

A signal that can’t peek at the future

A signal computed at the close of bar t must not be acted on until bar t+1; getting that alignment wrong is look-ahead that flatters every backtest. pomata makes it mechanical: the warm-up is null (never a fabricated value to trade on), and a single .shift(1) moves a close-computed decision onto the next bar:

>>> bars = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 10.0, 9.0, 10.5, 12.0]})
>>> signal = (rsi(pl.col("close"), 3) > 50.0).cast(pl.Int8)
>>> res = bars.with_columns(
...     decided_at_close=signal,
...     acted_next_bar=signal.shift(1),
... )
>>> res
shape: (8, 3)
┌───────┬──────────────────┬────────────────┐
│ close ┆ decided_at_close ┆ acted_next_bar │
│ ---   ┆ ---              ┆ ---            │
│ f64   ┆ i8               ┆ i8             │
╞═══════╪══════════════════╪════════════════╡
│ 10.0  ┆ null             ┆ null           │
│ 11.0  ┆ null             ┆ null           │
│ 12.0  ┆ null             ┆ null           │
│ 11.0  ┆ 1                ┆ null           │
│ 10.0  ┆ 0                ┆ 1              │
│ 9.0   ┆ 0                ┆ 0              │
│ 10.5  ┆ 1                ┆ 0              │
│ 12.0  ┆ 1                ┆ 1              │
└───────┴──────────────────┴────────────────┘

acted_next_bar is decided_at_close slid one bar forward — the decision lands where it can actually be filled, and the warm-up null never becomes a phantom position.

Multi-line indicators, one struct column

Bollinger Bands, MACD, and the stochastics are several series at once. Rather than return a loose tuple you have to re-align, pomata packs them into a single pl.Struct: pull one line with .struct.field(...), or fan them all out into columns with .struct.unnest():

>>> from pomata.indicators import macd
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 12.0, 13.0, 14.0, 13.0, 15.0, 16.0]})
>>> lines = macd(pl.col("close"), window_fast=2, window_slow=4, window_signal=2)
>>> frame.select("close", lines.struct.field("histogram").round(4).alias("hist"))
shape: (10, 2)
┌───────┬─────────┐
│ close ┆ hist    │
│ ---   ┆ ---     │
│ f64   ┆ f64     │
╞═══════╪═════════╡
│ 10.0  ┆ null    │
│ 11.0  ┆ null    │
│ 12.0  ┆ null    │
│ 11.0  ┆ null    │
│ 12.0  ┆ 0.0778  │
│ 13.0  ┆ 0.0965  │
│ 14.0  ┆ 0.0877  │
│ 13.0  ┆ -0.1108 │
│ 15.0  ┆ 0.0879  │
│ 16.0  ┆ 0.0849  │
└───────┴─────────┘
>>> frame.select(lines.alias("macd")).unnest("macd").with_columns(pl.all().round(4))
shape: (10, 3)
┌────────┬────────┬───────────┐
│ macd   ┆ signal ┆ histogram │
│ ---    ┆ ---    ┆ ---       │
│ f64    ┆ f64    ┆ f64       │
╞════════╪════════╪═══════════╡
│ null   ┆ null   ┆ null      │
│ null   ┆ null   ┆ null      │
│ null   ┆ null   ┆ null      │
│ 0.1667 ┆ null   ┆ null      │
│ 0.3222 ┆ 0.2444 ┆ 0.0778    │
│ 0.5341 ┆ 0.4375 ┆ 0.0965    │
│ 0.7007 ┆ 0.613  ┆ 0.0877    │
│ 0.2805 ┆ 0.3913 ┆ -0.1108   │
│ 0.655  ┆ 0.5671 ┆ 0.0879    │
│ 0.8219 ┆ 0.737  ┆ 0.0849    │
└────────┴────────┴───────────┘

One expression, one column, three lines inside it — aligned to the same index by construction, so there is no re-joining and no off-by-one between the macd, signal, and histogram series.