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[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 underliesmacd(), 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>= 1and>= window_fast.
- Returns:
The oscillator for each row, the same length as the input. Values are
nulluntil both EMAs leave their warm-up (the firstmax(window_fast, window_slow) - 1rows).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window_fast < 1,window_slow < 1, orwindow_fast > window_slow(the fast leg must be the shorter one;window_fast == window_slowis allowed and gives an identically-zero oscillator).
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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; composesma()directly for a simple-average oscillator.Edge-case behavior:
Null — a
nullcontaminates the recursive EMA state and yieldsnullfor subsequent rows.NaN — a
NaNpropagates through both EMAs, yieldingNaN.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
.overso 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 aNaN(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,
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
volumeweighted by where theclosesits inside the bar’s high-low range. The per-bar weight is the Money Flow Multiplier (MFM), bounded in \([-1, +1]\) (+1at the high,-1at the low); multiplying it byvolumegives the Money Flow Volume (MFV); the line is the running cumulative sum ofMFV:\[\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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Zero-range bars:
On a doji bar (
high == low) the Money Flow Multiplier is0by convention, so the denominator never hits0 / 0andclosedoes not enter the bar’s contribution.The zero-range convention applies only to a genuine equal-range bar (
high == low), where the multiplier is0andclosedoes not enter the contribution. AnullorNaNin any input instead leaves the rangenullorNaN(never== 0), so missing data propagates rather than being silently zeroed.Edge-case behavior:
Null — a row in which
high,low,close, orvolumeisnullyieldsnullat 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 is0andcloseis irrelevant, so anullincloseon such a bar still yields0rather thannull.NaN — a
NaNin any operand that reaches the cumulative sum latches: once present, every later non-null row of the line isNaN. A bar whosehighandloware bothNaNdoes not take the doji branch (NaN - NaNisNaN, never== 0), so theNaNpoisons the line rather than contributing0.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
accumulation_distribution_oscillator(): The Chaikin oscillator — fast minus slow EMA of this line.chaikin_money_flow(): The windowed money-flow ratio over the same multiplier.obv(): Another cumulative volume-flow line.
References
Chaikin, Marc. “Accumulation/Distribution Line”.
https://en.wikipedia.org/wiki/Accumulation/distribution_index
https://www.investopedia.com/terms/a/accumulationdistribution.asp
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
.overso 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 aNaN(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( ) 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 theaccumulation_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>= 1and>= window_fast.
- Returns:
The oscillator for each row, the same length as the inputs. The first
window_slow - 1values arenull(warm-up), inherited from the slowema()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, orwindow_fast > window_slow.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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 withvolume, so multiplying all four inputs bykscales the oscillator byk.Edge-case behavior:
Null — a
nullcontaminates the accumulation/distribution line and the EMAs, yieldingnull.NaN — a
NaNpropagates through the line and the EMAs, yieldingNaN.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
accumulation_distribution(): The line this oscillates.ema(): The exponential moving average of the two smoothings.macd(): The same fast-minus-slow-EMA oscillator, applied to price.
References
Chaikin, Marc. “Chaikin Oscillator”.
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
.overso 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 aNaN(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,
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 therma()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 — roughly2 * (window - 1)rows ofnull— since it smooths the already-smootheddx().- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose(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
nullreaching the recursion yieldsnullat that row.NaN — a
NaNpoisons the recursion and yieldsNaNfor every subsequent non-null row.Flat directional movement — when
di+anddi-are both zero the underlyingdx()isNaN(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
References
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Average_directional_movement_index
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
.overso 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
nullclose(absorbed by the true-range maximum) and a laterNaN(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,
Average Directional Index Rating (ADXR).
Wilder’s smoothing of the trend-strength reading: the mean of the current
adx()and the ADX fromwindowbars 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 theadx()warm-up plus a furtherwindowrows (the look-back of the averaging).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose.Seeding:
The warm-up inherits the recursive Wilder seeding of
rma()used throughout the cluster.Edge-case behavior:
Null / NaN — inherited from
adx(): anullyieldsnulland aNaNpropagates; a row whose ADX or whosewindow-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
References
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
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
.overso 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
nullclose(absorbed by the true-range maximum) and a laterNaN(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,
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](100when the extreme is the current bar,0when it is the oldest bar in the look-back). With a look-back ofwindow + 1bars 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
highandlow.- 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 + 1bars. Must be>= 1.
- Returns:
up— the Aroon Up line, in[0, 100].down— the Aroon Down line, in[0, 100].
Both are
nullfor the firstwindowrows (warm-up: a fullwindow + 1-bar look-back is needed). Access the fields with.struct.field("up")/"down"or.struct.unnest().- Return type:
A struct
pl.Exprwith twoFloat64fields, the same length as the inputs- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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
nullanywhere in the look-back yieldsnullon the affected line at that row.NaN — a
NaNanywhere in the look-back yieldsNaNon 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
aroon_oscillator(): The differenceup - downas a single line.donchian_channels(): The rolling high/low extremes Aroon locates in time.williams_r(): Another windowed high-low range oscillator.
References
Chande, Tushar (1995). “The Aroon Oscillator”. Technical Analysis of Stocks & Commodities.
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
.overso 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 aNaN(which propagates) inhighmake the handling visible on theupline:>>> 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,
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
upanddownthearoon()lines over the samewindow.- 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 + 1bars. Must be>= 1.
- Returns:
The oscillator for each row, the same length as the inputs, in
[-100, 100]. The firstwindowrows arenull(warm-up), inherited fromaroon().- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — the oscillator inherits
aroon()’s handling: anullanywhere in the look-back yieldsnulland aNaNyieldsNaN(nulltaking 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
Chande, Tushar (1995). “The Aroon Oscillator”. Technical Analysis of Stocks & Commodities.
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
.overso 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 aNaN(which propagates) inhighmake 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,
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()andrma(), 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 - lowspread 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 - 1values arenull(warm-up), inherited from therma()over the true-range series: the running average emits only oncewindownon-null true ranges have been counted, independent of where any interiornullfalls.The true range itself is defined from row
0(the first bar has no previous close, so it degenerates tohigh - lowwith the two gap terms dropped), so the ATR warm-up is exactly thermawarm-up ofwindow - 1.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Scaling:
Scaling is homogeneous of degree
1only for a positive factor: the true range is built from absolute differences, so multiplying every price bykscales the ATR by|k|, not byk.Seeding:
The Wilder smoothing (
rma()) is seeded with the simple average of the firstwindowtrue 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: anullin a singlehigh,low, orcloseinput 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: anullhighremoves thehigh - lowand|high - close_prev|terms (leaving|low - close_prev|), anulllowremoveshigh - lowand|low - close_prev|(leaving|high - close_prev|), and anullcloseonly blanks the two gap terms of the next bar (whose previous close is thennull). The true range is thereforenullonly when every candidate term isnull(e.g. the first bar with bothhighandlownull); anulltrue range yieldsnullat that row while the Wilder recursion preserves its state and bridges the gap.NaN — a
NaNin any active term poisons that true range and then the recursion, latchingNaNfor every subsequent value.window == 1 — the smoothing factor is
1and the warm-up vanishes, so the ATR reproduces the true range exactly: themax_horizontal-reduced true range (not a textbook three-term true range whenever a candidate term is dropped by anull).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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
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
.overso 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
nullclose(absorbed, so the next bar falls back tohigh - low) then aNaNclose(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,
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 samewindow:\[\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 - 1values arenull(warm-up), inherited from theatr().- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose(the ATR and the close scale together).Edge-case behavior:
Null — a
nullATR or anullcloseat a row yieldsnullthere (the ATR inheritsatr()’s per-term null handling).NaN — a
NaNATR orcloseyieldsNaN.Zero close — where
closeis0the ratio follows IEEE-754 (+/-inffor a non-zero ATR,NaNfor 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
.overso 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
nullclose(voiding the ratio at that row) then aNaNclose(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( ) 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_fastand \(n_s\) iswindow_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>= 1and>= window_fast.
- Returns:
The oscillator for each row, the same length as the inputs. The first
window_slow - 1values arenull(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, orwindow_fast > window_slow(the fast leg must be the shorter one;window_fast == window_slowis allowed and gives an identically-zero oscillator).
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
highandlowmust share a length and alignment (the same row index is one bar).Edge-case behavior:
Null / NaN — a window containing a
nullin either input yieldsnullthere (each average needs a full window of non-null medians); aNaNpropagates.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
absolute_price_oscillator(): The same fast-minus-slow shape on the close, with exponential averages.macd(): The exponential oscillator with an added signal line.price_median(): The bar median each average is taken over.
References
Williams, Bill (1998). New Trading Dimensions. Wiley.
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
.overso 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 yieldsnull) and aNaN(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,
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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
open,high,low, andcloseare the canonical OHLC roles in that positional order and must share a length and alignment (the same row index is one bar).balance_of_poweris scale-invariant: multiplying all four by a common factor leaves it unchanged.Edge-case behavior:
Flat bar — when
high == lowthe range is zero, so the result is0by convention (no range, no directional power) rather than the bare0 / 0. The zero-range branch fires first, so a finite flat bar reads0even whenopenorcloseisnull— only anullhighorlow, which leaves the range itselfnull, still yieldsnullon a flat bar.Null — a
nullin any input propagates on a non-flat bar: the row isnullwhenever an input isnull(nulltakes precedence overNaN).NaN — a
NaNin any input (with nonulland a non-zero range) propagates, yieldingNaN.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.overis required.
See also
price_average(): Another per-bar OHLC summary, the equal-weighted mean of the four prices.price_weighted_close(): A per-bar OHLC summary that leans on the close.price_typical(): The per-bar high-low-close average.
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
.overis 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, giving0), then anulland aNaNinclosemake 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[source]¶
Bollinger Bands, volatility bands around a moving average.
Introduced by John Bollinger in the 1980s: a center band that is the
sma()ofexpr, with an upper and a lower band placednum_stdpopulation 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 rollingstandard_deviation_rolling()ofexprover 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 composesma()andstandard_deviation_rolling()directly.
- Returns:
lower— the lower band,middle - num_std * sigma.middle— the center band, thesma()ofexpr.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 firstwindow - 1rows arenull(warm-up).- Return type:
A struct column (one struct per row, the same length as the input) with three
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1, or ifnum_stdis not a finite number> 0.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Composition:
The bands are built from
sma()(center) and the populationstandard_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
nullyieldsnullon all three fields (the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNon all three fields.window == 1 — the standard deviation is
0, so all three bands collapse ontoexpritself.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
sma(): The center band.standard_deviation_rolling(): The band half-width, before scaling bynum_std.keltner_channels(): The same band shape with ATR width instead of a standard deviation.
References
Bollinger, John (2001). Bollinger on Bollinger Bands.
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
.overso 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
nulland aNaNpropagate 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,
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, andclose(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 - 1values arenull(warm-up), inherited from thesma()of the typical price: the value is defined only once a full window of typical prices is available.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a window in which
high,low, orclosecontains anullyieldsnull(the typical price isnullthere, and so is any rolling quantity that covers it).NaN — a window containing a
NaN(and nonull) yieldsNaN.Flat window — when every typical price in the window is equal there is no spread to normalize by (the
0 / 0degenerate); the window is detected exactly (its rolling maximum equals its rolling minimum) and the result isNaN, 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
Lambert, Donald R. (1980). “Commodity Channel Index: Tools for Trading Cyclic Trends”. Commodities (now Futures) magazine.
https://www.investopedia.com/terms/c/commoditychannelindex.asp
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
.overso 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
nulland aNaNinclose(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,
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
closesits inside the bar’s range via the Money Flow Multiplier \(\mathrm{MFM}\), scaled byvolumeinto 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 - 1values arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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 is0(it adds0to the numerator while its volume still counts in the denominator). AnullorNaNin any input instead leaves that bar’s money-flow volumenullorNaN, so missing data propagates rather than being silently zeroed.Edge-case behavior:
Null — a window in which any of
high/low/close/volumecontains anullyieldsnull;nulltakes precedence overNaN.NaN — a window containing a
NaN(and nonull) yieldsNaN.Zero volume — a window whose volume is all zero is the
0 / 0degenerate; the window is detected exactly (the rolling maximum of the absolute volume is zero) and the result isNaN, 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
accumulation_distribution(): The cumulative (window-less) money-flow line.money_flow_index(): A bounded windowed money-flow oscillator.accumulation_distribution_oscillator(): Chaikin’s momentum oscillator over the same line.
References
Chaikin, Marc. “Chaikin Money Flow”.
https://en.wikipedia.org/wiki/Chaikin_Analytics#Chaikin_Money_Flow
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
.overso 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 yieldsnull) and aNaN(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,
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
windowrows arenull(warm-up): row0has no change, and the rolling sums needwindownon-null changes before emitting.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Flat window — an exactly-flat window (every change zero, the
0 / 0degenerate) is detected via the residual-free rolling maximum of|change|and returnsNaN. 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) yieldsnull.NaN — a window covering a
NaNchange (and nonull) yieldsNaN.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
References
Chande, Tushar S., and Kroll, Stanley (1994). The New Technical Trader. Wiley.
https://www.investopedia.com/terms/c/chandemomentumoscillator.asp
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
.overso 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 yieldsnull) and aNaN(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[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 theema()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; whenTrueuse the bias-corrected (adjusted) exponential weighting. The flag is forwarded unchanged to bothema()passes; the canonical DEMA usesFalse.
- Returns:
The DEMA for each row, the same length as
expr. The first2 * (window - 1)values arenull(warm-up), clamped to the series length: the value is composed from two chainedema()passes of the samewindow(each carrying awindow - 1warm-up), so the warm-up is twice that of a plain EMA. Each EMA is seeded with the SMA of the firstwindowobservations.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a leading
nullrun staysnulluntil the first non-null seed; an interiornullyieldsnullat that position while the decay continues across the gap.NaN — a
NaNcontaminates the recursive state and yieldsNaNfor 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
References
Mulloy, P. G. (1994). “Smoothing Data with Faster Moving Averages.” Technical Analysis of Stocks & Commodities, 12(1).
https://en.wikipedia.org/wiki/Double_exponential_moving_average
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
.overso 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 yieldsnull) and aNaN(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,
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 firstwindow - 1values arenull(warm-up).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose(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 / 0isNaN(the[0, 100]bound holds wherever the value is finite).Null — a
nullin the smoothed movement or the ATR at a row yieldsnullthere.NaN — a
NaNpropagates, yieldingNaN.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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Average_directional_movement_index
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
.overso 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
nullclose(absorbed by the ATR’s true-range maximum) and a laterNaN(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,
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 firstwindow - 1values arenull(warm-up).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose(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 / 0isNaN(the[0, 100]bound holds wherever the value is finite).Null — a
nullin the smoothed movement or the ATR at a row yieldsnullthere.NaN — a
NaNpropagates, yieldingNaN.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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Average_directional_movement_index
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
.overso 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
nullclose(absorbed by the ATR’s true-range maximum) and a laterNaN(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,
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 factor1 / 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 - 1values arenull(warm-up), inherited from therma().- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1in a positive common rescaling ofhighandlow(a range expansion in price units).Seeding:
The raw directional movement is smoothed by Wilder’s
rma(), the mean-scale recursionm_t = m_{t-1} - m_{t-1} / window + raw_t / window(smoothing factor1 / 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 firstwindowraw movements), which equalswindowtimes the mean-scale value in steady state. That factor ofwindowis structural and persists for every row — it is not a warm-up seed difference that washes out — so this series reads roughlywindowtimes smaller than the sum-scale convention throughout. The factor cancels indi_minus(),dx(), andadx(), which are therefore unaffected.Edge-case behavior:
First bar — row
0has no previous bar, so its raw movement is0and seeds the smoothing.Null — a
nullinhighorlowmakes the affected raw movement0for the rows whose difference it touches, while anullreaching therma()recursion yieldsnullthere.NaN — a
NaNinlow(the own-side input) poisons the recursion and yieldsNaNfor every subsequent non-null row; aNaNinhigh(the opposing side) instead makes the directional comparison false, so the affected raw movement is sent to0and 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 theatr().rma(): The Wilder moving average that smooths the raw movement.
References
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Average_directional_movement_index
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
.overso 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
nulllow(which zeroes the raw movement it touches) and a laterNaNlow(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,
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 factor1 / 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 - 1values arenull(warm-up), inherited from therma().- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1in a positive common rescaling ofhighandlow(a range expansion in price units).Seeding:
The raw directional movement is smoothed by Wilder’s
rma(), the mean-scale recursionm_t = m_{t-1} - m_{t-1} / window + raw_t / window(smoothing factor1 / 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 firstwindowraw movements), which equalswindowtimes the mean-scale value in steady state. That factor ofwindowis structural and persists for every row — it is not a warm-up seed difference that washes out — so this series reads roughlywindowtimes smaller than the sum-scale convention throughout. The factor cancels indi_plus(),dx(), andadx(), which are therefore unaffected.Edge-case behavior:
First bar — row
0has no previous bar, so its raw movement is0and seeds the smoothing.Null — a
nullinhighorlowmakes the affected raw movement0for the rows whose difference it touches, while anullreaching therma()recursion yieldsnullthere.NaN — a
NaNinhigh(the own-side input) poisons the recursion and yieldsNaNfor every subsequent non-null row; aNaNinlow(the opposing side) instead makes the directional comparison false, so the affected raw movement is sent to0and 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 theatr().rma(): The Wilder moving average that smooths the raw movement.
References
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Average_directional_movement_index
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
.overso 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
nullhigh(which zeroes the raw movement it touches) and a laterNaNhigh(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,
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 between0.67and1.5times 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 first32rows arenull(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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — a
nullorNaNprice latchesnullfor 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
dominant_cycle_phase(): The phase of the same dominant cycle.hilbert_phasor(): The phasor the period is measured from.hilbert_trendline(): Averages the price over one cycle of this period.
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
20bars):>>> 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,
Dominant Cycle Phase (Hilbert transform).
The phase of the market’s dominant cycle, in degrees:
0at the upward mean-crossing,90at the cycle high,270at 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 first63rows arenull(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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — a
nullorNaNprice latchesnullfor 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
±90as that projection vanishes), rather than the inventor’s fixed0.001absolute 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
dominant_cycle_period(): The length of the same dominant cycle.sine_wave(): The sine of this phase.mama(): The adaptive average this phase’s rate drives.
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,
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 lowestlow, 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 lowestlowover the window.middle— the channel midline,(upper + lower) / 2(identical tomidprice()).upper— the highesthighover the window.
Read one band with
.struct.field("upper")(etc.) or split all three into columns with.struct.unnest(). The firstwindow - 1rows arenull(warm-up).- Return type:
A struct column (one struct per row, the same length as the inputs) with three
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
highandlowmust share a length and alignment (the same row index is one bar). The channel does not assumehigh >= low: a malformed bar wherehigh < lowflows through unchanged (the upper band can then sit below the lower band) rather than being silently reordered.Edge-case behavior:
Null —
nullpropagates per band: anullin thehighwindow nullsupperandmiddle, anullin thelowwindow nullslowerandmiddle; a fully missing bar nulls all three.NaN — a
NaNpropagates the same way per band, becomingNaNon the band that reads it (nullstill takes precedence overNaN).window == 1 — the bands are the bar’s own
highandlow, and the middle is itsprice_median().Flat window — over a window where
highandlowhold 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
.overso 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 yieldsnullon every band) and aNaN(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,
Directional Index (DX).
The normalized spread between the plus and minus directional indicators — how one-sided the trend is, bounded in
[0, 100](0when up- and down-pressure are equal,100when 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 firstwindow - 1values arenull(warm-up), inherited from the directional indicators.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose.Seeding:
The warm-up inherits the recursive Wilder seeding of
rma()used throughout the cluster.Edge-case behavior:
Flat directional movement — when
+DIand-DIare both zero (no movement either way) the denominator is zero, so the result follows IEEE-754: the numerator is also zero, hence0 / 0isNaN.Null — a
nullin either indicator at a row yieldsnullthere.NaN — a
NaNpropagates, yieldingNaN.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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Average_directional_movement_index
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
.overso 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
nullclose(absorbed by the underlying ATR’s true-range maximum) and a laterNaN(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[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
windowobservations – 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. WhenTrueuse 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 firstwindow - 1values arenull(warm-up), matching the uniform warm-up of the moving-average family: the value is defined only oncewindownon-null observations have been seen.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Seeding:
The unadjusted recursion (the default) is seeded with the simple average of the first
windowobservations, the canonical EMA initialization; the adjusted form is exact from the first observation.Edge-case behavior:
Null — a leading
nullrun staysnulland does not consume warm-up budget. Null handling usesignore_nulls=False(Polars’ default), so an interiornullyieldsnullat that row without resetting the average: the weight of the last non-null observation decays by \((1 - \alpha)^k\) over thek-step gap, and the next non-null value resumes a gap-aware, renormalized recurrence rather than ignoring the missing rows.NaN — a
NaNpoisons the recursion arithmetically and yieldsNaNfor 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
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
.overso 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 yieldsnull) and aNaN(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,
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) / 2is first placed in its rolling channel and mapped to[-1, 1], smoothed with Ehlers’ fixed0.33 / 0.67recursion, held just inside\pm 1by 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
windowbars. Thesignalline 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.Exprwith fieldsfisher(the transform) andsignal(fisherlagged one bar), the same length as the inputs. The firstwindow - 1rows arenull(the channel’s warm-up);signalisnullfor one further row. Read a field with.struct.field("fisher")or split both with.struct.unnest().- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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.99straight 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 contributes0to each), matching Ehlers’ zero-initialized series; the smoothing then washes the seed out geometrically.Edge-case behavior:
Null — a
nullhighorlownulls the rolling channel for every window touching it, so those rows arenull; the recursion bridges them and resumes once the window clears.NaN — a
NaNpropagates through the channel toNaNat those rows, likewise bridged.Flat window — when
max == minover the window the normalization is0/0and the row isNaN.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
Ehlers, John F. (2002). “Using the Fisher Transform.” Technical Analysis of Stocks & Commodities, 20(11).
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
.overso 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 yieldsnull) and aNaN(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,
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.ExprwithFloat64fieldsin_phase/quadrature, the same length asexpr. The first32rows arenull(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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — a
nullorNaNprice latchesnullfor 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
dominant_cycle_period(): Measured from this phasor by the homodyne discriminator.mama(): Adapts on the rate of change of this phasor’s phase.dominant_cycle_phase(): The companion dominant-cycle phase.
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,
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 first63rows arenull(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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — a
nullorNaNprice latchesnullfor 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
trend_mode(): Uses the price’s deviation from this trendline.dominant_cycle_period(): The cycle length this averages the price over.mama(): The adaptive average from the same pipeline.
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,
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 firstwindow + s - 2values arenull(warm-up), where \(s = \lfloor \sqrt{n} + \tfrac{1}{2} \rfloor\): the innerWMA(x, window)needswindowobservations, after which the finalWMA(., s)needss - 1more.- 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 to1atwindow == 1and the HMA degenerates there, so the smallest meaningful window is2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Period rounding:
The two period reductions use round-half-up (
floor(window / 2 + 0.5)andfloor(sqrt(window) + 0.5)), not Python’s built-inround(which rounds half to even); the two disagree on the half-period wheneverwindow / 2lands exactly on a.5boundary (oddwindowsuch as5,9,13, …).Edge-case behavior:
Null — a window containing a
nullyieldsnullat that row, propagated through every composingwma().NaN — a window containing a
NaN(and nonull) yieldsNaNat 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
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
.overso 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 yieldsnull) and aNaN(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( ) 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>= 1and>= window_tenkan.window_senkou – Leading-span-B window (canonically
52). Must be>= 1and>= window_kijun.
- Returns:
A struct
pl.Exprwith fieldstenkan,kijun,senkou_a,senkou_b, the same length as the inputs. Each line isnullthrough its own warm-up:window_tenkan - 1rows fortenkan,window_kijun - 1forkijunandsenkou_a(which needs both),window_senkou - 1forsenkou_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 orderedwindow_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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Every line is homogeneous of degree
1under a positive common rescaling ofhighandlow(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_kijunbars into the future and a chikou (lagging) spanwindow_kijunbars 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 toclose, and its backward shift reads future bars, which must never enter a backtest.Edge-case behavior:
Null / NaN — a
nullin either input nulls every line whose window touches it; aNaNpropagates the same way, per the underlying rolling extremes (andnulltakes precedence overNaN).Flat window — over a constant window every line equals the price (the
highandlowextremes 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
Hosoda, Goichi (1969). Ichimoku Kinkō Hyō.
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
.overso 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 isnull) and aNaN(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[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\) iswindow_fast, and \(n_s\) iswindow_slow. The recurrence is seeded atcloseon the bar one step before the efficiency ratio is first defined (rowwindow - 1); the first adaptive update then runs at rowwindow, 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>= 1and<= 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 firstwindow - 1values arenull(warm-up); the value at rowwindow - 1iscloseitself (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, orwindow_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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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, sokama(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 than0 / 0), so the smoothing constant is the slow bound and KAMA barely moves.Null — a
nullreaching the recurrence (fromcloseor from a window touching one) yieldsnullat that row while the running average holds its state and bridges the gap.NaN — a
NaNflows into the recurrence and latchesNaNfor 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
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
.overso 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 aNaN(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( ) 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()ofclose, with upper and lower bands setmultiplieraverage 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\) iswindow_atr, and \(m\) ismultiplier.- 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, theema()ofclose.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 isnullthrough its own warm-up: the midline’s firstwindow - 1rows, the outer bands’ firstmax(window, window_atr) - 1rows (they also need the ATR).- Return type:
A struct column (one struct per row, the same length as the inputs) with three
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1,window_atr < 1, ormultiplieris not a finite number> 0.
Note
Precision – agrees with its independent reference oracle (a composition of the
ema()andatr()references) to ten significant figures (a1e-10band);CORRECTNESS.mdgives the method.Inputs:
high,low, andclosemust 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 fromema()and the bar range if ever needed.Edge-case behavior:
Null / NaN — flow through the recursive
ema()(midline) andatr()(band width) legs exactly as documented for each; the channel adds no propagation rule of its own.Flat series — over a constant
high == low == closerun the ATR is0, 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
Keltner, Chester W. (1960). How to Make Money in Commodities.
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
.overso 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(yieldsnullat that row) and aNaN(which propagates) incloseflow 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,
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
windowobservations 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 - 1values arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1inexpr(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
nullyieldsnull.NaN — a
NaNinside the window propagates, yieldingNaN.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
linear_regression_slope(): The slope of the same fitted line.linear_regression_intercept(): The line’s value at the oldest bar of the window.time_series_forecast(): The line extrapolated one bar into the future.
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
.overso 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 yieldsnull) and aNaN(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,
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 - 1values arenull(warm-up).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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 ofexprversus its bar spacing, so it is most meaningful on a chart’s own price/time units.Edge-case behavior:
Null — a window containing a
nullyieldsnull.NaN — a
NaNinside the window propagates, yieldingNaN.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
linear_regression_slope(): The slope this takes the arctangent of.linear_regression(): The fitted line’s endpoint whose steepness this reports.time_series_forecast(): The same line projected one bar ahead.
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
.overso 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 yieldsnull) and aNaN(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,
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. they-intercept of the regression of the lastwindowobservations 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 - 1values arenull(warm-up).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1inexpr(a fitted price scales with the price).Edge-case behavior:
Null — a window containing a
nullyieldsnull.NaN — a
NaNinside the window propagates, yieldingNaN.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
linear_regression(): The same line evaluated at the most recent bar instead of the oldest.linear_regression_slope(): The slope of the same fitted line.time_series_forecast(): The same line projected one bar past the most recent.
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
.overso 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 yieldsnull) and aNaN(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,
Linear Regression Slope (the slope of the rolling least-squares line).
The ordinary-least-squares slope of the last
windowobservations 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
ksteps 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 - 1values arenull(warm-up).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1inexpr(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
nullyieldsnull.NaN — a
NaNinside the window propagates, yieldingNaN.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
linear_regression(): The fitted line’s value at the most recent bar.linear_regression_angle(): This slope expressed as an angle in degrees.time_series_forecast(): The line projected one bar ahead using this slope.
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
.overso 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 yieldsnull) and aNaN(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[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>= 1and>= 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,nullfor itsmax(window_fast, window_slow) - 1warm-up rows.signal— the EMA of the MACD line, carrying the additionalwindow_signal - 1warm-up rows on top.histogram—macdminussignal, sharing the signal line’s warm-up.
Access the fields with
.struct.field("macd")/"signal"/"histogram"or.struct.unnest().- Return type:
A struct
pl.Exprwith threeFloat64fields, the same length asexpr- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window_fast < 1,window_slow < 1,window_signal < 1, orwindow_fast > window_slow(the fast leg must be the shorter one;window_fast == window_slowis 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Scaling: every field is homogeneous of degree
1inexpr(the EMAs and their differences all scale with the price), so multiplying the close bykscales all three fields byk.Edge-case behavior:
Null — a
nullcontaminates the recursive EMAs and yieldsnullfor subsequent rows on every field.NaN — a
NaNpropagates through the EMAs, yieldingNaN.Fast equals slow — when
window_fast == window_slowthe 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
absolute_price_oscillator(): The Absolute Price Oscillator, the MACD line without the signal and histogram.percentage_price_oscillator(): The percentage counterpart of the MACD line.ema(): The exponential moving average all three lines are built from.
References
Appel, Gerald (2005). Technical Analysis: Power Tools for Active Investors.
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
.overso 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 aNaN(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[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
1degree so thatlimit_fastis 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.ExprwithFloat64fieldsmama/fama, the same length asexpr. The first32rows arenull(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_fastorlimit_slowis outside(0, 1]— the smoothing constant is a weight, so a limit above1makes1 - alphanegative and the recurrence diverges — or iflimit_fast < limit_slow, which would pin the adaptive smoothing constant atlimit_slowand makelimit_fasta 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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Seeding:
Both lines are seeded at the price prefix —
MAMAandFAMAstart 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
nullorNaNprice latchesnullfor 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,
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 - 1values arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a window containing a
nullyieldsnull(the rolling max and min each needwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.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
.overso 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 yieldsnull) and aNaN(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,
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
highand the rolling minimum oflow. It is the two-input analogue ofmidpoint(), 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 - 1values arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
highandlowmust share a length and alignment (the same row index is one bar).Edge-case behavior:
Null — a window containing a
nullin either input yieldsnull(each rolling extreme needswindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.window == 1 — the extremes are the bar’s own
highandlow, so the midprice reduces to the per-barprice_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) / 2this collapses to atwindow == 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
.overso 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 yieldsnull) and aNaN(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,
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
windowperiods 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
windowperiods ago (upward momentum), a negative value the reverse, and zero a flat look-back; the magnitude is in the same units asexprand 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 inexpr(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 firstwindowvalues arenull(warm-up), clamped to the series length: unlike the moving-average family, whose warm-up iswindow - 1rows, the value at rowtneeds the observation at rowt - window, which first exists att == window.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a position whose current value or whose
window-back value isnullyieldsnull.NaN — a position whose current value or whose
window-back value isNaN(with nonull) yieldsNaN. Because the operation is a fixed-lag difference rather than a recurrence, anullorNaNcontaminates 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
roc(): The percentage-change sibling (scale-invariant).rsi(): A bounded momentum oscillator.chande_momentum_oscillator(): A bounded net-of-gains-and-losses momentum oscillator.
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
.overso 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 aNaN(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,
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
windowof changes (and thereforewindow + 1price 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 at100and one with no positive money flow at0.- 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 firstwindowvalues arenull(warm-up): the value is defined only oncewindowprice changes have accumulated, so the first defined row is at indexwindowrather thanwindow - 1.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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
nullinhigh,low, orclosevoids the typical price at that row and at the next change, so any window reaching either yieldsnull; anullinvolumevoids only that row’s money flow.nulltakes precedence overNaN.NaN — a
NaNin any input contaminates the affected money flow and yieldsNaNfor every window that contains it. ANaNtypical 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 asNaN, voiding every window that reaches either change (the same two-position taint as thenullanalogue, but surfaced asNaNrather thannull).Division by zero — a window with no negative money flow but non-zero positive flow has money ratio
+infand the MFI saturates at100; symmetrically an all-down window gives0. A window in which both flows are zero (the typical price never moves) leaves the money ratio at0 / 0and yieldsNaN– 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
rsi(): The price-only analogue (no volume weighting).chaikin_money_flow(): Another volume-weighted money-flow oscillator.price_typical(): The per-bar typical price this weights by volume.
References
Quong, Gene & Soudack, Avrum (1989). “Volume-Weighted RSI: Money Flow”. Technical Analysis of Stocks & Commodities, 7(3).
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
.overso 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 yieldsnull) and aNaN(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,
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
exprand \(V\) isvolume. The first bar has no predecessor, so its direction is undefined; it contributes0and 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
0on 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a
nullclose zeroes the direction both at its own row and at the following row (eachdifftouching thenullis itselfnulland is filled to0), so those bars contribute nothing while the running total carries on. Anullvolume makes that bar’s contributionnull(0 * nullisnull, so this holds even on the first or a flat bar where the direction is0): the output isnullat exactly that row while the cumulative sum skips it and continues from the prior total.NaN — a
NaNclose (viadiff) or aNaNvolume poisons the contribution at its row and, once summed, latches the running total toNaNfor every subsequent row; because0 * NaNisNaNunder IEEE-754, aNaNvolume contaminates the total even on a flat or first bar where the direction is0. Anull-contribution row still emitsnullat its own position even after the latch.Partitioning — wrap the call in
.over(...)for a multi-series panel so neither thediffnor the cumulative sum spans series boundaries, e.g.obv(pl.col("close"), pl.col("volume")).over("ticker").
See also
accumulation_distribution(): Another cumulative volume line.money_flow_index(): A bounded volume-weighted oscillator.chaikin_money_flow(): A windowed volume-weighted money-flow ratio.
References
Granville, Joseph E. (1963). Granville’s New Key to Stock Market Profits. Prentice-Hall.
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
.overso 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 aNaN(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( ) 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
EPis the highest high of the current up-trend (or lowest low of a down-trend) andAFstarts atacceleration, rising byaccelerationon each new extreme up tomaximum. 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,EPresets to the new low, andAFresets (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 abovemaximum(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 leastacceleration.
- Returns:
The Parabolic SAR for each row, the same length as the inputs. Row
0isnull(the trend is seeded from the first two bars); the value at row1is the seed stop, and the recurrence runs from there.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
accelerationormaximumis not in the half-open interval(0, 1], or ifacceleration > 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1under a positive common rescaling ofhighandlow(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
nullhighorlowyieldsnullat that row and is skipped; the running trend state bridges the gap and resumes on the next complete bar.NaN — a
NaNhighorlowyieldsNaNat that row and is skipped, exactly like anull: the raw high/low feed the kernel directly with no recurrence for aNaNto 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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
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
.overso 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
nullthen aNaNinhigheach yieldnull/NaNat 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[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-slowema()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>= 1and>= window_fast.
- Returns:
The oscillator (in percent) for each row, the same length as the input. Values are
nulluntil both EMAs leave their warm-up (the firstmax(window_fast, window_slow) - 1rows).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window_fast < 1,window_slow < 1, orwindow_fast > window_slow(the fast leg must be the shorter one;window_fast == window_slowis allowed and gives an identically-zero oscillator).
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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
nullcontaminates the recursive EMA state and yieldsnullfor subsequent rows.NaN — a
NaNpropagates through both EMAs, yieldingNaN.Division by zero — when the slow EMA is
0the ratio divides by zero following IEEE-754: a zero gap (0 / 0) isNaNand 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
.overso 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 aNaN(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,
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, andclose, 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
0and 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
open,high,low, andcloseare 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
nullin any of them propagates: the row isnullwhenever at least one of its four prices isnull(nulltakes precedence overNaN).NaN — a
NaNin any input (with nonullat that row) propagates, yieldingNaNfor 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.overis required to stop a window spanning series boundaries.
See also
price_median(): The midpoint of the bar’s range,(high + low) / 2.price_typical(): The equal-weighted mean of high, low, and close.price_weighted_close(): The OHLC summary that double-weights the close.
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
.overas the windowed indicators require — for this elementwise transform.overis 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
nullthen aNaNinclose(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,
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
0and each row depends only on its ownhighandlow.- 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
highandloware 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
nullin either propagates: the row isnullwheneverhighorlowisnull(nulltakes precedence overNaN).NaN — a
NaNin either input (with nonullat that row) propagates, yieldingNaNfor 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.overis 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
.overas the windowed indicators require — for this elementwise transform.overis 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
nullthen aNaNinhigh(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,
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, andclose. 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
0and each row depends only on its ownhigh,low, andclose.- 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
high,low, andcloseare 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
nullin any of them propagates: the row isnullwhenever at least one ofhigh/low/closeisnull(nulltakes precedence overNaN).NaN — a
NaNin any input (with nonullat that row) propagates, yieldingNaNfor 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.overis required to stop a window spanning series boundaries.
See also
cci(): The Commodity Channel Index, built on the typical price.price_average(): The equal-weighted mean of the four OHLC prices.price_weighted_close(): The OHLC summary that double-weights the close.
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
.overas the windowed indicators require — for this elementwise transform.overis 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
nullthen aNaNinclose(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,
Weighted Close Price, the OHLC summary that double-weights the close.
A representative price that gives the
closetwice the weight of thehighand thelow, 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
0and each row depends only on its ownhigh,low, andclose.- 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
high,low, andcloseare 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
nullin any of them propagates: the row isnullwhenever at least one ofhigh/low/closeisnull(nulltakes precedence overNaN).NaN — a
NaNin any input (with nonullat that row) propagates, yieldingNaNfor 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.overis required to stop a window spanning series boundaries.
See also
price_average(): The equal-weighted mean of the four OHLC prices.price_median(): The midpoint of the bar’s range,(high + low) / 2.price_typical(): The equal-weighted mean of high, low, and close.
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
.overas the windowed indicators require — for this elementwise transform.overis 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
nullthen aNaNinclose(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,
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 firstwindowobservations – 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
windownon-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 firstwindow - 1values arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a leading
nullrun is skipped and does not consume warm-up budget:min_samplescounts only non-null observations, so the warm-up gate is independent of where any interiornullfalls. An interiornullyieldsnullat that row while the path-dependent recursion bridges the gap rather than restarting, the running average’s weight decaying across it (Polarsewm_mean(adjust=False, ignore_nulls=False)semantics).NaN — once a
NaNenters it poisons the recursion and latchesNaNfor 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
References
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
https://en.wikipedia.org/wiki/Moving_average#Modified_moving_average
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
.overso 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 yieldsnull) and aNaN(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,
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
windowobservations 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
windowrows earlier (expr.shift(window)). It is the simple return overwindowperiods expressed in percent; a positive value means the series rose over the lookback, a negative value that it fell, and0that 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 firstwindowrows, so no change can be measured there.- Return type:
The ROC for each row, the same length as
expr. The firstwindowvalues arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a
nullat the current row or at the lagged row yieldsnullat that position.NaN — a
NaNat the current row or at the lagged row (and nonull) yieldsNaN.Division by zero — when the lagged value is
0the ratio divides by zero following IEEE-754: a zero change (0 / 0) isNaNand 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
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
.overso 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 aNaN(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,
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 + 1prices for its first value, since row0has no difference and the gain / loss averages countwindownon-null differences before emitting.- Return type:
The RSI for each row, the same length as
expr. The firstwindowvalues arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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 firstwindowgains and losses – Wilder’s canonical initialization, exact from the first emitted value.Edge-case behavior:
Null — a leading
nullrun is skipped: the warm-up counts only non-null observations, so thewindowwarm-up is measured from the first non-null value. An interiornullyieldsnullat that row while the Wilder recursion bridges the gap.NaN — a
NaNpoisons the recursion and latchesNaNfor every subsequent non-warm-up row.Flat window — no up and no down move is the indeterminate
0 / 0relative strength, surfaced asNaN(the value is genuinely undefined, not a conventional50or100).window == 1 — the smoothing vanishes: each row reports
100on an up move,0on a down move, andNaNon 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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems. Trend Research.
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
.overso 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 yieldsnull) and aNaN(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[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 itswindow_krange, and %D is thesma()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()overwindow_rsi, \(\mathrm{RSImin}_t\) and \(\mathrm{RSImax}_t\) are its lowest and highest values over thewindow_kbars ending at \(t\), and \(m\) iswindow_d.- Parameters:
expr – Input series, typically a price column (e.g.
pl.col("close")).window_rsi – Number of observations in the underlying
rsi()(canonically14). 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, thesma()of %K overwindow_d.
Read one line with
.struct.field("k")(etc.) or split both into columns with.struct.unnest(). The warm-up stacks thersi()warm-up (window_rsirows), thewindow_k - 1range look-back, and thewindow_d - 1of %D.- Return type:
A struct column (one struct per row, the same length as the input) with two
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window_rsi < 1,window_k < 1, orwindow_d < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Both lines lie in
[0, 100]. Because the underlyingrsi()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’sSeedingnote), then the %K range ratio, then thesma()of %K, so every stage’s warm-up and null / NaN handling stacks.Edge-case behavior:
Null — a
nullreaching any stage yieldsnullon the dependent field at that row.NaN — a
NaNpropagates, yieldingNaN.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
kfollows IEEE-754:0 / 0isNaN.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
rsi(): The oscillator this is the stochastic of.stochastic_fast(): The same %K / %D construction applied to price.stochastic_slow(): The smoothed %K / %D stochastic variant.
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
.overso 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 aNaN(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,
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.ExprwithFloat64fieldssine/lead_sinein[-1, 1], the same length asexpr. The first63rows arenull(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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — a
nullorNaNprice latchesnullfor 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
±90as that projection vanishes), rather than the inventor’s fixed0.001absolute 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
dominant_cycle_phase(): The phase these are the sine of.trend_mode(): Combines these sine-wave crossings.dominant_cycle_period(): The cycle these trace.
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,
Simple Moving Average (SMA).
The unweighted arithmetic mean of the last
windowobservations, 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
windowobservations have been seen.- Return type:
The SMA for each row, the same length as
expr. The firstwindow - 1values arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a window that contains a
nullyieldsnull.NaN — a window that contains a
NaNyieldsNaN.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
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
.overso 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 yieldsnull) and aNaN(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[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 asema()):\[\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; whenTrueuse the finite-window bias-corrected weighting (the same flag asema()).bias – When
True(default) the population standard deviation; whenFalsethe unbiased sample one.Truemirrors theddof = 0default ofstandard_deviation_rolling(). Seevariance_ewma().
- Returns:
The exponentially-weighted standard deviation for each row, the same length as the input. The first
window - 1values arenull(warm-up): the recursion emits only oncewindownon-null observations have been seen.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1inexpr(a spread in the input’s own units).Edge-case behavior:
Null — a leading
nullrun staysnulland does not consume warm-up; an interiornullyieldsnullat that row while the weights decay across the gap (ignore_nulls=False).NaN — a
NaNpoisons the recursion and yieldsNaNfor 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
variance_ewma(): The square of this, of which it is the root.standard_deviation_rolling(): The equal-weighted (rolling-window) counterpart.ema(): The exponential mean these deviations are measured from.
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
.overso 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 aNaN(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[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;1is the sample standard deviation. Must be< window. Seevariance_rolling().
- Returns:
The rolling standard deviation for each row, the same length as the input. The first
window - 1values arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1, or ifddof >= window(the divisorwindow - ddofwould be non-positive).
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Degrees of freedom:
ddofcarries the same meaning as invariance_rolling()(population vs sample); the standard deviation is just its square root. It must be strictly belowwindowso the divisor stays positive.Edge-case behavior:
Null — a window containing a
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.window == 1 — a single value has no spread, so the result is
0with the defaultddof = 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
.overso 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 yieldsnull) and aNaN(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( ) 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_krange, and the signal line %D is thesma()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_kbars ending at \(t\), and \(m\) iswindow_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, thesma()of %K overwindow_d.
Read one line with
.struct.field("k")(etc.) or split both into columns with.struct.unnest(). The firstwindow_k - 1rows arenullonk(the look-back warm-up), and a furtherwindow_d - 1ond.- Return type:
A struct column (one struct per row, the same length as the inputs) with two
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window_k < 1orwindow_d < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Both lines are scale-invariant under a positive common rescaling of
high,low, andclose(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
nullanywhere in the %K window (ahigh/lowover the look-back, or the currentclose) yieldsnullonkat that row; anullreaching the %D average yieldsnullond.NaN — a
NaNin the window propagates, yieldingNaN.Flat range — when the highest
highequals the lowestlow(no range over the look-back) the denominator is zero, sokfollows IEEE-754:0 / 0isNaNwhen the close sits on that flat level, and+/-infwhen 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
stochastic_slow(): The slow variant, %K smoothed once more before %D.rsi_stochastic(): The same oscillator applied torsi()instead of price.sma(): The moving average that forms %D.
References
Lane, George C. (1984). “Lane’s Stochastics.” Technical Analysis of Stocks & Commodities, 2(3).
https://www.investopedia.com/terms/s/stochasticoscillator.asp
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
.overso 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(yieldsnullonkat that row) and aNaN(which propagates) inclosesurface 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( ) 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_kbars ending at \(t\), \(p\) iswindow_slowing, and \(m\) iswindow_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, thesma()of the raw %K overwindow_slowing.d— the %D signal line, thesma()of the slow %K overwindow_d.
Read one line with
.struct.field("k")(etc.) or split both into columns with.struct.unnest(). The firstwindow_k + window_slowing - 2rows arenullonk, and a furtherwindow_d - 1ond.- Return type:
A struct column (one struct per row, the same length as the inputs) with two
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window_k < 1,window_slowing < 1, orwindow_d < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Both lines are scale-invariant under a positive common rescaling of
high,low, andclose, and lie in[0, 100]for well-formed bars (low <= close <= high). The slow %K equals the fast %D ofstochastic_fast()whenwindow_slowingmatches that call’swindow_d.Composition:
The slow %K is the
sma()of the raw %K, and %D is thesma()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
nullanywhere in a window yieldsnullon the dependent field at that row.NaN — a
NaNin a window propagates, yieldingNaN.Flat range — when the highest
highequals the lowestlow(no range over the look-back) the raw %K is0 / 0 = NaNwhen the close sits on that flat level (+/-infwhen 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
stochastic_fast(): The unsmoothed variant, whose raw %K this smooths.rsi_stochastic(): The stochastic applied torsi()instead of price.sma(): The moving average behind both the slowing and %D.
References
Lane, George C. (1984). “Lane’s Stochastics.” Technical Analysis of Stocks & Commodities, 2(3).
https://www.investopedia.com/terms/s/stochasticoscillator.asp
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
.overso 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 aNaN(which propagates the same way) inclosesurface 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( ) 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
directionbetween+1and-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.Exprwith fieldsline(the trailing stop) anddirection(+1.0in an up-trend, the line below price;-1.0in a down-trend, the line above price), the same length as the inputs. The firstwindow - 1rows arenull(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 < 1ormultiplieris not a finite number> 0.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.The
lineis homogeneous of degree1under a positive common rescaling ofhigh/low/close(a price level), whiledirectionis 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
nullhigh/low/closeyieldsnullon both fields and is skipped at the row; the running state, and the last valid close the ratchet reads, bridge the gap.NaN — a
NaNhigh/low/closeyieldsNaNon both fields. Forwindow >= 2it latches: it poisons the ATR recurrence, so the band staysNaNthereafter (only anullbridges). Atwindow == 1the ATR has no memory term, so theNaNself-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
Seban, Olivier (2009). Tout le monde mérite d’être riche.
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
.overso 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
nullincloseis skipped and bridged by the running state, while aNaNpoisons the ATR recursion and latchesNaNthereafter:>>> 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[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. Withv = volume_factorandEMAthe recursive exponential moving average of lengthwindow:\[\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
vcontrolling smoothing versus responsiveness; the canonical default is0.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 firstwindowobservations (False, the default).
- Returns:
The T3 for each row, the same length as
expr. Because the value is composed from six chainedema()passes of the samewindow(each carrying awindow - 1warm-up), the first6 * (window - 1)values arenull(warm-up), clamped to the series length.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1, or ifvolume_factoris not a finite number.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Seeding:
The recursive EMA is seeded with the SMA of the first
windowobservations.Edge-case behavior:
Null — a leading
nullrun staysnulluntil the first non-null seed; an interiornullyieldsnullat that position while the decay continues across the gap.NaN — a
NaNcontaminates the recursive state and yieldsNaNfor 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
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
.overso 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 yieldsnull) and aNaN(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[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 firstwindowobservations.- 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 first3 * (window - 1)values arenull(warm-up), clamped to the series length: the value is composed from three chainedema()passes of the samewindow(each carrying awindow - 1warm-up), so the warm-up is three times that of a plain EMA. Each EMA is seeded with the SMA of the firstwindowobservations.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a leading
nullrun staysnulluntil the first non-null seed; an interiornullyieldsnullat that position while the decay continues across the gap.NaN — a
NaNcontaminates the recursive state and yieldsNaNfor 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
References
Mulloy, P. G. (1994). “Smoothing Data with Faster Moving Averages.” Technical Analysis of Stocks & Commodities, 12(1).
https://en.wikipedia.org/wiki/Triple_exponential_moving_average
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
.overso 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 yieldsnull) and aNaN(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,
Time Series Forecast (the rolling least-squares line extrapolated one bar ahead).
The ordinary-least-squares line fitted to the last
windowobservations, 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 beyondlinear_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 - 1values arenull(warm-up).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is homogeneous of degree
1inexpr(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
nullyieldsnull.NaN — a
NaNinside the window propagates, yieldingNaN.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
linear_regression(): The same line evaluated at the current bar rather than one ahead.linear_regression_slope(): The slope used for the projection.linear_regression_intercept(): The same line’s value at the oldest bar of the window.
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
.overso 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 yieldsnull) and aNaN(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,
Hilbert Transform Trend vs Cycle Mode.
Ehlers’ market-mode flag:
1.0when the market is trending,0.0when 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.0trend /0.0cycle) for each row, the same length asexpr. The first63rows arenull(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-10band) 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.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null / NaN — a
nullorNaNprice latchesnullfor 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
±90as that projection vanishes), rather than the inventor’s fixed0.001absolute 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
hilbert_trendline(): The trendline the mode compares the price against.sine_wave(): The sine-wave crossings the mode combines.dominant_cycle_phase(): The phase rate the mode also uses.
References
Ehlers, John F. (2001). Rocket Science for Traders.
Examples
A pure cycle is never in a trend, so the mode flag stays
0over 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,
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 ansma(), with the two sub-windows chosen so the combined span iswindow:\[\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) / 2and for an even windowm_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 - 1values arenull(warm-up), matching the uniform warm-up of the moving-average family.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — built from two
sma()passes, so anullpropagates exactly as the SMA’smin_samples=windowcontract dictates: any window short of full non-null values isnull.NaN — a
NaNpropagates 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
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
.overso 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 aNaNmake 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,
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 thanwindow, then takes the one-period percentageroc()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) + 1rows arenull(warm-up): three chained EMAs plus the one-period rate of change.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a
nullcontaminates the recursive EMA chain and yieldsnullfor subsequent rows.NaN — a
NaNpropagates through the chain, yieldingNaN.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
References
Hutson, Jack K. (1983). “Good Trix”. Technical Analysis of Stocks & Commodities.
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
.overso 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 aNaN(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,
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,closeand \(c_{t-1}\) is the previousclose. The first row has no previousclose, 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 toatr()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 tohigh - lowbecause 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
high,low, andcloseare 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 skipsnullcandidates rather than propagating them: anullinhighorlow(or anullpreviousclose) simply drops that candidate, so the row still resolves from whichever distances remain. The result isnullonly when all three candidates arenull(highandlowbothnullat the row, and no usable previous close).NaN — a
NaNis not skipped: it dominates the maximum, so any row whose surviving candidates include aNaNyieldsNaN(aNaNclosetherefore 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 lastcloseof 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
Wilder, J. Welles (1978). New Concepts in Technical Trading Systems.
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
.overso 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
nullclose(skipped, so the next bar falls back tohigh - low) then aNaNclose(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( ) 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, canonically7). Must be>= 1.window_medium – Number of observations in the medium averaging window (weight
2, canonically14). Must be>= 1.window_long – Number of observations in the long averaging window (weight
1, canonically28). Must be>= 1.
- Returns:
The Ultimate Oscillator for each row, the same length as the inputs, in
[0, 100]for well-formed bars. The firstmax(window_short, window_medium, window_long) - 1values arenull(warm-up). The bound is not guaranteed for an incoherent bar: a missing orNaNlowon a down bar (the documented fallback below) substitutes the previouscloseinto 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 orderedwindow_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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.It is scale-invariant under a positive common rescaling of
high,low, andclose(each averaged term is a ratio of price ranges).Edge-case behavior:
First bar — row
0has no previous close, so the true low / high fall back to that bar’s own low / high.Null — a
nullin a singlehigh/low/closedrops only the terms that reference it (the true low / high followpl.min_horizontal/pl.max_horizontal, which skip nulls); anullreaching a period sum yieldsnullfor the rows whose window touches it.NaN — the per-field behavior is asymmetric. A
NaNinhighorclosepropagates (pl.max_horizontaltreats it as the largest value, and a corrupt close poisons the next bar’s true range), yieldingNaN. ANaNinlowon a bar with a finite previous close is instead treated as absent:pl.min_horizontalskips 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 row0, where there is no previous close, does aNaNlowpropagate).Flat window — the genuine
0 / 0degenerate (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 returnsNaN; a finite buying pressure over an exactly-zero true range — the missing-lowfallback — 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
Williams, Larry (1985). “The Ultimate Oscillator”. Technical Analysis of Stocks & Commodities.
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
.overso 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 aNaN(which propagates, also poisoning the next bar’s true range) inclosemake 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[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 ofvariance_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=Trueform; the defaultadjust=Falseinstead 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; whenTrueuse the finite-window bias-corrected weighting (the same flag asema()).bias – When
True(default) the population variance (divides by the weight total); whenFalsethe unbiased sample variance (the reliability correction1 - sum(w ** 2) / (sum w) ** 2).Truemirrors theddof = 0default ofvariance_rolling().
- Returns:
The exponentially-weighted variance for each row, the same length as the input. The first
window - 1values arenull(warm-up): the recursion emits only oncewindownon-null observations have been seen.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.windowmust be>= 2: a single observation yields a well-defined0under the defaultbias=True, but divides by zero under the unbiasedbias=Falsecorrection, so a minimum of2is enforced uniformly across both paths. It is homogeneous of degree2inexpr(a variance scales with the square of the input).Edge-case behavior:
Null — a leading
nullrun staysnulland does not consume warm-up; an interiornullyieldsnullat that row while the weights decay across the gap (ignore_nulls=False).NaN — a
NaNpoisons the recursion and yieldsNaNfor 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
standard_deviation_ewma(): Its square root, in the input’s own units.variance_rolling(): The equal-weighted (rolling-window) counterpart.ema(): The exponential mean these deviations are measured from.
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
.overso 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 aNaN(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[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 bywindow(the population variance);1divides bywindow - 1(the sample variance, the unbiased estimator used when the window is a sample of a larger population). Must be< window(the divisorwindow - ddofmust be positive).
- Returns:
The rolling variance for each row, the same length as the input. The first
window - 1values arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1, or ifddof >= window(the divisorwindow - ddofwould be non-positive).
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Degrees of freedom:
ddofselects the divisorwindow - ddof:ddof = 0is the population variance (÷window), the charting convention;ddof = 1is the sample variance (÷window - 1), Bessel’s unbiased estimator. The two differ by the factorwindow / (window - ddof)— e.g. on[10, 11, 12]the population variance is0.6667and the sample variance is1.0.ddofmust be strictly belowwindowso the divisor stays positive.Edge-case behavior:
Null — a window containing a
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.window == 1 — a single value has no spread, so the result is
0with the defaultddof = 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
standard_deviation_rolling(): Its square root, in the input’s own units.variance_ewma(): The exponentially-weighted counterpart, weighting recent observations more.sma(): The moving mean the deviations are measured from.
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
.overso 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 yieldsnull) and aNaN(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,
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+aboveVI-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
windowbars ending at \(t\), with \(\mathrm{TR}\) thetrue_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 lineVI+.minus— the negative vortex lineVI-.
Read one line with
.struct.field("plus")(etc.) or split both into columns with.struct.unnest(). The firstwindowrows arenull(warm-up): each line needswindowdefined vortex movements, and the first movement isnull(it reads the previous bar).- Return type:
A struct column (one struct per row, the same length as the inputs) with two
Float64fields- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Inputs:
high/low/closemust share a length and alignment (the same row index is one bar).Edge-case behavior:
Null / NaN — a
null/NaNin the window (including via the one-bar lag, which makes the first movementnull) propagates to the affected line at that row.Flat window — a flat window (zero summed true range and zero summed movement — the
0 / 0degenerate) is detected per line via the residual-free rolling maxima of the true range and the movement, and returnsNaN. 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
Botes, Etienne, and Douglas Siepman (2010). “The Vortex Indicator”, Technical Analysis of Stocks & Commodities, 28(1).
https://www.investopedia.com/terms/v/vortex-indicator-vi.asp
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
.overso 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
nullclose(absorbed by the true-range maximum) and a laterNaN(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,
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
0is defined as soon as its cumulative volume is positive (a leading zero-volume run readsNaNuntil 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-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives 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/volumemust share a length and alignment;volumeis 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 == NaNuntil volume accrues; an interior zero-volume bar adds nothing (the prefix sums carry forward, with no subtract-on-exit residual).Null — a
nullin any input nulls that bar’s contribution at its own row; both cumulative sums skip the bar together (anullprice input drops its volume from the denominator too), so the bar is a clean missing observation, not a denominator-only contribution.NaN — a
NaNin 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
.overso 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(yieldsnullat that row) and aNaN(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,
Volume-Weighted Moving Average (VWMA), also known as the Volume-Weighted MA.
The rolling mean of
exprweighted byvolumeover the lastwindowobservations:\[\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
exprand \(V\) isvolume. When every volume in the window is equal it reduces to the SMA ofexpr; withwindow == 1(and non-zero volume) it reproducesexpritself.- 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
windowobservations have been seen.- Return type:
The VWMA for each row, the same length as
expr. The firstwindow - 1values arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a window in which
exprorvolumecontains anullyieldsnull.NaN — a window that contains a
NaN(and nonull) yieldsNaN;nulltakes precedence overNaN.Zero volume — when every volume in the window is zero there is no weight to average by (the
0 / 0degenerate); the window is detected exactly (its rolling maximum is zero) and the result isNaN, 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 toexpritself, 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
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
.overso 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 yieldsnull) and aNaN(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,
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
windowbars, expressed on a \([-100, 0]\) scale.It is effectively the inverse of the Fast Stochastic %K: a reading near
0means the close is at the top of the recent range (overbought), while a reading near-100means 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 ofhigh,low, andclose(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 - 1values arenull(warm-up), matching the rolling moving-average family: the value is defined only oncewindowobservations have been seen.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Warm-up:
The warm-up is the canonical
window - 1leading nulls of the rolling family, and thenull/NaNcontract below matches the simple moving average.Edge-case behavior:
Null — a window in which any of
high,low, orclosecontains anullyieldsnull;nulltakes precedence overNaN.NaN — a window containing a
NaN(and nonull) yieldsNaN.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) isNaN, and a non-zero numerator over zero is+/-inf.window == 1 — the highest high and lowest low collapse to the single bar’s own
highandlow, 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
Williams, Larry (1973). How I Made One Million Dollars Last Year Trading Commodities.
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
.overso 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
nulland aNaNinclose(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,
Weighted Moving Average (WMA), also known as the Linear Weighted Moving Average (LWMA).
A moving average whose weights rise linearly from
1on the oldest observation in the window towindowon 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
windowobservations have been seen.- Return type:
The WMA for each row, the same length as
expr. The firstwindow - 1values arenull(warm-up)- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
Note
Precision – agrees with its independent reference oracle to ten significant figures (a
1e-10band) on any finite input within a sane dynamic range;CORRECTNESS.mdgives the method and the float-conditioning limit beyond it.Edge-case behavior:
Null — a window that contains a
nullyieldsnull.NaN — a window that contains a
NaNyieldsNaN.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
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
.overso 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 yieldsnull) and aNaN(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]