indicators

Technical-analysis indicators as pl.Expr factories.

Source is organized into theme modules for maintainability; this package re-exports a flat public API.

pomata.indicators.absolute_price_oscillator(
expr: Expr,
*,
window_fast: int,
window_slow: int,
) Expr[source]

Absolute Price Oscillator, also known as APO — the gap between a fast and a slow exponential moving average.

A momentum oscillator built from the difference of two ema() of the close, a fast one minus a slow one. It is the line that underlies macd(), expressed in price units:

\[\mathrm{APO}_t = \mathrm{EMA}(\mathrm{close}, n_{\mathrm{fast}})_t - \mathrm{EMA}(\mathrm{close}, n_{\mathrm{slow}})_t.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window_fast – Span of the fast EMA (canonically 12). Must be >= 1.

  • window_slow – Span of the slow EMA (canonically 26). Must be >= 1 and >= window_fast.

Returns:

The oscillator for each row, the same length as the input. Values are null until both EMAs leave their warm-up (the first max(window_fast, window_slow) - 1 rows).

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_fast < 1, window_slow < 1, or window_fast > window_slow (the fast leg must be the shorter one; window_fast == window_slow is allowed and gives an identically-zero oscillator).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Moving average: both legs use the exponential ema() (not a simple average), so APO is the MACD line without the signal; compose sma() directly for a simple-average oscillator.

Edge-case behavior:

  • Null — a null contaminates the recursive EMA state and yields null for subsequent rows.

  • NaN — a NaN propagates through both EMAs, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither EMA spans series boundaries, e.g. absolute_price_oscillator(pl.col("close")).over("ticker").

See also

  • percentage_price_oscillator(): The same gap expressed as a percentage of the slow EMA.

  • macd(): The oscillator this line underlies, adding a signal and histogram.

  • ema(): The exponential moving average each leg is built from.

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import absolute_price_oscillator
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0]})
>>> expr = absolute_price_oscillator(pl.col("close"), window_fast=2, window_slow=3).round(4)
>>> frame.select(expr.alias("apo"))["apo"].to_list()
[None, None, 0.5, 0.1667, 0.3889, 0.463, 0.1543, 0.3848]

On a multi-ticker panel, wrap the call in .over so each ticker’s EMAs warm up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "close": [10.0, 11.0, 12.0, 11.0, 20.0, 22.0, 24.0, 22.0]}
... )
>>> expr = absolute_price_oscillator(pl.col("close"), window_fast=2, window_slow=3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("apo"))["apo"].to_list()
[None, None, 0.5, 0.1667, None, None, 1.0, 0.3333]

A null (which the recursive EMA latches on) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, None, 13.0, float("nan"), 15.0]})
>>> expr = absolute_price_oscillator(pl.col("close"), window_fast=2, window_slow=3).round(4)
>>> frame.select(expr.alias("apo"))["apo"].to_list()
[None, None, None, 1.3095, nan, nan]
pomata.indicators.accumulation_distribution(
high: Expr,
low: Expr,
close: Expr,
volume: Expr,
) Expr[source]

Accumulation/Distribution Line (AD), also known as the Accumulation/Distribution Index or the Chaikin A/D Line.

A cumulative volume-flow indicator popularized by Marc Chaikin that gauges the cumulative flow of money into and out of an instrument. Each bar contributes a fraction of its volume weighted by where the close sits inside the bar’s high-low range. The per-bar weight is the Money Flow Multiplier (MFM), bounded in \([-1, +1]\) (+1 at the high, -1 at the low); multiplying it by volume gives the Money Flow Volume (MFV); the line is the running cumulative sum of MFV:

\[\begin{split}\mathrm{MFM}_t &= \frac{(\mathrm{close}_t - \mathrm{low}_t) - (\mathrm{high}_t - \mathrm{close}_t)} {\mathrm{high}_t - \mathrm{low}_t}, \\ \mathrm{MFV}_t &= \mathrm{MFM}_t \cdot \mathrm{volume}_t, \\ \mathrm{AD}_t &= \sum_{i=0}^{t} \mathrm{MFV}_i = \mathrm{AD}_{t-1} + \mathrm{MFV}_t.\end{split}\]

The line is unbounded and its absolute level is arbitrary (it depends on where the cumulative sum starts); only its slope and divergences from price are interpreted. There is no moving window: every bar from the start of the series contributes to the running total.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

Returns:

the first row already carries the first bar’s Money Flow Volume, and the line is the running cumulative sum from there.

Return type:

The Accumulation/Distribution Line for each row, the same length as the inputs. There is no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Zero-range bars:

On a doji bar (high == low) the Money Flow Multiplier is 0 by convention, so the denominator never hits 0 / 0 and close does not enter the bar’s contribution.

The zero-range convention applies only to a genuine equal-range bar (high == low), where the multiplier is 0 and close does not enter the contribution. A null or NaN in any input instead leaves the range null or NaN (never == 0), so missing data propagates rather than being silently zeroed.

Edge-case behavior:

  • Null — a row in which high, low, close, or volume is null yields null at that position and leaves the running total untouched (the cumulative sum skips it and continues from the prior total). On a genuine doji bar (high == low, both finite) the multiplier is 0 and close is irrelevant, so a null in close on such a bar still yields 0 rather than null.

  • NaN — a NaN in any operand that reaches the cumulative sum latches: once present, every later non-null row of the line is NaN. A bar whose high and low are both NaN does not take the doji branch (NaN - NaN is NaN, never == 0), so the NaN poisons the line rather than contributing 0.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the cumulative sum restarts per series and never spans series boundaries, e.g. accumulation_distribution(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume")).over("ticker")

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import accumulation_distribution
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 13.0, 14.0],
...         "low": [8.0, 9.0, 10.0, 11.0, 12.0],
...         "close": [9.0, 10.5, 10.0, 13.0, 12.5],
...         "volume": [100.0, 200.0, 300.0, 400.0, 500.0],
...     }
... )
>>> frame.select(
...     accumulation_distribution(
...         pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume")
...     ).round(4).alias("ad")
... )["ad"].to_list()
[0.0, 100.0, -200.0, 200.0, -50.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [12.0, 13.0, 12.5, 14.0, 22.0, 24.0, 23.0, 25.0],
...         "low": [10.0, 11.0, 11.0, 12.0, 20.0, 21.0, 21.0, 23.0],
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 100.0, 120.0, 90.0, 110.0],
...     }
... )
>>> expr = accumulation_distribution(
...     pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume")
... ).over("ticker").round(4)
>>> frame.with_columns(expr.alias("accumulation_distribution"))["accumulation_distribution"].to_list()
[0.0, 60.0, 30.0, 85.0, 50.0, -30.0, 15.0, 15.0]

A null (skipped, the running total carrying across it) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0],
...         "low": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
...         "close": [11.5, 12.5, 13.0, 14.5, None, 16.0, float("nan"), 18.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 95.0, 140.0],
...     }
... )
>>> expr = accumulation_distribution(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume")).round(4)
>>> frame.select(expr.alias("accumulation_distribution"))["accumulation_distribution"].to_list()
[50.0, 110.0, 110.0, 165.0, None, 165.0, nan, nan]
pomata.indicators.accumulation_distribution_oscillator(
high: Expr,
low: Expr,
close: Expr,
volume: Expr,
*,
window_fast: int,
window_slow: int,
) Expr[source]

Accumulation/Distribution Oscillator, also known as the Chaikin Oscillator.

Marc Chaikin’s volume-momentum oscillator: the gap between a fast and a slow ema() of the accumulation_distribution() line, so it measures the momentum of accumulation rather than its level and oscillates around zero. With \(\mathrm{AD}\) the accumulation/distribution line and \(n_f\), \(n_s\) the fast and slow spans:

\[\mathrm{ADOSC}_t = \mathrm{EMA}(\mathrm{AD}, n_f)_t - \mathrm{EMA}(\mathrm{AD}, n_s)_t.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

  • window_fast – Span of the fast EMA (canonically 3). Must be >= 1.

  • window_slow – Span of the slow EMA (canonically 10). Must be >= 1 and >= window_fast.

Returns:

The oscillator for each row, the same length as the inputs. The first window_slow - 1 values are null (warm-up), inherited from the slow ema() of the accumulation/distribution line, the later of the two to warm up.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_fast < 1, window_slow < 1, or window_fast > window_slow.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Scaling: homogeneous of degree 1 — the accumulation/distribution multiplier is scale-invariant in price while the line scales with volume, so multiplying all four inputs by k scales the oscillator by k.

Edge-case behavior:

  • Null — a null contaminates the accumulation/distribution line and the EMAs, yielding null.

  • NaN — a NaN propagates through the line and the EMAs, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the running sum and the EMAs do not span series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import accumulation_distribution_oscillator
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.2, 10.5, 10.7, 10.3, 10.8],
...         "low": [9.8, 10.0, 10.2, 9.9, 10.3],
...         "close": [10.0, 10.3, 10.5, 10.1, 10.6],
...         "volume": [100.0, 150.0, 120.0, 200.0, 180.0],
...     }
... )
>>> expr = accumulation_distribution_oscillator(
...     pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), window_fast=2, window_slow=3
... ).round(4)
>>> frame.select(expr.alias("adosc"))["adosc"].to_list()
[None, None, 13.0, 8.6667, 11.0556]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [12.0, 13.0, 12.5, 14.0, 22.0, 24.0, 23.0, 25.0],
...         "low": [10.0, 11.0, 11.0, 12.0, 20.0, 21.0, 21.0, 23.0],
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 100.0, 120.0, 90.0, 110.0],
...     }
... )
>>> expr = (
...     accumulation_distribution_oscillator(
...         pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), window_fast=2, window_slow=3
...     )
...     .over("ticker")
...     .round(4)
... )
>>> frame.with_columns(expr.alias("adosc"))["adosc"].to_list()
[None, None, 0.0, 9.1667, None, None, 1.6667, 1.1111]

A null (which voids the line and its EMAs) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0],
...         "low": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
...         "close": [11.5, 12.5, 13.0, 14.5, None, 16.0, float("nan"), 18.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 95.0, 140.0],
...     }
... )
>>> expr = accumulation_distribution_oscillator(
...     pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), window_fast=2, window_slow=3
... ).round(4)
>>> frame.select(expr.alias("adosc"))["adosc"].to_list()
[None, None, 10.0, 15.8333, None, 9.4048, nan, nan]
pomata.indicators.adx(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Average Directional Index (ADX).

The Wilder-smoothed dx(): a single, non-directional measure of trend strength (it says how strongly price is trending, not in which direction), bounded in [0, 100] — low values mean a range, high values a strong trend. It is the rma() of the directional index:

\[\mathrm{ADX}_t = \mathrm{RMA}(\mathrm{DX}, n)_t, \qquad n = \text{window}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The ADX for each row, the same length as the inputs, in [0, 100]. It carries a deep warm-up — roughly 2 * (window - 1) rows of null — since it smooths the already-smoothed dx().

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close (it is built from ratios of directional movement to the average true range).

Seeding:

The warm-up inherits the recursive Wilder seeding of rma() used throughout the cluster.

Edge-case behavior:

  • Null — a null reaching the recursion yields null at that row.

  • NaN — a NaN poisons the recursion and yields NaN for every subsequent non-null row.

  • Flat directional movement — when di+ and di- are both zero the underlying dx() is NaN (0 / 0), which then poisons the ADX recursion.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursions never span series boundaries, e.g. adx(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("ticker").

See also

  • dx(): The directional index this smooths.

  • adxr(): The ADX rating (this averaged with its own past).

  • di_plus(): The plus directional indicator.

References

Examples

On a small OHLC frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import adx
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5, 15.0, 14.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0, 14.5, 14.0],
...     }
... )
>>> expr = adx(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("adx"))["adx"].to_list()
[None, None, 100.0, 60.0, 68.2353, 44.1176, 58.3602, 39.1801, 55.4486, 37.7243]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 20.0, 22.0, 19.0, 23.0, 20.0, 24.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 18.0, 20.0, 17.0, 21.0, 18.0, 22.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 19.0, 21.0, 18.0, 22.0, 19.0, 23.0],
...     }
... )
>>> expr = adx(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("adx"))["adx"].to_list()
[None, None, 100.0, 60.0, 68.2353, 44.1176, None, None, 75.0, 62.5, 43.75, 45.0893]

A leading null close (absorbed by the true-range maximum) and a later NaN (which poisons the recursion and latches) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [None, 10.5, 11.5, 11.0, float("nan"), 12.0, 13.5, 13.0],
...     }
... )
>>> expr = adx(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("adx"))["adx"].to_list()
[None, None, 100.0, 60.0, 68.2353, nan, nan, nan]
pomata.indicators.adxr(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Average Directional Index Rating (ADXR).

Wilder’s smoothing of the trend-strength reading: the mean of the current adx() and the ADX from window bars ago, which damps the ADX and is often used to compare trend strength across time:

\[\mathrm{ADXR}_t = \frac{\mathrm{ADX}_t + \mathrm{ADX}_{t - n}}{2}, \qquad n = \text{window}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the Wilder moving window, and the look-back for the averaging. Must be >= 1.

Returns:

The ADXR for each row, the same length as the inputs, in [0, 100]. Its warm-up is the adx() warm-up plus a further window rows (the look-back of the averaging).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close.

Seeding:

The warm-up inherits the recursive Wilder seeding of rma() used throughout the cluster.

Edge-case behavior:

  • Null / NaN — inherited from adx(): a null yields null and a NaN propagates; a row whose ADX or whose window-ago ADX is missing is itself missing.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the recursion nor the look-back spans series boundaries, e.g. by wrapping the whole call in .over("ticker").

See also

  • adx(): The trend-strength index this averages with its own past.

  • dx(): The directional index the ADX smooths.

  • di_plus(): A directional indicator at the base of the system.

References

Examples

On a small OHLC frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import adxr
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5, 15.0, 14.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0, 14.5, 14.0],
...     }
... )
>>> expr = adxr(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("adxr"))["adxr"].to_list()
[None, None, None, None, 84.1176, 52.0588, 63.2977, 41.6489, 56.9044, 38.4522]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 20.0, 22.0, 19.0, 23.0, 20.0, 24.0, 21.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 18.0, 20.0, 17.0, 21.0, 18.0, 22.0, 19.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 19.0, 21.0, 18.0, 22.0, 19.0, 23.0, 20.0],
...     }
... )
>>> expr = adxr(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("adxr"))["adxr"].to_list()
[None, None, None, None, 84.1176, 52.0588, 63.2977, None, None, None, None, 59.375, 53.7946, 38.4358]

A leading null close (absorbed by the true-range maximum) and a later NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [None, 10.5, 11.5, 11.0, float("nan"), 12.0, 13.5, 13.0],
...     }
... )
>>> expr = adxr(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("adxr"))["adxr"].to_list()
[None, None, None, None, 84.1176, nan, nan, nan]
pomata.indicators.aroon(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Aroon (up and down).

Tushar Chande’s trend indicator (1995): each line measures how recently the window’s extreme occurred, reported as a percentage of the window so it sits in [0, 100] (100 when the extreme is the current bar, 0 when it is the oldest bar in the look-back). With a look-back of window + 1 bars and \(n = \text{window}\):

\[\begin{split}\mathrm{up}_t &= 100 \cdot \frac{n - (\text{bars since highest high})}{n}, \\ \mathrm{down}_t &= 100 \cdot \frac{n - (\text{bars since lowest low})}{n}.\end{split}\]

A rising Aroon Up with a falling Aroon Down signals an uptrend (recent highs, stale lows), and vice versa. On ties the most recent extreme is used. The lines depend only on the positions of the extremes, so they are invariant under any positive rescaling of high and low.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Look-back length; the extreme is sought over the last window + 1 bars. Must be >= 1.

Returns:

  • up — the Aroon Up line, in [0, 100].

  • down — the Aroon Down line, in [0, 100].

Both are null for the first window rows (warm-up: a full window + 1-bar look-back is needed). Access the fields with .struct.field("up") / "down" or .struct.unnest().

Return type:

A struct pl.Expr with two Float64 fields, the same length as the inputs

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Ties — when the extreme is attained more than once in the look-back, the most recent occurrence is used (so the line reads higher).

  • Null — a null anywhere in the look-back yields null on the affected line at that row.

  • NaN — a NaN anywhere in the look-back yields NaN on the affected line (it propagates rather than being treated as an extreme).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the rolling extremes do not span series boundaries, e.g. aroon(pl.col("high"), pl.col("low"), 25).over("ticker").

See also

References

Examples

Basic usage on high-low bars:

>>> import polars as pl
>>> from pomata.indicators import aroon
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 12.0, 14.0, 13.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 11.0, 13.0, 12.0],
...     }
... )
>>> bands = frame.select(aroon(pl.col("high"), pl.col("low"), 3).alias("aroon")).unnest("aroon")
>>> bands["up"].round(4).to_list()
[None, None, None, 66.6667, 100.0, 66.6667, 100.0, 66.6667]
>>> bands["down"].round(4).to_list()
[None, None, None, 0.0, 66.6667, 33.3333, 0.0, 33.3333]

On a multi-ticker panel, wrap the call in .over so each ticker’s channel warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 24.0, 22.0, 26.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 19.0, 21.0, 23.0, 21.0, 25.0],
...     }
... )
>>> expr = aroon(pl.col("high"), pl.col("low"), 3).over("ticker").struct.field("up").round(4)
>>> frame.with_columns(expr.alias("up"))["up"].to_list()
[None, None, None, 66.6667, 100.0, None, None, None, 66.6667, 100.0]

A null (which nulls the affected line) and a NaN (which propagates) in high make the handling visible on the up line:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 13.0, None, 15.0, 16.0, 17.0, 18.0, float("nan"), 20.0, 21.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0],
...     }
... )
>>> expr = aroon(pl.col("high"), pl.col("low"), 3).struct.field("up").round(4)
>>> frame.select(expr.alias("up"))["up"].to_list()
[None, None, None, 100.0, None, None, None, None, 100.0, nan, nan, nan]
pomata.indicators.aroon_oscillator(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Aroon Oscillator.

The single-line form of aroon(): Aroon Up minus Aroon Down, so it swings within [-100, 100] (positive when highs are more recent than lows — an uptrend — and negative in a downtrend):

\[\mathrm{AroonOsc}_t = \mathrm{up}_t - \mathrm{down}_t,\]

with up and down the aroon() lines over the same window.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Look-back length; the extremes are sought over the last window + 1 bars. Must be >= 1.

Returns:

The oscillator for each row, the same length as the inputs, in [-100, 100]. The first window rows are null (warm-up), inherited from aroon().

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — the oscillator inherits aroon()’s handling: a null anywhere in the look-back yields null and a NaN yields NaN (null taking precedence).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the rolling extremes do not span series boundaries, e.g. aroon_oscillator(pl.col("high"), pl.col("low"), 25).over("ticker").

See also

  • aroon(): The two-line indicator this collapses into one.

  • donchian_channels(): The rolling high/low extremes the lines are built from.

  • williams_r(): Another windowed high-low range oscillator.

References

Examples

Basic usage on high-low bars:

>>> import polars as pl
>>> from pomata.indicators import aroon_oscillator
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 12.0, 14.0, 13.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 11.0, 13.0, 12.0],
...     }
... )
>>> expr = aroon_oscillator(pl.col("high"), pl.col("low"), 3).round(4)
>>> frame.select(expr.alias("osc"))["osc"].to_list()
[None, None, None, 66.6667, 33.3333, 33.3333, 100.0, 33.3333]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 24.0, 22.0, 26.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 19.0, 21.0, 23.0, 21.0, 25.0],
...     }
... )
>>> expr = aroon_oscillator(pl.col("high"), pl.col("low"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("osc"))["osc"].to_list()
[None, None, None, 66.6667, 33.3333, None, None, None, 66.6667, 33.3333]

A null (which nulls the oscillator) and a NaN (which propagates) in high make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 13.0, None, 15.0, 16.0, 17.0, 18.0, float("nan"), 20.0, 21.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0],
...     }
... )
>>> expr = aroon_oscillator(pl.col("high"), pl.col("low"), 3).round(4)
>>> frame.select(expr.alias("osc"))["osc"].to_list()
[None, None, None, 100.0, None, None, None, None, 100.0, nan, nan, nan]
pomata.indicators.atr(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Average True Range (ATR), Wilder’s volatility measure.

Introduced by J. Welles Wilder in New Concepts in Technical Trading Systems (1978) as the Wilder-smoothed average of the true_range(). The true range captures the largest of the three candidate moves on each bar — the current high-low spread and the two gaps from the previous close — and the ATR smooths that series with Wilder’s running average:

\[\begin{split}\mathrm{TR}_t &= \max\!\bigl(\,\mathrm{high}_t - \mathrm{low}_t,\; \lvert \mathrm{high}_t - \mathrm{close}_{t-1} \rvert,\; \lvert \mathrm{low}_t - \mathrm{close}_{t-1} \rvert \,\bigr), \\ \mathrm{ATR}_t &= \mathrm{RMA}(\mathrm{TR})_t, \qquad \alpha = \frac{1}{n}, \quad n = \text{window}.\end{split}\]

It is computed by composing the public true_range() and rma(), so the Wilder smoothing (\(\alpha = 1 / n\)) is shared with the rest of Wilder’s family (RSI, ADX, DMI) and the result is unit-consistent with price.

Because every true-range candidate is a non-negative magnitude (the high - low spread of a well-formed bar, or one of the two absolute gap terms), the true range is non-negative, and the Wilder average of a non-negative series is itself non-negative for well-formed bars (high >= low).

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")); the previous close seeds the two gap terms.

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The ATR for each row, the same length as the inputs. The first window - 1 values are null (warm-up), inherited from the rma() over the true-range series: the running average emits only once window non-null true ranges have been counted, independent of where any interior null falls.

The true range itself is defined from row 0 (the first bar has no previous close, so it degenerates to high - low with the two gap terms dropped), so the ATR warm-up is exactly the rma warm-up of window - 1.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Scaling:

Scaling is homogeneous of degree 1 only for a positive factor: the true range is built from absolute differences, so multiplying every price by k scales the ATR by |k|, not by k.

Seeding:

The Wilder smoothing (rma()) is seeded with the simple average of the first window true ranges – Wilder’s canonical initialization. The first true range is the bar’s high-low range (no prior close extends it), so the seed and warm-up include it.

Edge-case behavior:

  • Null — null handling follows pl.max_horizontal: a null in a single high, low, or close input drops only the candidate terms that reference it, leaving the true range as the maximum of the remaining non-null terms. The roles are not interchangeable: a null high removes the high - low and |high - close_prev| terms (leaving |low - close_prev|), a null low removes high - low and |low - close_prev| (leaving |high - close_prev|), and a null close only blanks the two gap terms of the next bar (whose previous close is then null). The true range is therefore null only when every candidate term is null (e.g. the first bar with both high and low null); a null true range yields null at that row while the Wilder recursion preserves its state and bridges the gap.

  • NaN — a NaN in any active term poisons that true range and then the recursion, latching NaN for every subsequent value.

  • window == 1 — the smoothing factor is 1 and the warm-up vanishes, so the ATR reproduces the true range exactly: the max_horizontal-reduced true range (not a textbook three-term true range whenever a candidate term is dropped by a null).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the previous-close shift nor the Wilder recursion spans series boundaries, e.g. atr(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("ticker").

See also

  • true_range(): The per-bar range this Wilder-smooths.

  • rma(): The Wilder moving average used for the smoothing.

  • atr_normalized(): The same ATR expressed as a percent of the current close.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import atr
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 12.0, 13.0, 12.0, 14.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0],
...         "close": [9.5, 11.0, 12.0, 11.0, 13.0],
...     }
... )
>>> frame.select(
...     atr(pl.col("high"), pl.col("low"), pl.col("close"), window=3).round(4).alias("atr_3")
... )["atr_3"].to_list()
[None, None, 1.8333, 1.8889, 2.2593]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [12.0, 13.0, 12.5, 14.0, 22.0, 24.0, 23.0, 25.0],
...         "low": [10.0, 11.0, 11.0, 12.0, 20.0, 21.0, 21.0, 23.0],
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...     }
... )
>>> expr = atr(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("atr"))["atr"].to_list()
[None, 2.0, 1.75, 2.125, None, 2.5, 2.25, 2.375]

A null close (absorbed, so the next bar falls back to high - low) then a NaN close (which the Wilder recursion latches from the next bar on) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0],
...         "low": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
...         "close": [11.5, 12.5, None, 14.5, 15.5, float("nan"), 17.5, 18.0],
...     }
... )
>>> expr = atr(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("atr"))["atr"].to_list()
[None, 2.0, 2.0, 2.0, 2.0, 2.0, nan, nan]
pomata.indicators.atr_normalized(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Normalized Average True Range (NATR).

The atr() expressed as a percentage of the current close, so volatility is comparable across instruments and price levels (unlike the raw ATR, which is in price units). With the ATR over the same window:

\[\mathrm{NATR}_t = 100 \cdot \frac{\mathrm{ATR}_t}{\mathrm{close}_t}, \qquad n = \text{window}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The NATR (in percent) for each row, the same length as the inputs. The first window - 1 values are null (warm-up), inherited from the atr().

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close (the ATR and the close scale together).

Edge-case behavior:

  • Null — a null ATR or a null close at a row yields null there (the ATR inherits atr()’s per-term null handling).

  • NaN — a NaN ATR or close yields NaN.

  • Zero close — where close is 0 the ratio follows IEEE-754 (+/-inf for a non-zero ATR, NaN for a zero ATR).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the underlying ATR does not span series boundaries, e.g. atr_normalized(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("t").

See also

  • atr(): The raw (price-unit) average true range this normalizes.

  • true_range(): The per-bar range underlying the ATR.

  • bollinger_bands(): Another volatility view, standard-deviation bands around a moving average.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import atr_normalized
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.2, 10.5, 10.7, 10.3, 10.8],
...         "low": [9.8, 10.0, 10.2, 9.9, 10.3],
...         "close": [10.0, 10.3, 10.5, 10.1, 10.6],
...     }
... )
>>> expr = atr_normalized(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("natr"))["natr"].to_list()
[None, 4.3689, 4.5238, 5.3218, 5.8373]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [10.2, 10.5, 10.7, 10.3, 20.4, 21.0, 21.4, 20.6],
...         "low": [9.8, 10.0, 10.2, 9.9, 19.6, 20.0, 20.4, 19.8],
...         "close": [10.0, 10.3, 10.5, 10.1, 20.0, 20.6, 21.0, 20.2],
...     }
... )
>>> expr = atr_normalized(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("natr"))["natr"].to_list()
[None, 4.3689, 4.5238, 5.3218, None, 4.3689, 4.5238, 5.3218]

A null close (voiding the ratio at that row) then a NaN close (which propagates through the ratio and the latched ATR) make the missing-data handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.2, 10.5, 10.7, 10.9, 11.1, 11.3, 11.5, 11.7],
...         "low": [9.8, 10.0, 10.2, 10.4, 10.6, 10.8, 11.0, 11.2],
...         "close": [10.0, 10.3, None, 10.7, float("nan"), 11.1, 11.3, 11.5],
...     }
... )
>>> expr = atr_normalized(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("natr"))["natr"].to_list()
[None, 4.3689, None, 4.5561, nan, nan, nan, nan]
pomata.indicators.awesome_oscillator(
high: Expr,
low: Expr,
*,
window_fast: int,
window_slow: int,
) Expr[source]

Awesome Oscillator (Bill Williams) — the gap between a fast and a slow simple average of the median price.

Bill Williams’ momentum gauge: a fast simple moving average of each bar’s median price minus a slow one. It reads momentum off the midpoint of the bar rather than the close, crossing zero as the short-term average overtakes the long-term:

