Coverage for src/tinycta/osc.py: 100%

22 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-06 05:36 +0000

1"""Oscillator signal utilities built on Polars expressions. 

2 

3This module provides a helper to compute an oscillator from price series using 

4exponentially weighted moving averages (EWMA) and an analytical scaling factor. 

5The functions are designed to be used inside Polars pipelines 

6(e.g., with DataFrame.with_columns) and operate column-wise on numeric data. 

7""" 

8 

9import math 

10 

11import polars as pl 

12 

13 

14def osc(x: pl.Expr, fast: int, slow: int, min_samples: int = 1) -> pl.Expr: 

15 """Compute an analytically scaled EWMA-difference oscillator. 

16 

17 The oscillator is defined as (EMA_fast - EMA_slow) divided by the 

18 theoretical standard deviation of that difference under a unit-variance 

19 random walk: 

20 s = sqrt(1/(1-f²) - 2/(1-fg) + 1/(1-g²)) 

21 where f = 1 - 1/fast and g = 1 - 1/slow. 

22 

23 This gives consistent signal magnitudes regardless of the fast/slow 

24 parameter choice, without requiring a separate volatility lookback. 

25 

26 Args: 

27 x: Polars expression representing the price series to transform. 

28 fast: Fast EWMA length (interpreted via ``com=fast-1``). Must be > 1. 

29 slow: Slow EWMA length (interpreted via ``com=slow-1``). Must be > 1 and ``slow > fast``. 

30 min_samples: Minimum number of observations required before EWMA 

31 means are emitted; controls warmup period (earlier rows are 

32 null until this threshold is met). 

33 

34 Returns: 

35 pl.Expr: A Polars expression representing the oscillator values. 

36 

37 Raises: 

38 TypeError: If ``fast`` or ``slow`` are not integers. 

39 ValueError: If ``fast <= 1``, ``slow <= 1``, or ``fast >= slow``. 

40 

41 Example: 

42 >>> prices = pl.DataFrame({"A": [1,2,3,4,5,6,7,8,9,10]}) 

43 >>> df = prices.with_columns(osc(pl.col("A"), fast=2, slow=6).alias("osc_A")) 

44 """ 

45 if not isinstance(fast, int): 

46 msg = "fast must be an integer" 

47 raise TypeError(msg) 

48 if not isinstance(slow, int): 

49 msg = "slow must be an integer" 

50 raise TypeError(msg) 

51 if fast <= 1: 

52 msg = "fast must be greater than 1" 

53 raise ValueError(msg) 

54 if slow <= 1: 

55 msg = "slow must be greater than 1" 

56 raise ValueError(msg) 

57 if fast >= slow: 

58 msg = "fast must be less than slow" 

59 raise ValueError(msg) 

60 

61 f, g = 1 - 1 / fast, 1 - 1 / slow 

62 s = math.sqrt(1.0 / (1 - f * f) - 2.0 / (1 - f * g) + 1.0 / (1 - g * g)) 

63 

64 diff = x.ewm_mean(com=fast - 1, adjust=True, min_samples=min_samples) - x.ewm_mean( 

65 com=slow - 1, adjust=True, min_samples=min_samples 

66 ) 

67 return diff / s