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.
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.
Trend¶
Trailing-stop trend followers that flip with the move.
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.