\[\mathrm{AO}_t = \mathrm{SMA}(\mathrm{median}, n_f)_t - \mathrm{SMA}(\mathrm{median}, n_s)_t, \qquad \mathrm{median}_t = \frac{\mathrm{high}_t + \mathrm{low}_t}{2},\]

where \(n_f\) is window_fast and \(n_s\) is window_slow.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window_fast – Window of the fast simple moving average (canonically 5). Must be >= 1.

  • window_slow – Window of the slow simple moving average (canonically 34). Must be >= 1 and >= window_fast.

Returns:

The oscillator for each row, the same length as the inputs. The first window_slow - 1 values are null (warm-up): both averages must be defined before their difference is.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_fast < 1, window_slow < 1, or window_fast > window_slow (the fast leg must be the shorter one; window_fast == window_slow is allowed and gives an identically-zero oscillator).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high and low must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null / NaN — a window containing a null in either input yields null there (each average needs a full window of non-null medians); a NaN propagates.

  • Flat window — over a constant median run both averages equal it, so the oscillator is 0.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither average spans series boundaries.

See also

References

Examples

Basic usage on high-low bars:

>>> import polars as pl
>>> from pomata.indicators import awesome_oscillator
>>>
>>> frame = pl.DataFrame({"high": [2.0, 4.0, 6.0, 8.0, 10.0], "low": [0.0, 2.0, 4.0, 6.0, 8.0]})
>>> expr = awesome_oscillator(pl.col("high"), pl.col("low"), window_fast=2, window_slow=3)
>>> frame.select(expr.round(4).alias("ao"))["ao"].to_list()
[None, None, 1.0, 1.0, 1.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [11.0, 12.0, 13.0, 12.5, 14.0, 21.0, 22.0, 23.0, 22.5, 24.0],
...         "low": [9.0, 10.0, 11.0, 11.0, 12.0, 19.0, 20.0, 21.0, 21.0, 22.0],
...     }
... )
>>> expr = awesome_oscillator(pl.col("high"), pl.col("low"), window_fast=2, window_slow=3)
>>> frame.with_columns(expr.over("ticker").round(4).alias("ao"))["ao"].to_list()
[None, None, 0.5, 0.2917, 0.125, None, None, 0.5, 0.2917, 0.125]

A null (a window touching it yields null) and a NaN (which propagates) make it visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 12.5, 14.0, None, 15.0, float("nan"), 16.0, 17.0],
...         "low": [9.0, 10.0, 11.0, 11.0, 12.0, 12.0, 13.0, 13.0, 14.0, 15.0],
...     }
... )
>>> expr = awesome_oscillator(pl.col("high"), pl.col("low"), window_fast=2, window_slow=3)
>>> frame.select(expr.round(4).alias("ao"))["ao"].to_list()
[None, None, 0.5, 0.2917, 0.125, None, None, None, nan, nan]
pomata.indicators.balance_of_power(
open: Expr,
high: Expr,
low: Expr,
close: Expr,
) Expr[source]

Balance of Power, also known as BOP — where each bar closed within its range, relative to where it opened.

A per-bar momentum gauge: the close-minus-open move as a fraction of the bar’s whole high-low range, reporting how decisively buyers or sellers controlled the bar (positive = buyers, negative = sellers; bounded in [-1, 1] for a well-formed bar whose open and close sit inside its range):

\[\mathrm{BOP}_t = \frac{\mathrm{close}_t - \mathrm{open}_t}{\mathrm{high}_t - \mathrm{low}_t}.\]
Parameters:
  • open – Open-price series (e.g. pl.col("open")).

  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

Returns:

every row is defined from row 0.

Return type:

The balance of power for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

open, high, low, and close are the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar). balance_of_power is scale-invariant: multiplying all four by a common factor leaves it unchanged.

Edge-case behavior:

  • Flat bar — when high == low the range is zero, so the result is 0 by convention (no range, no directional power) rather than the bare 0 / 0. The zero-range branch fires first, so a finite flat bar reads 0 even when open or close is null — only a null high or low, which leaves the range itself null, still yields null on a flat bar.

  • Null — a null in any input propagates on a non-flat bar: the row is null whenever an input is null (null takes precedence over NaN).

  • NaN — a NaN in any input (with no null and a non-zero range) propagates, yielding NaN.

  • Partitioning — the transform is elementwise (each row uses only its own bar), so .over(...) is optional here (the result is identical), unlike the windowed indicators where .over is required.

See also

References

  • Livshin, Igor (2001). “Using the Balance of Power Indicator”. Technical Analysis of Stocks & Commodities.

Examples

Basic usage on a small OHLC frame:

>>> import polars as pl
>>> from pomata.indicators import balance_of_power
>>>
>>> frame = pl.DataFrame(
...     {
...         "open": [10.0, 11.0, 12.0, 11.0],
...         "high": [11.0, 13.0, 12.0, 13.0],
...         "low": [9.0, 10.0, 11.0, 10.0],
...         "close": [10.5, 12.0, 11.5, 12.0],
...     }
... )
>>> expr = balance_of_power(pl.col("open"), pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("bop"))["bop"].to_list()
[0.25, 0.3333, -0.5, 0.3333]

Balance of Power is elementwise, so .over is optional; each ticker yields the same per-bar reading:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "open": [10.0, 11.0, 12.0, 11.0, 20.0, 21.0, 22.0, 21.0],
...         "high": [11.0, 13.0, 12.0, 13.0, 21.0, 23.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 19.0, 20.0, 21.0, 20.0],
...         "close": [10.5, 12.0, 11.5, 12.0, 20.5, 22.0, 21.5, 22.0],
...     }
... )
>>> expr = balance_of_power(pl.col("open"), pl.col("high"), pl.col("low"), pl.col("close"))
>>> frame.with_columns(expr.over("ticker").round(4).alias("bop"))["bop"].to_list()
[0.25, 0.3333, -0.5, 0.3333, 0.25, 0.3333, -0.5, 0.3333]

A flat bar (high == low, giving 0), then a null and a NaN in close make the edge handling visible:

>>> frame = pl.DataFrame(
...     {
...         "open": [10.0, 12.0, 11.0, 12.0, 12.0],
...         "high": [11.0, 12.0, 13.0, 14.0, 13.0],
...         "low": [9.0, 12.0, 11.0, 12.0, 11.0],
...         "close": [10.5, 12.0, None, 13.0, float("nan")],
...     }
... )
>>> expr = balance_of_power(pl.col("open"), pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("bop"))["bop"].to_list()
[0.25, 0.0, None, 0.5, nan]
pomata.indicators.bollinger_bands(
expr: Expr,
window: int,
*,
num_std: float = 2.0,
) Expr[source]

Bollinger Bands, volatility bands around a moving average.

Introduced by John Bollinger in the 1980s: a center band that is the sma() of expr, with an upper and a lower band placed num_std population standard deviations away. The bands widen as volatility rises and contract as it falls, so price is read relative to a band that breathes with the market:

\[\begin{split}\mathrm{middle}_t &= \mathrm{SMA}(\mathrm{expr}, n)_t, \\ \mathrm{upper}_t &= \mathrm{middle}_t + k \, \sigma_t, \\ \mathrm{lower}_t &= \mathrm{middle}_t - k \, \sigma_t,\end{split}\]

where \(n\) is the window, \(k\) is num_std, and \(\sigma_t\) is the population rolling standard_deviation_rolling() of expr over the same window.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

  • num_std – Number of standard deviations between the center band and each outer band (default 2.0). Must be a finite number > 0 (a non-positive width would collapse or invert the bands). The bands are symmetric; for asymmetric bands compose sma() and standard_deviation_rolling() directly.

Returns:

  • lower — the lower band, middle - num_std * sigma.

  • middle — the center band, the sma() of expr.

  • upper — the upper band, middle + num_std * sigma.

Read one band with .struct.field("middle") (etc.) or split all three into columns with .struct.unnest(). The first window - 1 rows are null (warm-up).

Return type:

A struct column (one struct per row, the same length as the input) with three Float64 fields

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, or if num_std is not a finite number > 0.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Composition:

The bands are built from sma() (center) and the population standard_deviation_rolling() (width), so they inherit the warm-up and missing-data behavior of both — identically on every field of the struct.

Edge-case behavior:

  • Null — a window containing a null yields null on all three fields (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN on all three fields.

  • window == 1 — the standard deviation is 0, so all three bands collapse onto expr itself.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so no window spans series boundaries, e.g. bollinger_bands(pl.col("close"), 20).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import bollinger_bands
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0]})
>>> bands = bollinger_bands(pl.col("close"), 3)
>>> frame.select(bands.struct.field("lower").round(4).alias("l"))["l"].to_list()
[None, None, 9.367, 10.3905, 10.367]
>>> frame.select(bands.struct.field("middle").round(4).alias("m"))["m"].to_list()
[None, None, 11.0, 11.3333, 12.0]
>>> frame.select(bands.struct.field("upper").round(4).alias("u"))["u"].to_list()
[None, None, 12.633, 12.2761, 13.633]

Split the struct into three columns with .struct.unnest():

>>> frame.select(bands.alias("bb")).unnest("bb").columns
['lower', 'middle', 'upper']

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame({"ticker": ["A"] * 3 + ["B"] * 3, "close": [10.0, 11.0, 12.0, 20.0, 22.0, 21.0]})
>>> expr = bollinger_bands(pl.col("close"), 2).over("ticker").struct.field("middle").round(4)
>>> frame.with_columns(expr.alias("middle"))["middle"].to_list()
[None, 10.5, 11.5, None, 21.0, 21.5]

A null and a NaN propagate to every band; the middle band makes the handling visible:

>>> frame = pl.DataFrame({"close": [10.0, None, 12.0, float("nan"), 14.0, 15.0]})
>>> expr = bollinger_bands(pl.col("close"), 2).struct.field("middle").round(4)
>>> frame.select(expr.alias("middle"))["middle"].to_list()
[None, None, None, nan, nan, 14.5]
pomata.indicators.cci(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Commodity Channel Index (CCI).

A momentum oscillator introduced by Donald Lambert (1980) that measures how far the current typical price has strayed from its statistical mean, scaled by the typical price’s own mean absolute deviation so the result is comparable across instruments and price levels.

With the typical price \(\mathrm{TP} = (H + L + C) / 3\) and \(n = \text{window}\):

\[\mathrm{CCI}_t = \frac{\mathrm{TP}_t - \mathrm{SMA}(\mathrm{TP}, n)_t}{0.015 \cdot \mathrm{MAD}_t}, \qquad \mathrm{MAD}_t = \frac{1}{n} \sum_{i=0}^{n-1} \bigl\lvert \mathrm{TP}_{t-i} - \mathrm{SMA}(\mathrm{TP}, n)_t \bigr\rvert.\]

The denominator is the rolling mean absolute deviation about the rolling mean: every observation in the window is measured against the same current \(\mathrm{SMA}(\mathrm{TP}, n)_t\), not against its own moving average. Lambert fixed the constant at \(0.015\) so that roughly 70-80% of values fall in \([-100, +100]\); the index is unbounded and routinely overshoots that band on strong moves. It is scale-invariant under a positive common rescaling of high, low, and close (numerator and denominator scale together) and flips sign under a negative one.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The CCI for each row, the same length as the inputs. The first window - 1 values are null (warm-up), inherited from the sma() of the typical price: the value is defined only once a full window of typical prices is available.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a window in which high, low, or close contains a null yields null (the typical price is null there, and so is any rolling quantity that covers it).

  • NaN — a window containing a NaN (and no null) yields NaN.

  • Flat window — when every typical price in the window is equal there is no spread to normalize by (the 0 / 0 degenerate); the window is detected exactly (its rolling maximum equals its rolling minimum) and the result is NaN, not the rounding noise a sub-ULP denominator residual would otherwise produce.

  • window == 1 — every one-bar window is trivially flat, so every result is NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the rolling mean nor the shifts span series boundaries, e.g. cci(pl.col("high"), pl.col("low"), pl.col("close"), 20).over("ticker").

See also

  • price_typical(): The typical price the index is built on.

  • sma(): The simple moving average of the typical price it composes.

  • rsi(): A bounded momentum oscillator.

References

Examples

Basic usage on high-low-close bars:

>>> import polars as pl
>>> from pomata.indicators import cci
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [24.2, 24.3, 24.7, 25.0, 24.8, 24.5, 24.6],
...         "low": [23.9, 24.1, 24.3, 24.6, 24.4, 24.1, 24.2],
...         "close": [24.0, 24.2, 24.5, 24.8, 24.6, 24.3, 24.4],
...     }
... )
>>> frame.select(cci(pl.col("high"), pl.col("low"), pl.col("close"), window=3).round(4).alias("cci_3"))[
...     "cci_3"
... ].to_list()
[None, None, 100.0, 100.0, -20.0, -100.0, -20.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [11.0, 12.0, 13.0, 12.0, 21.0, 23.0, 22.0, 24.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 19.0, 21.0, 20.0, 22.0],
...         "close": [10.0, 11.0, 12.0, 11.0, 20.0, 22.0, 21.0, 23.0],
...     }
... )
>>> expr = cci(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("cci"))["cci"].to_list()
[None, 66.6667, 66.6667, -66.6667, None, 66.6667, -66.6667, 66.6667]

A null and a NaN in close (each voiding every window that covers it) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0],
...         "close": [10.0, 11.0, 12.0, None, 14.0, 15.0, float("nan"), 17.0],
...     }
... )
>>> expr = cci(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("cci"))["cci"].to_list()
[None, 66.6667, 66.6667, None, None, 66.6667, nan, nan]
pomata.indicators.chaikin_money_flow(
high: Expr,
low: Expr,
close: Expr,
volume: Expr,
window: int,
) Expr[source]

Chaikin Money Flow (CMF).

A volume-weighted breadth oscillator (Marc Chaikin) that gauges buying versus selling pressure over a rolling window. Each bar is first scored by where its close sits inside the bar’s range via the Money Flow Multiplier \(\mathrm{MFM}\), scaled by volume into the Money Flow Volume \(\mathrm{MFV}\), and the CMF is the ratio of the windowed sums of money-flow volume to raw volume:

\[\begin{split}\mathrm{MFM}_t &= \frac{(C_t - L_t) - (H_t - C_t)}{H_t - L_t}, \\ \mathrm{MFV}_t &= \mathrm{MFM}_t \, V_t, \\ \mathrm{CMF}_t &= \frac{\sum_{i=0}^{n-1} \mathrm{MFV}_{t-i}}{\sum_{i=0}^{n-1} V_{t-i}}, \qquad n = \text{window},\end{split}\]

where \(H\), \(L\), \(C\), \(V\) are high, low, close, volume. The multiplier lives in \([-1, +1]\): it is \(+1\) when the close prints at the high (maximum buying pressure), \(-1\) at the low (maximum selling pressure), and \(0\) at the midpoint. A zero-range bar (\(H_t = L_t\)) has an undefined multiplier, so by convention \(\mathrm{MFM}_t = 0\) there — that bar contributes nothing to the numerator while its volume still counts in the denominator. Because the CMF is a volume-weighted average of multipliers, it is itself bounded in \([-1, +1]\).

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

the value is defined only once a full window of bars is available.

Return type:

The CMF for each row, the same length as the inputs. The first window - 1 values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Zero-range bars:

The zero-range convention applies only to a genuine equal-range bar (high == low), where the multiplier is 0 (it adds 0 to the numerator while its volume still counts in the denominator). A null or NaN in any input instead leaves that bar’s money-flow volume null or NaN, so missing data propagates rather than being silently zeroed.

Edge-case behavior:

  • Null — a window in which any of high / low / close / volume contains a null yields null; null takes precedence over NaN.

  • NaN — a window containing a NaN (and no null) yields NaN.

  • Zero volume — a window whose volume is all zero is the 0 / 0 degenerate; the window is detected exactly (the rolling maximum of the absolute volume is zero) and the result is NaN, not the rounding noise a sub-ULP residual in the rolling-sum denominator would otherwise produce. With non-negative volume this is the only reachable division-by-zero case, since an all-zero volume window also zeroes the numerator.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither rolling sum spans series boundaries, e.g. chaikin_money_flow(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), 20).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import chaikin_money_flow
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 12.0, 11.0, 13.0, 14.0],
...         "low": [8.0, 9.0, 9.0, 10.0, 11.0],
...         "close": [9.0, 11.0, 10.0, 12.0, 13.0],
...         "volume": [100.0, 200.0, 150.0, 300.0, 250.0],
...     }
... )
>>> frame.select(
...     chaikin_money_flow(
...         pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), window=3
...     ).round(4).alias("cmf_3")
... )["cmf_3"].to_list()
[None, None, 0.1481, 0.2564, 0.2619]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [12.0, 13.0, 12.5, 14.0, 22.0, 24.0, 23.0, 25.0],
...         "low": [10.0, 11.0, 11.0, 12.0, 20.0, 21.0, 21.0, 23.0],
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 100.0, 120.0, 90.0, 110.0],
...     }
... )
>>> expr = chaikin_money_flow(
...     pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), 2
... ).over("ticker").round(4)
>>> frame.with_columns(expr.alias("chaikin_money_flow"))["chaikin_money_flow"].to_list()
[None, 0.2727, 0.1429, 0.125, None, -0.1364, -0.1667, 0.225]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0],
...         "low": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
...         "close": [11.5, 12.5, 13.0, 14.5, None, 16.0, float("nan"), 18.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 95.0, 140.0],
...     }
... )
>>> expr = chaikin_money_flow(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), 2).round(4)
>>> frame.select(expr.alias("chaikin_money_flow"))["chaikin_money_flow"].to_list()
[None, 0.5, 0.2857, 0.275, None, None, nan, nan]
pomata.indicators.chande_momentum_oscillator(
expr: Expr,
window: int,
) Expr[source]

Chande Momentum Oscillator, also known as CMO.

A momentum oscillator introduced by Tushar Chande (1994) that measures the net of up-moves versus down-moves over a window as a fraction of the total movement, so it swings within [-100, +100] (positive when gains dominate, negative when losses do). With one-step changes \(\Delta_t = x_t - x_{t-1}\) split into gains \(U_t = \max(\Delta_t, 0)\) and losses \(D_t = \max(-\Delta_t, 0)\), and \(n = \text{window}\):

\[\mathrm{CMO}_t = 100 \cdot \frac{\sum_{i=0}^{n-1} U_{t-i} - \sum_{i=0}^{n-1} D_{t-i}} {\sum_{i=0}^{n-1} U_{t-i} + \sum_{i=0}^{n-1} D_{t-i}}.\]

Unlike the RSI, which Wilder-smooths the gains and losses, the CMO sums them over a fixed window and keeps both signs in the numerator, so it crosses zero rather than oscillating around 50. It is scale-invariant under a positive common rescaling of expr (gains and losses scale together) and flips sign under a negative one.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of one-step changes summed in the window. Must be >= 1.

Returns:

The oscillator (in percent) for each row, the same length as the input. The first window rows are null (warm-up): row 0 has no change, and the rolling sums need window non-null changes before emitting.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Flat window — an exactly-flat window (every change zero, the 0 / 0 degenerate) is detected via the residual-free rolling maximum of |change| and returns NaN. A near-flat window (tiny changes after a much larger one has slid out) is not silenced: its streaming quotient is clipped to [-100, +100], so it stays in range but, past a sane dynamic range, degrades in precision (see the precision note above).

  • Null — a window covering a null (including the leading row, which has no change) yields null.

  • NaN — a window covering a NaN change (and no null) yields NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the differencing nor the rolling sums span series boundaries, e.g. chande_momentum_oscillator(pl.col("close"), 14).over("ticker").

See also

  • rsi(): The Wilder-smoothed sibling, bounded in [0, 100].

  • roc(): A simpler single-horizon momentum measure.

  • mom(): The absolute-difference momentum sibling.

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import chande_momentum_oscillator
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0]})
>>> frame.select(chande_momentum_oscillator(pl.col("close"), 3).round(4).alias("cmo"))["cmo"].to_list()
[None, None, None, 33.3333, 50.0, 50.0, 50.0, 50.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 19.0, 21.0, 22.0, 20.0],
...     }
... )
>>> expr = chande_momentum_oscillator(pl.col("close"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("cmo"))["cmo"].to_list()
[None, None, None, 33.3333, 50.0, None, None, None, 50.0, 20.0]

A null (any window it touches yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, None, 14.0, float("nan"), 16.0, 17.0]})
>>> frame.select(chande_momentum_oscillator(pl.col("close"), 3).round(4).alias("cmo"))["cmo"].to_list()
[None, None, None, None, None, None, None, nan]
pomata.indicators.dema(
expr: Expr,
window: int,
*,
adjust: bool = False,
) Expr[source]

Double Exponential Moving Average (DEMA), also known as Mulloy’s DEMA.

A lag-reduced moving average introduced by Patrick Mulloy (1994). Despite its name it is not an EMA applied twice but a linear combination of a single ema() and the ema() of that result, engineered to cancel most of the first-order lag while staying smoother than the raw series:

\[\mathrm{DEMA}_t = 2\,\mathrm{EMA}(x)_t - \mathrm{EMA}\!\bigl(\mathrm{EMA}(x)\bigr)_t, \qquad \alpha = \frac{2}{\text{window} + 1}.\]

Both exponential passes use the same window. The inner term \(\mathrm{EMA}(\mathrm{EMA}(x))\) is the EMA of the already-smoothed series.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Span of the exponential weighting, mapped to alpha = 2 / (window + 1). Must be >= 1.

  • adjust – When False (default) use the recursive technical-analysis EMA form; when True use the bias-corrected (adjusted) exponential weighting. The flag is forwarded unchanged to both ema() passes; the canonical DEMA uses False.

Returns:

The DEMA for each row, the same length as expr. The first 2 * (window - 1) values are null (warm-up), clamped to the series length: the value is composed from two chained ema() passes of the same window (each carrying a window - 1 warm-up), so the warm-up is twice that of a plain EMA. Each EMA is seeded with the SMA of the first window observations.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a leading null run stays null until the first non-null seed; an interior null yields null at that position while the decay continues across the gap.

  • NaN — a NaN contaminates the recursive state and yields NaN for every subsequent non-null position.

  • window == 1 — each EMA reduces to the identity, so the expression reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither EMA pass spans series boundaries, e.g. dema(pl.col("close"), 20).over("ticker").

See also

  • ema(): The single exponential pass this is built from.

  • tema(): The triple-EMA sibling.

  • t3(): The six-pass Tillson member of the lag-reduced family.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import dema
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0, 12.0]})
>>> frame.select(dema(pl.col("close"), window=2).round(4).alias("dema_2"))["dema_2"].to_list()
[None, None, 6.0, 8.0, 10.0, 12.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(dema(pl.col("close"), 2).over("ticker").round(4).alias("dema"))["dema"].to_list()
[None, None, 12.0, 11.2222, 12.8148, None, None, 21.0, 22.7778, 22.1852]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(dema(pl.col("close"), 2).round(4).alias("dema"))["dema"].to_list()
[None, None, 12.0, 13.0, None, 15.0204, nan, nan, nan, nan]
pomata.indicators.di_minus(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Minus Directional Indicator (-DI).

The Wilder-smoothed minus directional movement (dm_minus()) as a percentage of the average true range (atr()), so downward trend pressure is comparable across instruments and bounded in [0, 100]:

\[-\mathrm{DI}_t = 100 \cdot \frac{\mathrm{dm\_minus}_t}{\mathrm{ATR}_t}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The minus directional indicator for each row, the same length as the inputs, in [0, 100]. The first window - 1 values are null (warm-up).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close (the smoothed movement and the average true range scale together).

Seeding:

The warm-up inherits the recursive Wilder seeding of rma() used throughout the cluster.

Edge-case behavior:

  • Flat window — when the window is fully flat the average true range is zero, so the result follows IEEE-754: the smoothed movement is also zero, hence 0 / 0 is NaN (the [0, 100] bound holds wherever the value is finite).

  • Null — a null in the smoothed movement or the ATR at a row yields null there.

  • NaN — a NaN propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursions never span series boundaries, e.g. di_minus(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("ticker").

See also

  • di_plus(): The plus counterpart.

  • dm_minus(): The smoothed minus directional movement in the numerator.

  • dx(): The directional index built from the two indicators.

References

Examples

On a small OHLC frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import di_minus
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0],
...     }
... )
>>> expr = di_minus(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("di_minus"))["di_minus"].to_list()
[None, 0.0, 0.0, 21.0526, 7.8431, 24.0964, 9.4787, 24.7788]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 22.0, 19.0, 23.0, 20.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 18.0, 20.0, 17.0, 21.0, 18.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 19.0, 21.0, 18.0, 22.0, 19.0],
...     }
... )
>>> expr = di_minus(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("di_minus"))["di_minus"].to_list()
[None, 0.0, 0.0, 21.0526, 7.8431, None, 0.0, 46.1538, 18.1818, 46.1538]

A leading null close (absorbed by the ATR’s true-range maximum) and a later NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [None, 10.5, 11.5, 11.0, float("nan"), 12.0, 13.5, 13.0],
...     }
... )
>>> expr = di_minus(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("di_minus"))["di_minus"].to_list()
[None, 0.0, 0.0, 22.2222, 8.0, nan, nan, nan]
pomata.indicators.di_plus(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Plus Directional Indicator (+DI).

The Wilder-smoothed plus directional movement (dm_plus()) as a percentage of the average true range (atr()), so upward trend pressure is comparable across instruments and bounded in [0, 100]:

\[+\mathrm{DI}_t = 100 \cdot \frac{\mathrm{dm\_plus}_t}{\mathrm{ATR}_t}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The plus directional indicator for each row, the same length as the inputs, in [0, 100]. The first window - 1 values are null (warm-up).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close (the smoothed movement and the average true range scale together).

Seeding:

The warm-up inherits the recursive Wilder seeding of rma() used throughout the cluster.

Edge-case behavior:

  • Flat window — when the window is fully flat the average true range is zero, so the result follows IEEE-754: the smoothed movement is also zero, hence 0 / 0 is NaN (the [0, 100] bound holds wherever the value is finite).

  • Null — a null in the smoothed movement or the ATR at a row yields null there.

  • NaN — a NaN propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursions never span series boundaries, e.g. di_plus(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("ticker").

See also

  • di_minus(): The minus counterpart.

  • dm_plus(): The smoothed plus directional movement in the numerator.

  • dx(): The directional index built from the two indicators.

References

Examples

On a small OHLC frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import di_plus
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0],
...     }
... )
>>> expr = di_plus(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("di_plus"))["di_plus"].to_list()
[None, 40.0, 54.5455, 31.5789, 58.8235, 36.1446, 59.7156, 37.1681]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 22.0, 19.0, 23.0, 20.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 18.0, 20.0, 17.0, 21.0, 18.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 19.0, 21.0, 18.0, 22.0, 19.0],
...     }
... )
>>> expr = di_plus(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("di_plus"))["di_plus"].to_list()
[None, 40.0, 54.5455, 31.5789, 58.8235, None, 40.0, 15.3846, 54.5455, 27.6923]

