1+ import pytest
2+ import polars as pl
3+ import inspect
4+ import importlib
5+ import pkgutil
6+ from project_x_py .indicators .base import BaseIndicator
7+
8+ def _concrete_indicator_classes ():
9+ # Recursively discover all non-abstract subclasses of BaseIndicator in project_x_py.indicators.*
10+ import project_x_py .indicators
11+ seen = set ()
12+ result = []
13+
14+ def onclass (cls ):
15+ if cls in seen :
16+ return
17+ seen .add (cls )
18+ # Must be subclass of BaseIndicator but not the base class itself
19+ if not issubclass (cls , BaseIndicator ) or cls is BaseIndicator :
20+ return
21+ # Skip abstract classes (those with any abstractmethods)
22+ if getattr (cls , "__abstractmethods__" , None ):
23+ return
24+ # Only include classes defined in project_x_py.indicators.*
25+ if not cls .__module__ .startswith ("project_x_py.indicators." ):
26+ return
27+ result .append (cls )
28+
29+ # Walk all modules in project_x_py.indicators package
30+ for finder , name , ispkg in pkgutil .walk_packages (project_x_py .indicators .__path__ , project_x_py .indicators .__name__ + "." ):
31+ try :
32+ mod = importlib .import_module (name )
33+ except Exception :
34+ continue # If import fails, skip that module
35+ for _ , obj in inspect .getmembers (mod , inspect .isclass ):
36+ onclass (obj )
37+ # Remove duplicates, sort by class name for determinism
38+ return sorted (set (result ), key = lambda cls : cls .__name__ )
39+
40+ @pytest .mark .parametrize ("indicator_cls" , _concrete_indicator_classes (), ids = lambda cls : cls .__name__ )
41+ def test_indicator_calculate_adds_new_column (indicator_cls , sample_ohlcv_df ):
42+ """
43+ For every indicator class: instantiate with default ctor, call .calculate() or __call__ on sample data.
44+ - No exception is raised.
45+ - Result is a polars.DataFrame with same row count.
46+ - At least one new column is present.
47+ """
48+ instance = indicator_cls ()
49+ input_cols = set (sample_ohlcv_df .columns )
50+ # Try __call__ first (uses caching), then fallback to .calculate
51+ try :
52+ out_df = instance (sample_ohlcv_df )
53+ except Exception :
54+ out_df = instance .calculate (sample_ohlcv_df )
55+
56+ assert isinstance (out_df , pl .DataFrame ), f"{ indicator_cls .__name__ } output is not a polars.DataFrame"
57+ assert out_df .height == sample_ohlcv_df .height , (
58+ f"{ indicator_cls .__name__ } output row count { out_df .height } != input { sample_ohlcv_df .height } "
59+ )
60+ new_cols = set (out_df .columns ) - input_cols
61+ assert new_cols , f"{ indicator_cls .__name__ } did not add any new columns"
62+
63+ def _get_new_column_names (indicator_cls , input_cols , df ):
64+ return set (df .columns ) - set (input_cols )
65+
66+ @pytest .mark .parametrize ("indicator_cls" , _concrete_indicator_classes (), ids = lambda cls : cls .__name__ )
67+ def test_indicator_caching_returns_same_object (indicator_cls , sample_ohlcv_df ):
68+ """
69+ Calling the indicator twice with the same df on the same instance should return the exact same DataFrame object (proves internal cache).
70+ """
71+ instance = indicator_cls ()
72+ # Use __call__ to trigger cache logic
73+ out1 = instance (sample_ohlcv_df )
74+ out2 = instance (sample_ohlcv_df )
75+ assert out1 is out2 , (
76+ f"{ indicator_cls .__name__ } did not return identical object on repeated call (cache broken?)"
77+ )
0 commit comments