Polars-native technical analysis indicator library
Project description
PolarTicks
A Polars-native technical analysis library for Python.
Every indicator is implemented directly against the Polars API — no pandas
conversions, no NumPy loops where a vectorised expression will do. Inputs and
outputs are plain pl.Series or pl.DataFrame objects, so the results drop
straight into your existing Polars pipeline.
Contents
- Installation
- Quick start
- Input conventions
- Null-prefix semantics
- API reference
- Running tests
- Running benchmarks
Installation
# with uv (recommended)
uv add polarticks
# with pip
pip install polarticks
Requires Python ≥ 3.11 and Polars ≥ 1.0.
Quick start
import polars as pl
import polarticks
# Load your OHLCV data however you like
df = pl.read_csv("prices.csv")
# Single-series indicators
close = df["close"]
rsi_14 = polarticks.rsi(close, period=14) # pl.Series
ema_20 = polarticks.ema(close, period=20) # pl.Series
macd_df = polarticks.macd(close) # pl.DataFrame (3 columns)
# Multi-column indicators
bb = polarticks.bollinger_bands(close, period=20) # pl.DataFrame (5 columns)
atr = polarticks.atr(df, period=14) # pl.Series
# Pattern detection
signals = polarticks.is_bullish_engulfing(df) # pl.Series[bool]
# Attach results to your DataFrame
df = df.with_columns([
rsi_14.alias("rsi_14"),
ema_20.alias("ema_20"),
*bb.get_columns(), # spread all band columns
signals.alias("bull_engulf"),
])
A complete signal pipeline
import polars as pl
import polarticks
df = pl.read_csv("eurusd_h1.csv")
close = df["close"]
# Compute a fast/slow EMA crossover and RSI filter
fast = polarticks.ema(close, 9)
slow = polarticks.ema(close, 21)
df = df.with_columns([
fast.alias("ema_9"),
slow.alias("ema_21"),
polarticks.rsi(close, 14).alias("rsi"),
polarticks.crossover(fast, slow).alias("cross_up"),
polarticks.crossunder(fast, slow).alias("cross_dn"),
])
# Long entry: crossover AND RSI not overbought
df = df.with_columns(
(pl.col("cross_up") & (pl.col("rsi") < 70)).alias("long_entry")
)
Input conventions
Single-series indicators
Functions like sma, ema, rsi, roc, hma accept a pl.Series:
result = polarticks.sma(df["close"], period=20)
OHLC / OHLCV indicators
Functions that need multiple price columns accept a pl.DataFrame. The
expected column names are always lowercase: open, high, low, close,
volume.
result = polarticks.atr(df, period=14) # needs high, low, close
result = polarticks.mfi(df, period=14) # needs high, low, close, volume
result = polarticks.stochastic(df) # needs high, low, close
Pivot points
Pivot point functions accept individual pl.Series (one per OHLC component)
rather than a DataFrame. This lets you broadcast yesterday's session values
across today's intraday bars however your data model requires.
# Scalar broadcast: yesterday's values repeated across all bars
n = len(df)
levels = polarticks.pivot_points_floor(
prev_high = pl.Series([prev_high] * n),
prev_low = pl.Series([prev_low] * n),
prev_close = pl.Series([prev_close] * n),
)
# Rolling: shift the daily OHLC so each bar sees the prior day
daily = df.group_by_dynamic("date", every="1d").agg(...)
levels = polarticks.pivot_points_floor(
prev_high = daily["high"].shift(1),
prev_low = daily["low"].shift(1),
prev_close = daily["close"].shift(1),
)
Null-prefix semantics
Every indicator returns exactly as many leading null values as its algorithm
requires before it can produce a valid output. No zeros are substituted in
the warm-up region. This means:
sma(n)→n − 1leading nullsema(n)→n − 1leading nullsrsi(n)→nleading nulls (one extra from the initialdiff)dema(n)→2 × (n − 1)leading nulls (two EMA passes)
When you attach indicator columns to a DataFrame, Polars propagates nulls through any downstream arithmetic exactly as you would expect — no silent zeroes contaminating your signals.
# Safe: Polars propagates nulls through arithmetic
signal = pl.col("rsi") < 30 # null where rsi is null, False otherwise (after fill)
# If you need to fill before a join or export:
rsi = polarticks.rsi(close, 14).fill_null(strategy="forward")
API reference
Moving averages
All moving averages accept (series: pl.Series, period: int) unless noted
and return a pl.Series.
| Function | Description | Leading nulls |
|---|---|---|
sma(series, period) |
Simple Moving Average | period − 1 |
ema(series, period) |
Exponential MA (α = 2/(n+1)) | period − 1 |
wma(series, period) |
Linearly Weighted MA | period − 1 |
wilder_smooth(series, period) |
Wilder's RMA (α = 1/n) | period − 1 |
dema(series, period) |
Double EMA — 2·EMA − EMA(EMA) |
2·(period−1) |
tema(series, period) |
Triple EMA | 3·(period−1) |
hma(series, period) |
Hull MA — reduces lag | (period−1) + (√period−1) |
vwma(price, volume, period) |
Volume-Weighted MA | period − 1 |
mcginley_dynamic(series, period) |
Self-adjusting MA | period − 1 |
close = df["close"]
volume = df["volume"]
sma20 = polarticks.sma(close, 20)
ema20 = polarticks.ema(close, 20)
hma20 = polarticks.hma(close, 20)
vwma20 = polarticks.vwma(close, volume, 20)
HMA is particularly useful when you need low lag without excessive noise.
It runs WMA(2·WMA(n/2) − WMA(n), √n) and has a warm-up of
(n−1) + (⌈√n⌉−1) bars.
McGinley Dynamic self-corrects its smoothing speed based on how fast price
is moving relative to the current indicator value. Seed is the SMA of the
first period bars.
Momentum & oscillators
rsi(series, period=14) → pl.Series
Relative Strength Index via Wilder's smoothing. Values in [0, 100].
rsi = polarticks.rsi(df["close"], 14)
overbought = rsi > 70
oversold = rsi < 30
macd(series, fast=12, slow=26, signal=9) → pl.DataFrame
Returns a DataFrame with three columns: macd_line, macd_signal,
macd_histogram.
m = polarticks.macd(df["close"])
# m["macd_line"] — fast EMA minus slow EMA
# m["macd_signal"] — EMA of the MACD line
# m["macd_histogram"] — line minus signal
stochastic(ohlc, k_period=14, d_period=3) → pl.DataFrame
Returns stoch_k and stoch_d. Both range from 0 to 100.
st = polarticks.stochastic(df)
cross_up = polarticks.crossover(st["stoch_k"], st["stoch_d"])
williams_r(ohlc, period=14) → pl.Series
Williams %R in the range [−100, 0]. Readings near 0 are overbought; near −100 are oversold.
cci(ohlc, period=20) → pl.Series
Commodity Channel Index. Readings above +100 suggest overbought; below −100 suggest oversold.
roc(series, period=10) → pl.Series
Rate of Change as a percentage: 100 × (close − close[n]) / close[n].
Has period leading nulls (one more than most indicators) because it uses
shift(period).
mfi(ohlcv, period=14) → pl.Series
Money Flow Index — the volume-weighted version of RSI. Requires a volume
column. Values in [0, 100].
cmf(ohlcv, period=20) → pl.Series
Chaikin Money Flow. Measures buying vs. selling pressure in the range [−1, 1]. Positive values indicate accumulation.
tsi(series, slow=25, fast=13) → pl.Series
True Strength Index — double-smoothed momentum oscillator. Values in
(−100, +100). Signal line: apply ema(tsi, 7) to the output.
tsi_vals = polarticks.tsi(df["close"])
signal = polarticks.ema(tsi_vals.fill_null(0.0), 7) # fill before second EMA
ultimate_oscillator(ohlc, period1=7, period2=14, period3=28) → pl.Series
Weighted blend of three time-frame buying-pressure ratios. Values in [0, 100].
Warm-up is period3 − 1 bars.
Volatility
true_range(ohlc) → pl.Series
Single-bar True Range — the greatest of H−L, |H−prev_C|, |L−prev_C|.
The first bar has no prior close; its TR collapses to H−L (Wilder's
convention). Zero leading nulls.
atr(ohlc, period=14) → pl.Series
Average True Range via Wilder's smoothing. period − 1 leading nulls.
bollinger_bands(series, period=20, num_std=2.0) → pl.DataFrame
Returns five columns:
| Column | Description |
|---|---|
bb_middle_{period} |
SMA of close |
bb_upper_{period} |
Middle + num_std × rolling std |
bb_lower_{period} |
Middle − num_std × rolling std |
bb_pct_b_{period} |
Position within the band (0 = lower, 1 = upper) |
bb_width_{period} |
(Upper − Lower) / Middle |
bb = polarticks.bollinger_bands(df["close"], 20)
squeeze = bb["bb_width_20"] < bb["bb_width_20"].rolling_mean(20)
keltner_channels(ohlc, ema_period=20, atr_period=10, multiplier=2.0) → pl.DataFrame
Returns kc_middle, kc_upper, kc_lower. Uses ATR for band width rather
than standard deviation, making the channels less reactive to individual large
moves.
chaikin_volatility(ohlc, ema_period=10, roc_period=10) → pl.Series
Rate of change of the EMA of the high-low range. Rising values signal
increasing volatility. Leading nulls: (ema_period − 1) + roc_period.
historical_volatility(series, period=20, annualise=True, trading_days=252) → pl.Series
Rolling annualised standard deviation of log returns. Set trading_days=365
for crypto or 260 for FX. period leading nulls.
ulcer_index(series, period=14) → pl.Series
Drawdown-based volatility: √(mean(pct_drawdown², period)). Only penalises
downside moves; useful for risk-adjusted metrics like the Ulcer Performance
Index. Leading nulls: 2 × (period − 1) (rolling max then rolling mean).
Trend
donchian_channels(ohlc, period=20) → pl.DataFrame
Returns dc_upper_{period}, dc_lower_{period}, dc_middle_{period}.
Breakout above the upper channel or below the lower channel signals a
trend initiation.
adx(ohlc, period=14) → pl.DataFrame
Average Directional Index with directional components.
| Column | Description |
|---|---|
adx_{period} |
Trend strength (0–100; >25 = trending) |
plus_di_{period} |
Bullish directional movement |
minus_di_{period} |
Bearish directional movement |
Leading nulls: period − 1 for the DI columns; 2 × (period − 1) for ADX
(it is Wilder-smoothed DX, which is itself Wilder-smoothed).
result = polarticks.adx(df, 14)
trending = result["adx_14"] > 25
bull_trend = result["plus_di_14"] > result["minus_di_14"]
supertrend(ohlc, period=7, multiplier=3.0) → pl.DataFrame
ATR-based trailing stop that also indicates trend direction.
| Column | Description |
|---|---|
supertrend |
Band level (support in uptrend, resistance in downtrend) |
supertrend_direction |
+1 (bullish) or −1 (bearish) |
st = polarticks.supertrend(df, period=10, multiplier=2.0)
entries = st["supertrend_direction"].diff() == 2 # flipped to bullish
parabolic_sar(ohlc, initial_af=0.02, step_af=0.02, max_af=0.20) → pl.DataFrame
Parabolic SAR dot plot. Returns psar (price level) and psar_direction
(+1 uptrend / −1 downtrend). One leading null (initialised from bar 1).
sar = polarticks.parabolic_sar(df)
flip_to_bull = sar["psar_direction"].diff() == 2
Volume
obv(ohlcv) → pl.Series
On-Balance Volume — running cumulative sum of signed volume. Volume is added on up-bars and subtracted on down-bars. No leading nulls; starts accumulating from bar 0.
obv = polarticks.obv(df)
obv_trend = polarticks.ema(obv, 20) # smooth OBV to spot divergences
vwap(ohlcv, session_start_hour=22) → pl.Series
Session-anchored VWAP. If your DataFrame has a time column (Polars
Datetime), a new session is started at every bar whose UTC hour equals
session_start_hour. Without a time column the entire series is treated
as one session. No leading nulls.
vwap = polarticks.vwap(df, session_start_hour=0) # midnight UTC sessions
above_vwap = df["close"] > vwap
vwap_bands(ohlcv, session_start_hour=22) → pl.DataFrame
VWAP with ±1σ and ±2σ volume-weighted standard-deviation bands. Returns
vwap, upper_1, lower_1, upper_2, lower_2. No leading nulls.
Levels (pivot points)
All pivot point functions accept pl.Series arguments (one per price
component) and return a pl.DataFrame. Pass yesterday's values — scalar
broadcast or a shifted daily series — aligned to your current-session bars.
pivot_points_floor(prev_high, prev_low, prev_close) → pl.DataFrame
Classic floor-trader pivots. Columns: pp, r1, r2, r3, s1, s2, s3.
pivot_points_camarilla(prev_high, prev_low, prev_close) → pl.DataFrame
Camarilla equation (multiplier 1.1). Produces tighter intraday levels suited
to mean-reversion scalping. Columns: cam_r1–cam_r4, cam_s1–cam_s4.
pivot_points_fibonacci(prev_high, prev_low, prev_close) → pl.DataFrame
Fibonacci-ratio levels (0.382, 0.618, 1.000 × range).
Columns: fib_pp, fib_r1–fib_r3, fib_s1–fib_s3.
pivot_points_woodie(prev_high, prev_low, prev_close) → pl.DataFrame
Double-weights the prior close. Columns: wood_pp, wood_r1, wood_r2,
wood_s1, wood_s2.
pivot_points_demark(prev_open, prev_high, prev_low, prev_close) → pl.DataFrame
Adapts the formula based on whether the prior session closed above, below, or
equal to its open. Returns a single resistance and support level.
Columns: dm_pp, dm_r1, dm_s1.
n = len(df)
levels = polarticks.pivot_points_floor(
pl.Series([yesterday_high] * n),
pl.Series([yesterday_low] * n),
pl.Series([yesterday_close] * n),
)
df = df.with_columns(levels.get_columns())
Candlestick patterns
All pattern functions accept a pl.DataFrame with open, high, low,
close columns and return a Boolean pl.Series — True on bars where the
pattern is present, False everywhere else (including leading bars that
cannot satisfy the look-back requirement).
Single-bar patterns
| Function | Description |
|---|---|
is_doji(ohlc, threshold=0.1) |
Body is < 10% of the bar's range |
is_pin_bar_bullish(ohlc, wick_ratio=0.6, body_ratio=0.25) |
Hammer: small body, long lower wick |
is_pin_bar_bearish(ohlc, wick_ratio=0.6, body_ratio=0.25) |
Shooting star: small body, long upper wick |
Two-bar patterns
| Function | Description |
|---|---|
is_bullish_engulfing(ohlc) |
Bearish bar followed by a larger bullish bar that engulfs it |
is_bearish_engulfing(ohlc) |
Bullish bar followed by a larger bearish bar that engulfs it |
is_inside_bar(ohlc) |
Current bar's range is entirely within the prior bar's range |
is_bullish_harami(ohlc) |
Small bullish body inside a large prior bearish body |
is_bearish_harami(ohlc) |
Small bearish body inside a large prior bullish body |
Three-bar patterns
| Function | Description |
|---|---|
is_three_white_soldiers(ohlc, body_ratio=0.5) |
Three consecutive advancing bullish candles |
is_three_black_crows(ohlc, body_ratio=0.5) |
Three consecutive declining bearish candles |
is_morning_star(ohlc, body_ratio=0.3, star_body_ratio=0.15) |
Bearish → small star → bullish reversal |
is_evening_star(ohlc, body_ratio=0.3, star_body_ratio=0.15) |
Bullish → small star → bearish reversal |
# Combine patterns and indicators for a signal
bull_signals = (
polarticks.is_bullish_engulfing(df)
| polarticks.is_morning_star(df)
| polarticks.is_pin_bar_bullish(df)
)
df = df.with_columns([
bull_signals.alias("bull_pattern"),
polarticks.rsi(df["close"], 14).alias("rsi"),
])
entries = df.filter(pl.col("bull_pattern") & (pl.col("rsi") < 40))
Utilities
crossover(fast, slow, atol=0.0) → pl.Series[bool]
True on the single bar where fast crosses above slow. The optional
atol prevents double-signals from floating-point noise right at the crossing
price.
crossunder(fast, slow, atol=0.0) → pl.Series[bool]
True on the single bar where fast crosses below slow.
fast = polarticks.ema(close, 9)
slow = polarticks.ema(close, 21)
long_entry = polarticks.crossover(fast, slow)
short_entry = polarticks.crossunder(fast, slow)
# Noise-tolerant version for choppy markets
long_entry = polarticks.crossover(fast, slow, atol=0.05)
log_returns(series) → pl.Series
Bar-to-bar log returns: ln(price[t] / price[t-1]). One leading null.
simple_returns(series) → pl.Series
Bar-to-bar simple (arithmetic) returns. One leading null.
Running tests
uv run pytest tests/unit/ # 232 unit tests
uv run pytest tests/ # all tests (includes benchmarks — takes ~90 s)
To run only the null-prefix consistency audit:
uv run pytest tests/unit/test_null_prefix.py -v
Type-check the package:
uv run mypy src/polarticks/ --strict
Running benchmarks
The benchmark suite exercises every indicator on a 100 000-bar OHLCV series:
uv run pytest tests/benchmark/ --benchmark-only
Save a baseline and compare across changes:
uv run pytest tests/benchmark/ --benchmark-only --benchmark-save=baseline
# ... make changes ...
uv run pytest tests/benchmark/ --benchmark-only --benchmark-compare=baseline
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file polarticks-0.1.0.tar.gz.
File metadata
- Download URL: polarticks-0.1.0.tar.gz
- Upload date:
- Size: 29.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"25.10","id":"questing","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3f1f625d171c856ffcb2a3e12c825ba4514c18961a5939c5d721e7a507f44fab
|
|
| MD5 |
306b7df104ccb1ce771560f7a149b781
|
|
| BLAKE2b-256 |
4d4186d2c1f403b668684c781366b31d9adf5111527ad51c0547d60866d8a056
|
File details
Details for the file polarticks-0.1.0-py3-none-any.whl.
File metadata
- Download URL: polarticks-0.1.0-py3-none-any.whl
- Upload date:
- Size: 34.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"25.10","id":"questing","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
273d493e52792cee7739d829184e7afed74db62f5a2080ba386221d776c0b48c
|
|
| MD5 |
d870ddbb9901e7daab779bd03ea8e490
|
|
| BLAKE2b-256 |
35c21325952166006ae4afb9ce066f978bafa636c2c939a58b2591448c3a9de0
|