A leading null close (absorbed by the ATR’s true-range maximum) and a later NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [None, 10.5, 11.5, 11.0, float("nan"), 12.0, 13.5, 13.0],
...     }
... )
>>> expr = di_plus(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("di_plus"))["di_plus"].to_list()
[None, 50.0, 60.0, 33.3333, 60.0, nan, nan, nan]
pomata.indicators.dm_minus(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Minus Directional Movement (-DM), Wilder-smoothed.

Part of J. Welles Wilder’s directional-movement system (1978). The raw minus directional movement is the bar’s downward range expansion — how much further the low fell than the high rose — counted only when the down-move leads:

\[\begin{split}\mathrm{down}_t &= \mathrm{low}_{t-1} - \mathrm{low}_t, \qquad \mathrm{up}_t = \mathrm{high}_t - \mathrm{high}_{t-1}, \\ -\mathrm{DM}_t &= \begin{cases} \mathrm{down}_t & \mathrm{down}_t > \mathrm{up}_t \ \text{and}\ \mathrm{down}_t > 0 \\ 0 & \text{otherwise} \end{cases}\end{split}\]

The raw values are then smoothed by Wilder’s moving average (rma(), smoothing factor 1 / window).

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The smoothed minus directional movement for each row, the same length as the inputs. The first window - 1 values are null (warm-up), inherited from the rma().

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in a positive common rescaling of high and low (a range expansion in price units).

Seeding:

The raw directional movement is smoothed by Wilder’s rma(), the mean-scale recursion m_t = m_{t-1} - m_{t-1} / window + raw_t / window (smoothing factor 1 / window). Wilder’s original presentation instead smooths on the sum scale (S_t = S_{t-1} - S_{t-1} / window + raw_t, seeded from a simple sum of the first window raw movements), which equals window times the mean-scale value in steady state. That factor of window is structural and persists for every row — it is not a warm-up seed difference that washes out — so this series reads roughly window times smaller than the sum-scale convention throughout. The factor cancels in di_minus(), dx(), and adx(), which are therefore unaffected.

Edge-case behavior:

  • First bar — row 0 has no previous bar, so its raw movement is 0 and seeds the smoothing.

  • Null — a null in high or low makes the affected raw movement 0 for the rows whose difference it touches, while a null reaching the rma() recursion yields null there.

  • NaN — a NaN in low (the own-side input) poisons the recursion and yields NaN for every subsequent non-null row; a NaN in high (the opposing side) instead makes the directional comparison false, so the affected raw movement is sent to 0 and genuine downward movement is silently dropped there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the differencing and the recursion never span series boundaries, e.g. dm_minus(pl.col("high"), pl.col("low"), 14).over("ticker").

See also

  • dm_plus(): The plus counterpart.

  • di_minus(): The minus directional indicator built from this and the atr().

  • rma(): The Wilder moving average that smooths the raw movement.

References

Examples

On a small high/low frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import dm_minus
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...     }
... )
>>> frame.select(dm_minus(pl.col("high"), pl.col("low"), 2).round(4).alias("dm_minus"))["dm_minus"].to_list()
[None, 0.0, 0.0, 0.25, 0.125, 0.3125, 0.1562, 0.3281]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 22.0, 19.0, 23.0, 20.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 18.0, 20.0, 17.0, 21.0, 18.0],
...     }
... )
>>> expr = dm_minus(pl.col("high"), pl.col("low"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("dm_minus"))["dm_minus"].to_list()
[None, 0.0, 0.0, 0.25, 0.125, None, 0.0, 1.5, 0.75, 1.875]

On a falling frame, a leading null low (which zeroes the raw movement it touches) and a later NaN low (the own side, which poisons the recursion) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [9.0, 8.0, 7.5, 6.5, 7.0, 6.0, 5.5, 5.0],
...         "low": [None, 7.0, 6.5, 5.5, float("nan"), 5.0, 4.5, 4.0],
...     }
... )
>>> frame.select(dm_minus(pl.col("high"), pl.col("low"), 2).round(4).alias("dm_minus"))["dm_minus"].to_list()
[None, 0.0, 0.25, 0.625, nan, nan, nan, nan]
pomata.indicators.dm_plus(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Plus Directional Movement (+DM), Wilder-smoothed.

Part of J. Welles Wilder’s directional-movement system (1978). The raw plus directional movement is the bar’s upward range expansion — how much further the high rose than the low fell — counted only when the up-move leads:

\[\begin{split}\mathrm{up}_t &= \mathrm{high}_t - \mathrm{high}_{t-1}, \qquad \mathrm{down}_t = \mathrm{low}_{t-1} - \mathrm{low}_t, \\ +\mathrm{DM}_t &= \begin{cases} \mathrm{up}_t & \mathrm{up}_t > \mathrm{down}_t \ \text{and}\ \mathrm{up}_t > 0 \\ 0 & \text{otherwise} \end{cases}\end{split}\]

The raw values are then smoothed by Wilder’s moving average (rma(), smoothing factor 1 / window).

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The smoothed plus directional movement for each row, the same length as the inputs. The first window - 1 values are null (warm-up), inherited from the rma().

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in a positive common rescaling of high and low (a range expansion in price units).

Seeding:

The raw directional movement is smoothed by Wilder’s rma(), the mean-scale recursion m_t = m_{t-1} - m_{t-1} / window + raw_t / window (smoothing factor 1 / window). Wilder’s original presentation instead smooths on the sum scale (S_t = S_{t-1} - S_{t-1} / window + raw_t, seeded from a simple sum of the first window raw movements), which equals window times the mean-scale value in steady state. That factor of window is structural and persists for every row — it is not a warm-up seed difference that washes out — so this series reads roughly window times smaller than the sum-scale convention throughout. The factor cancels in di_plus(), dx(), and adx(), which are therefore unaffected.

Edge-case behavior:

  • First bar — row 0 has no previous bar, so its raw movement is 0 and seeds the smoothing.

  • Null — a null in high or low makes the affected raw movement 0 for the rows whose difference it touches, while a null reaching the rma() recursion yields null there.

  • NaN — a NaN in high (the own-side input) poisons the recursion and yields NaN for every subsequent non-null row; a NaN in low (the opposing side) instead makes the directional comparison false, so the affected raw movement is sent to 0 and genuine upward movement is silently dropped there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the differencing and the recursion never span series boundaries, e.g. dm_plus(pl.col("high"), pl.col("low"), 14).over("ticker").

See also

  • dm_minus(): The minus counterpart.

  • di_plus(): The plus directional indicator built from this and the atr().

  • rma(): The Wilder moving average that smooths the raw movement.

References

Examples

On a small high/low frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import dm_plus
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...     }
... )
>>> frame.select(dm_plus(pl.col("high"), pl.col("low"), 2).round(4).alias("dm_plus"))["dm_plus"].to_list()
[None, 0.5, 0.75, 0.375, 0.9375, 0.4688, 0.9844, 0.4922]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 22.0, 19.0, 23.0, 20.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 18.0, 20.0, 17.0, 21.0, 18.0],
...     }
... )
>>> expr = dm_plus(pl.col("high"), pl.col("low"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("dm_plus"))["dm_plus"].to_list()
[None, 0.5, 0.75, 0.375, 0.9375, None, 1.0, 0.5, 2.25, 1.125]

A leading null high (which zeroes the raw movement it touches) and a later NaN high (the own side, which poisons the recursion) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [None, 11.0, 12.0, 11.5, float("nan"), 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...     }
... )
>>> frame.select(dm_plus(pl.col("high"), pl.col("low"), 2).round(4).alias("dm_plus"))["dm_plus"].to_list()
[None, 0.0, 0.5, 0.25, nan, nan, nan, nan]
pomata.indicators.dominant_cycle_period(
expr: Expr,
) Expr[source]

Dominant Cycle Period (Hilbert transform).

John Ehlers’ real-time measurement of the market’s dominant cycle length, in bars. The price is smoothed and detrended, a Hilbert-transform FIR filter resolves it into in-phase and quadrature components, and a homodyne discriminator gives the instantaneous period from the angle of the resulting beat note:

\[\mathrm{Period}_t = \frac{360}{\arctan(\mathrm{Im}_t / \mathrm{Re}_t)},\]

with \(\arctan\) returning degrees, which is then clamped to [6, 50] bars (and to between 0.67 and 1.5 times the previous bar — +50% up, -33% down) and exponentially smoothed.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

The dominant-cycle period for each row, the same length as expr, in [6, 50]. The first 32 rows are null (the warm-up the recursive smoothers need to settle).

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. dominant_cycle_period(pl.col("close")).over("ticker").

See also

References

  • Ehlers, John F. (2001). Rocket Science for Traders.

  • Ehlers, John F. “MAMA — The Mother of Adaptive Moving Averages”.

Examples

The dominant cycle of a clean period-20 sine, read at the last bar (close to its true length of 20 bars):

>>> import math
>>> import polars as pl
>>> from pomata.indicators import dominant_cycle_period
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> round(frame.select(dominant_cycle_period(pl.col("close")).alias("p"))["p"][-1], 2)
20.03
pomata.indicators.dominant_cycle_phase(
expr: Expr,
) Expr[source]

Dominant Cycle Phase (Hilbert transform).

The phase of the market’s dominant cycle, in degrees: 0 at the upward mean-crossing, 90 at the cycle high, 270 at the cycle low, advancing through the cycle. It is read off a running discrete transform of the smoothed price over the dominant-cycle window, then lag-compensated.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

The dominant-cycle phase in degrees for each row, the same length as expr. The first 63 rows are null (the warm-up: the smoothers’ settling plus the dominant-cycle look-back).

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. dominant_cycle_phase(pl.col("close")).over("ticker").

When it breaks:

On a constant (flat) price the discrete transform’s projections are pure cancellation residuals, so the phase is numerically arbitrary — there is no cycle to measure. The phase branch guards an exact zero of the cosine projection (saturating to ±90 as that projection vanishes), rather than the inventor’s fixed 0.001 absolute cutoff; this is the continuous limit and keeps the phase invariant under a lossless rescale of the price, whereas a fixed threshold would be scale-dependent.

See also

References

  • Ehlers, John F. (2001). Rocket Science for Traders.

Examples

The dominant-cycle phase of a clean period-20 sine, read at the last bar (in degrees):

>>> import math
>>> import polars as pl
>>> from pomata.indicators import dominant_cycle_phase
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> round(frame.select(dominant_cycle_phase(pl.col("close")).alias("p"))["p"][-1], 2)
-17.84
pomata.indicators.donchian_channels(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Donchian Channels — the highest high and lowest low over a trailing window, with their midline.

Introduced by Richard Donchian: an upper band tracking the window’s highest high, a lower band tracking its lowest low, and a middle band halfway between. A breakout system reads price against a channel that widens only to admit a new extreme and never narrows within the window:

\[\begin{split}\mathrm{upper}_t &= \max(\mathrm{high}_{t-n+1 \ldots t}), \\ \mathrm{lower}_t &= \min(\mathrm{low}_{t-n+1 \ldots t}), \\ \mathrm{middle}_t &= \frac{\mathrm{upper}_t + \mathrm{lower}_t}{2}, \qquad n = \text{window}.\end{split}\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Number of observations in the moving window (canonically 20, the Donchian period). Must be >= 1.

Returns:

  • lower — the lowest low over the window.

  • middle — the channel midline, (upper + lower) / 2 (identical to midprice()).

  • upper — the highest high over the window.

Read one band with .struct.field("upper") (etc.) or split all three into columns with .struct.unnest(). The first window - 1 rows are null (warm-up).

Return type:

A struct column (one struct per row, the same length as the inputs) with three Float64 fields

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high and low must share a length and alignment (the same row index is one bar). The channel does not assume high >= low: a malformed bar where high < low flows through unchanged (the upper band can then sit below the lower band) rather than being silently reordered.

Edge-case behavior:

  • Nullnull propagates per band: a null in the high window nulls upper and middle, a null in the low window nulls lower and middle; a fully missing bar nulls all three.

  • NaN — a NaN propagates the same way per band, becoming NaN on the band that reads it (null still takes precedence over NaN).

  • window == 1 — the bands are the bar’s own high and low, and the middle is its price_median().

  • Flat window — over a window where high and low hold one repeated value, all three bands equal it.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. donchian_channels(pl.col("high"), pl.col("low"), 20).over("ticker").

See also

  • midprice(): The channel’s middle band on its own.

  • keltner_channels(): The same band shape, an EMA midline with ATR width instead of window extremes.

  • bollinger_bands(): Volatility bands around a moving average rather than around the window’s extremes.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import donchian_channels
>>>
>>> frame = pl.DataFrame({"high": [11.0, 12.0, 13.0, 12.5, 14.0], "low": [9.0, 10.0, 11.0, 11.0, 12.0]})
>>> out = frame.select(donchian_channels(pl.col("high"), pl.col("low"), 3).alias("dc")).unnest("dc")
>>> out["upper"].round(4).to_list()
[None, None, 13.0, 13.0, 14.0]
>>> out["lower"].round(4).to_list()
[None, None, 9.0, 10.0, 11.0]
>>> out["middle"].round(4).to_list()
[None, None, 11.0, 11.5, 12.5]

On a multi-ticker panel, wrap the call in .over so each ticker’s bands warm up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A", "A", "A", "B", "B", "B"],
...         "high": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 19.0, 20.0, 21.0],
...     }
... )
>>> frame.with_columns(
...     donchian_channels(pl.col("high"), pl.col("low"), 2).over("ticker").struct.field("middle").alias("mid")
... )["mid"].to_list()
[None, 10.5, 11.5, None, 20.5, 21.5]

A null (a window touching it yields null on every band) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"high": [11.0, None, 13.0, float("nan"), 15.0], "low": [9.0, 10.0, 11.0, 12.0, 13.0]})
>>> out = frame.select(donchian_channels(pl.col("high"), pl.col("low"), 2).alias("dc")).unnest("dc")
>>> out["middle"].to_list()
[None, None, None, nan, nan]
pomata.indicators.dx(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Directional Index (DX).

The normalized spread between the plus and minus directional indicators — how one-sided the trend is, bounded in [0, 100] (0 when up- and down-pressure are equal, 100 when only one side moves):

\[\mathrm{DX}_t = 100 \cdot \frac{\lvert +\mathrm{DI}_t - (-\mathrm{DI}_t) \rvert}{+\mathrm{DI}_t + (-\mathrm{DI}_t)}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the Wilder moving window. Must be >= 1.

Returns:

The directional index for each row, the same length as the inputs, in [0, 100]. The first window - 1 values are null (warm-up), inherited from the directional indicators.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close.

Seeding:

The warm-up inherits the recursive Wilder seeding of rma() used throughout the cluster.

Edge-case behavior:

  • Flat directional movement — when +DI and -DI are both zero (no movement either way) the denominator is zero, so the result follows IEEE-754: the numerator is also zero, hence 0 / 0 is NaN.

  • Null — a null in either indicator at a row yields null there.

  • NaN — a NaN propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursions never span series boundaries, e.g. dx(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("ticker").

See also

  • di_plus(): The plus directional indicator.

  • di_minus(): The minus directional indicator.

  • adx(): The Wilder-smoothed average of this.

References

Examples

On a small OHLC frame with a short window:

>>> import polars as pl
>>> from pomata.indicators import dx
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0],
...     }
... )
>>> expr = dx(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("dx"))["dx"].to_list()
[None, 100.0, 100.0, 20.0, 76.4706, 20.0, 72.6027, 20.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 22.0, 19.0, 23.0, 20.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 18.0, 20.0, 17.0, 21.0, 18.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 19.0, 21.0, 18.0, 22.0, 19.0],
...     }
... )
>>> expr = dx(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("dx"))["dx"].to_list()
[None, 100.0, 100.0, 20.0, 76.4706, None, 100.0, 50.0, 50.0, 25.0]

A leading null close (absorbed by the underlying ATR’s true-range maximum) and a later NaN (which propagates through the directional indicators) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [None, 10.5, 11.5, 11.0, float("nan"), 12.0, 13.5, 13.0],
...     }
... )
>>> expr = dx(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("dx"))["dx"].to_list()
[None, 100.0, 100.0, 20.0, 76.4706, nan, nan, nan]
pomata.indicators.ema(
expr: Expr,
window: int,
*,
adjust: bool = False,
) Expr[source]

Exponential Moving Average (EMA), also known as the Exponentially Weighted Moving Average (EWMA).

The TA-standard recursive form, with smoothing factor \(\alpha = 2 / (n + 1)\):

\[\mathrm{EMA}_{n-1} = \frac{1}{n} \sum_{i=0}^{n-1} x_i, \qquad \mathrm{EMA}_t = \alpha\, x_t + (1 - \alpha)\, \mathrm{EMA}_{t-1}, \qquad \alpha = \frac{2}{n + 1}, \quad n = \text{window}.\]

The recursive form is the TA standard, seeded with the simple average of the first window observations – the classical EMA initialization, so the warm-up matches the industry reference.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Span of the exponential weighting, mapped to alpha = 2 / (window + 1). Must be >= 1.

  • adjust – When False (default) use the recursive form above. When True use the finite-window bias-corrected (adjusted) weighting that divides by the decaying sum of weights at each step. The two forms differ at every row, the gap largest near the start of the series and decaying geometrically as the history grows.

Returns:

The EMA for each row, the same length as expr. The first window - 1 values are null (warm-up), matching the uniform warm-up of the moving-average family: the value is defined only once window non-null observations have been seen.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Seeding:

The unadjusted recursion (the default) is seeded with the simple average of the first window observations, the canonical EMA initialization; the adjusted form is exact from the first observation.

Edge-case behavior:

  • Null — a leading null run stays null and does not consume warm-up budget. Null handling uses ignore_nulls=False (Polars’ default), so an interior null yields null at that row without resetting the average: the weight of the last non-null observation decays by \((1 - \alpha)^k\) over the k-step gap, and the next non-null value resumes a gap-aware, renormalized recurrence rather than ignoring the missing rows.

  • NaN — a NaN poisons the recursion arithmetically and yields NaN for itself and every subsequent non-null row.

  • window == 1 — the smoothing factor is 1, so the EMA reduces to the identity and reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. ema(pl.col("close"), 20).over("ticker").

See also

  • rma(): Wilder’s variant, with smoothing factor 1 / window.

  • dema(): A lag-reduced average built from two chained EMAs.

  • sma(): The equal-weight simple average this is the exponential analogue of.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import ema
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0]})
>>> frame.select(ema(pl.col("close"), window=3).round(4).alias("ema_3"))["ema_3"].to_list()
[None, None, 4.0, 6.0, 8.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(ema(pl.col("close"), 2).over("ticker").round(4).alias("ema"))["ema"].to_list()
[None, 10.5, 11.5, 11.1667, 12.3889, None, 21.0, 21.0, 22.3333, 22.1111]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(ema(pl.col("close"), 2).round(4).alias("ema"))["ema"].to_list()
[None, 10.5, 11.5, 12.5, None, 14.6429, nan, nan, nan, nan]
pomata.indicators.fisher_transform(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Fisher Transform.

Introduced by John F. Ehlers (2002): it presses the price into a sharply-peaked, near-Gaussian oscillator so that turning points stand out as decisive extremes rather than the rounded humps of a raw price channel. The median price (high + low) / 2 is first placed in its rolling channel and mapped to [-1, 1], smoothed with Ehlers’ fixed 0.33 / 0.67 recursion, held just inside \pm 1 by a clamp, then run through the Fisher transform – the inverse hyperbolic tangent, which stretches the tails toward \pm\infty:

\[\begin{split}x_t &= 0.33 \left( 2 \, \frac{p_t - \min_w p}{\max_w p - \min_w p} - 1 \right) + 0.67 \, x_{t-1}, \qquad x_t \leftarrow \operatorname{clamp}(x_t, -0.999, 0.999), \\ \mathrm{Fisher}_t &= 0.5 \, \ln\!\frac{1 + x_t}{1 - x_t} + 0.5 \, \mathrm{Fisher}_{t-1},\end{split}\]

where \(p_t = (\mathrm{high}_t + \mathrm{low}_t)/2\) is the median price and \(\min_w, \max_w\) are its rolling minimum and maximum over window bars. The signal line is the Fisher value lagged one bar, the trigger a crossover system reads against the Fisher line.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Number of observations in the moving window (canonically 10). Must be >= 1.

Returns:

A struct pl.Expr with fields fisher (the transform) and signal (fisher lagged one bar), the same length as the inputs. The first window - 1 rows are null (the channel’s warm-up); signal is null for one further row. Read a field with .struct.field("fisher") or split both with .struct.unnest().

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is invariant under a positive affine rescaling of the inputs: the channel normalization (p - \min)/(\max - \min) cancels any common scale, so the transform depends only on the price’s shape, not its level or units.

Clamp convention:

The smoothed position is held to a symmetric [-0.999, 0.999] – a monotone clamp at the threshold, keeping the argument strictly inside the log’s domain. Ehlers’ original snaps any value past \pm 0.99 straight to \pm 0.999; pomata uses the monotone form (the modern convention), which agrees everywhere except on the thin (0.99, 0.999] band the original discontinuously lifts.

Seeding:

Both recursions start from 0 (the bar before the first defined row contributes 0 to each), matching Ehlers’ zero-initialized series; the smoothing then washes the seed out geometrically.

Edge-case behavior:

  • Null — a null high or low nulls the rolling channel for every window touching it, so those rows are null; the recursion bridges them and resumes once the window clears.

  • NaN — a NaN propagates through the channel to NaN at those rows, likewise bridged.

  • Flat window — when max == min over the window the normalization is 0/0 and the row is NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recurrence does not span series boundaries, e.g. fisher_transform(pl.col("high"), pl.col("low")).over("ticker").

See also

  • williams_r(): The raw channel position the transform sharpens.

  • rsi_stochastic(): Another channel-normalized momentum oscillator, bounded rather than tail-stretched.

  • stochastic_fast(): The %K channel position, the same normalization before the transform.

References

Examples

Basic usage on high-low bars:

>>> import polars as pl
>>> from pomata.indicators import fisher_transform
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 13.0, 14.0, 13.0, 12.0, 13.0, 14.0, 15.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 12.0, 11.0, 12.0, 13.0, 14.0],
...     }
... )
>>> out = frame.select(fisher_transform(pl.col("high"), pl.col("low"), 3).alias("ft")).unnest("ft")
>>> out.select(pl.col("fisher").round(4))["fisher"].to_list()
[None, None, 0.3428, 0.7914, 1.2615, 0.7701, 0.1432, 0.2444, 0.6002, 1.038]
>>> out.select(pl.col("signal").round(4))["signal"].to_list()
[None, None, None, 0.3428, 0.7914, 1.2615, 0.7701, 0.1432, 0.2444, 0.6002]

On a multi-ticker panel, wrap the call in .over so each ticker’s channel warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 21.0, 22.0, 21.5, 23.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 19.0, 20.0, 21.0, 20.5, 22.0],
...     }
... )
>>> expr = fisher_transform(pl.col("high"), pl.col("low"), 3)
>>> frame.with_columns(expr.over("ticker").struct.field("fisher").round(4).alias("f"))["f"].to_list()
[None, None, 0.3428, 0.3962, 0.7187, None, None, 0.3428, 0.3962, 0.7187]

A null (a window touching it yields null) and a NaN (which propagates) make it visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, None, 13.0, float("nan"), 15.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 14.0],
...     }
... )
>>> expr = fisher_transform(pl.col("high"), pl.col("low"), 3)
>>> frame.select(expr.struct.field("fisher").round(4).alias("f"))["f"].to_list()
[None, None, 0.3428, None, None, None, nan]
pomata.indicators.hilbert_phasor(
expr: Expr,
) Expr[source]

Hilbert Transform Phasor — in-phase and quadrature components.

The detrended price resolved into its in-phase and quadrature components by the Hilbert transform: the complex phasor whose rotation traces the dominant cycle.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

A struct pl.Expr with Float64 fields in_phase / quadrature, the same length as expr. The first 32 rows are null (warm-up). Read one line with .struct.field("in_phase") or split both with .struct.unnest().

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. hilbert_phasor(pl.col("close")).over("ticker").

See also

References

  • Ehlers, John F. (2001). Rocket Science for Traders.

Examples

The in-phase and quadrature components on a clean period-20 sine, at the last bar:

>>> import math
>>> import polars as pl
>>> from pomata.indicators import hilbert_phasor
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> phasor = frame.select(hilbert_phasor(pl.col("close")).alias("h")).unnest("h")
>>> round(phasor["in_phase"][-1], 2), round(phasor["quadrature"][-1], 2)
(-0.8, 0.61)
pomata.indicators.hilbert_trendline(
expr: Expr,
) Expr[source]

Hilbert Transform Instantaneous Trendline.

Ehlers’ instantaneous trendline: the price averaged over exactly one dominant cycle (a self-adjusting moving average), then smoothed. Because it spans a whole cycle, the cyclic component cancels and only the trend remains.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

The instantaneous trendline for each row, the same length as expr, on the price scale. The first 63 rows are null (warm-up).

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. hilbert_trendline(pl.col("close")).over("ticker").

See also

References

  • Ehlers, John F. (2001). Rocket Science for Traders.

Examples

Spanning a whole cycle, the trendline cancels the swing and tracks the mean level (here 100):

>>> import math
>>> import polars as pl
>>> from pomata.indicators import hilbert_trendline
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> round(frame.select(hilbert_trendline(pl.col("close")).alias("t"))["t"][-1], 2)
100.0
pomata.indicators.hma(
expr: Expr,
window: int,
) Expr[source]

Hull Moving Average (HMA), also known as the Hull MA.

A low-lag, smooth moving average (Alan Hull, 2005) built from three weighted moving averages. With \(n = \text{window}\), the half-period \(h = \lfloor n / 2 + \tfrac{1}{2} \rfloor\) and the smoothing period \(s = \lfloor \sqrt{n} + \tfrac{1}{2} \rfloor\) (both rounded half up):

\[\begin{split}\mathrm{raw}_t &= 2 \, \mathrm{WMA}(x, h)_t - \mathrm{WMA}(x, n)_t \\ \mathrm{HMA}_t &= \mathrm{WMA}(\mathrm{raw}, s)_t.\end{split}\]

The inner difference \(2\,\mathrm{WMA}(x, h) - \mathrm{WMA}(x, n)\) cancels most of the lag of a plain weighted average, and the final smoothing over \(s\) observations restores smoothness. Because the lag correction can over- and under-shoot, the HMA may exceed the range of the input window.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 2.

Returns:

The HMA for each row, the same length as expr. The first window + s - 2 values are null (warm-up), where \(s = \lfloor \sqrt{n} + \tfrac{1}{2} \rfloor\): the inner WMA(x, window) needs window observations, after which the final WMA(., s) needs s - 1 more.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2. The half-period \(\lfloor n / 2 + \tfrac{1}{2} \rfloor\) collapses to 1 at window == 1 and the HMA degenerates there, so the smallest meaningful window is 2.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Period rounding:

The two period reductions use round-half-up (floor(window / 2 + 0.5) and floor(sqrt(window) + 0.5)), not Python’s built-in round (which rounds half to even); the two disagree on the half-period whenever window / 2 lands exactly on a .5 boundary (odd window such as 5, 9, 13, …).

Edge-case behavior:

  • Null — a window containing a null yields null at that row, propagated through every composing wma().

  • NaN — a window containing a NaN (and no null) yields NaN at that row.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the windows do not span series boundaries, e.g. hma(pl.col("close"), 16).over("ticker").

See also

  • wma(): The weighted mean this composes.

  • sma(): The unweighted baseline.

  • dema(): A lag-reduced average built by the same doubling correction.

References

  • Hull, A. (2005). “Hull Moving Average”.

Examples

