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 │
└───────┴────────┴───────┘