Concepts¶
Five ideas do all the work. Internalize them once, and every function in pomata composes the same way.
1. Everything is a pl.Expr factory¶
Every public function returns a Polars expression — it never touches your data. Name it, compose it, pass it
around, and run it in any context: a select, a with_columns, eager or lazy. Nothing forces a DataFrame shape on
you.
from pomata.indicators import ema
macd_line = ema(pl.col("close"), 12) - ema(pl.col("close"), 26) # just an expression
frame.with_columns(macd=macd_line) # run it wherever you like
2. .over for multi-asset panels¶
A panel of many tickers is one DataFrame and one query: wrap the call in .over(...) and each group is reduced
independently — windows and recursions never bleed across a boundary.
>>> import polars as pl
>>> from pomata.indicators import sma
>>>
>>> frame = pl.DataFrame({"ticker": ["A", "A", "A", "B", "B", "B"], "close": [1.0, 2.0, 3.0, 10.0, 20.0, 30.0]})
>>> frame.with_columns(sma=sma(pl.col("close"), 2).over("ticker"))
shape: (6, 3)
┌────────┬───────┬──────┐
│ ticker ┆ close ┆ sma │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 │
╞════════╪═══════╪══════╡
│ A ┆ 1.0 ┆ null │
│ A ┆ 2.0 ┆ 1.5 │
│ A ┆ 3.0 ┆ 2.5 │
│ B ┆ 10.0 ┆ null │
│ B ┆ 20.0 ┆ 15.0 │
│ B ┆ 30.0 ┆ 25.0 │
└────────┴───────┴──────┘
Ticker B starts its own warm-up (None) instead of inheriting A’s tail.
3. Warm-up is null, never fabricated¶
Until a window fills, the output is null — never a zero, never a forward-filled guess:
>>> frame = pl.DataFrame({"close": [1.0, 2.0, 3.0, 4.0, 5.0]})
>>> frame.with_columns(sma=sma(pl.col("close"), 3))
shape: (5, 2)
┌───────┬──────┐
│ close ┆ sma │
│ --- ┆ --- │
│ f64 ┆ f64 │
╞═══════╪══════╡
│ 1.0 ┆ null │
│ 2.0 ┆ null │
│ 3.0 ┆ 2.0 │
│ 4.0 ┆ 3.0 │
│ 5.0 ┆ 4.0 │
└───────┴──────┘
A fabricated warm-up value is a silent look-ahead. pomata refuses to invent one, so a null always means not
enough data yet — exactly what you want before you trade on it.
4. No look-ahead, by construction¶
A signal computed at the close can only act on the next bar. That is a single .shift(1), and it is the whole
story — no hidden alignment, no off-by-one:
from pomata.indicators import rsi
weight = (rsi(pl.col("close"), 14) < 30).cast(pl.Float64).shift(1) # decide at close t, act at t+1
5. Multi-output indicators return a pl.Struct¶
Anything with several lines — Bollinger Bands, MACD, Stochastic Oscillator, Ichimoku Cloud — returns one struct column. Pick a line
with .struct.field(...), or expand them all with .struct.unnest():
>>> from pomata.indicators import bollinger_bands
>>>
>>> frame = pl.DataFrame({"close": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]})
>>> frame.select(bollinger_bands(pl.col("close"), 3).alias("bb")).unnest("bb").with_columns(pl.all().round(4))
shape: (6, 3)
┌───────┬────────┬───────┐
│ lower ┆ middle ┆ upper │
│ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 │
╞═══════╪════════╪═══════╡
│ null ┆ null ┆ null │
│ null ┆ null ┆ null │
│ 0.367 ┆ 2.0 ┆ 3.633 │
│ 1.367 ┆ 3.0 ┆ 4.633 │
│ 2.367 ┆ 4.0 ┆ 5.633 │
│ 3.367 ┆ 5.0 ┆ 6.633 │
└───────┴────────┴───────┘