>>> import polars as pl
>>> from pomata.indicators import hma
>>>
>>> frame = pl.DataFrame({"close": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]})
>>> frame.select(hma(pl.col("close"), window=4).round(4).alias("hma_4"))["hma_4"].to_list()
[None, None, None, None, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(hma(pl.col("close"), 2).over("ticker").round(4).alias("hma"))["hma"].to_list()
[None, 11.3333, 12.3333, 10.6667, 13.6667, None, 22.6667, 20.6667, 23.6667, 21.6667]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(hma(pl.col("close"), 2).round(4).alias("hma"))["hma"].to_list()
[None, 11.3333, 12.3333, 13.3333, None, None, nan, nan, 18.3333, 19.3333]
pomata.indicators.ichimoku(
high: Expr,
low: Expr,
*,
window_tenkan: int,
window_kijun: int,
window_senkou: int,
) Expr[source]

Ichimoku Kinkō Hyō (Ichimoku Cloud).

Introduced by Goichi Hosoda: a one-glance equilibrium chart built from rolling midpoints of the high-low range over three horizons. The conversion line (tenkan-sen) and base line (kijun-sen) are short and medium midpoints, and the two leading spans (senkou span A and senkou span B) that bound the cloud are the midpoint of those two and a long midpoint:

\[\begin{split}\mathrm{tenkan}_t &= \frac{\max(\mathrm{high}_{t-a+1 \ldots t}) + \min(\mathrm{low}_{t-a+1 \ldots t})}{2}, \\ \mathrm{kijun}_t &= \frac{\max(\mathrm{high}_{t-b+1 \ldots t}) + \min(\mathrm{low}_{t-b+1 \ldots t})}{2}, \\ \mathrm{senkou\_a}_t &= \frac{\mathrm{tenkan}_t + \mathrm{kijun}_t}{2}, \\ \mathrm{senkou\_b}_t &= \frac{\max(\mathrm{high}_{t-c+1 \ldots t}) + \min(\mathrm{low}_{t-c+1 \ldots t})}{2},\end{split}\]

with a = window_tenkan, b = window_kijun, c = window_senkou.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window_tenkan – Conversion-line window (canonically 9). Must be >= 1.

  • window_kijun – Base-line window (canonically 26). Must be >= 1 and >= window_tenkan.

  • window_senkou – Leading-span-B window (canonically 52). Must be >= 1 and >= window_kijun.

Returns:

A struct pl.Expr with fields tenkan, kijun, senkou_a, senkou_b, the same length as the inputs. Each line is null through its own warm-up: window_tenkan - 1 rows for tenkan, window_kijun - 1 for kijun and senkou_a (which needs both), window_senkou - 1 for senkou_b. Read a field with .struct.field("tenkan") or split all four with .struct.unnest().

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If any window is < 1, or the windows are not ordered window_tenkan <= window_kijun <=     window_senkou (equality is allowed and collapses the corresponding lines onto each other).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Every line is homogeneous of degree 1 under a positive common rescaling of high and low (each is a midpoint of price extremes).

Displacement (no lookahead):

Each line is emitted aligned to the row it is computed from – zero displacement – so the output never reads a future bar and is safe to feed a backtest directly. The traditional chart instead plots the two leading spans window_kijun bars into the future and a chikou (lagging) span window_kijun bars into the past; that is a presentation choice, applied on the user’s side with .shift(...) – e.g. ...struct.field("senkou_a") .shift(window_kijun) to lead, pl.col("close").shift(-window_kijun) to lag. The chikou span is deliberately not emitted: un-displaced it is identical to close, and its backward shift reads future bars, which must never enter a backtest.

Edge-case behavior:

  • Null / NaN — a null in either input nulls every line whose window touches it; a NaN propagates the same way, per the underlying rolling extremes (and null takes precedence over NaN).

  • Flat window — over a constant window every line equals the price (the high and low extremes coincide).

  • Partitioning — append .over("ticker") to the call for a multi-series panel so no window spans series boundaries.

See also

  • midprice(): The single rolling high-low midpoint each Ichimoku line is built from.

  • donchian_channels(): The same window extremes kept as separate bands rather than midpoints.

  • keltner_channels(): Another channel, an EMA midline with ATR bands rather than rolling midpoints.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import ichimoku
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 12.0, 11.0, 13.0, 14.0, 12.0, 15.0, 13.0],
...         "low": [8.0, 9.0, 10.0, 11.0, 12.0, 10.0, 12.0, 11.0],
...     }
... )
>>> out = frame.select(
...     ichimoku(pl.col("high"), pl.col("low"), window_tenkan=2, window_kijun=3, window_senkou=4).alias("ic")
... ).unnest("ic")
>>> out.select(pl.col("tenkan").round(4))["tenkan"].to_list()
[None, 10.0, 10.5, 11.5, 12.5, 12.0, 12.5, 13.0]
>>> out.select(pl.col("senkou_b").round(4))["senkou_b"].to_list()
[None, None, None, 10.5, 11.5, 12.0, 12.5, 12.5]

On a multi-ticker panel, wrap the call in .over so each ticker’s lines warm up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 12.0, 11.0, 13.0, 14.0, 20.0, 22.0, 21.0, 23.0, 24.0],
...         "low": [8.0, 9.0, 10.0, 11.0, 12.0, 18.0, 19.0, 20.0, 21.0, 22.0],
...     }
... )
>>> kijun = ichimoku(pl.col("high"), pl.col("low"), window_tenkan=2, window_kijun=3, window_senkou=4)
>>> frame.with_columns(kijun.over("ticker").struct.field("kijun").round(4).alias("k"))["k"].to_list()
[None, None, 10.0, 11.0, 12.0, None, None, 20.0, 21.0, 22.0]

A null (any line whose window touches it is null) and a NaN (which propagates) make it visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 12.0, None, 13.0, float("nan"), 12.0, 15.0],
...         "low": [8.0, 9.0, 10.0, 11.0, 12.0, 10.0, 12.0],
...     }
... )
>>> tenkan = ichimoku(pl.col("high"), pl.col("low"), window_tenkan=2, window_kijun=3, window_senkou=4)
>>> frame.select(tenkan.struct.field("tenkan").round(4).alias("t"))["t"].to_list()
[None, 10.0, None, None, nan, nan, 12.5]
pomata.indicators.kama(
expr: Expr,
*,
window: int,
window_fast: int,
window_slow: int,
) Expr[source]

Kaufman’s Adaptive Moving Average (KAMA).

Introduced by Perry Kaufman (1995): a moving average whose smoothing constant adapts to how efficiently price is moving. Over the window, the efficiency ratio compares the net move to the summed bar-to-bar travel; a high ratio (a clean trend) drives the smoothing toward a fast average, a low ratio (chop) toward a slow one. KAMA then follows price closely in trends and flattens in noise:

\[\begin{split}\mathrm{ER}_t &= \frac{\lvert \mathrm{close}_t - \mathrm{close}_{t-n} \rvert}{\sum_{i=1}^{n} \lvert \mathrm{close}_{t-i+1} - \mathrm{close}_{t-i} \rvert}, \\ \mathrm{SC}_t &= \bigl( \mathrm{ER}_t \, (f - s) + s \bigr)^2, \qquad f = \frac{2}{n_f + 1}, \quad s = \frac{2}{n_s + 1}, \\ \mathrm{KAMA}_t &= \mathrm{KAMA}_{t-1} + \mathrm{SC}_t \, (\mathrm{close}_t - \mathrm{KAMA}_{t-1}),\end{split}\]

where \(n\) is window, \(n_f\) is window_fast, and \(n_s\) is window_slow. The recurrence is seeded at close on the bar one step before the efficiency ratio is first defined (row window - 1); the first adaptive update then runs at row window, where the efficiency ratio becomes available.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the efficiency-ratio look-back. Must be >= 1.

  • window_fast – Period of the fast smoothing-constant bound (canonically 2), 2 / (window_fast + 1). Must be >= 1 and <= window_slow (the fast bound is the more responsive end of the adaptive range).

  • window_slow – Period of the slow smoothing-constant bound (canonically 30), 2 / (window_slow + 1). Must be >= 1.

Returns:

The KAMA for each row, the same length as expr. The first window - 1 values are null (warm-up); the value at row window - 1 is close itself (the seed), and the adaptive recurrence runs from there.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, window_fast < 1, window_slow < 1, or window_fast > window_slow.

Note

Precision – the efficiency ratio and adaptive smoothing constant are checked against an independent reference, but the seeded recurrence they drive is one-shape with the implementation, so the oracle confirms its internal consistency, not its independence; the independent witnesses are the TA-Lib differential and frozen hand-derived golden masters. Agreement holds to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 (the efficiency ratio is scale-invariant — a ratio of absolute moves — and the recurrence is linear in the input, so kama(k * x) == k * kama(x)).

Edge-case behavior:

  • Flat window — when there is no bar-to-bar travel the efficiency ratio is taken as 0 (rather than 0 / 0), so the smoothing constant is the slow bound and KAMA barely moves.

  • Null — a null reaching the recurrence (from close or from a window touching one) yields null at that row while the running average holds its state and bridges the gap.

  • NaN — a NaN flows into the recurrence and latches NaN for every subsequent row.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recurrence does not span series boundaries, e.g. kama(pl.col("close"), window=10, window_fast=2, window_slow=30).over("ticker").

See also

  • ema(): The fixed-smoothing exponential average KAMA adapts between.

  • rma(): Wilder’s fixed-smoothing average.

  • mama(): The MESA adaptive average, steered by cycle phase rather than efficiency.

References

  • Kaufman, Perry J. (1995). Smarter Trading.

Examples

>>> import polars as pl
>>> from pomata.indicators import kama
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0, 12.5]})
>>> frame.select(
...     kama(pl.col("close"), window=2, window_fast=2, window_slow=30).round(4).alias("kama")
... )["kama"].to_list()
[None, 11.0, 11.4444, 11.4426, 11.5522, 11.724]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 5 + ["B"] * 5, "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0]}
... )
>>> frame.with_columns(
...     kama(pl.col("close"), window=2, window_fast=2, window_slow=30).over("ticker").round(4).alias("kama")
... )["kama"].to_list()
[None, 11.0, 11.4444, 11.4426, 11.5522, None, 22.0, 21.9297, 22.0049, 22.0046]

A null (bridged) and a NaN (latched) make the handling visible:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, None, 13.0, float("nan"), 15.0, 16.0]})
>>> frame.select(
...     kama(pl.col("close"), window=2, window_fast=2, window_slow=30).round(4).alias("kama")
... )["kama"].to_list()
[None, 11.0, 11.4444, None, None, None, nan, nan]
pomata.indicators.keltner_channels(
high: Expr,
low: Expr,
close: Expr,
*,
window: int,
window_atr: int,
multiplier: float = 2.0,
) Expr[source]

Keltner Channels — an EMA midline with bands an ATR multiple away (the modern Linda Raschke form).

Chester Keltner’s channel in its modern form (popularized by Linda Raschke): a center band that is the ema() of close, with upper and lower bands set multiplier average true ranges (atr()) away. Unlike Bollinger Bands, whose width tracks the standard deviation, Keltner’s width tracks the ATR – it breathes with the trading range rather than the dispersion:

\[\begin{split}\mathrm{middle}_t &= \mathrm{EMA}(\mathrm{close}, n)_t, \\ \mathrm{upper}_t &= \mathrm{middle}_t + m \cdot \mathrm{ATR}(n_a)_t, \\ \mathrm{lower}_t &= \mathrm{middle}_t - m \cdot \mathrm{ATR}(n_a)_t,\end{split}\]

where \(n\) is window, \(n_a\) is window_atr, and \(m\) is multiplier.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the EMA midline window (canonically 20). Must be >= 1.

  • window_atr – Number of observations in the ATR window (canonically 10). Must be >= 1.

  • multiplier – Number of ATRs between the midline and each band (canonically 2.0). Must be a finite number > 0.

Returns:

  • lower — the lower band, middle - multiplier * atr.

  • middle — the center band, the ema() of close.

  • upper — the upper band, middle + multiplier * atr.

Read one band with .struct.field("middle") (etc.) or split all three into columns with .struct.unnest(). Each band is null through its own warm-up: the midline’s first window - 1 rows, the outer bands’ first max(window, window_atr) - 1 rows (they also need the ATR).

Return type:

A struct column (one struct per row, the same length as the inputs) with three Float64 fields

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, window_atr < 1, or multiplier is not a finite number > 0.

Note

Precision – agrees with its independent reference oracle (a composition of the ema() and atr() references) to ten significant figures (a 1e-10 band); CORRECTNESS.md gives the method.

Inputs:

high, low, and close must share a length and alignment (the same row index is one bar). The original high-low band variant (Keltner’s 1960 form) is not provided; compose it from ema() and the bar range if ever needed.

Edge-case behavior:

  • Null / NaN — flow through the recursive ema() (midline) and atr() (band width) legs exactly as documented for each; the channel adds no propagation rule of its own.

  • Flat series — over a constant high == low == close run the ATR is 0, so all three bands collapse onto the EMA.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither smoother spans series boundaries.

See also

  • ema(): The midline.

  • atr(): The basis of the band half-width.

  • bollinger_bands(): The same idea with standard-deviation width instead of ATR.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import keltner_channels
>>>
>>> frame = pl.DataFrame(
...     {"high": [3.0, 4.0, 5.0, 6.0], "low": [1.0, 2.0, 3.0, 4.0], "close": [2.0, 3.0, 4.0, 5.0]}
... )
>>> bands = keltner_channels(pl.col("high"), pl.col("low"), pl.col("close"), window=2, window_atr=2)
>>> frame.select(bands.struct.field("middle").round(4).alias("middle"))["middle"].to_list()
[None, 2.5, 3.5, 4.5]

On a multi-ticker panel, wrap the call in .over so each ticker’s bands warm up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [3.0, 4.0, 5.0, 6.0, 13.0, 14.0, 15.0, 16.0],
...         "low": [1.0, 2.0, 3.0, 4.0, 11.0, 12.0, 13.0, 14.0],
...         "close": [2.0, 3.0, 4.0, 5.0, 12.0, 13.0, 14.0, 15.0],
...     }
... )
>>> bands = keltner_channels(pl.col("high"), pl.col("low"), pl.col("close"), window=2, window_atr=2)
>>> frame.with_columns(bands.over("ticker").struct.field("middle").round(4).alias("m"))["m"].to_list()
[None, 2.5, 3.5, 4.5, None, 12.5, 13.5, 14.5]

A null (yields null at that row) and a NaN (which propagates) in close flow through the midline:

>>> frame = pl.DataFrame(
...     {
...         "high": [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
...         "low": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
...         "close": [2.0, 3.0, None, 5.0, float("nan"), 7.0, 8.0],
...     }
... )
>>> bands = keltner_channels(pl.col("high"), pl.col("low"), pl.col("close"), window=2, window_atr=2)
>>> frame.select(bands.struct.field("middle").round(4).alias("m"))["m"].to_list()
[None, 2.5, None, 4.6429, nan, nan, nan]
pomata.indicators.linear_regression(
expr: Expr,
window: int,
) Expr[source]

Linear Regression (the endpoint of the rolling least-squares line).

The value at the most recent bar of the ordinary-least-squares line fitted to the last window observations against their position in the window: a smoothed, lag-reduced estimate of where the fitted trend “is now”.

\[\mathrm{LINEARREG}_t = \bar{x}_t + \mathrm{slope}_t \cdot \frac{n - 1}{2}, \qquad n = \text{window},\]

with \(\bar{x}_t\) the window mean and \(\mathrm{slope}_t\) the rolling linear_regression_slope().

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the regression window. Must be >= 2 (a line needs at least two points).

Returns:

The fitted endpoint for each row, the same length as the input. The first window - 1 values are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in expr (a fitted price scales with the price). For a perfectly linear input the endpoint reproduces the series exactly.

Edge-case behavior:

  • Null — a window containing a null yields null.

  • NaN — a NaN inside the window propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. linear_regression(pl.col("close"), 14).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import linear_regression
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(linear_regression(pl.col("x"), 3).round(4).alias("linreg"))["linreg"].to_list()
[None, None, 12.8333, 12.5, 13.5, 13.5, 14.5]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = linear_regression(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("linreg"))["linreg"].to_list()
[None, None, 12.8333, 12.5, None, None, 21.5, 23.3333]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0]})
>>> frame.select(linear_regression(pl.col("x"), 3).round(4).alias("linreg"))["linreg"].to_list()
[None, None, 12.8333, None, None, None, nan]
pomata.indicators.linear_regression_angle(
expr: Expr,
window: int,
) Expr[source]

Linear Regression Angle (the slope of the rolling least-squares line, in degrees).

The arctangent of the rolling linear_regression_slope(), converted to degrees, so the trend’s steepness reads on a bounded \((-90, 90)\) scale:

\[\mathrm{ANGLE}_t = \frac{180}{\pi} \arctan\bigl(\mathrm{slope}_t\bigr).\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the regression window. Must be >= 2 (a line needs at least two points).

Returns:

The angle in degrees for each row, the same length as the input, in \((-90, 90)\). The first window - 1 values are null (warm-up).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Unlike the other regression outputs, the angle is not homogeneous in expr: the arctangent is non-linear, so scaling the input does not scale the angle (it bends it towards \(\pm 90\)). The angle depends on the numeric scale of expr versus its bar spacing, so it is most meaningful on a chart’s own price/time units.

Edge-case behavior:

  • Null — a window containing a null yields null.

  • NaN — a NaN inside the window propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. linear_regression_angle(pl.col("close"), 14).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import linear_regression_angle
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(linear_regression_angle(pl.col("x"), 3).round(4).alias("angle"))["angle"].to_list()
[None, None, 56.3099, 26.5651, 26.5651, 26.5651, 26.5651]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = linear_regression_angle(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("angle"))["angle"].to_list()
[None, None, 56.3099, 26.5651, None, None, 26.5651, 45.0]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0]})
>>> frame.select(linear_regression_angle(pl.col("x"), 3).round(4).alias("angle"))["angle"].to_list()
[None, None, 56.3099, None, None, None, nan]
pomata.indicators.linear_regression_intercept(
expr: Expr,
window: int,
) Expr[source]

Linear Regression Intercept (the rolling least-squares line at the oldest bar of the window).

The value of the fitted line at the first observation of the window (position 0), i.e. the y-intercept of the regression of the last window observations against their in-window position:

\[\mathrm{INTERCEPT}_t = \bar{x}_t - \mathrm{slope}_t \cdot \frac{n - 1}{2}, \qquad n = \text{window},\]

with \(\bar{x}_t\) the window mean and \(\mathrm{slope}_t\) the rolling linear_regression_slope().

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the regression window. Must be >= 2 (a line needs at least two points).

Returns:

The fitted intercept for each row, the same length as the input. The first window - 1 values are null (warm-up).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in expr (a fitted price scales with the price).

Edge-case behavior:

  • Null — a window containing a null yields null.

  • NaN — a NaN inside the window propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. linear_regression_intercept(pl.col("close"), 14).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import linear_regression_intercept
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(linear_regression_intercept(pl.col("x"), 3).round(4).alias("intercept"))["intercept"].to_list()
[None, None, 9.8333, 11.5, 12.5, 12.5, 13.5]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = linear_regression_intercept(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("intercept"))["intercept"].to_list()
[None, None, 9.8333, 11.5, None, None, 20.5, 21.3333]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0]})
>>> frame.select(linear_regression_intercept(pl.col("x"), 3).round(4).alias("intercept"))["intercept"].to_list()
[None, None, 9.8333, None, None, None, nan]
pomata.indicators.linear_regression_slope(
expr: Expr,
window: int,
) Expr[source]

Linear Regression Slope (the slope of the rolling least-squares line).

The ordinary-least-squares slope of the last window observations regressed against their position in the window — the per-bar rate of change of the fitted trend. With the in-window position \(i\) running over the window and \(n = \text{window}\):

\[\mathrm{slope}_t = \frac{n \sum i\,x - \sum i \sum x}{n \sum i^2 - \bigl(\sum i\bigr)^2}.\]

The implementation evaluates this closed form as a fixed-weight rolling sum over the window — the weight of the bar k steps back is its position’s deviation from the window center, divided by the position variance — which is numerically stable (it never forms a growing running index that would lose precision through cancellation).

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the regression window. Must be >= 2 (a line needs at least two points).

Returns:

The fitted slope for each row, the same length as the input. The first window - 1 values are null (warm-up).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in expr (the rise scales with the price while the run is fixed). For a perfectly linear input it returns the exact constant slope.

Edge-case behavior:

  • Null — a window containing a null yields null.

  • NaN — a NaN inside the window propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. linear_regression_slope(pl.col("close"), 14).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import linear_regression_slope
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(linear_regression_slope(pl.col("x"), 3).round(4).alias("slope"))["slope"].to_list()
[None, None, 1.5, 0.5, 0.5, 0.5, 0.5]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = linear_regression_slope(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("slope"))["slope"].to_list()
[None, None, 1.5, 0.5, None, None, 0.5, 1.0]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0]})
>>> frame.select(linear_regression_slope(pl.col("x"), 3).round(4).alias("slope"))["slope"].to_list()
[None, None, 1.5, None, None, None, nan]
pomata.indicators.macd(
expr: Expr,
*,
window_fast: int,
window_slow: int,
window_signal: int,
) Expr[source]

Moving Average Convergence/Divergence (MACD).

Gerald Appel’s trend-and-momentum oscillator (late 1970s): the gap between a fast and a slow ema() of the close (the MACD line), a further EMA of that gap (the signal line), and their difference (the histogram), returned together as one struct. With \(n_f\), \(n_s\), \(n_g\) the fast, slow, and signal spans:

\[\begin{split}\mathrm{MACD}_t &= \mathrm{EMA}(\mathrm{close}, n_f)_t - \mathrm{EMA}(\mathrm{close}, n_s)_t, \\ \mathrm{signal}_t &= \mathrm{EMA}(\mathrm{MACD}, n_g)_t, \\ \mathrm{histogram}_t &= \mathrm{MACD}_t - \mathrm{signal}_t.\end{split}\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window_fast – Span of the fast EMA (canonically 12). Must be >= 1.

  • window_slow – Span of the slow EMA (canonically 26). Must be >= 1 and >= window_fast.

  • window_signal – Span of the signal EMA over the MACD line (canonically 9). Must be >= 1.

Returns:

  • macd — the fast-minus-slow EMA gap, null for its max(window_fast, window_slow) - 1 warm-up rows.

  • signal — the EMA of the MACD line, carrying the additional window_signal - 1 warm-up rows on top.

  • histogrammacd minus signal, sharing the signal line’s warm-up.

Access the fields with .struct.field("macd") / "signal" / "histogram" or .struct.unnest().

Return type:

A struct pl.Expr with three Float64 fields, the same length as expr

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_fast < 1, window_slow < 1, window_signal < 1, or window_fast >     window_slow (the fast leg must be the shorter one; window_fast == window_slow is allowed and gives an identically-zero MACD line, signal, and histogram).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Scaling: every field is homogeneous of degree 1 in expr (the EMAs and their differences all scale with the price), so multiplying the close by k scales all three fields by k.

Edge-case behavior:

  • Null — a null contaminates the recursive EMAs and yields null for subsequent rows on every field.

  • NaN — a NaN propagates through the EMAs, yielding NaN.

  • Fast equals slow — when window_fast == window_slow the MACD line is identically zero, and so are the signal and histogram.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the EMAs re-seed per series, e.g. macd(pl.col("close")).over("ticker").

See also

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import macd
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0]})
>>> bands = frame.select(
...     macd(pl.col("close"), window_fast=2, window_slow=3, window_signal=2).alias("macd")
... ).unnest("macd")
>>> bands["macd"].round(4).to_list()
[None, None, 0.5, 0.1667, 0.3889, 0.463, 0.1543, 0.3848]
>>> bands["signal"].round(4).to_list()
[None, None, None, 0.3333, 0.3704, 0.4321, 0.2469, 0.3388]
>>> bands["histogram"].round(4).to_list()
[None, None, None, -0.1667, 0.0185, 0.0309, -0.0926, 0.046]

On a multi-ticker panel, wrap the call in .over so each ticker’s EMAs warm up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "close": [10.0, 11.0, 12.0, 11.0, 20.0, 22.0, 24.0, 22.0]}
... )
>>> expr = macd(pl.col("close"), window_fast=2, window_slow=3, window_signal=2)
>>> frame.with_columns(expr.over("ticker").struct.field("macd").round(4).alias("macd"))["macd"].to_list()
[None, None, 0.5, 0.1667, None, None, 1.0, 0.3333]

A null (which the recursive EMAs latch on) and a NaN (which propagates) make the handling visible on the MACD line:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, None, 13.0, float("nan"), 15.0]})
>>> expr = macd(pl.col("close"), window_fast=2, window_slow=3, window_signal=2)
>>> frame.select(expr.struct.field("macd").round(4).alias("macd"))["macd"].to_list()
[None, None, None, 1.3095, nan, nan]
pomata.indicators.mama(
expr: Expr,
*,
limit_fast: float = 0.5,
limit_slow: float = 0.05,
) Expr[source]

MESA Adaptive Moving Average (MAMA), with its companion FAMA.

John Ehlers’ adaptive average: the smoothing constant tracks the rate of change of the dominant-cycle phase, so the average follows price closely when the cycle phase turns quickly and lags when it is slow. FAMA (the Following Adaptive Moving Average) is a second, slower pass used as a signal line:

\[\begin{split}\alpha_t &= \max\!\Bigl(\text{slow\_limit},\ \frac{\text{fast\_limit}}{\Delta\phi_t}\Bigr), \\ \mathrm{MAMA}_t &= \alpha_t\,\mathrm{price}_t + (1 - \alpha_t)\,\mathrm{MAMA}_{t-1}, \\ \mathrm{FAMA}_t &= \tfrac{1}{2}\alpha_t\,\mathrm{MAMA}_t + (1 - \tfrac{1}{2}\alpha_t)\,\mathrm{FAMA}_{t-1},\end{split}\]

where \(\Delta\phi_t = \max(1,\ \phi_{t-1} - \phi_t)\) is the per-bar decrease of the phasor phase, floored at 1 degree so that limit_fast is the true upper bound on \(\alpha_t\).

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • limit_fast – Upper bound on the smoothing constant (a fast cycle). Must be in (0, 1] and >= limit_slow.

  • limit_slow – Lower bound on the smoothing constant (a slow cycle). Must be in (0, 1].

Returns:

A struct pl.Expr with Float64 fields mama / fama, the same length as expr. The first 32 rows are null (warm-up). Read one line with .struct.field("mama") or split both with .struct.unnest().

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If limit_fast or limit_slow is outside (0, 1] — the smoothing constant is a weight, so a limit above 1 makes 1 - alpha negative and the recurrence diverges — or if limit_fast <     limit_slow, which would pin the adaptive smoothing constant at limit_slow and make limit_fast a false upper bound.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Seeding:

Both lines are seeded at the price prefix — MAMA and FAMA start from the price and the recurrence runs from there. Ehlers’ original presentation instead zero-initializes both lines, so the two report different values across the warm-up region before the exponential weighting washes the seed out; pomata’s price seed is the saner choice for a price-level average. Port warm-up-sensitive logic accordingly.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. mama(pl.col("close")).over("ticker").

See also

  • hilbert_phasor(): The phasor whose phase rate sets the smoothing constant.

  • kama(): Another adaptive moving average, adapting on the efficiency ratio.

  • dominant_cycle_phase(): The dominant-cycle phase from the same pipeline.

References

  • Ehlers, John F. “MAMA — The Mother of Adaptive Moving Averages”.

Examples

Both adaptive lines track the level of a clean period-20 cycle (here 100), at the last bar:

>>> import math
>>> import polars as pl
>>> from pomata.indicators import mama
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> lines = frame.select(mama(pl.col("close")).alias("m")).unnest("m")
>>> round(lines["mama"][-1], 2), round(lines["fama"][-1], 2)
(99.67, 99.96)
pomata.indicators.midpoint(
expr: Expr,
window: int,
) Expr[source]

Midpoint over a window — the mean of the highest and lowest values in the trailing window.

The center of the window’s range: halfway between its rolling maximum and rolling minimum. Unlike a moving average it ignores everything between the extremes, tracking only the band the series has traversed:

