polarticks 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,610 @@
1
+ Metadata-Version: 2.3
2
+ Name: polarticks
3
+ Version: 0.1.0
4
+ Summary: Polars-native technical analysis indicator library
5
+ Author: Molingus
6
+ Author-email: Molingus <stephen.c976@gmail.com>
7
+ Requires-Dist: polars>=1.0.0
8
+ Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
9
+ Requires-Dist: pytest-cov>=5.0.0 ; extra == 'dev'
10
+ Requires-Dist: ruff>=0.4.0 ; extra == 'dev'
11
+ Requires-Dist: mypy>=1.10.0 ; extra == 'dev'
12
+ Requires-Python: >=3.11
13
+ Provides-Extra: dev
14
+ Description-Content-Type: text/markdown
15
+
16
+ # PolarTicks
17
+
18
+ A Polars-native technical analysis library for Python.
19
+
20
+ Every indicator is implemented directly against the Polars API — no pandas
21
+ conversions, no NumPy loops where a vectorised expression will do. Inputs and
22
+ outputs are plain `pl.Series` or `pl.DataFrame` objects, so the results drop
23
+ straight into your existing Polars pipeline.
24
+
25
+ ---
26
+
27
+ ## Contents
28
+
29
+ - [Installation](#installation)
30
+ - [Quick start](#quick-start)
31
+ - [Input conventions](#input-conventions)
32
+ - [Null-prefix semantics](#null-prefix-semantics)
33
+ - [API reference](#api-reference)
34
+ - [Moving averages](#moving-averages)
35
+ - [Momentum & oscillators](#momentum--oscillators)
36
+ - [Volatility](#volatility)
37
+ - [Trend](#trend)
38
+ - [Volume](#volume)
39
+ - [Levels (pivot points)](#levels-pivot-points)
40
+ - [Candlestick patterns](#candlestick-patterns)
41
+ - [Utilities](#utilities)
42
+ - [Running tests](#running-tests)
43
+ - [Running benchmarks](#running-benchmarks)
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ # with uv (recommended)
51
+ uv add polarticks
52
+
53
+ # with pip
54
+ pip install polarticks
55
+ ```
56
+
57
+ Requires Python ≥ 3.11 and Polars ≥ 1.0.
58
+
59
+ ---
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ import polars as pl
65
+ import polarticks
66
+
67
+ # Load your OHLCV data however you like
68
+ df = pl.read_csv("prices.csv")
69
+
70
+ # Single-series indicators
71
+ close = df["close"]
72
+ rsi_14 = polarticks.rsi(close, period=14) # pl.Series
73
+ ema_20 = polarticks.ema(close, period=20) # pl.Series
74
+ macd_df = polarticks.macd(close) # pl.DataFrame (3 columns)
75
+
76
+ # Multi-column indicators
77
+ bb = polarticks.bollinger_bands(close, period=20) # pl.DataFrame (5 columns)
78
+ atr = polarticks.atr(df, period=14) # pl.Series
79
+
80
+ # Pattern detection
81
+ signals = polarticks.is_bullish_engulfing(df) # pl.Series[bool]
82
+
83
+ # Attach results to your DataFrame
84
+ df = df.with_columns([
85
+ rsi_14.alias("rsi_14"),
86
+ ema_20.alias("ema_20"),
87
+ *bb.get_columns(), # spread all band columns
88
+ signals.alias("bull_engulf"),
89
+ ])
90
+ ```
91
+
92
+ ### A complete signal pipeline
93
+
94
+ ```python
95
+ import polars as pl
96
+ import polarticks
97
+
98
+ df = pl.read_csv("eurusd_h1.csv")
99
+ close = df["close"]
100
+
101
+ # Compute a fast/slow EMA crossover and RSI filter
102
+ fast = polarticks.ema(close, 9)
103
+ slow = polarticks.ema(close, 21)
104
+
105
+ df = df.with_columns([
106
+ fast.alias("ema_9"),
107
+ slow.alias("ema_21"),
108
+ polarticks.rsi(close, 14).alias("rsi"),
109
+ polarticks.crossover(fast, slow).alias("cross_up"),
110
+ polarticks.crossunder(fast, slow).alias("cross_dn"),
111
+ ])
112
+
113
+ # Long entry: crossover AND RSI not overbought
114
+ df = df.with_columns(
115
+ (pl.col("cross_up") & (pl.col("rsi") < 70)).alias("long_entry")
116
+ )
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Input conventions
122
+
123
+ ### Single-series indicators
124
+
125
+ Functions like `sma`, `ema`, `rsi`, `roc`, `hma` accept a `pl.Series`:
126
+
127
+ ```python
128
+ result = polarticks.sma(df["close"], period=20)
129
+ ```
130
+
131
+ ### OHLC / OHLCV indicators
132
+
133
+ Functions that need multiple price columns accept a `pl.DataFrame`. The
134
+ expected column names are always lowercase: `open`, `high`, `low`, `close`,
135
+ `volume`.
136
+
137
+ ```python
138
+ result = polarticks.atr(df, period=14) # needs high, low, close
139
+ result = polarticks.mfi(df, period=14) # needs high, low, close, volume
140
+ result = polarticks.stochastic(df) # needs high, low, close
141
+ ```
142
+
143
+ ### Pivot points
144
+
145
+ Pivot point functions accept individual `pl.Series` (one per OHLC component)
146
+ rather than a DataFrame. This lets you broadcast yesterday's session values
147
+ across today's intraday bars however your data model requires.
148
+
149
+ ```python
150
+ # Scalar broadcast: yesterday's values repeated across all bars
151
+ n = len(df)
152
+ levels = polarticks.pivot_points_floor(
153
+ prev_high = pl.Series([prev_high] * n),
154
+ prev_low = pl.Series([prev_low] * n),
155
+ prev_close = pl.Series([prev_close] * n),
156
+ )
157
+
158
+ # Rolling: shift the daily OHLC so each bar sees the prior day
159
+ daily = df.group_by_dynamic("date", every="1d").agg(...)
160
+ levels = polarticks.pivot_points_floor(
161
+ prev_high = daily["high"].shift(1),
162
+ prev_low = daily["low"].shift(1),
163
+ prev_close = daily["close"].shift(1),
164
+ )
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Null-prefix semantics
170
+
171
+ Every indicator returns exactly as many leading `null` values as its algorithm
172
+ requires before it can produce a valid output. **No zeros are substituted in
173
+ the warm-up region.** This means:
174
+
175
+ - `sma(n)` → `n − 1` leading nulls
176
+ - `ema(n)` → `n − 1` leading nulls
177
+ - `rsi(n)` → `n` leading nulls (one extra from the initial `diff`)
178
+ - `dema(n)` → `2 × (n − 1)` leading nulls (two EMA passes)
179
+
180
+ When you attach indicator columns to a DataFrame, Polars propagates nulls
181
+ through any downstream arithmetic exactly as you would expect — no silent
182
+ zeroes contaminating your signals.
183
+
184
+ ```python
185
+ # Safe: Polars propagates nulls through arithmetic
186
+ signal = pl.col("rsi") < 30 # null where rsi is null, False otherwise (after fill)
187
+
188
+ # If you need to fill before a join or export:
189
+ rsi = polarticks.rsi(close, 14).fill_null(strategy="forward")
190
+ ```
191
+
192
+ ---
193
+
194
+ ## API reference
195
+
196
+ ### Moving averages
197
+
198
+ All moving averages accept `(series: pl.Series, period: int)` unless noted
199
+ and return a `pl.Series`.
200
+
201
+ | Function | Description | Leading nulls |
202
+ |---|---|---|
203
+ | `sma(series, period)` | Simple Moving Average | `period − 1` |
204
+ | `ema(series, period)` | Exponential MA (α = 2/(n+1)) | `period − 1` |
205
+ | `wma(series, period)` | Linearly Weighted MA | `period − 1` |
206
+ | `wilder_smooth(series, period)` | Wilder's RMA (α = 1/n) | `period − 1` |
207
+ | `dema(series, period)` | Double EMA — `2·EMA − EMA(EMA)` | `2·(period−1)` |
208
+ | `tema(series, period)` | Triple EMA | `3·(period−1)` |
209
+ | `hma(series, period)` | Hull MA — reduces lag | `(period−1) + (√period−1)` |
210
+ | `vwma(price, volume, period)` | Volume-Weighted MA | `period − 1` |
211
+ | `mcginley_dynamic(series, period)` | Self-adjusting MA | `period − 1` |
212
+
213
+ ```python
214
+ close = df["close"]
215
+ volume = df["volume"]
216
+
217
+ sma20 = polarticks.sma(close, 20)
218
+ ema20 = polarticks.ema(close, 20)
219
+ hma20 = polarticks.hma(close, 20)
220
+ vwma20 = polarticks.vwma(close, volume, 20)
221
+ ```
222
+
223
+ **HMA** is particularly useful when you need low lag without excessive noise.
224
+ It runs `WMA(2·WMA(n/2) − WMA(n), √n)` and has a warm-up of
225
+ `(n−1) + (⌈√n⌉−1)` bars.
226
+
227
+ **McGinley Dynamic** self-corrects its smoothing speed based on how fast price
228
+ is moving relative to the current indicator value. Seed is the SMA of the
229
+ first `period` bars.
230
+
231
+ ---
232
+
233
+ ### Momentum & oscillators
234
+
235
+ #### `rsi(series, period=14)` → `pl.Series`
236
+
237
+ Relative Strength Index via Wilder's smoothing. Values in [0, 100].
238
+
239
+ ```python
240
+ rsi = polarticks.rsi(df["close"], 14)
241
+ overbought = rsi > 70
242
+ oversold = rsi < 30
243
+ ```
244
+
245
+ #### `macd(series, fast=12, slow=26, signal=9)` → `pl.DataFrame`
246
+
247
+ Returns a DataFrame with three columns: `macd_line`, `macd_signal`,
248
+ `macd_histogram`.
249
+
250
+ ```python
251
+ m = polarticks.macd(df["close"])
252
+ # m["macd_line"] — fast EMA minus slow EMA
253
+ # m["macd_signal"] — EMA of the MACD line
254
+ # m["macd_histogram"] — line minus signal
255
+ ```
256
+
257
+ #### `stochastic(ohlc, k_period=14, d_period=3)` → `pl.DataFrame`
258
+
259
+ Returns `stoch_k` and `stoch_d`. Both range from 0 to 100.
260
+
261
+ ```python
262
+ st = polarticks.stochastic(df)
263
+ cross_up = polarticks.crossover(st["stoch_k"], st["stoch_d"])
264
+ ```
265
+
266
+ #### `williams_r(ohlc, period=14)` → `pl.Series`
267
+
268
+ Williams %R in the range [−100, 0]. Readings near 0 are overbought; near
269
+ −100 are oversold.
270
+
271
+ #### `cci(ohlc, period=20)` → `pl.Series`
272
+
273
+ Commodity Channel Index. Readings above +100 suggest overbought; below −100
274
+ suggest oversold.
275
+
276
+ #### `roc(series, period=10)` → `pl.Series`
277
+
278
+ Rate of Change as a percentage: `100 × (close − close[n]) / close[n]`.
279
+ Has `period` leading nulls (one more than most indicators) because it uses
280
+ `shift(period)`.
281
+
282
+ #### `mfi(ohlcv, period=14)` → `pl.Series`
283
+
284
+ Money Flow Index — the volume-weighted version of RSI. Requires a `volume`
285
+ column. Values in [0, 100].
286
+
287
+ #### `cmf(ohlcv, period=20)` → `pl.Series`
288
+
289
+ Chaikin Money Flow. Measures buying vs. selling pressure in the range [−1, 1].
290
+ Positive values indicate accumulation.
291
+
292
+ #### `tsi(series, slow=25, fast=13)` → `pl.Series`
293
+
294
+ True Strength Index — double-smoothed momentum oscillator. Values in
295
+ (−100, +100). Signal line: apply `ema(tsi, 7)` to the output.
296
+
297
+ ```python
298
+ tsi_vals = polarticks.tsi(df["close"])
299
+ signal = polarticks.ema(tsi_vals.fill_null(0.0), 7) # fill before second EMA
300
+ ```
301
+
302
+ #### `ultimate_oscillator(ohlc, period1=7, period2=14, period3=28)` → `pl.Series`
303
+
304
+ Weighted blend of three time-frame buying-pressure ratios. Values in [0, 100].
305
+ Warm-up is `period3 − 1` bars.
306
+
307
+ ---
308
+
309
+ ### Volatility
310
+
311
+ #### `true_range(ohlc)` → `pl.Series`
312
+
313
+ Single-bar True Range — the greatest of `H−L`, `|H−prev_C|`, `|L−prev_C|`.
314
+ The first bar has no prior close; its TR collapses to `H−L` (Wilder's
315
+ convention). Zero leading nulls.
316
+
317
+ #### `atr(ohlc, period=14)` → `pl.Series`
318
+
319
+ Average True Range via Wilder's smoothing. `period − 1` leading nulls.
320
+
321
+ #### `bollinger_bands(series, period=20, num_std=2.0)` → `pl.DataFrame`
322
+
323
+ Returns five columns:
324
+
325
+ | Column | Description |
326
+ |---|---|
327
+ | `bb_middle_{period}` | SMA of close |
328
+ | `bb_upper_{period}` | Middle + `num_std` × rolling std |
329
+ | `bb_lower_{period}` | Middle − `num_std` × rolling std |
330
+ | `bb_pct_b_{period}` | Position within the band (0 = lower, 1 = upper) |
331
+ | `bb_width_{period}` | (Upper − Lower) / Middle |
332
+
333
+ ```python
334
+ bb = polarticks.bollinger_bands(df["close"], 20)
335
+ squeeze = bb["bb_width_20"] < bb["bb_width_20"].rolling_mean(20)
336
+ ```
337
+
338
+ #### `keltner_channels(ohlc, ema_period=20, atr_period=10, multiplier=2.0)` → `pl.DataFrame`
339
+
340
+ Returns `kc_middle`, `kc_upper`, `kc_lower`. Uses ATR for band width rather
341
+ than standard deviation, making the channels less reactive to individual large
342
+ moves.
343
+
344
+ #### `chaikin_volatility(ohlc, ema_period=10, roc_period=10)` → `pl.Series`
345
+
346
+ Rate of change of the EMA of the high-low range. Rising values signal
347
+ increasing volatility. Leading nulls: `(ema_period − 1) + roc_period`.
348
+
349
+ #### `historical_volatility(series, period=20, annualise=True, trading_days=252)` → `pl.Series`
350
+
351
+ Rolling annualised standard deviation of log returns. Set `trading_days=365`
352
+ for crypto or `260` for FX. `period` leading nulls.
353
+
354
+ #### `ulcer_index(series, period=14)` → `pl.Series`
355
+
356
+ Drawdown-based volatility: `√(mean(pct_drawdown², period))`. Only penalises
357
+ downside moves; useful for risk-adjusted metrics like the Ulcer Performance
358
+ Index. Leading nulls: `2 × (period − 1)` (rolling max then rolling mean).
359
+
360
+ ---
361
+
362
+ ### Trend
363
+
364
+ #### `donchian_channels(ohlc, period=20)` → `pl.DataFrame`
365
+
366
+ Returns `dc_upper_{period}`, `dc_lower_{period}`, `dc_middle_{period}`.
367
+ Breakout above the upper channel or below the lower channel signals a
368
+ trend initiation.
369
+
370
+ #### `adx(ohlc, period=14)` → `pl.DataFrame`
371
+
372
+ Average Directional Index with directional components.
373
+
374
+ | Column | Description |
375
+ |---|---|
376
+ | `adx_{period}` | Trend strength (0–100; >25 = trending) |
377
+ | `plus_di_{period}` | Bullish directional movement |
378
+ | `minus_di_{period}` | Bearish directional movement |
379
+
380
+ Leading nulls: `period − 1` for the DI columns; `2 × (period − 1)` for ADX
381
+ (it is Wilder-smoothed DX, which is itself Wilder-smoothed).
382
+
383
+ ```python
384
+ result = polarticks.adx(df, 14)
385
+ trending = result["adx_14"] > 25
386
+ bull_trend = result["plus_di_14"] > result["minus_di_14"]
387
+ ```
388
+
389
+ #### `supertrend(ohlc, period=7, multiplier=3.0)` → `pl.DataFrame`
390
+
391
+ ATR-based trailing stop that also indicates trend direction.
392
+
393
+ | Column | Description |
394
+ |---|---|
395
+ | `supertrend` | Band level (support in uptrend, resistance in downtrend) |
396
+ | `supertrend_direction` | `+1` (bullish) or `−1` (bearish) |
397
+
398
+ ```python
399
+ st = polarticks.supertrend(df, period=10, multiplier=2.0)
400
+ entries = st["supertrend_direction"].diff() == 2 # flipped to bullish
401
+ ```
402
+
403
+ #### `parabolic_sar(ohlc, initial_af=0.02, step_af=0.02, max_af=0.20)` → `pl.DataFrame`
404
+
405
+ Parabolic SAR dot plot. Returns `psar` (price level) and `psar_direction`
406
+ (`+1` uptrend / `−1` downtrend). One leading null (initialised from bar 1).
407
+
408
+ ```python
409
+ sar = polarticks.parabolic_sar(df)
410
+ flip_to_bull = sar["psar_direction"].diff() == 2
411
+ ```
412
+
413
+ ---
414
+
415
+ ### Volume
416
+
417
+ #### `obv(ohlcv)` → `pl.Series`
418
+
419
+ On-Balance Volume — running cumulative sum of signed volume. Volume is added
420
+ on up-bars and subtracted on down-bars. No leading nulls; starts accumulating
421
+ from bar 0.
422
+
423
+ ```python
424
+ obv = polarticks.obv(df)
425
+ obv_trend = polarticks.ema(obv, 20) # smooth OBV to spot divergences
426
+ ```
427
+
428
+ #### `vwap(ohlcv, session_start_hour=22)` → `pl.Series`
429
+
430
+ Session-anchored VWAP. If your DataFrame has a `time` column (Polars
431
+ `Datetime`), a new session is started at every bar whose UTC hour equals
432
+ `session_start_hour`. Without a `time` column the entire series is treated
433
+ as one session. No leading nulls.
434
+
435
+ ```python
436
+ vwap = polarticks.vwap(df, session_start_hour=0) # midnight UTC sessions
437
+ above_vwap = df["close"] > vwap
438
+ ```
439
+
440
+ #### `vwap_bands(ohlcv, session_start_hour=22)` → `pl.DataFrame`
441
+
442
+ VWAP with ±1σ and ±2σ volume-weighted standard-deviation bands. Returns
443
+ `vwap`, `upper_1`, `lower_1`, `upper_2`, `lower_2`. No leading nulls.
444
+
445
+ ---
446
+
447
+ ### Levels (pivot points)
448
+
449
+ All pivot point functions accept `pl.Series` arguments (one per price
450
+ component) and return a `pl.DataFrame`. Pass yesterday's values — scalar
451
+ broadcast or a shifted daily series — aligned to your current-session bars.
452
+
453
+ #### `pivot_points_floor(prev_high, prev_low, prev_close)` → `pl.DataFrame`
454
+
455
+ Classic floor-trader pivots. Columns: `pp`, `r1`, `r2`, `r3`, `s1`, `s2`, `s3`.
456
+
457
+ #### `pivot_points_camarilla(prev_high, prev_low, prev_close)` → `pl.DataFrame`
458
+
459
+ Camarilla equation (multiplier 1.1). Produces tighter intraday levels suited
460
+ to mean-reversion scalping. Columns: `cam_r1`–`cam_r4`, `cam_s1`–`cam_s4`.
461
+
462
+ #### `pivot_points_fibonacci(prev_high, prev_low, prev_close)` → `pl.DataFrame`
463
+
464
+ Fibonacci-ratio levels (0.382, 0.618, 1.000 × range).
465
+ Columns: `fib_pp`, `fib_r1`–`fib_r3`, `fib_s1`–`fib_s3`.
466
+
467
+ #### `pivot_points_woodie(prev_high, prev_low, prev_close)` → `pl.DataFrame`
468
+
469
+ Double-weights the prior close. Columns: `wood_pp`, `wood_r1`, `wood_r2`,
470
+ `wood_s1`, `wood_s2`.
471
+
472
+ #### `pivot_points_demark(prev_open, prev_high, prev_low, prev_close)` → `pl.DataFrame`
473
+
474
+ Adapts the formula based on whether the prior session closed above, below, or
475
+ equal to its open. Returns a single resistance and support level.
476
+ Columns: `dm_pp`, `dm_r1`, `dm_s1`.
477
+
478
+ ```python
479
+ n = len(df)
480
+ levels = polarticks.pivot_points_floor(
481
+ pl.Series([yesterday_high] * n),
482
+ pl.Series([yesterday_low] * n),
483
+ pl.Series([yesterday_close] * n),
484
+ )
485
+ df = df.with_columns(levels.get_columns())
486
+ ```
487
+
488
+ ---
489
+
490
+ ### Candlestick patterns
491
+
492
+ All pattern functions accept a `pl.DataFrame` with `open`, `high`, `low`,
493
+ `close` columns and return a Boolean `pl.Series` — `True` on bars where the
494
+ pattern is present, `False` everywhere else (including leading bars that
495
+ cannot satisfy the look-back requirement).
496
+
497
+ #### Single-bar patterns
498
+
499
+ | Function | Description |
500
+ |---|---|
501
+ | `is_doji(ohlc, threshold=0.1)` | Body is < 10% of the bar's range |
502
+ | `is_pin_bar_bullish(ohlc, wick_ratio=0.6, body_ratio=0.25)` | Hammer: small body, long lower wick |
503
+ | `is_pin_bar_bearish(ohlc, wick_ratio=0.6, body_ratio=0.25)` | Shooting star: small body, long upper wick |
504
+
505
+ #### Two-bar patterns
506
+
507
+ | Function | Description |
508
+ |---|---|
509
+ | `is_bullish_engulfing(ohlc)` | Bearish bar followed by a larger bullish bar that engulfs it |
510
+ | `is_bearish_engulfing(ohlc)` | Bullish bar followed by a larger bearish bar that engulfs it |
511
+ | `is_inside_bar(ohlc)` | Current bar's range is entirely within the prior bar's range |
512
+ | `is_bullish_harami(ohlc)` | Small bullish body inside a large prior bearish body |
513
+ | `is_bearish_harami(ohlc)` | Small bearish body inside a large prior bullish body |
514
+
515
+ #### Three-bar patterns
516
+
517
+ | Function | Description |
518
+ |---|---|
519
+ | `is_three_white_soldiers(ohlc, body_ratio=0.5)` | Three consecutive advancing bullish candles |
520
+ | `is_three_black_crows(ohlc, body_ratio=0.5)` | Three consecutive declining bearish candles |
521
+ | `is_morning_star(ohlc, body_ratio=0.3, star_body_ratio=0.15)` | Bearish → small star → bullish reversal |
522
+ | `is_evening_star(ohlc, body_ratio=0.3, star_body_ratio=0.15)` | Bullish → small star → bearish reversal |
523
+
524
+ ```python
525
+ # Combine patterns and indicators for a signal
526
+ bull_signals = (
527
+ polarticks.is_bullish_engulfing(df)
528
+ | polarticks.is_morning_star(df)
529
+ | polarticks.is_pin_bar_bullish(df)
530
+ )
531
+
532
+ df = df.with_columns([
533
+ bull_signals.alias("bull_pattern"),
534
+ polarticks.rsi(df["close"], 14).alias("rsi"),
535
+ ])
536
+
537
+ entries = df.filter(pl.col("bull_pattern") & (pl.col("rsi") < 40))
538
+ ```
539
+
540
+ ---
541
+
542
+ ### Utilities
543
+
544
+ #### `crossover(fast, slow, atol=0.0)` → `pl.Series[bool]`
545
+
546
+ `True` on the single bar where `fast` crosses above `slow`. The optional
547
+ `atol` prevents double-signals from floating-point noise right at the crossing
548
+ price.
549
+
550
+ #### `crossunder(fast, slow, atol=0.0)` → `pl.Series[bool]`
551
+
552
+ `True` on the single bar where `fast` crosses below `slow`.
553
+
554
+ ```python
555
+ fast = polarticks.ema(close, 9)
556
+ slow = polarticks.ema(close, 21)
557
+
558
+ long_entry = polarticks.crossover(fast, slow)
559
+ short_entry = polarticks.crossunder(fast, slow)
560
+
561
+ # Noise-tolerant version for choppy markets
562
+ long_entry = polarticks.crossover(fast, slow, atol=0.05)
563
+ ```
564
+
565
+ #### `log_returns(series)` → `pl.Series`
566
+
567
+ Bar-to-bar log returns: `ln(price[t] / price[t-1])`. One leading null.
568
+
569
+ #### `simple_returns(series)` → `pl.Series`
570
+
571
+ Bar-to-bar simple (arithmetic) returns. One leading null.
572
+
573
+ ---
574
+
575
+ ## Running tests
576
+
577
+ ```bash
578
+ uv run pytest tests/unit/ # 232 unit tests
579
+ uv run pytest tests/ # all tests (includes benchmarks — takes ~90 s)
580
+ ```
581
+
582
+ To run only the null-prefix consistency audit:
583
+
584
+ ```bash
585
+ uv run pytest tests/unit/test_null_prefix.py -v
586
+ ```
587
+
588
+ Type-check the package:
589
+
590
+ ```bash
591
+ uv run mypy src/polarticks/ --strict
592
+ ```
593
+
594
+ ---
595
+
596
+ ## Running benchmarks
597
+
598
+ The benchmark suite exercises every indicator on a 100 000-bar OHLCV series:
599
+
600
+ ```bash
601
+ uv run pytest tests/benchmark/ --benchmark-only
602
+ ```
603
+
604
+ Save a baseline and compare across changes:
605
+
606
+ ```bash
607
+ uv run pytest tests/benchmark/ --benchmark-only --benchmark-save=baseline
608
+ # ... make changes ...
609
+ uv run pytest tests/benchmark/ --benchmark-only --benchmark-compare=baseline
610
+ ```