\[\mathrm{MIDPOINT}_t = \frac{\max(x_{t-n+1 \ldots t}) + \min(x_{t-n+1 \ldots t})}{2}, \qquad n = \text{window}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The window midpoint for each row, the same length as the input. The first window - 1 values are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a window containing a null yields null (the rolling max and min each need window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • window == 1 — the max and min are the single value, so the midpoint reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. midpoint(pl.col("close"), 20).over("ticker").

See also

  • midprice(): The same midpoint taken across a bar’s high and low instead of one series.

  • sma(): The moving mean of the window, which uses every value rather than only the extremes.

  • donchian_channels(): The high-low band system built from the same rolling extremes.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import midpoint
>>>
>>> frame = pl.DataFrame({"x": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]})
>>> frame.select(midpoint(pl.col("x"), 3).round(4).alias("midpoint"))["midpoint"].to_list()
[None, None, 2.0, 3.0, 4.0, 5.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame({"ticker": ["A"] * 3 + ["B"] * 3, "x": [1.0, 2.0, 3.0, 10.0, 20.0, 30.0]})
>>> expr = midpoint(pl.col("x"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("midpoint"))["midpoint"].to_list()
[None, 1.5, 2.5, None, 15.0, 25.0]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [1.0, None, 3.0, float("nan"), 5.0, 6.0]})
>>> frame.select(midpoint(pl.col("x"), 2).round(4).alias("midpoint"))["midpoint"].to_list()
[None, None, None, nan, nan, 5.5]
pomata.indicators.midprice(
high: Expr,
low: Expr,
window: int,
) Expr[source]

Midprice over a window — the mean of the highest high and the lowest low in the trailing window.

The center of the price range a bar series has covered: halfway between the rolling maximum of high and the rolling minimum of low. It is the two-input analogue of midpoint(), reading a bar’s extremes rather than a single series:

\[\mathrm{MIDPRICE}_t = \frac{\max(\mathrm{high}_{t-n+1 \ldots t}) + \min(\mathrm{low}_{t-n+1 \ldots t})}{2}, \qquad n = \text{window}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The window midprice for each row, the same length as the inputs. The first window - 1 values are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high and low must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null — a window containing a null in either input yields null (each rolling extreme needs window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • window == 1 — the extremes are the bar’s own high and low, so the midprice reduces to the per-bar price_median().

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. midprice(pl.col("high"), pl.col("low"), 20).over("ticker").

See also

  • midpoint(): The same midpoint over a single series instead of a bar’s high and low.

  • price_median(): The per-bar (high + low) / 2 this collapses to at window == 1.

  • donchian_channels(): The channel whose middle band is exactly this midprice.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import midprice
>>>
>>> frame = pl.DataFrame({"high": [11.0, 12.0, 13.0, 12.5, 14.0], "low": [9.0, 10.0, 11.0, 11.0, 12.0]})
>>> expr = midprice(pl.col("high"), pl.col("low"), 3).round(4)
>>> frame.select(expr.alias("midprice"))["midprice"].to_list()
[None, None, 11.0, 11.5, 12.5]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "high": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 19.0, 20.0, 21.0],
...     }
... )
>>> expr = midprice(pl.col("high"), pl.col("low"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("midprice"))["midprice"].to_list()
[None, 10.5, 11.5, None, 20.5, 21.5]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"high": [11.0, None, 13.0, float("nan"), 15.0], "low": [9.0, 10.0, 11.0, 12.0, 13.0]})
>>> expr = midprice(pl.col("high"), pl.col("low"), 2).round(4)
>>> frame.select(expr.alias("midprice"))["midprice"].to_list()
[None, None, None, nan, nan]
pomata.indicators.mom(
expr: Expr,
window: int,
) Expr[source]

Momentum, also known as MOM or the rate-of-change in absolute (price-difference) form.

The oldest and simplest momentum oscillator: the signed difference between the current observation and the observation window periods earlier, measuring how far price has traveled over the look-back horizon:

\[\mathrm{MOM}_t = x_t - x_{t - n}, \qquad n = \text{window}.\]

A positive value means price is higher than window periods ago (upward momentum), a negative value the reverse, and zero a flat look-back; the magnitude is in the same units as expr and scales linearly with price, so it is not comparable across instruments at different price levels (use a ratio-based rate-of-change for that). The result is homogeneous of degree 1 in expr (mom(k * x) == k * mom(x)) and invariant to an additive constant only at the per-element level once both endpoints are shifted by the same amount.

It is the unbounded, absolute-difference sibling of the percentage rate-of-change.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations to look back. Must be >= 1.

Returns:

The momentum for each row, the same length as expr. The first window values are null (warm-up), clamped to the series length: unlike the moving-average family, whose warm-up is window - 1 rows, the value at row t needs the observation at row t - window, which first exists at t == window.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a position whose current value or whose window-back value is null yields null.

  • NaN — a position whose current value or whose window-back value is NaN (with no null) yields NaN. Because the operation is a fixed-lag difference rather than a recurrence, a null or NaN contaminates only the (at most two) positions that reference it and never latches onto the rest of the series.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the shift never reaches across series boundaries, e.g. mom(pl.col("close"), 10).over("ticker").

See also

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import mom
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0]})
>>> frame.select(mom(pl.col("close"), window=2).round(4).alias("mom_2"))["mom_2"].to_list()
[None, None, 4.0, 4.0, 4.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(mom(pl.col("close"), 2).over("ticker").round(4).alias("mom"))["mom"].to_list()
[None, None, 2.0, 0.0, 1.0, None, None, 1.0, 1.0, 1.0]

A null (voiding the rows that reference it) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(mom(pl.col("close"), 2).round(4).alias("mom"))["mom"].to_list()
[None, None, 2.0, 2.0, None, 2.0, None, 2.0, nan, 2.0]
pomata.indicators.money_flow_index(
high: Expr,
low: Expr,
close: Expr,
volume: Expr,
window: int,
) Expr[source]

Money Flow Index (MFI), also known as the volume-weighted Relative Strength Index.

A bounded [0, 100] momentum oscillator (Quong & Soudack, 1989) that grades buying versus selling pressure by weighting each bar’s typical price by its traded volume. It is the volume-aware analogue of the RSI: where the RSI accumulates price gains and losses, the MFI accumulates the raw money flow (typical price times volume) on up-days versus down-days. With \(n = \text{window}\), typical price \(\mathrm{TP}_t = (H_t + L_t + C_t) / 3\) and raw money flow \(\mathrm{RMF}_t = \mathrm{TP}_t \cdot V_t\):

\[\begin{split}\mathrm{PF}_t &= \mathrm{RMF}_t \,\mathbf{1}\!\left[\mathrm{TP}_t > \mathrm{TP}_{t-1}\right], \qquad \mathrm{NF}_t = \mathrm{RMF}_t \,\mathbf{1}\!\left[\mathrm{TP}_t < \mathrm{TP}_{t-1}\right], \\[4pt] \mathrm{MR}_t &= \frac{\sum_{i=0}^{n-1} \mathrm{PF}_{t-i}}{\sum_{i=0}^{n-1} \mathrm{NF}_{t-i}}, \qquad \mathrm{MFI}_t = 100 - \frac{100}{1 + \mathrm{MR}_t}.\end{split}\]

A bar whose typical price is unchanged (\(\mathrm{TP}_t = \mathrm{TP}_{t-1}\)) contributes to neither the positive nor the negative money flow. The first row has no predecessor and so contributes no money flow, which is why a full window of changes (and therefore window + 1 price bars) is needed before the first value is defined. As a volume-weighted RSI it is bounded in \([0, 100]\): a window with no negative money flow saturates the oscillator at 100 and one with no positive money flow at 0.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The MFI for each row, bounded in [0, 100] and the same length as the inputs. The first window values are null (warm-up): the value is defined only once window price changes have accumulated, so the first defined row is at index window rather than window - 1.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Classification:

The up / down classification compares each typical price with the previous one, so a missing or undefined typical price taints two consecutive change positions (its own and the following one).

Edge-case behavior:

  • Null — a null in high, low, or close voids the typical price at that row and at the next change, so any window reaching either yields null; a null in volume voids only that row’s money flow. null takes precedence over NaN.

  • NaN — a NaN in any input contaminates the affected money flow and yields NaN for every window that contains it. A NaN typical price makes both its own change and the next one undefined in sign, so each is poisoned into the positive and the negative money flow as NaN, voiding every window that reaches either change (the same two-position taint as the null analogue, but surfaced as NaN rather than null).

  • Division by zero — a window with no negative money flow but non-zero positive flow has money ratio +inf and the MFI saturates at 100; symmetrically an all-down window gives 0. A window in which both flows are zero (the typical price never moves) leaves the money ratio at 0 / 0 and yields NaN – the oscillator is genuinely undefined there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the difference nor the rolling sums span series boundaries, e.g. money_flow_index(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), 14).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import money_flow_index
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0],
...         "low": [8.0, 9.0, 10.0, 9.0, 11.0, 12.0, 11.0, 13.0],
...         "close": [9.0, 10.0, 11.0, 10.0, 12.0, 13.0, 12.0, 14.0],
...         "volume": [100.0, 150.0, 120.0, 130.0, 110.0, 160.0, 140.0, 170.0],
...     }
... )
>>> frame.select(
...     money_flow_index(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), window=3)
...     .round(4)
...     .alias("mfi_3")
... )["mfi_3"].to_list()
[None, None, None, 68.4466, 67.0051, 72.3404, 66.9291, 72.6384]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [12.0, 13.0, 12.5, 14.0, 22.0, 24.0, 23.0, 25.0],
...         "low": [10.0, 11.0, 11.0, 12.0, 20.0, 21.0, 21.0, 23.0],
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 100.0, 120.0, 90.0, 110.0],
...     }
... )
>>> expr = (
...     money_flow_index(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), 2)
...     .over("ticker")
...     .round(4)
... )
>>> frame.with_columns(expr.alias("money_flow_index"))["money_flow_index"].to_list()
[None, None, 58.1673, 57.972, None, None, 100.0, 100.0]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0],
...         "low": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
...         "close": [11.5, 12.5, 13.0, 14.5, None, 16.0, float("nan"), 18.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 95.0, 140.0],
...     }
... )
>>> expr = money_flow_index(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"), 2).round(4)
>>> frame.select(expr.alias("money_flow_index"))["money_flow_index"].to_list()
[None, None, 100.0, 100.0, None, None, None, nan]
pomata.indicators.obv(
expr: Expr,
volume: Expr,
) Expr[source]

On-Balance Volume (OBV), also known as Granville’s cumulative volume.

A momentum indicator that ties volume to price direction (Joseph Granville, 1963). Each bar adds the whole bar volume to a running total when the close rises, subtracts it when the close falls, and leaves the total unchanged when the close is flat; the cumulative line is read for divergences against price rather than for its absolute level:

\[\begin{split}\mathrm{OBV}_t = \sum_{i=1}^{t} \operatorname{sign}(x_i - x_{i-1})\, V_i, \qquad \operatorname{sign}(d) = \begin{cases} +1, & d > 0, \\ \phantom{+}0, & d = 0, \\ -1, & d < 0, \end{cases}\end{split}\]

where \(x\) is expr and \(V\) is volume. The first bar has no predecessor, so its direction is undefined; it contributes 0 and the series therefore starts at \(\mathrm{OBV}_0 = 0\).

OBV is an unbounded, level-arbitrary cumulative series; only its slope and divergences against price are meaningful, not its absolute magnitude.

Because the direction is the sign of the bar-to-bar close change, it is invariant to any additive shift of the price level and homogeneous of degree one in volume (scaling all volumes by a constant scales OBV by that constant).

Parameters:
  • expr – Input series, conventionally the close (any series is accepted; e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

Returns:

every row is defined, starting at 0 on the first row.

Return type:

The OBV for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a null close zeroes the direction both at its own row and at the following row (each diff touching the null is itself null and is filled to 0), so those bars contribute nothing while the running total carries on. A null volume makes that bar’s contribution null (0 * null is null, so this holds even on the first or a flat bar where the direction is 0): the output is null at exactly that row while the cumulative sum skips it and continues from the prior total.

  • NaN — a NaN close (via diff) or a NaN volume poisons the contribution at its row and, once summed, latches the running total to NaN for every subsequent row; because 0 * NaN is NaN under IEEE-754, a NaN volume contaminates the total even on a flat or first bar where the direction is 0. A null-contribution row still emits null at its own position even after the latch.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the diff nor the cumulative sum spans series boundaries, e.g. obv(pl.col("close"), pl.col("volume")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import obv
>>>
>>> frame = pl.DataFrame(
...     {
...         "close": [10.0, 12.0, 11.0, 11.0, 13.0, 9.0],
...         "volume": [100.0, 200.0, 150.0, 80.0, 300.0, 250.0],
...     }
... )
>>> frame.select(obv(pl.col("close"), pl.col("volume")).round(4).alias("obv"))["obv"].to_list()
[0.0, 200.0, 50.0, 50.0, 350.0, 100.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 100.0, 120.0, 90.0, 110.0],
...     }
... )
>>> expr = obv(pl.col("close"), pl.col("volume")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("obv"))["obv"].to_list()
[0.0, 120.0, 30.0, 140.0, 0.0, 0.0, 90.0, 200.0]

A null (skipped, the running total carrying across it) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 95.0, 140.0, 105.0, 115.0],
...     }
... )
>>> expr = obv(pl.col("close"), pl.col("volume")).round(4)
>>> frame.select(expr.alias("obv"))["obv"].to_list()
[0.0, 120.0, 210.0, 320.0, 320.0, 320.0, nan, nan, nan, nan]
pomata.indicators.parabolic_sar(
high: Expr,
low: Expr,
*,
acceleration: float = 0.02,
maximum: float = 0.2,
) Expr[source]

Parabolic SAR (Stop And Reverse).

Introduced by J. Welles Wilder (1978): a trend-following stop that trails price, tightening as a trend extends and flipping to the other side when price crosses it. Each bar the stop steps a fraction — the acceleration factor – of the way toward the trend’s extreme point:

\[\mathrm{SAR}_t = \mathrm{SAR}_{t-1} + \mathrm{AF}_{t-1} \, (\mathrm{EP}_{t-1} - \mathrm{SAR}_{t-1}),\]

where EP is the highest high of the current up-trend (or lowest low of a down-trend) and AF starts at acceleration, rising by acceleration on each new extreme up to maximum. In an up-trend the stop is held at or below the prior two lows; when a low crosses it the trend reverses, the stop jumps to the prior extreme, EP resets to the new low, and AF resets (symmetrically for a down-trend).

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • acceleration – Starting acceleration factor, and its per-extreme increment. A fraction in (0, 1], and never above maximum (so the factor is capped from the seed onward, not only on the increment path).

  • maximum – Cap on the acceleration factor. A fraction in (0, 1], and at least acceleration.

Returns:

The Parabolic SAR for each row, the same length as the inputs. Row 0 is null (the trend is seeded from the first two bars); the value at row 1 is the seed stop, and the recurrence runs from there.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If acceleration or maximum is not in the half-open interval (0, 1], or if acceleration > maximum.

Note

Precision – the parabolic SAR is a path-dependent stop-and-reverse recurrence, so its reference oracle necessarily mirrors the implementation’s state machine and confirms internal consistency, not independence; the independent witness is the set of golden masters hand-computed from Wilder’s published rules. Agreement holds to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 under a positive common rescaling of high and low (the stop is a price level and the recurrence and crossings are linear in price).

Seeding:

Wilder’s original leaves the initial trend unspecified; here it is taken long when the first bar-to-bar up-move is at least the down-move, else short, and the first stop is the prior low (long) or high (short).

Edge-case behavior:

  • Null — a null high or low yields null at that row and is skipped; the running trend state bridges the gap and resumes on the next complete bar.

  • NaN — a NaN high or low yields NaN at that row and is skipped, exactly like a null: the raw high/low feed the kernel directly with no recurrence for a NaN to latch onto, so the running trend state bridges the gap and resumes on the next complete bar.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recurrence does not span series boundaries, e.g. parabolic_sar(pl.col("high"), pl.col("low")).over("ticker").

See also

  • supertrend(): The other trailing-stop trend tool, ATR-scaled rather than accelerating.

  • adx(): Wilder’s directional-movement trend-strength index.

  • atr(): Wilder’s volatility average.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import parabolic_sar
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 13.0, 14.0, 13.0, 12.0, 11.0, 10.0, 11.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 12.0, 11.0, 10.0, 9.0, 10.0],
...     }
... )
>>> frame.select(parabolic_sar(pl.col("high"), pl.col("low")).round(4).alias("sar"))["sar"].to_list()
[None, 9.0, 9.0, 9.12, 9.3528, 9.7246, 10.0666, 14.0, 13.92, 13.7232]

On a multi-ticker panel, wrap the call in .over so each ticker seeds independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 13.0, 14.0, 20.0, 21.0, 22.0, 21.0, 20.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 19.0, 20.0, 21.0, 20.0, 19.0],
...     }
... )
>>> expr = parabolic_sar(pl.col("high"), pl.col("low")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("sar"))["sar"].to_list()
[None, 9.0, 9.0, 9.12, 9.3528, None, 19.0, 19.0, 19.12, 22.0]

A null then a NaN in high each yield null / NaN at that row and are skipped, the running trend state bridging the gap:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, None, 14.0, float("nan"), 12.0, 11.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 12.0, 11.0, 10.0],
...     }
... )
>>> frame.select(parabolic_sar(pl.col("high"), pl.col("low")).round(4).alias("sar"))["sar"].to_list()
[None, 9.0, 9.0, None, 9.12, nan, 9.4128, 9.688]
pomata.indicators.percentage_price_oscillator(
expr: Expr,
*,
window_fast: int,
window_slow: int,
) Expr[source]

Percentage Price Oscillator, also known as PPO — the fast/slow EMA gap as a percentage of the slow EMA.

The scale-free sibling of absolute_price_oscillator(): the same fast-minus-slow ema() gap, divided by the slow EMA and put on a percentage scale, so oscillators of differently-priced series are comparable:

\[\mathrm{PPO}_t = 100 \cdot \frac{\mathrm{EMA}(\mathrm{close}, n_{\mathrm{fast}})_t - \mathrm{EMA}(\mathrm{close}, n_{\mathrm{slow}})_t}{\mathrm{EMA}(\mathrm{close}, n_{\mathrm{slow}})_t}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window_fast – Span of the fast EMA (canonically 12). Must be >= 1.

  • window_slow – Span of the slow EMA (canonically 26). Must be >= 1 and >= window_fast.

Returns:

The oscillator (in percent) for each row, the same length as the input. Values are null until both EMAs leave their warm-up (the first max(window_fast, window_slow) - 1 rows).

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_fast < 1, window_slow < 1, or window_fast > window_slow (the fast leg must be the shorter one; window_fast == window_slow is allowed and gives an identically-zero oscillator).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Moving average: both legs use the exponential ema(). Being scale-free, PPO is invariant to the price’s unit — multiplying the close by a constant leaves it unchanged.

Edge-case behavior:

  • Null — a null contaminates the recursive EMA state and yields null for subsequent rows.

  • NaN — a NaN propagates through both EMAs, yielding NaN.

  • Division by zero — when the slow EMA is 0 the ratio divides by zero following IEEE-754: a zero gap (0 / 0) is NaN and a non-zero gap over zero is +/-inf. This is the documented and intended behavior rather than an error.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither EMA spans series boundaries, e.g. percentage_price_oscillator(pl.col("close")).over("ticker").

See also

  • absolute_price_oscillator(): The same gap in price units, before dividing by the slow EMA.

  • macd(): The oscillator built on this gap, with an added signal line.

  • ema(): The exponential moving average each leg is built from.

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import percentage_price_oscillator
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0]})
>>> expr = percentage_price_oscillator(pl.col("close"), window_fast=2, window_slow=3).round(4)
>>> frame.select(expr.alias("ppo"))["ppo"].to_list()
[None, None, 4.5455, 1.5152, 3.2407, 3.5613, 1.1871, 2.7484]

On a multi-ticker panel, wrap the call in .over so each ticker’s EMAs warm up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "close": [10.0, 11.0, 12.0, 11.0, 20.0, 22.0, 24.0, 22.0]}
... )
>>> expr = percentage_price_oscillator(pl.col("close"), window_fast=2, window_slow=3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("ppo"))["ppo"].to_list()
[None, None, 4.5455, 1.5152, None, None, 4.5455, 1.5152]

A null (which the recursive EMA latches on) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, None, 13.0, float("nan"), 15.0]})
>>> expr = percentage_price_oscillator(pl.col("close"), window_fast=2, window_slow=3).round(4)
>>> frame.select(expr.alias("ppo"))["ppo"].to_list()
[None, None, None, 11.5546, nan, nan]
pomata.indicators.price_average(
open: Expr,
high: Expr,
low: Expr,
close: Expr,
) Expr[source]

Average Price, the equal-weighted mean of the four OHLC prices.

The simplest representative price for a bar: the arithmetic mean of its open, high, low, and close, weighting each equally.

\[\mathrm{AVGPRICE}_t = \frac{\mathrm{open}_t + \mathrm{high}_t + \mathrm{low}_t + \mathrm{close}_t}{4}.\]

It is a pure per-bar transform — no window, no recursion, no cross-bar state — so it is defined from row 0 and each row depends only on its own four prices.

Parameters:
  • open – Open-price series (e.g. pl.col("open")).

  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

Returns:

every row is defined from row 0.

Return type:

The average price for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

open, high, low, and close are taken as the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null — the mean is a plain sum over the four inputs, so a null in any of them propagates: the row is null whenever at least one of its four prices is null (null takes precedence over NaN).

  • NaN — a NaN in any input (with no null at that row) propagates, yielding NaN for that row.

  • Partitioning — the transform is elementwise (each row uses only its own bar), so it is already correct on a multi-series panel: .over(...) partitions identically and is therefore optional here (the result is the same either way), unlike the windowed indicators where .over is required to stop a window spanning series boundaries.

See also

Examples

>>> import polars as pl
>>> from pomata.indicators import price_average
>>>
>>> frame = pl.DataFrame(
...     {
...         "open": [10.0, 11.0, 12.0, 11.5, 13.0],
...         "high": [11.0, 12.0, 13.0, 12.5, 14.0],
...         "low": [9.0, 10.0, 11.0, 11.0, 12.0],
...         "close": [10.0, 11.5, 12.5, 11.5, 13.5],
...     }
... )
>>> expr = price_average(pl.col("open"), pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("price_average"))["price_average"].to_list()
[10.0, 11.125, 12.125, 11.625, 13.125]

On a multi-ticker panel, partition with .over as the windowed indicators require — for this elementwise transform .over is optional (the result is identical without it) and shown here only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "open": [10.0, 11.0, 12.0, 20.0, 21.0, 22.0],
...         "high": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 19.0, 20.0, 21.0],
...         "close": [10.0, 11.5, 12.5, 20.0, 21.5, 22.5],
...     }
... )
>>> expr = price_average(pl.col("open"), pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("price_average"))["price_average"].to_list()
[10.0, 11.125, 12.125, 20.0, 21.125, 22.125]

A null then a NaN in close (both propagate through the sum) make the missing-data handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "open": [10.0, 11.0, 12.0, 13.0, 14.0],
...         "high": [11.0, 12.0, 13.0, 14.0, 15.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0],
...         "close": [10.0, None, 12.5, float("nan"), 14.5],
...     }
... )
>>> expr = price_average(pl.col("open"), pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("price_average"))["price_average"].to_list()
[10.0, None, 12.125, nan, 14.125]
pomata.indicators.price_median(
high: Expr,
low: Expr,
) Expr[source]

Median Price, the midpoint of the bar’s range.

The center of the high-low range — the mean of just the two extremes, ignoring open and close:

\[\mathrm{MEDPRICE}_t = \frac{\mathrm{high}_t + \mathrm{low}_t}{2}.\]

It is a pure per-bar transform — no window, no recursion, no cross-bar state — so it is defined from row 0 and each row depends only on its own high and low.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

Returns:

every row is defined from row 0.

Return type:

The median price for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high and low are taken as the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null — the midpoint is a plain sum over the two inputs, so a null in either propagates: the row is null whenever high or low is null (null takes precedence over NaN).

  • NaN — a NaN in either input (with no null at that row) propagates, yielding NaN for that row.

  • Partitioning — the transform is elementwise (each row uses only its own bar), so it is already correct on a multi-series panel: .over(...) partitions identically and is therefore optional here (the result is the same either way), unlike the windowed indicators where .over is required to stop a window spanning series boundaries.

See also

  • midprice(): The rolling midpoint of the high-low range over a window.

  • price_average(): The equal-weighted mean of the four OHLC prices.

  • price_typical(): The equal-weighted mean of high, low, and close.

Examples

>>> import polars as pl
>>> from pomata.indicators import price_median
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 12.5, 14.0],
...         "low": [9.0, 10.0, 11.0, 11.0, 12.0],
...     }
... )
>>> expr = price_median(pl.col("high"), pl.col("low")).round(4)
>>> frame.select(expr.alias("price_median"))["price_median"].to_list()
[10.0, 11.0, 12.0, 11.75, 13.0]

On a multi-ticker panel, partition with .over as the windowed indicators require — for this elementwise transform .over is optional (the result is identical without it) and shown here only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "high": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 19.0, 20.0, 21.0],
...     }
... )
>>> expr = price_median(pl.col("high"), pl.col("low")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("price_median"))["price_median"].to_list()
[10.0, 11.0, 12.0, 20.0, 21.0, 22.0]

A null then a NaN in high (both propagate through the sum) make the missing-data handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, None, 13.0, float("nan"), 15.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0],
...     }
... )
>>> expr = price_median(pl.col("high"), pl.col("low")).round(4)
>>> frame.select(expr.alias("price_median"))["price_median"].to_list()
[10.0, None, 12.0, nan, 14.0]
pomata.indicators.price_typical(
high: Expr,
low: Expr,
close: Expr,
) Expr[source]

Typical Price, the equal-weighted mean of high, low, and close.

A common single-number summary of a bar — the average of its high, low, and close. It is the price series the Commodity Channel Index (cci()) is built on:

\[\mathrm{TYPPRICE}_t = \frac{\mathrm{high}_t + \mathrm{low}_t + \mathrm{close}_t}{3}.\]

It is a pure per-bar transform — no window, no recursion, no cross-bar state — so it is defined from row 0 and each row depends only on its own high, low, and close.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

Returns:

every row is defined from row 0.

Return type:

The typical price for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high, low, and close are taken as the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null — the mean is a plain sum over the three inputs, so a null in any of them propagates: the row is null whenever at least one of high / low / close is null (null takes precedence over NaN).

  • NaN — a NaN in any input (with no null at that row) propagates, yielding NaN for that row.

  • Partitioning — the transform is elementwise (each row uses only its own bar), so it is already correct on a multi-series panel: .over(...) partitions identically and is therefore optional here (the result is the same either way), unlike the windowed indicators where .over is required to stop a window spanning series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import price_typical
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 12.5, 14.0],
...         "low": [9.0, 10.0, 11.0, 11.0, 12.0],
...         "close": [10.0, 11.5, 12.5, 11.5, 13.5],
...     }
... )
>>> expr = price_typical(pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("price_typical"))["price_typical"].to_list()
[10.0, 11.1667, 12.1667, 11.6667, 13.1667]

On a multi-ticker panel, partition with .over as the windowed indicators require — for this elementwise transform .over is optional (the result is identical without it) and shown here only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "high": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 19.0, 20.0, 21.0],
...         "close": [10.0, 11.5, 12.5, 20.0, 21.5, 22.5],
...     }
... )
>>> expr = price_typical(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("price_typical"))["price_typical"].to_list()
[10.0, 11.1667, 12.1667, 20.0, 21.1667, 22.1667]

A null then a NaN in close (both propagate through the sum) make the missing-data handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 14.0, 15.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0],
...         "close": [10.0, None, 12.5, float("nan"), 14.5],
...     }
... )
>>> expr = price_typical(pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("price_typical"))["price_typical"].to_list()
[10.0, None, 12.1667, nan, 14.1667]
pomata.indicators.price_weighted_close(
high: Expr,
low: Expr,
close: Expr,
) Expr[source]

Weighted Close Price, the OHLC summary that double-weights the close.

A representative price that gives the close twice the weight of the high and the low, emphasizing where the bar settled:

\[\mathrm{WCLPRICE}_t = \frac{\mathrm{high}_t + \mathrm{low}_t + 2\,\mathrm{close}_t}{4}.\]

It is a pure per-bar transform — no window, no recursion, no cross-bar state — so it is defined from row 0 and each row depends only on its own high, low, and close.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")); weighted twice.

Returns:

every row is defined from row 0.

Return type:

The weighted close price for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high, low, and close are taken as the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null — the result is a plain weighted sum over the three inputs, so a null in any of them propagates: the row is null whenever at least one of high / low / close is null (null takes precedence over NaN).

  • NaN — a NaN in any input (with no null at that row) propagates, yielding NaN for that row.

  • Partitioning — the transform is elementwise (each row uses only its own bar), so it is already correct on a multi-series panel: .over(...) partitions identically and is therefore optional here (the result is the same either way), unlike the windowed indicators where .over is required to stop a window spanning series boundaries.

See also

Examples

>>> import polars as pl
>>> from pomata.indicators import price_weighted_close
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 12.5, 14.0],
...         "low": [9.0, 10.0, 11.0, 11.0, 12.0],
...         "close": [10.0, 11.5, 12.5, 11.5, 13.5],
...     }
... )
>>> expr = price_weighted_close(pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("price_weighted_close"))["price_weighted_close"].to_list()
[10.0, 11.25, 12.25, 11.625, 13.25]

On a multi-ticker panel, partition with .over as the windowed indicators require — for this elementwise transform .over is optional (the result is identical without it) and shown here only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "high": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 19.0, 20.0, 21.0],
...         "close": [10.0, 11.5, 12.5, 20.0, 21.5, 22.5],
...     }
... )
>>> expr = price_weighted_close(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("price_weighted_close"))["price_weighted_close"].to_list()
[10.0, 11.25, 12.25, 20.0, 21.25, 22.25]

A null then a NaN in close (both propagate through the sum) make the missing-data handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 14.0, 15.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0],
...         "close": [10.0, None, 12.5, float("nan"), 14.5],
...     }
... )
>>> expr = price_weighted_close(pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("price_weighted_close"))["price_weighted_close"].to_list()
[10.0, None, 12.25, nan, 14.25]
pomata.indicators.rma(
expr: Expr,
window: int,
) Expr[source]

Wilder Moving Average (RMA), also known as SMMA / Wilder smoothing / Modified MA.

An exponential moving average whose smoothing factor is pinned to \(\alpha = 1 / n\), evaluated in its recursive (unadjusted) form:

\[\begin{split}\mathrm{RMA}_t = \begin{cases} \dfrac{1}{n} \sum_{i=0}^{n-1} x_i, & t = n - 1, \\[4pt] \mathrm{RMA}_{t-1} + \dfrac{1}{n}\,\bigl(x_t - \mathrm{RMA}_{t-1}\bigr) = \Bigl(1 - \tfrac{1}{n}\Bigr)\,\mathrm{RMA}_{t-1} + \tfrac{1}{n}\,x_t, & t \ge n, \end{cases} \qquad n = \text{window}.\end{split}\]

It is the smoothing Wilder used throughout New Concepts in Technical Trading Systems (RSI, ATR, ADX, DMI). Equivalently it is an EMA with alpha = 1 / window, seeded with the simple average of the first window observations – Wilder’s initialization, so the warm-up matches the industry reference from the first emitted value.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

the recursion emits only once window non-null observations have been counted – seeded there with their simple average – after which it is defined for every later row.

Return type:

The RMA for each row, the same length as expr. The first window - 1 values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a leading null run is skipped and does not consume warm-up budget: min_samples counts only non-null observations, so the warm-up gate is independent of where any interior null falls. An interior null yields null at that row while the path-dependent recursion bridges the gap rather than restarting, the running average’s weight decaying across it (Polars ewm_mean(adjust=False, ignore_nulls=False) semantics).

  • NaN — once a NaN enters it poisons the recursion and latches NaN for every subsequent value.

  • window == 1 — the smoothing factor is 1, the warm-up vanishes, and the result reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion does not span series boundaries, e.g. rma(pl.col("close"), 14).over("ticker").

See also

  • ema(): The same recursion with smoothing factor 2 / (window + 1).

  • atr(): The volatility average that smooths the true range with this Wilder mean.

  • sma(): The equal-weight baseline.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import rma
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0]})
>>> frame.select(rma(pl.col("close"), window=3).round(4).alias("rma_3"))["rma_3"].to_list()
[None, None, 4.0, 5.3333, 6.8889]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(rma(pl.col("close"), 2).over("ticker").round(4).alias("rma"))["rma"].to_list()
[None, 10.5, 11.25, 11.125, 12.0625, None, 21.0, 21.0, 22.0, 22.0]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(rma(pl.col("close"), 2).round(4).alias("rma"))["rma"].to_list()
[None, 10.5, 11.25, 12.125, None, 14.0417, nan, nan, nan, nan]
pomata.indicators.roc(
expr: Expr,
window: int,
) Expr[source]

Rate of Change (ROC), also known as the Price Rate of Change (PROC) or n-day momentum (percent).

The percentage change of the series relative to its value window observations ago — a pure momentum oscillator that is unbounded above and below and crosses zero when price returns to its lagged level:

\[\mathrm{ROC}_t = 100 \cdot \frac{x_t - x_{t-n}}{x_{t-n}}, \qquad n = \text{window},\]

where \(x_{t-n}\) is the value window rows earlier (expr.shift(window)). It is the simple return over window periods expressed in percent; a positive value means the series rose over the lookback, a negative value that it fell, and 0 that it is unchanged.

ROC is a ratio, so it is scale-invariant rather than scale-homogeneous: multiplying the input by a non-zero constant leaves the output unchanged (roc(k * x) == roc(x)), because the constant cancels between numerator and denominator.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations to look back. Must be >= 1.

Returns:

the lagged term expr.shift(window) is undefined for the first window rows, so no change can be measured there.

Return type:

The ROC for each row, the same length as expr. The first window values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a null at the current row or at the lagged row yields null at that position.

  • NaN — a NaN at the current row or at the lagged row (and no null) yields NaN.

  • Division by zero — when the lagged value is 0 the ratio divides by zero following IEEE-754: a zero change (0 / 0) is NaN and a non-zero change over zero is +/-inf (the sign tracks the change relative to the signed zero). This is the documented and intended behavior rather than an error.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the shift never reaches across series boundaries, e.g. roc(pl.col("close"), 12).over("ticker").

See also

  • mom(): The absolute-difference sibling.

  • trix(): The one-period rate of change of a triple-smoothed EMA.

  • rsi(): A bounded momentum oscillator.

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import roc
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0]})
>>> frame.select(roc(pl.col("close"), window=2).round(4).alias("roc_2"))["roc_2"].to_list()
[None, None, 200.0, 100.0, 66.6667]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(roc(pl.col("close"), 2).over("ticker").round(4).alias("roc"))["roc"].to_list()
[None, None, 20.0, 0.0, 8.3333, None, None, 5.0, 4.5455, 4.7619]

A null (voiding the rows that reference it) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(roc(pl.col("close"), 2).round(4).alias("roc"))["roc"].to_list()
[None, None, 20.0, 18.1818, None, 15.3846, None, 13.3333, nan, 11.7647]
pomata.indicators.rsi(
expr: Expr,
window: int,
) Expr[source]

Relative Strength Index (RSI), also known as Wilder’s RSI.

A bounded momentum oscillator (J. Welles Wilder, 1978) that measures the magnitude of recent up moves relative to recent down moves, mapped onto a fixed [0, 100] scale.

With one-step price changes \(\Delta_t = x_t - x_{t-1}\) split into gains and losses \(U_t = \max(\Delta_t, 0)\), \(D_t = \max(-\Delta_t, 0)\), smoothed by the Wilder moving average rma() (an exponential average with \(\alpha = 1 / n\)):

\[\mathrm{RS}_t = \frac{\mathrm{RMA}(U)_t}{\mathrm{RMA}(D)_t}, \qquad \mathrm{RSI}_t = 100 - \frac{100}{1 + \mathrm{RS}_t}, \qquad n = \text{window}.\]

Equivalently \(\mathrm{RSI}_t = 100 \cdot \mathrm{RMA}(U)_t / (\mathrm{RMA}(U)_t + \mathrm{RMA}(D)_t)\), which makes the bounds explicit: a window with no losses gives \(\mathrm{RSI} = 100\), a window with no gains gives \(\mathrm{RSI} = 0\), and the value lives in \([0, 100]\) everywhere in between.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

Wilder’s RSI needs window + 1 prices for its first value, since row 0 has no difference and the gain / loss averages count window non-null differences before emitting.

Return type:

The RSI for each row, the same length as expr. The first window values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Seeding:

The gain and loss averages use Wilder’s rma(), seeded with the simple average of the first window gains and losses – Wilder’s canonical initialization, exact from the first emitted value.

Edge-case behavior:

  • Null — a leading null run is skipped: the warm-up counts only non-null observations, so the window warm-up is measured from the first non-null value. An interior null yields null at that row while the Wilder recursion bridges the gap.

  • NaN — a NaN poisons the recursion and latches NaN for every subsequent non-warm-up row.

  • Flat window — no up and no down move is the indeterminate 0 / 0 relative strength, surfaced as NaN (the value is genuinely undefined, not a conventional 50 or 100).

  • window == 1 — the smoothing vanishes: each row reports 100 on an up move, 0 on a down move, and NaN on no move.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the differencing nor the recursion spans series boundaries, e.g. rsi(pl.col("close"), 14).over("ticker").

See also

  • rma(): Wilder’s moving average that smooths the gains and losses RSI is built on.

  • money_flow_index(): The volume-weighted analogue — the same oscillator on raw money flow.

  • chande_momentum_oscillator(): The unsmoothed sibling that sums gains and losses over a fixed window.

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import rsi
>>>
>>> frame = pl.DataFrame({"close": [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42]})
>>> frame.select(rsi(pl.col("close"), window=3).round(4).alias("rsi_3"))["rsi_3"].to_list()
[None, None, None, 7.0588, 59.0674, 74.1408, 80.0819, 85.8581]

On a multi-ticker panel, wrap the call in .over so the difference and the recursion restart per group – note that each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A", "A", "A", "A", "A", "B", "B", "B", "B", "B"],
...         "close": [10.0, 11.0, 10.5, 11.5, 12.5, 50.0, 49.0, 51.0, 50.5, 52.0],
...     }
... )
>>> frame.with_columns(rsi(pl.col("close"), window=3).over("ticker").round(2).alias("rsi"))["rsi"].to_list()
[None, None, None, 80.0, 87.5, None, None, None, 57.14, 73.91]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(rsi(pl.col("close"), 2).round(4).alias("rsi"))["rsi"].to_list()
[None, None, 100.0, 100.0, None, None, nan, nan, nan, nan]
pomata.indicators.rsi_stochastic(
expr: Expr,
*,
window_rsi: int,
window_k: int,
window_d: int,
) Expr[source]

Stochastic Relative Strength Index, also known as the Stochastic RSI.

Introduced by Tushar Chande and Stanley Kroll (1994): the Fast Stochastic Oscillator applied to rsi() instead of price, which sharpens the bounded RSI into a faster [0, 100] oscillator by locating it within its own recent range. The raw line %K places the RSI within its window_k range, and %D is the sma() of %K:

\[\begin{split}\%\mathrm{K}_t &= 100 \cdot \frac{\mathrm{RSI}_t - \mathrm{RSImin}_t}{\mathrm{RSImax}_t - \mathrm{RSImin}_t}, \\ \%\mathrm{D}_t &= \mathrm{SMA}(\%\mathrm{K}, m)_t,\end{split}\]

where \(\mathrm{RSI}\) is the rsi() over window_rsi, \(\mathrm{RSImin}_t\) and \(\mathrm{RSImax}_t\) are its lowest and highest values over the window_k bars ending at \(t\), and \(m\) is window_d.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window_rsi – Number of observations in the underlying rsi() (canonically 14). Must be >= 1.

  • window_k – Number of observations in the %K look-back range over the RSI (canonically 14). Must be >= 1.

  • window_d – Number of observations in the %D moving average of %K (canonically 3). Must be >= 1.

Returns:

  • k — the raw %K line, 100 * (rsi - RSImin) / (RSImax - RSImin).

  • d — the %D signal line, the sma() of %K over window_d.

Read one line with .struct.field("k") (etc.) or split both into columns with .struct.unnest(). The warm-up stacks the rsi() warm-up (window_rsi rows), the window_k - 1 range look-back, and the window_d - 1 of %D.

Return type:

A struct column (one struct per row, the same length as the input) with two Float64 fields

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_rsi < 1, window_k < 1, or window_d < 1.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Both lines lie in [0, 100]. Because the underlying rsi() is already scale-invariant, so is this; there is no homogeneity to test.

Composition:

Built from rsi() (whose recursive Wilder seeding it inherits — see that function’s Seeding note), then the %K range ratio, then the sma() of %K, so every stage’s warm-up and null / NaN handling stacks.

Edge-case behavior:

  • Null — a null reaching any stage yields null on the dependent field at that row.

  • NaN — a NaN propagates, yielding NaN.

  • Flat RSI — when the RSI does not move over the look-back (highest equals lowest, e.g. a sustained trend pinning the RSI) the denominator is zero, so k follows IEEE-754: 0 / 0 is NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the RSI recursion nor any window spans series boundaries, e.g. rsi_stochastic(pl.col("close")).over("ticker").

See also

References

  • Chande, Tushar S., and Kroll, Stanley (1994). The New Technical Trader. Wiley.

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import rsi_stochastic
>>>
>>> frame = pl.DataFrame({"close": [50.0, 51.0, 50.5, 52.0, 51.5, 53.0, 52.0, 54.0, 53.5, 55.0]})
>>> oscillator = rsi_stochastic(pl.col("close"), window_rsi=3, window_k=3, window_d=2)
>>> frame.select(oscillator.struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, None, None, None, 94.7368, 0.0, 81.5861, 44.2237, 100.0]
>>> frame.select(oscillator.struct.field("d").round(4).alias("d"))["d"].to_list()
[None, None, None, None, None, None, 47.3684, 40.793, 62.9049, 72.1118]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 8 + ["B"] * 8,
...         "close": [50.0, 51.0, 50.5, 52.0, 51.5, 53.0, 52.0, 54.0,
...                   40.0, 41.0, 40.5, 42.0, 41.5, 43.0, 42.0, 44.0],
...     }
... )
>>> expr = rsi_stochastic(pl.col("close"), window_rsi=3, window_k=3, window_d=2)
>>> frame.with_columns(expr.over("ticker").struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, None, None, None, 94.7368, 0.0, 81.5861, None, None, None, None, None, 94.7368, 0.0, 81.5861]

A null (which nulls the dependent %K) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {"close": [50.0, 51.0, 50.5, 52.0, 51.5, 53.0, 52.0, 54.0, None, 55.0, float("nan"), 56.0, 57.0, 58.0]}
... )
>>> expr = rsi_stochastic(pl.col("close"), window_rsi=3, window_k=3, window_d=2)
>>> frame.select(expr.struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, None, None, None, 94.7368, 0.0, 81.5861, None, None, None, None, nan, nan]
pomata.indicators.sine_wave(
expr: Expr,
) Expr[source]

Hilbert Transform Sine Wave.

Ehlers’ sine-wave indicator: the sine of the dominant-cycle phase, and a lead sine advanced by 45°. Their crossings mark cycle turning points and lead the price in a cycle (and diverge in a trend).

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

A struct pl.Expr with Float64 fields sine / lead_sine in [-1, 1], the same length as expr. The first 63 rows are null (warm-up). Read one line with .struct.field("sine") or split both with .struct.unnest().

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. sine_wave(pl.col("close")).over("ticker").

The underlying phase branch guards an exact zero of the cosine projection (saturating to ±90 as that projection vanishes), rather than the inventor’s fixed 0.001 absolute cutoff; this is the continuous limit and keeps the sine invariant under a lossless rescale of the price, whereas a fixed threshold would be scale-dependent.

See also

References

  • Ehlers, John F. (2001). Rocket Science for Traders.

Examples

The sine and lead-sine of a clean period-20 cycle, at the last bar (both in [-1, 1]):

>>> import math
>>> import polars as pl
>>> from pomata.indicators import sine_wave
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> waves = frame.select(sine_wave(pl.col("close")).alias("s")).unnest("s")
>>> round(waves["sine"][-1], 2), round(waves["lead_sine"][-1], 2)
(-0.31, 0.46)
pomata.indicators.sma(
expr: Expr,
window: int,
) Expr[source]

Simple Moving Average (SMA).

The unweighted arithmetic mean of the last window observations, assigning equal weight to every point in the window:

\[\mathrm{SMA}_t = \frac{1}{n} \sum_{i=0}^{n-1} x_{t-i}, \qquad n = \text{window}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

the value is defined only once window observations have been seen.

Return type:

The SMA for each row, the same length as expr. The first window - 1 values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a window that contains a null yields null.

  • NaN — a window that contains a NaN yields NaN.

  • window == 1 — the one-point mean is the input itself, so the SMA reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. sma(pl.col("close"), 20).over("ticker").

See also

  • ema(): The exponentially-weighted analogue, more responsive to recent values.

  • wma(): The linearly-weighted analogue.

  • trima(): The triangular average, a simple average of a simple average.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import sma
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0]})
>>> frame.select(sma(pl.col("close"), window=3).round(4).alias("sma_3"))["sma_3"].to_list()
[None, None, 4.0, 6.0, 8.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(sma(pl.col("close"), 2).over("ticker").round(4).alias("sma"))["sma"].to_list()
[None, 10.5, 11.5, 11.5, 12.0, None, 21.0, 21.5, 22.0, 22.5]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(sma(pl.col("close"), 2).round(4).alias("sma"))["sma"].to_list()
[None, 10.5, 11.5, 12.5, None, None, nan, nan, 17.5, 18.5]
pomata.indicators.standard_deviation_ewma(
expr: Expr,
window: int,
*,
adjust: bool = False,
bias: bool = True,
) Expr[source]

Exponentially-Weighted Standard Deviation over a window.

The square root of the exponentially-weighted variance_ewma() — the spread of the input around its exponentially-weighted mean, in the same units as the input, with recent observations weighted more heavily (smoothing factor \(\alpha = 2 / (\text{window} + 1)\), the same span convention as ema()):

\[\sigma^{\mathrm{ewm}}_t = \sqrt{\mathrm{Var}^{\mathrm{ewm}}_t}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Span of the exponential weighting, mapped to alpha = 2 / (window + 1). Must be >= 2.

  • adjust – When False (default) use the recursive form; when True use the finite-window bias-corrected weighting (the same flag as ema()).

  • bias – When True (default) the population standard deviation; when False the unbiased sample one. True mirrors the ddof = 0 default of standard_deviation_rolling(). See variance_ewma().

Returns:

The exponentially-weighted standard deviation for each row, the same length as the input. The first window - 1 values are null (warm-up): the recursion emits only once window non-null observations have been seen.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in expr (a spread in the input’s own units).

Edge-case behavior:

  • Null — a leading null run stays null and does not consume warm-up; an interior null yields null at that row while the weights decay across the gap (ignore_nulls=False).

  • NaN — a NaN poisons the recursion and yields NaN for every subsequent non-null row.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series, e.g. standard_deviation_ewma(pl.col("close"), 20).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import standard_deviation_ewma
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(standard_deviation_ewma(pl.col("x"), 3).round(4).alias("std"))["std"].to_list()
[None, None, 1.299, 0.927, 1.2484, 0.8833, 1.1923]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = standard_deviation_ewma(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("std"))["std"].to_list()
[None, None, 1.299, 0.927, None, None, 0.7071, 1.5811]

A null (decays across the gap) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0, 17.0]})
>>> frame.select(standard_deviation_ewma(pl.col("x"), 3).round(4).alias("std"))["std"].to_list()
[None, None, 1.299, None, 1.299, nan, nan, nan]
pomata.indicators.standard_deviation_rolling(
expr: Expr,
window: int,
*,
ddof: int = 0,
) Expr[source]

Rolling Standard Deviation over a window.

The square root of the rolling variance_rolling() — a measure of how widely the values in each window spread around their mean, in the same units as the input:

\[\sigma_t = \sqrt{\mathrm{Var}_t} = \sqrt{\frac{1}{n - \mathrm{ddof}} \sum_{i=t-n+1}^{t} \bigl(x_i - \bar{x}_t\bigr)^2}, \qquad n = \text{window}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

  • ddof – Delta degrees of freedom — the divisor is window - ddof. 0 (default) is the population standard deviation; 1 is the sample standard deviation. Must be < window. See variance_rolling().

Returns:

The rolling standard deviation for each row, the same length as the input. The first window - 1 values are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, or if ddof >= window (the divisor window - ddof would be non-positive).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Degrees of freedom:

ddof carries the same meaning as in variance_rolling() (population vs sample); the standard deviation is just its square root. It must be strictly below window so the divisor stays positive.

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • window == 1 — a single value has no spread, so the result is 0 with the default ddof = 0.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. standard_deviation_rolling(pl.col("close"), 20).over("ticker").

See also

  • variance_rolling(): The square of this, of which it is the root.

  • sma(): The moving mean the deviations are measured from.

  • bollinger_bands(): Volatility bands placed a multiple of this standard deviation around the mean.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import standard_deviation_rolling
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 12.0, 11.0, 13.0]})
>>> frame.select(standard_deviation_rolling(pl.col("x"), 3).round(4).alias("std"))["std"].to_list()
[None, None, 0.8165, 0.4714, 0.8165]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame({"ticker": ["A"] * 3 + ["B"] * 3, "x": [10.0, 11.0, 12.0, 20.0, 22.0, 21.0]})
>>> expr = standard_deviation_rolling(pl.col("x"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("std"))["std"].to_list()
[None, 0.5, 0.5, None, 1.0, 0.5]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, None, 12.0, float("nan"), 14.0, 15.0]})
>>> frame.select(standard_deviation_rolling(pl.col("x"), 2).round(4).alias("std"))["std"].to_list()
[None, None, None, nan, nan, 0.5]
pomata.indicators.stochastic_fast(
high: Expr,
low: Expr,
close: Expr,
*,
window_k: int,
window_d: int,
) Expr[source]

Fast Stochastic Oscillator (%K and %D).

Introduced by George Lane in the late 1950s: a bounded momentum oscillator that locates the close within its recent high-low range. The raw line %K is the close as a percentage of the window_k range, and the signal line %D is the sma() of %K:

\[\begin{split}\%\mathrm{K}_t &= 100 \cdot \frac{\mathrm{close}_t - \mathrm{LL}_t}{\mathrm{HH}_t - \mathrm{LL}_t}, \\ \%\mathrm{D}_t &= \mathrm{SMA}(\%\mathrm{K}, m)_t,\end{split}\]

where \(\mathrm{LL}_t\) and \(\mathrm{HH}_t\) are the lowest low and highest high over the window_k bars ending at \(t\), and \(m\) is window_d.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window_k – Number of observations in the %K look-back range (canonically 14). Must be >= 1.

  • window_d – Number of observations in the %D moving average of %K (canonically 3). Must be >= 1.

Returns:

  • k — the raw %K line, 100 * (close - LL) / (HH - LL).

  • d — the %D signal line, the sma() of %K over window_d.

Read one line with .struct.field("k") (etc.) or split both into columns with .struct.unnest(). The first window_k - 1 rows are null on k (the look-back warm-up), and a further window_d - 1 on d.

Return type:

A struct column (one struct per row, the same length as the inputs) with two Float64 fields

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Both lines are scale-invariant under a positive common rescaling of high, low, and close (a ratio of price ranges), and lie in [0, 100] for well-formed bars (low <= close <= high).

Composition:

%D is the sma() of %K, so it inherits that warm-up and the null / NaN handling on top of %K’s own.

Edge-case behavior:

  • Null — a null anywhere in the %K window (a high / low over the look-back, or the current close) yields null on k at that row; a null reaching the %D average yields null on d.

  • NaN — a NaN in the window propagates, yielding NaN.

  • Flat range — when the highest high equals the lowest low (no range over the look-back) the denominator is zero, so k follows IEEE-754: 0 / 0 is NaN when the close sits on that flat level, and +/-inf when it lies outside it (a gap or malformed bar).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so no window spans series boundaries, e.g. stochastic_fast(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import stochastic_fast
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0],
...     }
... )
>>> oscillator = stochastic_fast(pl.col("high"), pl.col("low"), pl.col("close"), window_k=3, window_d=2)
>>> frame.select(oscillator.struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, 83.3333, 50.0, 80.0, 60.0, 80.0, 60.0]
>>> frame.select(oscillator.struct.field("d").round(4).alias("d"))["d"].to_list()
[None, None, None, 66.6667, 65.0, 70.0, 70.0, 70.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [10.0, 11.0, 12.0, 11.5, 20.0, 21.0, 22.0, 21.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 19.0, 20.0, 21.0, 20.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 19.5, 20.5, 21.5, 21.0],
...     }
... )
>>> expr = stochastic_fast(pl.col("high"), pl.col("low"), pl.col("close"), window_k=2, window_d=2)
>>> frame.with_columns(expr.over("ticker").struct.field("k").round(4).alias("k"))["k"].to_list()
[None, 75.0, 75.0, 33.3333, None, 75.0, 75.0, 33.3333]

A null (yields null on k at that row) and a NaN (which propagates) in close surface on the %K line:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, None, 12.5, float("nan"), 13.5, 13.0],
...     }
... )
>>> oscillator = stochastic_fast(pl.col("high"), pl.col("low"), pl.col("close"), window_k=3, window_d=2)
>>> frame.select(oscillator.struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, 83.3333, None, 80.0, nan, 80.0, 60.0]
pomata.indicators.stochastic_slow(
high: Expr,
low: Expr,
close: Expr,
*,
window_k: int,
window_slowing: int,
window_d: int,
) Expr[source]

Slow Stochastic Oscillator (%K and %D).

The smoothed form of stochastic_fast(): the raw %K is averaged once to give the slow %K (which damps the noise of the fast line), then averaged again to give the slow %D signal:

\[\begin{split}\mathrm{raw}_t &= 100 \cdot \frac{\mathrm{close}_t - \mathrm{LL}_t}{\mathrm{HH}_t - \mathrm{LL}_t}, \\ \%\mathrm{K}_t &= \mathrm{SMA}(\mathrm{raw}, p)_t, \\ \%\mathrm{D}_t &= \mathrm{SMA}(\%\mathrm{K}, m)_t,\end{split}\]

where \(\mathrm{LL}_t\) and \(\mathrm{HH}_t\) are the lowest low and highest high over the window_k bars ending at \(t\), \(p\) is window_slowing, and \(m\) is window_d.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window_k – Number of observations in the raw %K look-back range (canonically 14). Must be >= 1.

  • window_slowing – Number of observations in the slowing average that turns the raw %K into the slow %K (canonically 3). Must be >= 1.

  • window_d – Number of observations in the %D moving average of the slow %K (canonically 3). Must be >= 1.

Returns:

  • k — the slow %K line, the sma() of the raw %K over window_slowing.

  • d — the %D signal line, the sma() of the slow %K over window_d.

Read one line with .struct.field("k") (etc.) or split both into columns with .struct.unnest(). The first window_k + window_slowing - 2 rows are null on k, and a further window_d - 1 on d.

Return type:

A struct column (one struct per row, the same length as the inputs) with two Float64 fields

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_k < 1, window_slowing < 1, or window_d < 1.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Both lines are scale-invariant under a positive common rescaling of high, low, and close, and lie in [0, 100] for well-formed bars (low <= close <= high). The slow %K equals the fast %D of stochastic_fast() when window_slowing matches that call’s window_d.

Composition:

The slow %K is the sma() of the raw %K, and %D is the sma() of the slow %K, so each averaging inherits the warm-up and null / NaN handling on top of the raw %K’s own.

Edge-case behavior:

  • Null — a null anywhere in a window yields null on the dependent field at that row.

  • NaN — a NaN in a window propagates, yielding NaN.

  • Flat range — when the highest high equals the lowest low (no range over the look-back) the raw %K is 0 / 0 = NaN when the close sits on that flat level (+/-inf when it lies outside it), which then propagates through both averages.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so no window spans series boundaries, e.g. stochastic_slow(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import stochastic_slow
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0],
...     }
... )
>>> oscillator = stochastic_slow(
...     pl.col("high"), pl.col("low"), pl.col("close"), window_k=3, window_slowing=2, window_d=2
... )
>>> frame.select(oscillator.struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, None, 66.6667, 65.0, 70.0, 70.0, 70.0]
>>> frame.select(oscillator.struct.field("d").round(4).alias("d"))["d"].to_list()
[None, None, None, None, 65.8333, 67.5, 70.0, 70.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 20.0, 21.0, 22.0, 21.5, 23.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 19.0, 20.0, 21.0, 20.5, 22.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 19.5, 20.5, 21.5, 21.0, 22.5],
...     }
... )
>>> expr = stochastic_slow(
...     pl.col("high"), pl.col("low"), pl.col("close"), window_k=2, window_slowing=2, window_d=2
... )
>>> frame.with_columns(expr.over("ticker").struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, 75.0, 54.1667, 56.6667, None, None, 75.0, 54.1667, 56.6667]

A null (nulls every slow %K window it falls in) and a NaN (which propagates the same way) in close surface on the slow %K line:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5],
...         "close": [9.5, 10.5, 11.5, None, 12.5, float("nan"), 13.5, 13.0],
...     }
... )
>>> oscillator = stochastic_slow(
...     pl.col("high"), pl.col("low"), pl.col("close"), window_k=2, window_slowing=2, window_d=2
... )
>>> frame.select(oscillator.struct.field("k").round(4).alias("k"))["k"].to_list()
[None, None, 75.0, None, None, nan, nan, 56.6667]
pomata.indicators.supertrend(
high: Expr,
low: Expr,
close: Expr,
window: int,
*,
multiplier: float = 3.0,
) Expr[source]

SuperTrend.

Introduced by Olivier Seban: an ATR-scaled trailing band that follows price on one side and flips to the other when a close crosses it, so the line reads as a dynamic stop and the sign of its move as the prevailing trend. Each bar sets a basic band around the median price by a multiple of the Average True Range,

\[\mathrm{upper}_t = \frac{\mathrm{high}_t + \mathrm{low}_t}{2} + m \, \mathrm{ATR}_t, \qquad \mathrm{lower}_t = \frac{\mathrm{high}_t + \mathrm{low}_t}{2} - m \, \mathrm{ATR}_t,\]

then ratchets a final band from it: the final upper only falls while the prior close stays below it, the final lower only rises while the prior close stays above it. The line tracks the final lower in an up-trend and the final upper in a down-trend, flipping – and switching direction between +1 and -1 – when a close strictly crosses the active band.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the ATR moving window (canonically 10). Must be >= 1.

  • multiplier – Band half-width as a multiple of the ATR (canonically 3.0). Must be a finite number > 0 (a non-positive multiplier would collapse or invert the bands).

Returns:

A struct pl.Expr with fields line (the trailing stop) and direction (+1.0 in an up-trend, the line below price; -1.0 in a down-trend, the line above price), the same length as the inputs. The first window - 1 rows are null (the ATR’s warm-up). Read a field with .struct.field("line") or split both with .struct.unnest().

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1 or multiplier is not a finite number > 0.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

The line is homogeneous of degree 1 under a positive common rescaling of high / low / close (a price level), while direction is scale-invariant (the crossings compare like-scaled quantities).

Tie-break and seeding:

A flip needs a strict cross, so a close exactly on the active band holds the current trend; over a flat series the bands collapse onto the midpoint and the line tracks it. The trend seeds short when the first valid close is at or below the lower band, else long – chosen so the line sits on the correct side of price from row one.

Edge-case behavior:

  • Null — a null high / low / close yields null on both fields and is skipped at the row; the running state, and the last valid close the ratchet reads, bridge the gap.

  • NaN — a NaN high / low / close yields NaN on both fields. For window >= 2 it latches: it poisons the ATR recurrence, so the band stays NaN thereafter (only a null bridges). At window == 1 the ATR has no memory term, so the NaN self-heals once the true range is finite again.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recurrence does not span series boundaries, e.g. supertrend(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").

See also

  • parabolic_sar(): The other trailing-stop trend tool, accelerating rather than ATR-scaled.

  • atr(): The volatility average that sets the band half-width.

  • keltner_channels(): The other ATR-scaled band envelope, centered on an EMA rather than ratcheting.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import supertrend
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 12.0, 14.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 11.0, 13.0],
...         "close": [9.5, 10.8, 11.8, 10.2, 12.8, 11.2, 13.8],
...     }
... )
>>> expr = supertrend(pl.col("high"), pl.col("low"), pl.col("close"), 2, multiplier=2.0)
>>> out = frame.select(expr.alias("st")).unnest("st")
>>> out.select(pl.col("line").round(4))["line"].to_list()
[None, 8.0, 9.05, 9.05, 9.05, 9.05, 9.05]
>>> out["direction"].to_list()
[None, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]

On a multi-ticker panel, wrap the call in .over so each ticker’s ratchet warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 21.0, 22.0, 21.0, 23.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 19.0, 20.0, 21.0, 20.0, 22.0],
...         "close": [9.5, 10.8, 11.8, 10.2, 12.8, 19.5, 20.8, 21.8, 20.2, 22.8],
...     }
... )
>>> expr = supertrend(pl.col("high"), pl.col("low"), pl.col("close"), 2, multiplier=2.0)
>>> frame.with_columns(expr.over("ticker").struct.field("line").round(4).alias("l"))["l"].to_list()
[None, 8.0, 9.05, 9.05, 9.05, None, 18.0, 19.05, 19.05, 19.05]

A null in close is skipped and bridged by the running state, while a NaN poisons the ATR recursion and latches NaN thereafter:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.0, 13.0, 12.0, 14.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 12.0, 11.0, 13.0],
...         "close": [9.5, 10.8, 11.8, None, 12.8, float("nan"), 13.8],
...     }
... )
>>> expr = supertrend(pl.col("high"), pl.col("low"), pl.col("close"), 2, multiplier=2.0)
>>> frame.select(expr.struct.field("line").round(4).alias("l"))["l"].to_list()
[None, 8.0, 9.05, None, 9.9875, nan, nan]
pomata.indicators.t3(
expr: Expr,
window: int,
*,
volume_factor: float = 0.7,
adjust: bool = False,
) Expr[source]

Tillson T3 Moving Average (T3), also known as the Tillson Moving Average.

A heavily smoothed yet low-lag moving average built by applying a Generalized DEMA (GD) three times. With v = volume_factor and EMA the recursive exponential moving average of length window:

\[\mathrm{GD}(x) = (1 + v)\,\mathrm{EMA}(x) - v\,\mathrm{EMA}(\mathrm{EMA}(x)), \qquad \mathrm{T3} = \mathrm{GD}(\mathrm{GD}(\mathrm{GD}(x))).\]

Expanded over the six chained EMAs \(e_1 = \mathrm{EMA}(x),\, e_2 = \mathrm{EMA}(e_1), \dots,\, e_6 = \mathrm{EMA}(e_5)\), this equals — for the ideal EMA operator — the closed coefficient form computed here (the two agree exactly once warmed up; during the warm-up region the masked-EMA composition and the coefficient form differ in the transient and converge only asymptotically):

\[\mathrm{T3} = c_1 e_6 + c_2 e_5 + c_3 e_4 + c_4 e_3,\]
\[c_1 = -v^3,\quad c_2 = 3v^2 + 3v^3,\quad c_3 = -6v^2 - 3v - 3v^3,\quad c_4 = 1 + 3v + 3v^2 + v^3.\]

The coefficients sum to exactly 1 (\(c_1 + c_2 + c_3 + c_4 = 1\)), so T3 of a constant series is that constant.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

  • volume_factor – The Tillson volume factor v controlling smoothing versus responsiveness; the canonical default is 0.7. Must be a finite number.

  • adjust – Whether to use the assumption-light adjusted EMA weights (True), which differ from the recursive form at every emitted row — the gap largest near the start and decaying as history grows — or the recursive Technical-Analysis EMA seeded with the SMA of the first window observations (False, the default).

Returns:

The T3 for each row, the same length as expr. Because the value is composed from six chained ema() passes of the same window (each carrying a window - 1 warm-up), the first 6 * (window - 1) values are null (warm-up), clamped to the series length.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, or if volume_factor is not a finite number.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Seeding:

The recursive EMA is seeded with the SMA of the first window observations.

Edge-case behavior:

  • Null — a leading null run stays null until the first non-null seed; an interior null yields null at that position while the decay continues across the gap.

  • NaN — a NaN contaminates the recursive state and yields NaN for every subsequent non-null position.

  • window == 1 — each EMA reduces to the identity, so the expression reproduces the input up to a floating-point rounding (unlike dema / tema, the coefficient form does not cancel exactly).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion does not span series boundaries, e.g. t3(pl.col("close"), 5).over("ticker").

See also

  • dema(): The double-EMA lag-reduced average.

  • tema(): The triple-EMA lag-reduced average.

  • ema(): The exponential pass T3 chains six times.

References

  • Tillson, Tim (1998). “Better Moving Averages”. Technical Analysis of Stocks & Commodities, 16(1).

Examples

>>> import polars as pl
>>> from pomata.indicators import t3
>>>
>>> frame = pl.DataFrame({"close": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]})
>>> frame.select(t3(pl.col("close"), window=2).round(4).alias("t3_2"))["t3_2"].to_list()
[None, None, None, None, None, None, 6.55, 7.55]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 8 + ["B"] * 8,
...         "close": [
...             10.0,
...             11.0,
...             12.0,
...             11.0,
...             13.0,
...             14.0,
...             13.0,
...             15.0,
...             20.0,
...             22.0,
...             21.0,
...             23.0,
...             22.0,
...             24.0,
...             25.0,
...             24.0,
...         ],
...     }
... )
>>> frame.with_columns(t3(pl.col("close"), 2).over("ticker").round(4).alias("t3"))["t3"].to_list()
[None, None, None, None, None, None, 13.3568, 14.2815, None, None, None, None, None, None, 24.4079, 24.3942]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "close": [
...             10.0,
...             11.0,
...             12.0,
...             13.0,
...             14.0,
...             15.0,
...             16.0,
...             17.0,
...             None,
...             19.0,
...             float("nan"),
...             21.0,
...             22.0,
...         ],
...     }
... )
>>> frame.select(t3(pl.col("close"), 2).round(4).alias("t3"))["t3"].to_list()
[None, None, None, None, None, None, 15.55, 16.55, None, 18.7118, nan, nan, nan]
pomata.indicators.tema(
expr: Expr,
window: int,
*,
adjust: bool = False,
) Expr[source]

Triple Exponential Moving Average (TEMA), also known as the triple EMA.

A low-lag smoother (Mulloy, 1994) built from three nested exponential moving averages of the same window. With \(\mathrm{EMA}^{(1)} = \mathrm{EMA}(x)\), \(\mathrm{EMA}^{(2)} = \mathrm{EMA}(\mathrm{EMA}^{(1)})\) and \(\mathrm{EMA}^{(3)} = \mathrm{EMA}(\mathrm{EMA}^{(2)})\), the triple-EMA correction cancels the lag of the cascade:

\[\mathrm{TEMA}_t = 3\,\mathrm{EMA}^{(1)}_t - 3\,\mathrm{EMA}^{(2)}_t + \mathrm{EMA}^{(3)}_t.\]

Each ema() uses the recursive technical-analysis form with smoothing factor \(\alpha = 2 / (\text{window} + 1)\), seeded with the SMA of the first window observations.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Span of the exponential weighting, mapped to alpha = 2 / (window + 1). Must be >= 1.

  • adjust – Whether to use the bias-corrected expanding-weights EMA. False (the default) selects the recursive technical-analysis EMA.

Returns:

The TEMA for each row, the same length as expr. The first 3 * (window - 1) values are null (warm-up), clamped to the series length: the value is composed from three chained ema() passes of the same window (each carrying a window - 1 warm-up), so the warm-up is three times that of a plain EMA. Each EMA is seeded with the SMA of the first window observations.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a leading null run stays null until the first non-null seed; an interior null yields null at that position while the decay continues across the gap.

  • NaN — a NaN contaminates the recursive state and yields NaN for every subsequent non-null position.

  • window == 1 — each EMA reduces to the identity, so the expression reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so no EMA pass spans series boundaries, e.g. tema(pl.col("close"), 20).over("ticker").

See also

  • dema(): The double-EMA sibling.

  • t3(): The six-pass Tillson sibling.

  • ema(): The exponential pass this chains three times.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import tema
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0, 12.0]})
>>> frame.select(tema(pl.col("close"), window=2).round(4).alias("tema_2"))["tema_2"].to_list()
[None, None, None, 8.0, 10.0, 12.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(tema(pl.col("close"), 2).over("ticker").round(4).alias("tema"))["tema"].to_list()
[None, None, None, 11.2222, 12.9383, None, None, None, 22.7778, 22.0617]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(tema(pl.col("close"), 2).round(4).alias("tema"))["tema"].to_list()
[None, None, None, 13.0, None, 15.0029, nan, nan, nan, nan]
pomata.indicators.time_series_forecast(
expr: Expr,
window: int,
) Expr[source]

Time Series Forecast (the rolling least-squares line extrapolated one bar ahead).

The ordinary-least-squares line fitted to the last window observations, evaluated one bar beyond the window – a one-step-ahead projection of the fitted trend:

\[\mathrm{TSF}_t = \bar{x}_t + \mathrm{slope}_t \cdot \frac{n + 1}{2}, \qquad n = \text{window},\]

with \(\bar{x}_t\) the window mean and \(\mathrm{slope}_t\) the rolling linear_regression_slope(). It is exactly one slope step beyond linear_regression() (the line at the current bar).

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the regression window. Must be >= 2 (a line needs at least two points).

Returns:

The one-step-ahead forecast for each row, the same length as the input. The first window - 1 values are null (warm-up).

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is homogeneous of degree 1 in expr (a projected price scales with the price). For a perfectly linear input the forecast equals the next value of the line exactly.

Edge-case behavior:

  • Null — a window containing a null yields null.

  • NaN — a NaN inside the window propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. time_series_forecast(pl.col("close"), 14).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import time_series_forecast
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(time_series_forecast(pl.col("x"), 3).round(4).alias("tsf"))["tsf"].to_list()
[None, None, 14.3333, 13.0, 14.0, 14.0, 15.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = time_series_forecast(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("tsf"))["tsf"].to_list()
[None, None, 14.3333, 13.0, None, None, 22.0, 24.3333]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0]})
>>> frame.select(time_series_forecast(pl.col("x"), 3).round(4).alias("tsf"))["tsf"].to_list()
[None, None, 14.3333, None, None, None, nan]
pomata.indicators.trend_mode(
expr: Expr,
) Expr[source]

Hilbert Transform Trend vs Cycle Mode.

Ehlers’ market-mode flag: 1.0 when the market is trending, 0.0 when it is cycling. It combines the sine-wave crossings, the dominant-cycle phase rate, and the deviation of the smoothed price from the instantaneous trendline.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

The mode flag (1.0 trend / 0.0 cycle) for each row, the same length as expr. The first 63 rows are null (warm-up).

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – the fixed FIR smoothing and quadrature stages are computed independently, but the adaptive dominant-cycle period feeds back into its own measurement and the stages built on it, so the reference oracle replays Ehlers’ pipeline and confirms its internal consistency rather than independence; the independent witness is the set of frozen golden masters (and, for MAMA, TA-Lib parity). Where measurable the oracle agrees to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range, except on a flat or period-two (even-lag) series, where the Hilbert quadrature is a pure cancellation residual and the measurement is ill-conditioned (there is no cycle to measure). CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null / NaN — a null or NaN price latches null for every row from there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series and never spans series boundaries, e.g. trend_mode(pl.col("close")).over("ticker").

The underlying phase branch guards an exact zero of the cosine projection (saturating to ±90 as that projection vanishes), rather than the inventor’s fixed 0.001 absolute cutoff; this is the continuous limit and keeps the phase invariant under a lossless rescale of the price, whereas a fixed threshold would be scale-dependent.

See also

References

  • Ehlers, John F. (2001). Rocket Science for Traders.

Examples

A pure cycle is never in a trend, so the mode flag stays 0 over a clean period-20 sine:

>>> import math
>>> import polars as pl
>>> from pomata.indicators import trend_mode
>>>
>>> frame = pl.select(close=100.0 + (2 * math.pi * pl.int_range(200) / 20).sin())
>>> frame.select(trend_mode(pl.col("close")).alias("t"))["t"].drop_nulls().unique().to_list()
[0.0]
pomata.indicators.trima(
expr: Expr,
window: int,
) Expr[source]

Triangular Moving Average — a double-smoothed mean that weights the middle of the window most.

A moving average run twice, so the weights form a triangle (or trapezoid) peaking at the center of the window rather than being uniform. It is equivalent to an sma() of an sma(), with the two sub-windows chosen so the combined span is window:

\[\mathrm{TRIMA}_t = \mathrm{SMA}\bigl(\mathrm{SMA}(x, m_1), m_2\bigr)_t, \qquad m_1 + m_2 = n + 1,\]

where for an odd window m_1 = m_2 = (n + 1) / 2 and for an even window m_1 = n / 2, m_2 = n / 2 + 1 (the order does not matter — the double average is commutative).

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The triangular moving average for each row, the same length as the input. The first window - 1 values are null (warm-up), matching the uniform warm-up of the moving-average family.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — built from two sma() passes, so a null propagates exactly as the SMA’s min_samples=window contract dictates: any window short of full non-null values is null.

  • NaN — a NaN propagates through both passes, contaminating every window that spans it.

  • window == 1 — both sub-windows are 1, so the TRIMA reduces to the identity and reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither pass spans series boundaries, e.g. trima(pl.col("close"), 20).over("ticker").

See also

  • sma(): The single-pass simple moving average this double-smooths.

  • wma(): A single-pass linearly-weighted average, also tilting the window’s weights off uniform.

  • hma(): Another average built by composing simpler moving averages.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import trima
>>>
>>> frame = pl.DataFrame({"x": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]})
>>> frame.select(trima(pl.col("x"), 4).round(4).alias("trima"))["trima"].to_list()
[None, None, None, 2.5, 3.5, 4.5]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame({"ticker": ["A"] * 3 + ["B"] * 3, "x": [1.0, 2.0, 3.0, 10.0, 20.0, 30.0]})
>>> expr = trima(pl.col("x"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("trima"))["trima"].to_list()
[None, 1.5, 2.5, None, 15.0, 25.0]

A null (which both passes propagate) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame({"x": [1.0, None, 3.0, float("nan"), 5.0, 6.0]})
>>> frame.select(trima(pl.col("x"), 3).round(4).alias("trima"))["trima"].to_list()
[None, None, None, None, nan, nan]
pomata.indicators.trix(
expr: Expr,
window: int,
) Expr[source]

TRIX — the one-period rate of change of a triple-smoothed exponential moving average.

A momentum oscillator that triple-smooths the close with chained ema() passes to strip out cycles shorter than window, then takes the one-period percentage roc() of that smoothed line, so it oscillates around zero and filters minor noise:

\[\mathrm{TRIX}_t = 100 \cdot \frac{\mathrm{TE}_t - \mathrm{TE}_{t-1}}{\mathrm{TE}_{t-1}}, \qquad \mathrm{TE} = \mathrm{EMA}\bigl(\mathrm{EMA}(\mathrm{EMA}(\mathrm{close}, n), n), n\bigr).\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Span of each of the three EMA passes. Must be >= 1.

Returns:

The oscillator (in percent) for each row, the same length as the input. The first 3 * (window - 1) + 1 rows are null (warm-up): three chained EMAs plus the one-period rate of change.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a null contaminates the recursive EMA chain and yields null for subsequent rows.

  • NaN — a NaN propagates through the chain, yielding NaN.

  • window == 1 — each EMA pass is the identity, so TRIX is the one-period rate of change of expr.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the chain re-seeds per series, e.g. trix(pl.col("close"), 15).over("ticker").

See also

  • ema(): The exponential moving average chained three times.

  • roc(): The one-period rate of change applied to the smoothed line.

  • tema(): Another triple-EMA construction, blending the three passes differently.

References

Examples

Basic usage on a single price series:

>>> import polars as pl
>>> from pomata.indicators import trix
>>>
>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0]})
>>> frame.select(trix(pl.col("close"), 2).round(4).alias("trix"))["trix"].to_list()
[None, None, None, None, 5.4718, 7.4466, 2.989, 5.4253]

On a multi-ticker panel, wrap the call in .over so each ticker’s EMA chain warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "close": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 20.0, 22.0, 24.0, 26.0, 28.0, 30.0],
...     }
... )
>>> expr = trix(pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("trix"))["trix"].to_list()
[None, None, None, None, 8.6957, 8.0, None, None, None, None, 8.6957, 8.0]

A null (which the EMA chain latches on) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, None, 14.0, float("nan"), 16.0, 17.0]})
>>> frame.select(trix(pl.col("close"), 2).round(4).alias("trix"))["trix"].to_list()
[None, None, None, None, None, nan, nan, nan]
pomata.indicators.true_range(
high: Expr,
low: Expr,
close: Expr,
) Expr[source]

True Range (TR), also known as Wilder’s True Range.

The single-bar volatility primitive J. Welles Wilder introduced as the building block of the Average True Range and the Directional Movement system. It generalises the bar’s high-low spread to account for gaps relative to the prior close, taking the largest of three distances:

\[\mathrm{TR}_t = \max\!\Bigl( h_t - l_t,\; \lvert h_t - c_{t-1} \rvert,\; \lvert l_t - c_{t-1} \rvert \Bigr),\]

where \(h\), \(l\), \(c\) are high, low, close and \(c_{t-1}\) is the previous close. The first row has no previous close, so the two gap terms vanish and \(\mathrm{TR}_0 = h_0 - l_0\) (Wilder’s original definition). TR is a base building block: it composes nothing and is itself the input to atr() and the volatility-normalized directional indicators.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")); the previous close supplies the two gap terms.

Returns:

every row is defined from row 0, which falls back to high - low because no previous close exists. On well-formed OHLC data (high >= low) every value is non-negative.

Return type:

The True Range for each row, the same length as the inputs. There is no window and no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high, low, and close are taken as the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null — null handling follows pl.max_horizontal, which skips null candidates rather than propagating them: a null in high or low (or a null previous close) simply drops that candidate, so the row still resolves from whichever distances remain. The result is null only when all three candidates are null (high and low both null at the row, and no usable previous close).

  • NaN — a NaN is not skipped: it dominates the maximum, so any row whose surviving candidates include a NaN yields NaN (a NaN close therefore contaminates the two gap terms of the next row only, not the whole series).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the previous-close shift never reaches across series boundaries, e.g. true_range(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker") — without it the first bar of one series would borrow the last close of the previous series.

See also

  • atr(): The Wilder-smoothed average of this per-bar range.

  • atr_normalized(): That average expressed as a percent of the current close.

  • vortex(): A directional indicator that normalizes its movement by this range.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import true_range
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 12.0, 11.5, 13.0, 12.5],
...         "low": [9.0, 10.5, 10.0, 11.0, 11.5],
...         "close": [9.5, 11.0, 10.5, 12.5, 12.0],
...     }
... )
>>> frame.select(true_range(pl.col("high"), pl.col("low"), pl.col("close")).round(4).alias("true_range"))[
...     "true_range"
... ].to_list()
[1.0, 2.5, 1.5, 2.5, 1.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [12.0, 13.0, 12.5, 14.0, 22.0, 24.0, 23.0, 25.0],
...         "low": [10.0, 11.0, 11.0, 12.0, 20.0, 21.0, 21.0, 23.0],
...         "close": [11.0, 12.5, 11.5, 13.5, 21.5, 21.5, 22.5, 24.0],
...     }
... )
>>> expr = true_range(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("true_range"))["true_range"].to_list()
[2.0, 2.0, 1.5, 2.5, 2.0, 3.0, 2.0, 2.5]

A null close (skipped, so the next bar falls back to high - low) then a NaN close (which contaminates only the following bar’s gap terms) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0],
...         "low": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
...         "close": [11.5, 12.5, None, 14.5, 15.5, float("nan"), 17.5, 18.0],
...     }
... )
>>> expr = true_range(pl.col("high"), pl.col("low"), pl.col("close")).round(4)
>>> frame.select(expr.alias("true_range"))["true_range"].to_list()
[2.0, 2.0, 2.0, 2.0, 2.0, 2.0, nan, 2.0]
pomata.indicators.ultimate_oscillator(
high: Expr,
low: Expr,
close: Expr,
*,
window_short: int,
window_medium: int,
window_long: int,
) Expr[source]

Ultimate Oscillator.

Introduced by Larry Williams (1976): a momentum oscillator that blends buying pressure over three time frames to damp the false divergences a single-period oscillator throws off. Each bar’s buying pressure is the close above its true low, normalized by the true range; the three period sums are averaged with weights 4 : 2 : 1 (the shortest matters most) and scaled to [0, 100]:

\[\begin{split}\mathrm{BP}_t &= \mathrm{close}_t - \min(\mathrm{low}_t, \mathrm{close}_{t-1}), \\ \mathrm{TR}_t &= \max(\mathrm{high}_t, \mathrm{close}_{t-1}) - \min(\mathrm{low}_t, \mathrm{close}_{t-1}), \\ \mathrm{avg}_n &= \frac{\sum_{i} \mathrm{BP}_{t-i}}{\sum_{i} \mathrm{TR}_{t-i}} \quad (i = 0 \dots n - 1), \\ \mathrm{UO}_t &= 100 \cdot \frac{4\,\mathrm{avg}_{n_s} + 2\,\mathrm{avg}_{n_m} + \mathrm{avg}_{n_l}}{7},\end{split}\]

where \(n_s\), \(n_m\), \(n_l\) are window_short / window_medium / window_long.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window_short – Number of observations in the short averaging window (weight 4, canonically 7). Must be >= 1.

  • window_medium – Number of observations in the medium averaging window (weight 2, canonically 14). Must be >= 1.

  • window_long – Number of observations in the long averaging window (weight 1, canonically 28). Must be >= 1.

Returns:

The Ultimate Oscillator for each row, the same length as the inputs, in [0, 100] for well-formed bars. The first max(window_short, window_medium, window_long) - 1 values are null (warm-up). The bound is not guaranteed for an incoherent bar: a missing or NaN low on a down bar (the documented fallback below) substitutes the previous close into the true low, which can make the buying pressure negative and push the value outside [0, 100].

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window_short < 1, window_medium < 1, window_long < 1, or the periods are not ordered window_short <= window_medium <= window_long (the three windows must run shortest to longest).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

It is scale-invariant under a positive common rescaling of high, low, and close (each averaged term is a ratio of price ranges).

Edge-case behavior:

  • First bar — row 0 has no previous close, so the true low / high fall back to that bar’s own low / high.

  • Null — a null in a single high / low / close drops only the terms that reference it (the true low / high follow pl.min_horizontal / pl.max_horizontal, which skip nulls); a null reaching a period sum yields null for the rows whose window touches it.

  • NaN — the per-field behavior is asymmetric. A NaN in high or close propagates (pl.max_horizontal treats it as the largest value, and a corrupt close poisons the next bar’s true range), yielding NaN. A NaN in low on a bar with a finite previous close is instead treated as absent: pl.min_horizontal skips it and the true low falls back to the previous close, so the bar reports a finite value computed from the substituted close (only at row 0, where there is no previous close, does a NaN low propagate).

  • Flat window — the genuine 0 / 0 degenerate (an exactly-flat true range where a flat well-formed bar drags the buying pressure to zero too) is detected via the residual-free rolling maxima of the true range and the buying pressure and returns NaN; a finite buying pressure over an exactly-zero true range — the missing-low fallback — is left to IEEE-754 as ±inf. A near-flat range is not silenced: the [0, 100] bound is conditional on well-formed bars (above), so the value is reported rather than clipped, and past a sane dynamic range its precision degrades (see the precision note above).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so no window spans series boundaries, e.g. ultimate_oscillator(pl.col("high"), pl.col("low"), pl.col("close")).over("ticker").

See also

  • rsi(): The single-period momentum oscillator this generalises across three.

  • williams_r(): Another high-low-range momentum oscillator.

  • true_range(): The per-bar true range the buying pressure is normalized by.

References

Examples

Basic usage on high-low-close bars:

>>> import polars as pl
>>> from pomata.indicators import ultimate_oscillator
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0],
...     }
... )
>>> expr = ultimate_oscillator(
...     pl.col("high"), pl.col("low"), pl.col("close"), window_short=2, window_medium=3, window_long=4
... )
>>> frame.select(expr.round(4).alias("uo"))["uo"].to_list()
[None, None, None, 60.7143, 66.6667, 65.0433]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 20.0, 21.0, 22.0, 21.5, 23.0, 22.5],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 19.0, 20.0, 21.0, 20.5, 22.0, 21.5],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 19.5, 20.5, 21.5, 21.0, 22.5, 22.0],
...     }
... )
>>> expr = ultimate_oscillator(
...     pl.col("high"), pl.col("low"), pl.col("close"), window_short=2, window_medium=3, window_long=4
... )
>>> frame.with_columns(expr.over("ticker").round(4).alias("uo"))["uo"].to_list()
[None, None, None, 60.7143, 66.6667, 65.0433, None, None, None, 60.7143, 66.6667, 65.0433]

A null (which nulls the windows that cover it) and a NaN (which propagates, also poisoning the next bar’s true range) in close make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5, 15.0, 14.5, 16.0, 15.5, 17.0],
...         "low": [9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5, 15.0, 14.5, 16.0],
...         "close": [9.5, 10.5, 11.5, 11.0, 12.5, 12.0, None, 13.0, 12.5, 13.5, float("nan"), 14.0, 15.0],
...     }
... )
>>> expr = ultimate_oscillator(
...     pl.col("high"), pl.col("low"), pl.col("close"), window_short=2, window_medium=3, window_long=4
... )
>>> frame.select(expr.round(4).alias("uo"))["uo"].to_list()
[None, None, None, 60.7143, 66.6667, 65.0433, None, None, None, None, nan, nan, nan]
pomata.indicators.variance_ewma(
expr: Expr,
window: int,
*,
adjust: bool = False,
bias: bool = True,
) Expr[source]

Exponentially-Weighted Variance over a window.

The exponentially-weighted variance of the input around its exponentially-weighted mean — recent observations weighted more heavily, with the smoothing factor \(\alpha = 2 / (\text{window} + 1)\) (the same span convention as ema()). The exponential counterpart of variance_rolling():

\[\mathrm{Var}^{\mathrm{ewm}}_t = \frac{\sum_i w_i \,(x_{t-i} - \bar{x}_t)^2}{\sum_i w_i}, \qquad w_i = (1 - \alpha)^i,\]

with \(\bar{x}_t\) the exponentially-weighted mean (the weights decay by \(1 - \alpha\) per step). The displayed weights \(w_i = (1 - \alpha)^i\) are the adjust=True form; the default adjust=False instead uses the recursive weighting \(\alpha (1 - \alpha)^i\), with the oldest observation carrying \((1 - \alpha)^t\) — a different weighting that yields different numbers (the values in the Examples below are the default).

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Span of the exponential weighting, mapped to alpha = 2 / (window + 1). Must be >= 2.

  • adjust – When False (default) use the recursive form; when True use the finite-window bias-corrected weighting (the same flag as ema()).

  • bias – When True (default) the population variance (divides by the weight total); when False the unbiased sample variance (the reliability correction 1 - sum(w ** 2) / (sum w) ** 2). True mirrors the ddof = 0 default of variance_rolling().

Returns:

The exponentially-weighted variance for each row, the same length as the input. The first window - 1 values are null (warm-up): the recursion emits only once window non-null observations have been seen.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

window must be >= 2: a single observation yields a well-defined 0 under the default bias=True, but divides by zero under the unbiased bias=False correction, so a minimum of 2 is enforced uniformly across both paths. It is homogeneous of degree 2 in expr (a variance scales with the square of the input).

Edge-case behavior:

  • Null — a leading null run stays null and does not consume warm-up; an interior null yields null at that row while the weights decay across the gap (ignore_nulls=False).

  • NaN — a NaN poisons the recursion and yields NaN for every subsequent non-null row.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the recursion re-seeds per series, e.g. variance_ewma(pl.col("close"), 20).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import variance_ewma
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, 12.0, 14.0, 13.0, 15.0]})
>>> frame.select(variance_ewma(pl.col("x"), 3).round(4).alias("var"))["var"].to_list()
[None, None, 1.6875, 0.8594, 1.5586, 0.7803, 1.4216]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {"ticker": ["A"] * 4 + ["B"] * 4, "x": [10.0, 11.0, 13.0, 12.0, 20.0, 22.0, 21.0, 24.0]}
... )
>>> expr = variance_ewma(pl.col("x"), 3).over("ticker").round(4)
>>> frame.with_columns(expr.alias("var"))["var"].to_list()
[None, None, 1.6875, 0.8594, None, None, 0.5, 2.5]

A null (decays across the gap) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, 11.0, 13.0, None, 14.0, float("nan"), 16.0, 17.0]})
>>> frame.select(variance_ewma(pl.col("x"), 3).round(4).alias("var"))["var"].to_list()
[None, None, 1.6875, None, 1.6875, nan, nan, nan]
pomata.indicators.variance_rolling(
expr: Expr,
window: int,
*,
ddof: int = 0,
) Expr[source]

Rolling Variance over a window.

The mean squared deviation of the values in each window from their window mean — a measure of dispersion in squared units of the input:

\[\mathrm{Var}_t = \frac{1}{n - \mathrm{ddof}} \sum_{i=t-n+1}^{t} \bigl(x_i - \bar{x}_t\bigr)^2, \qquad \bar{x}_t = \frac{1}{n} \sum_{i=t-n+1}^{t} x_i, \qquad n = \text{window}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

  • ddof – Delta degrees of freedom — the divisor is window - ddof. 0 (default) divides by window (the population variance); 1 divides by window - 1 (the sample variance, the unbiased estimator used when the window is a sample of a larger population). Must be < window (the divisor window - ddof must be positive).

Returns:

The rolling variance for each row, the same length as the input. The first window - 1 values are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, or if ddof >= window (the divisor window - ddof would be non-positive).

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Degrees of freedom:

ddof selects the divisor window - ddof: ddof = 0 is the population variance (÷ window), the charting convention; ddof = 1 is the sample variance (÷ window - 1), Bessel’s unbiased estimator. The two differ by the factor window / (window - ddof) — e.g. on [10, 11, 12] the population variance is 0.6667 and the sample variance is 1.0. ddof must be strictly below window so the divisor stays positive.

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • window == 1 — a single value has no spread, so the result is 0 with the default ddof = 0.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. variance_rolling(pl.col("close"), 20).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.indicators import variance_rolling
>>>
>>> frame = pl.DataFrame({"x": [10.0, 11.0, 12.0, 11.0, 13.0]})
>>> frame.select(variance_rolling(pl.col("x"), 3).round(4).alias("variance"))["variance"].to_list()
[None, None, 0.6667, 0.2222, 0.6667]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame({"ticker": ["A"] * 3 + ["B"] * 3, "x": [10.0, 11.0, 12.0, 20.0, 22.0, 21.0]})
>>> expr = variance_rolling(pl.col("x"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("variance"))["variance"].to_list()
[None, 0.25, 0.25, None, 1.0, 0.25]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame({"x": [10.0, None, 12.0, float("nan"), 14.0, 15.0]})
>>> frame.select(variance_rolling(pl.col("x"), 2).round(4).alias("variance"))["variance"].to_list()
[None, None, None, nan, nan, 0.25]
pomata.indicators.vortex(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Vortex Indicator (Botes & Siepman) — paired oscillators tracking upward and downward trend movement.

Etienne Botes and Douglas Siepman’s trend gauge: positive vortex movement |high_t - low_{t-1}| and negative |low_t - high_{t-1}|, each summed over the window and normalized by the summed true range. VI+ above VI- signals an uptrend, and their crosses mark trend changes:

\[\mathrm{VI}^{+}_t = \frac{\sum |\mathrm{high}_i - \mathrm{low}_{i-1}|}{\sum \mathrm{TR}_i}, \qquad \mathrm{VI}^{-}_t = \frac{\sum |\mathrm{low}_i - \mathrm{high}_{i-1}|}{\sum \mathrm{TR}_i},\]

each sum running over the window bars ending at \(t\), with \(\mathrm{TR}\) the true_range().

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the moving window (canonically 14). Must be >= 1.

Returns:

  • plus — the positive vortex line VI+.

  • minus — the negative vortex line VI-.

Read one line with .struct.field("plus") (etc.) or split both into columns with .struct.unnest(). The first window rows are null (warm-up): each line needs window defined vortex movements, and the first movement is null (it reads the previous bar).

Return type:

A struct column (one struct per row, the same length as the inputs) with two Float64 fields

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Inputs:

high / low / close must share a length and alignment (the same row index is one bar).

Edge-case behavior:

  • Null / NaN — a null / NaN in the window (including via the one-bar lag, which makes the first movement null) propagates to the affected line at that row.

  • Flat window — a flat window (zero summed true range and zero summed movement — the 0 / 0 degenerate) is detected per line via the residual-free rolling maxima of the true range and the movement, and returns NaN. A near-flat window (tiny ranges after a much larger one has slid out) is not silenced: VI+ is unbounded above, so the streaming quotient cannot be clipped to a range and, past a sane dynamic range, degrades in precision (see the precision note above).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so neither the lag nor the window spans series boundaries.

See also

  • di_plus(): The Wilder directional indicator, the same movement-over-range idea, exponentially smoothed.

  • true_range(): The per-bar basis of the shared denominator.

  • di_minus(): The minus directional indicator, the Wilder analog of the negative vortex line.

References

Examples

On a small OHLC frame, reading each vortex line with .struct.field:

>>> import polars as pl
>>> from pomata.indicators import vortex
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [2.0, 4.0, 6.0, 5.0, 7.0, 6.5, 8.0, 7.5],
...         "low": [1.0, 3.0, 4.0, 4.0, 5.0, 5.5, 6.0, 6.5],
...         "close": [1.5, 3.5, 5.0, 4.5, 6.0, 6.0, 7.0, 7.0],
...     }
... )
>>> bands = vortex(pl.col("high"), pl.col("low"), pl.col("close"), 2)
>>> frame.select(bands.struct.field("plus").round(4).alias("plus"))["plus"].to_list()
[None, None, 1.2, 1.1429, 1.1429, 1.2857, 1.3333, 1.3333]
>>> frame.select(bands.struct.field("minus").round(4).alias("minus"))["minus"].to_list()
[None, None, 0.2, 0.5714, 0.5714, 0.4286, 0.6667, 0.6667]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "high": [2.0, 4.0, 6.0, 5.0, 7.0, 12.0, 11.0, 13.0, 10.0, 12.0],
...         "low": [1.0, 3.0, 4.0, 4.0, 5.0, 10.0, 9.0, 11.0, 8.0, 10.0],
...         "close": [1.5, 3.5, 5.0, 4.5, 6.0, 11.0, 10.0, 12.0, 9.0, 11.0],
...     }
... )
>>> expr = vortex(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker")
>>> frame.with_columns(expr.struct.field("plus").round(4).alias("plus"))["plus"].to_list()
[None, None, 1.2, 1.1429, 1.1429, None, None, 1.0, 0.7143, 0.7143]
>>> frame.with_columns(expr.struct.field("minus").round(4).alias("minus"))["minus"].to_list()
[None, None, 0.2, 0.5714, 0.5714, None, None, 0.6, 0.7143, 0.7143]

A leading null close (absorbed by the true-range maximum) and a later NaN (which contaminates only the bars whose window spans it, then clears) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [2.0, 4.0, 6.0, 5.0, 7.0, 6.5, 8.0, 7.5],
...         "low": [1.0, 3.0, 4.0, 4.0, 5.0, 5.5, 6.0, 6.5],
...         "close": [None, 3.5, 5.0, 4.5, float("nan"), 6.0, 7.0, 7.0],
...     }
... )
>>> bands = vortex(pl.col("high"), pl.col("low"), pl.col("close"), 2)
>>> frame.select(bands.struct.field("plus").round(4).alias("plus"))["plus"].to_list()
[None, None, 1.7143, 1.1429, 1.1429, nan, nan, 1.3333]
>>> frame.select(bands.struct.field("minus").round(4).alias("minus"))["minus"].to_list()
[None, None, 0.2857, 0.5714, 0.5714, nan, nan, 0.6667]
pomata.indicators.vwap(
high: Expr,
low: Expr,
close: Expr,
volume: Expr,
) Expr[source]

Volume-Weighted Average Price (VWAP) — the running volume-weighted mean of the typical price.

The benchmark intraday price: each bar’s typical price (price_typical()) weighted by its volume, accumulated from the start of the partition. It anchors to that start, so the canonical use is one session per .over(...) group – an un-anchored VWAP over years of data is rarely meaningful:

\[\mathrm{VWAP}_t = \frac{\sum_{i \le t} \mathrm{typical}_i \, V_i}{\sum_{i \le t} V_i}, \quad V_i = \mathrm{volume}_i, \quad \mathrm{typical}_i = \frac{\mathrm{high}_i + \mathrm{low}_i + \mathrm{close}_i}{3}.\]
Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

Returns:

row 0 is defined as soon as its cumulative volume is positive (a leading zero-volume run reads NaN until volume accrues).

Return type:

The running VWAP for each row, the same length as the inputs. There is no warm-up

Raises:

TypeError – If any input is not a pl.Expr.

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the conditioning limit of the long cumulative sums beyond it.

Anchoring: VWAP accumulates from the start of the partition, so wrap the call in .over(session_key) to reset it per session (e.g. one trading day): vwap(...).over("session"). Without an anchor it accumulates across the whole series, the classic VWAP misuse.

Inputs:

high / low / close / volume must share a length and alignment; volume is expected non-negative (a negative volume is summed as-is, with no guard – garbage in, garbage out).

Edge-case behavior:

  • Zero volume — at the head a zero cumulative volume gives 0 / 0 == NaN until volume accrues; an interior zero-volume bar adds nothing (the prefix sums carry forward, with no subtract-on-exit residual).

  • Null — a null in any input nulls that bar’s contribution at its own row; both cumulative sums skip the bar together (a null price input drops its volume from the denominator too), so the bar is a clean missing observation, not a denominator-only contribution.

  • NaN — a NaN in any input poisons the cumulative sum from its row onward (it cannot be subtracted out).

  • Partitioning — see Anchoring above; .over(...) is the intended use, not an afterthought.

See also

  • vwma(): The windowed volume-weighted moving average, for a rolling rather than anchored weight.

  • price_typical(): The per-bar price this weights.

  • sma(): The equal-weighted moving average, the volume-blind analogue.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import vwap
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [2.0, 4.0, 6.0],
...         "low": [0.0, 2.0, 4.0],
...         "close": [1.0, 3.0, 5.0],
...         "volume": [10.0, 20.0, 30.0],
...     }
... )
>>> expr = vwap(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"))
>>> frame.select(expr.round(4).alias("vwap"))["vwap"].to_list()
[1.0, 2.3333, 3.6667]

Anchor per session with .over so each day’s VWAP restarts:

>>> frame = pl.DataFrame(
...     {
...         "session": ["a", "a", "b", "b"],
...         "high": [2.0, 4.0, 12.0, 14.0],
...         "low": [0.0, 2.0, 10.0, 12.0],
...         "close": [1.0, 3.0, 11.0, 13.0],
...         "volume": [10.0, 20.0, 10.0, 20.0],
...     }
... )
>>> expr = vwap(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume")).over("session")
>>> frame.select(expr.round(4).alias("vwap"))["vwap"].to_list()
[1.0, 2.3333, 11.0, 12.3333]

A null (yields null at that row) and a NaN (which latches in the running totals) make it visible:

>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
...         "low": [8.0, 9.0, 10.0, 11.0, 12.0, 13.0],
...         "close": [9.0, 10.0, None, 12.0, float("nan"), 14.0],
...         "volume": [100.0, 200.0, 300.0, 400.0, 500.0, 600.0],
...     }
... )
>>> expr = vwap(pl.col("high"), pl.col("low"), pl.col("close"), pl.col("volume"))
>>> frame.select(expr.round(4).alias("vwap"))["vwap"].to_list()
[9.0, 9.6667, None, 11.0, nan, nan]
pomata.indicators.vwma(
expr: Expr,
volume: Expr,
window: int,
) Expr[source]

Volume-Weighted Moving Average (VWMA), also known as the Volume-Weighted MA.

The rolling mean of expr weighted by volume over the last window observations:

\[\mathrm{VWMA}_t = \frac{\sum_{i=0}^{n-1} P_{t-i}\, V_{t-i}}{\sum_{i=0}^{n-1} V_{t-i}}, \qquad n = \text{window},\]

where \(P\) is expr and \(V\) is volume. When every volume in the window is equal it reduces to the SMA of expr; with window == 1 (and non-zero volume) it reproduces expr itself.

Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • volume – Traded-volume series (e.g. pl.col("volume")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

the value is defined only once window observations have been seen.

Return type:

The VWMA for each row, the same length as expr. The first window - 1 values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a window in which expr or volume contains a null yields null.

  • NaN — a window that contains a NaN (and no null) yields NaN; null takes precedence over NaN.

  • Zero volume — when every volume in the window is zero there is no weight to average by (the 0 / 0 degenerate); the window is detected exactly (its rolling maximum is zero) and the result is NaN, not the rounding noise a sub-ULP residual in the rolling-sum numerator would otherwise turn into +/-inf.

  • window == 1 — with non-zero volume the single (price, volume) pair reduces to expr itself, so the VWMA reproduces the price.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. vwma(pl.col("close"), pl.col("volume"), 20).over("ticker").

See also

  • sma(): The equal-weight mean it reduces to when volume is constant.

  • vwap(): The cumulative volume-weighted price, the session-anchored cousin.

  • wma(): The linearly-weighted mean.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import vwma
>>>
>>> frame = pl.DataFrame(
...     {
...         "close": [10.0, 11.0, 12.0, 13.0, 14.0],
...         "volume": [100.0, 200.0, 300.0, 400.0, 500.0],
...     }
... )
>>> frame.select(vwma(pl.col("close"), pl.col("volume"), window=3).round(4).alias("vwma_3"))["vwma_3"].to_list()
[None, None, 11.3333, 12.2222, 13.1667]

On a multi-series panel, wrap the call in .over so each group warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "price": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 120.0, 90.0, 110.0, 130.0],
...     }
... )
>>> expr = vwma(pl.col("price"), pl.col("volume"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("vwma"))["vwma"].to_list()
[None, 10.5455, 11.4286, 11.45, 12.0833, None, 21.0909, 21.5714, 22.1, 22.4583]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "price": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0],
...         "volume": [100.0, 120.0, 90.0, 110.0, 130.0, 100.0, 95.0, 140.0, 105.0, 115.0],
...     }
... )
>>> expr = vwma(pl.col("price"), pl.col("volume"), 2).round(4)
>>> frame.select(expr.alias("vwma"))["vwma"].to_list()
[None, 10.5455, 11.4286, 12.55, None, None, nan, nan, 17.4286, 18.5227]
pomata.indicators.williams_r(
high: Expr,
low: Expr,
close: Expr,
window: int,
) Expr[source]

Williams %R (Williams Percent Range), also known as %R / Williams Overbought-Oversold Index.

A bounded momentum oscillator introduced by Larry Williams that reports where the current close sits inside the high-low range of the last window bars, expressed on a \([-100, 0]\) scale.

It is effectively the inverse of the Fast Stochastic %K: a reading near 0 means the close is at the top of the recent range (overbought), while a reading near -100 means it is at the bottom (oversold):

\[\%R_t = -100 \cdot \frac{\mathrm{HH}_t - C_t}{\mathrm{HH}_t - \mathrm{LL}_t}, \qquad \mathrm{HH}_t = \max_{0 \le i < n} H_{t-i}, \quad \mathrm{LL}_t = \min_{0 \le i < n} L_{t-i}, \quad n = \text{window},\]

where \(H\), \(L\), \(C\) are high, low, close, \(\mathrm{HH}\) is the highest high and \(\mathrm{LL}\) the lowest low over the window. For well-formed bars (\(L \le C \le H\)) the close lies inside the windowed range, so \(\%R \in [-100, 0]\). It is invariant to a common positive rescaling of high, low, and close (it is a ratio of price differences) and to a common additive shift of all three, so it carries no price units. The original convention writes the oscillator as -100 * (HH - C) / (HH - LL); some charting packages flip the sign to plot it on [0, 100], which is the same information mirrored about zero.

Parameters:
  • high – High-price series (e.g. pl.col("high")).

  • low – Low-price series (e.g. pl.col("low")).

  • close – Close-price series (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

Williams %R for each row, the same length as the inputs. The first window - 1 values are null (warm-up), matching the rolling moving-average family: the value is defined only once window observations have been seen.

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Warm-up:

The warm-up is the canonical window - 1 leading nulls of the rolling family, and the null / NaN contract below matches the simple moving average.

Edge-case behavior:

  • Null — a window in which any of high, low, or close contains a null yields null; null takes precedence over NaN.

  • NaN — a window containing a NaN (and no null) yields NaN.

  • HH == LL — when the windowed range collapses (\(\mathrm{HH} = \mathrm{LL}\), e.g. a flat high-low over the whole window) the denominator is zero and the result follows IEEE-754: 0 / 0 (the close also equal to that level) is NaN, and a non-zero numerator over zero is +/-inf.

  • window == 1 — the highest high and lowest low collapse to the single bar’s own high and low, so \(\%R = -100\,(H - C) / (H - L)\).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. williams_r(pl.col("high"), pl.col("low"), pl.col("close"), 14).over("ticker").

See also

  • stochastic_fast(): The Fast Stochastic %K this oscillator inverts.

  • rsi(): A bounded momentum oscillator on the same [0, 100]-style scale.

  • cci(): Another bounded oscillator over a rolling window.

References

Examples

Basic usage on high-low-close bars:

>>> import polars as pl
>>> from pomata.indicators import williams_r
>>>
>>> frame = pl.DataFrame(
...     {
...         "high": [10.0, 12.0, 11.0, 13.0, 15.0, 14.0],
...         "low": [8.0, 9.0, 10.0, 11.0, 12.0, 13.0],
...         "close": [9.0, 11.0, 10.5, 12.0, 14.0, 13.5],
...     }
... )
>>> frame.select(williams_r(pl.col("high"), pl.col("low"), pl.col("close"), window=3).round(4).alias("wr_3"))[
...     "wr_3"
... ].to_list()
[None, None, -37.5, -25.0, -20.0, -37.5]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "high": [11.0, 12.0, 13.0, 12.0, 21.0, 23.0, 22.0, 24.0],
...         "low": [9.0, 10.0, 11.0, 10.0, 19.0, 21.0, 20.0, 22.0],
...         "close": [10.0, 11.0, 12.0, 11.0, 20.0, 22.0, 21.0, 23.0],
...     }
... )
>>> expr = williams_r(pl.col("high"), pl.col("low"), pl.col("close"), 2).over("ticker").round(4)
>>> frame.with_columns(expr.alias("williams_r"))["williams_r"].to_list()
[None, -33.3333, -33.3333, -66.6667, None, -25.0, -66.6667, -25.0]

A null and a NaN in close (each confined to its own bar, since the close enters elementwise) make the exact handling visible at a glance:

>>> frame = pl.DataFrame(
...     {
...         "high": [11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0],
...         "low": [9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0],
...         "close": [10.0, 11.0, 12.0, None, 14.0, 15.0, float("nan"), 17.0],
...     }
... )
>>> expr = williams_r(pl.col("high"), pl.col("low"), pl.col("close"), 2).round(4)
>>> frame.select(expr.alias("williams_r"))["williams_r"].to_list()
[None, -33.3333, -33.3333, None, -33.3333, -33.3333, nan, -33.3333]
pomata.indicators.wma(
expr: Expr,
window: int,
) Expr[source]

Weighted Moving Average (WMA), also known as the Linear Weighted Moving Average (LWMA).

A moving average whose weights rise linearly from 1 on the oldest observation in the window to window on the most recent, normalized by the sum of the weights. The most recent price therefore carries the highest weight, making the WMA more responsive to recent price action than the equally-weighted SMA:

\[\mathrm{WMA}_t = \frac{\sum_{i=0}^{n-1} (n - i)\, x_{t-i}}{\sum_{i=1}^{n} i} = \frac{\sum_{i=0}^{n-1} (n - i)\, x_{t-i}}{\tfrac{n(n+1)}{2}}, \qquad n = \text{window}.\]
Parameters:
  • expr – Input series, typically a price column (e.g. pl.col("close")).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

the value is defined only once window observations have been seen.

Return type:

The WMA for each row, the same length as expr. The first window - 1 values are null (warm-up)

Raises:

Note

Precision – agrees with its independent reference oracle to ten significant figures (a 1e-10 band) on any finite input within a sane dynamic range; CORRECTNESS.md gives the method and the float-conditioning limit beyond it.

Edge-case behavior:

  • Null — a window that contains a null yields null.

  • NaN — a window that contains a NaN yields NaN.

  • window == 1 — the single weight normalizes to one, so the WMA reproduces the input.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the window never spans series boundaries, e.g. wma(pl.col("close"), 20).over("ticker").

See also

  • sma(): The unweighted analogue.

  • hma(): A low-lag average built by composing weighted means.

  • ema(): The exponentially-weighted analogue.

References

Examples

>>> import polars as pl
>>> from pomata.indicators import wma
>>>
>>> frame = pl.DataFrame({"close": [2.0, 4.0, 6.0, 8.0, 10.0]})
>>> frame.select(wma(pl.col("close"), window=3).round(4).alias("wma_3"))["wma_3"].to_list()
[None, None, 4.6667, 6.6667, 8.6667]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "close": [10.0, 11.0, 12.0, 11.0, 13.0, 20.0, 22.0, 21.0, 23.0, 22.0],
...     }
... )
>>> frame.with_columns(wma(pl.col("close"), 2).over("ticker").round(4).alias("wma"))["wma"].to_list()
[None, 10.6667, 11.6667, 11.3333, 12.3333, None, 21.3333, 21.3333, 22.3333, 22.3333]

A null (skipped, and any window it touches yields null) and a NaN (which propagates) make the exact handling visible at a glance:

>>> frame = pl.DataFrame({"close": [10.0, 11.0, 12.0, 13.0, None, 15.0, float("nan"), 17.0, 18.0, 19.0]})
>>> frame.select(wma(pl.col("close"), 2).round(4).alias("wma"))["wma"].to_list()
[None, 10.6667, 11.6667, 12.6667, None, None, nan, nan, 17.6667, 18.6667]