From d286f65634d444e121745761f203eb85aa35cb49 Mon Sep 17 00:00:00 2001 From: wxs-lang <393456156@qq.com> Date: Sun, 25 Oct 2026 11:30:48 +0800 Subject: [PATCH 1/5] Add pytest coverage for indicator windows, column syntax and static helpers (129 tests) Add conftest.py to configure test environment for stockstats --- tests/conftest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..518192a3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Make the single-module ``stockstats`` package importable. + +In the repository the tests live in ``tests/`` next to ``stockstats.py``, +so the repository root (the parent of this directory) is added to +``sys.path``. No production code is touched. +""" + +import os +import sys + +sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) From fa4b67a3893912ca7ce7bf27bec0adad8bd6a83a Mon Sep 17 00:00:00 2001 From: wxs-lang <393456156@qq.com> Date: Sun, 25 Oct 2026 11:31:31 +0800 Subject: [PATCH 2/5] Add pytest coverage for indicator windows, column syntax and static helpers (129 tests) --- tests/test_meta_and_names.py | 200 +++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/test_meta_and_names.py diff --git a/tests/test_meta_and_names.py b/tests/test_meta_and_names.py new file mode 100644 index 00000000..04d9cbc3 --- /dev/null +++ b/tests/test_meta_and_names.py @@ -0,0 +1,200 @@ +"""Tests for the column-name machinery: ``_Meta``, default windows and +column parsing. + +All values below were measured by probing the implementation, not taken +from the documentation. The upstream suite covers a few of these paths; +this file pins the rest, including the ``~`` range syntax and the +partial-window fallback to defaults. +""" + +import pytest + +import stockstats +from stockstats import ( + StockDataFrame, + _Meta, + dft_column, + dft_windows, + set_dft_window, +) + + +def test_dft_windows_int_and_tuple(): + assert dft_windows("rsi") == "14" + assert dft_windows("macd") == "12,26,9" + assert dft_windows("boll") == "20" + + +def test_dft_windows_unknown_is_none(): + assert dft_windows("no_such_indicator") is None + + +def test_dft_column_known_and_unknown(): + assert dft_column("dma") == "close" + assert dft_column("kama") == "close" + assert dft_column("rsi") is None + assert dft_column("no_such_indicator") is None + + +def test_meta_single_window(): + meta = _Meta("rsi", windows="5") + assert meta.ints == [5] + assert meta.as_int == 5 + assert meta.name == "rsi_5" + + +def test_meta_range_window(): + meta = _Meta("rsi", windows="1~3") + assert meta.ints == [1, 2, 3] + + +def test_meta_comma_windows(): + meta = _Meta("macd", windows="5,10,3") + assert meta.ints == [5, 10, 3] + assert meta.name == "macd_5,10,3" + + +def test_meta_partial_windows_fall_back_to_defaults(): + # Only two windows given for MACD; the third falls back to the + # default signal window (9), and the name keeps the given windows. + meta = _Meta("macd", windows="5,10") + assert meta.int0 == 5 + assert meta.int1 == 10 + assert meta.int2 == 9 + assert meta.name == "macd_5,10" + assert meta.name_ex("s") == "macds_5,10" + + +def test_meta_name_without_windows(): + meta = _Meta("rsi") + assert meta.name == "rsi" + assert meta.name_ex("s") == "rsis" + + +def test_meta_name_with_column_and_windows(): + meta = _Meta("dma", windows="10,50", column="high") + assert meta.column == "high" + assert meta.name == "high_10,50_dma" + + +def test_meta_column_defaults_to_dft_column(): + meta = _Meta("dma", windows="10,50") + assert meta.column == "close" + + +def test_meta_column_none_when_no_default(): + meta = _Meta("rsi", windows="5") + assert meta.column is None + + +def test_meta_as_int_rejects_multiple_numbers(): + with pytest.raises(stockstats.StockStatsError): + _Meta("macd", windows="5,10").as_int + + +def test_meta_positive_int_rejects_zero(): + with pytest.raises(stockstats.StockStatsError): + _Meta("rsi", windows="0").positive_int + + +def test_meta_positive_int_accepts_positive(): + assert _Meta("rsi", windows="7").positive_int == 7 + + +def test_meta_set_name(): + meta = _Meta("rvgi") + assert meta.set_name("rvgi") is meta + assert meta.name == "rvgi" + + +def test_set_dft_window_returns_old_and_restores(): + old = set_dft_window("rsi", 7) + try: + assert old == 14 + assert dft_windows("rsi") == "7" + finally: + set_dft_window("rsi", old) + assert dft_windows("rsi") == "14" + + +def test_set_dft_window_new_name_returns_none(): + old = set_dft_window("zzz_probe_name", 3) + try: + assert old is None + assert dft_windows("zzz_probe_name") == "3" + finally: + stockstats._dft_windows.pop("zzz_probe_name", None) + + +def test_parse_column_name_with_range(): + assert StockDataFrame.parse_column_name("close_1~3_max") == ( + "close", "1~3", "max") + + +def test_parse_column_name_plain_window(): + assert StockDataFrame.parse_column_name("close_5_dma") == ( + "close", "5", "dma") + + +def test_parse_column_name_two_part(): + assert StockDataFrame.parse_column_name("ppo_5,10,3") == ( + "ppo", "5,10,3") + + +def test_parse_column_name_no_match(): + assert StockDataFrame.parse_column_name("eri") == (None,) + assert StockDataFrame.parse_column_name("bogus") == (None,) + + +def test_to_ints_sorted_and_deduped(): + assert StockDataFrame.to_ints("1~3,5,3") == [1, 2, 3, 5] + assert StockDataFrame.to_ints("2") == [2] + + +def test_to_int_single_and_multi(): + assert StockDataFrame.to_int("7") == 7 + with pytest.raises(IndexError): + StockDataFrame.to_int("1,2") + + +def test_process_shifts_segment_range_and_single(): + seg = StockDataFrame._process_shifts_segment + assert list(seg("2~4")) == [2, 3, 4] + assert seg("9") == [9] + + +def test_is_cross_columns(): + assert StockDataFrame.is_cross_columns("close_x_macd") + assert StockDataFrame.is_cross_columns("close_xu_macd") + assert StockDataFrame.is_cross_columns("close_xd_macd") + assert not StockDataFrame.is_cross_columns("close_5_sma") + + +def test_is_compare_columns(): + for op in ("le", "ge", "lt", "gt", "eq", "ne"): + assert StockDataFrame.is_compare_columns(f"close_{op}_macd") + assert not StockDataFrame.is_compare_columns("close_x_macd") + + +def test_parse_cross_column(): + # Success returns a tuple; no match returns a list of Nones. + assert StockDataFrame.parse_cross_column("close_xu_macd") == ( + "close", "xu", "macd") + assert StockDataFrame.parse_cross_column("plain") == [None, None, None] + + +def test_parse_compare_column(): + assert StockDataFrame.parse_compare_column("close_ne_macd") == ( + "close", "ne", "macd") + assert StockDataFrame.parse_compare_column("plain") == [ + None, None, None] + + +def test_get_int_positive_int_zero_quirk(): + # Quirk, pinned as-is: the int path does not check positivity, only + # the string path does. + assert StockDataFrame.get_int_positive(0) == 0 + with pytest.raises(IndexError): + StockDataFrame.get_int_positive("0") + assert StockDataFrame.get_int_positive("5") == 5 + assert StockDataFrame.get_int_positive(5) == 5 From 2ebe41f0b000193b0560af592aafe2f27f95df15 Mon Sep 17 00:00:00 2001 From: wxs-lang <393456156@qq.com> Date: Sun, 25 Oct 2026 11:32:22 +0800 Subject: [PATCH 3/5] Add pytest coverage for indicator windows, column syntax and static helpers (129 tests) --- tests/test_static_helpers.py | 194 +++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/test_static_helpers.py diff --git a/tests/test_static_helpers.py b/tests/test_static_helpers.py new file mode 100644 index 00000000..6e2a3e8f --- /dev/null +++ b/tests/test_static_helpers.py @@ -0,0 +1,194 @@ +"""Tests for the pure static helpers on ``StockDataFrame``. + +These helpers are exercised directly (no DataFrame column access), so +each expectation is a plain numeric fact measured by probing. +""" + +import math + +import numpy as np +import pandas as pd +import pytest + +from stockstats import StockDataFrame + + +def test_roc_size_zero_is_all_zeros(): + out = StockDataFrame.roc(pd.Series([1.0, 2.0, 3.0]), 0) + assert list(out) == [0.0, 0.0, 0.0] + + +def test_roc_positive_window(): + out = StockDataFrame.roc(pd.Series([10.0, 20.0, 30.0]), 1) + assert out[0] == 0.0 + assert out[1] == pytest.approx(100.0, rel=1e-3) + assert out[2] == pytest.approx(50.0, rel=1e-3) + + +def test_roc_negative_window_looks_forward(): + out = StockDataFrame.roc(pd.Series([10.0, 20.0, 30.0]), -1) + assert out[0] == pytest.approx(-50.0, rel=1e-3) + assert out[1] == pytest.approx(-33.3333, rel=1e-3) + assert out[2] == 0.0 + + +def test_roc_zero_base_gives_inf_quirk(): + # Quirk, pinned as-is: roc() does not guard division by zero, unlike + # _divide(); a zero base yields inf (with a RuntimeWarning). + out = StockDataFrame.roc(pd.Series([0.0, 10.0, 20.0]), 1) + assert out[0] == 0.0 + assert math.isinf(out[1]) + assert out[2] == pytest.approx(100.0, rel=1e-3) + + +def test_s_shift_zero_returns_copy(): + series = pd.Series([1.0, 2.0, 3.0]) + out = StockDataFrame.s_shift(series, 0) + assert list(out) == [1.0, 2.0, 3.0] + assert out is not series + + +def test_s_shift_positive_pulls_future_and_repeats_last(): + out = StockDataFrame.s_shift(pd.Series([1.0, 2.0, 3.0, 4.0]), 1) + assert list(out) == [2.0, 3.0, 4.0, 4.0] + + +def test_s_shift_negative_pulls_past_and_repeats_first(): + out = StockDataFrame.s_shift(pd.Series([1.0, 2.0, 3.0, 4.0]), -1) + assert list(out) == [1.0, 1.0, 2.0, 3.0] + + +def test_s_shift_empty_series(): + out = StockDataFrame.s_shift(pd.Series([], dtype=float), 3) + assert len(out) == 0 + + +def test_set_nan_positive_shift_nans_tail(): + series = pd.Series([1.0, 2.0, 3.0, 4.0]) + StockDataFrame.set_nan(series, 2) + assert series[0] == 1.0 and series[1] == 2.0 + assert math.isnan(series[2]) and math.isnan(series[3]) + + +def test_set_nan_negative_shift_nans_head(): + series = pd.Series([1.0, 2.0, 3.0, 4.0]) + StockDataFrame.set_nan(series, -2) + assert math.isnan(series[0]) and math.isnan(series[1]) + assert series[2] == 3.0 and series[3] == 4.0 + + +def test_set_nan_list_nans_both_ends(): + series = pd.Series([1.0, 2.0, 3.0, 4.0]) + StockDataFrame.set_nan(series, [1, 3]) + assert series[0] == 1.0 + assert math.isnan(series[1]) + assert math.isnan(series[2]) + assert math.isnan(series[3]) + + +def test_divide_zero_divisor_gives_zero_not_inf(): + out = StockDataFrame._divide( + np.array([1.0, 2.0]), np.array([0.0, 2.0])) + assert list(out) == [0.0, 1.0] + + +def test_divide_empty_arrays(): + out = StockDataFrame._divide(np.array([]), np.array([])) + assert len(out) == 0 + + +def test_rolling_sum_partial_windows(): + out = StockDataFrame._rolling_sum(np.array([1.0, 0.0, 1.0, 1.0]), 2) + assert list(out) == [1.0, 1.0, 1.0, 2.0] + + +def test_mad_window_larger_than_series_is_all_nan(): + out = StockDataFrame._mad(pd.Series([1.0, 2.0]), 5) + assert math.isnan(out[0]) and math.isnan(out[1]) + + +def test_mad_exact_value(): + out = StockDataFrame._mad(pd.Series([2.0, 4.0, 4.0, 4.0, 5.0, 5.0]), 6) + assert out[5] == pytest.approx(0.6667, rel=1e-3) + + +def test_linear_wma_window_larger_than_series_is_zeros(): + out = StockDataFrame.linear_wma(pd.Series([1.0, 2.0]), 5) + assert list(out) == [0.0, 0.0] + + +def test_linear_wma_exact_value_and_wma_alias(): + series = pd.Series([10.0, 15.0, 15.0, 17.0, 18.0, 21.0]) + out = StockDataFrame.linear_wma(series, 6) + assert out[0] == 0.0 + assert out[5] == pytest.approx(17.5714, rel=1e-3) + assert list(StockDataFrame.wma(series, 6)) == list(out) + + +def test_sym_wma4_exact_value(): + out = StockDataFrame.sym_wma4(pd.Series([1.0, 2.0, 3.0, 4.0])) + assert list(out[:3]) == [0.0, 0.0, 0.0] + assert out[3] == pytest.approx(2.5, rel=1e-3) + + +def test_hma_window_one_is_identity(): + series = pd.Series([10.0, 15.0, 15.0, 17.0, 18.0, 21.0]) + assert list(StockDataFrame.hma(series, 1)) == list(series) + + +def test_hma_window_larger_than_series_is_zeros(): + series = pd.Series([10.0, 15.0, 15.0, 17.0, 18.0, 21.0]) + assert list(StockDataFrame.hma(series, 10)) == [0.0] * 6 + + +def test_ssl_channel_two_rows_bullish(): + high = pd.Series([10.0, 12.0]) + low = pd.Series([8.0, 10.0]) + close = pd.Series([11.0, 13.0]) + up, down = StockDataFrame.ssl_channel(high, low, close, 2) + # SMA(high, 2) = [10, 11], SMA(low, 2) = [8, 9]; close stays above + # the high SMA, so hlv stays +1 and up is the high SMA. + assert list(up) == [10.0, 11.0] + assert list(down) == [8.0, 9.0] + + +def test_ssl_channel_empty_series(): + up, down = StockDataFrame.ssl_channel( + pd.Series(dtype=float), pd.Series(dtype=float), + pd.Series(dtype=float), 5) + assert len(up) == 0 and len(down) == 0 + + +def test_linear_reg_window_larger_than_series_is_zeros(): + series = pd.Series([1.0, 2.0]) + assert list(StockDataFrame.linear_reg(series, 5)) == [0.0, 0.0] + assert list(StockDataFrame.linear_reg(series, 5, correlation=True)) == [ + 0.0, 0.0] + + +def test_linear_reg_perfect_line_predicts_next_point(): + # Perfectly linear series 1..6, window 3: the fitted line evaluated + # at x = window - 1 gives 2.0, 3.0, 4.0, 5.0 at the window ends. + series = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + out = StockDataFrame.linear_reg(series, 3) + assert list(out[:2]) == [0.0, 0.0] + assert list(out[2:]) == [2.0, 3.0, 4.0, 5.0] + + +def test_ema_and_sma_are_plain_pandas_rolling(): + series = pd.Series([10.0, 11.0, 12.0, 13.0]) + assert list(StockDataFrame.sma(series, 2)) == [ + 10.0, 10.5, 11.5, 12.5] + ema = StockDataFrame.ema(series, 2) + assert ema[0] == 10.0 + assert ema.iloc[-1] == pytest.approx(12.55, rel=1e-3) + + +def test_mov_min_max_std_var(): + series = pd.Series([3.0, 1.0, 2.0]) + assert list(StockDataFrame.mov_min(series, 2)) == [3.0, 1.0, 1.0] + assert list(StockDataFrame.mov_max(series, 2)) == [3.0, 3.0, 2.0] + assert StockDataFrame.mov_sum(series, 2)[2] == pytest.approx(3.0, rel=1e-3) + # Single-value rolling std is NaN (pandas default ddof=1). + assert math.isnan(StockDataFrame.mov_std(series, 2)[0]) + assert math.isnan(StockDataFrame.mov_var(series, 2)[0]) From 9249f2efcae10710bddf272eb67c04b533ea5a72 Mon Sep 17 00:00:00 2001 From: wxs-lang <393456156@qq.com> Date: Sun, 25 Oct 2026 11:33:18 +0800 Subject: [PATCH 4/5] Add pytest coverage for indicator windows, column syntax and static helpers (129 tests) --- tests/test_indicators_synthetic.py | 382 +++++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 tests/test_indicators_synthetic.py diff --git a/tests/test_indicators_synthetic.py b/tests/test_indicators_synthetic.py new file mode 100644 index 00000000..f24a9c30 --- /dev/null +++ b/tests/test_indicators_synthetic.py @@ -0,0 +1,382 @@ +"""Indicator behaviour on synthetic flat / rising / falling series. + +A flat series (all prices equal) and a perfectly rising series +(close = 10, 11, ..., 29) make most indicators take exact, hand-checkable +values. Every number below was measured by probing the implementation. +""" + +import math + +import numpy as np +import pandas as pd +import pytest + +from stockstats import StockDataFrame, wrap + + +def make_frame(close, volume=1000.0): + close = np.asarray(close, dtype=float) + n = len(close) + return wrap(pd.DataFrame({ + "date": range(n), + "open": close - 0.1, + "high": close + 0.5, + "low": close - 0.5, + "close": close, + "volume": np.full(n, float(volume)), + })) + + +def flat(n=20, value=10.0): + # Truly flat candles: open == high == low == close, matching the + # probe frame (a constant close with a high/low range is NOT flat: + # it gives WR = -50, RSV = 50 and a non-zero supertrend). + return wrap(pd.DataFrame({ + "date": range(n), + "open": [value] * n, + "high": [value] * n, + "low": [value] * n, + "close": [value] * n, + "volume": np.full(n, 1000.0), + })) + + +def rising(n=20): + return make_frame(np.arange(10.0, 10.0 + n)) + + +def falling(n=20): + return make_frame(np.arange(29.0, 29.0 - n, -1.0)) + + +def seeded(n=40, seed=0): + rng = np.random.default_rng(seed) + return make_frame(10 + np.cumsum(rng.normal(0, 0.3, n))) + + +# --- flat series ------------------------------------------------------- + +def test_flat_rsi_is_50_everywhere(): + assert list(flat()["rsi"]) == [50.0] * 20 + assert list(flat()["rsi_5"]) == [50.0] * 20 + + +def test_flat_stochrsi_wr_cci_atr_are_zero(): + frame = flat() + assert list(frame["stochrsi"]) == [0.0] * 20 + assert list(frame["wr"]) == [-0.0] * 20 + assert list(frame["cci"]) == [0.0] * 20 + assert list(frame["atr"]) == [0.0] * 20 + + +def test_flat_macd_family_is_zero(): + frame = flat() + assert list(frame["macd"]) == [0.0] * 20 + assert list(frame["macds"]) == [0.0] * 20 + assert list(frame["macdh"]) == [0.0] * 20 + + +def test_flat_bollinger_collapses_to_price_after_first_row(): + frame = flat() + assert list(frame["boll"]) == [10.0] * 20 + # Quirk: the bands use the rolling std, whose first value is NaN + # (single-sample std), so the first band values stay NaN. + assert math.isnan(frame["boll_ub"][0]) + assert math.isnan(frame["boll_lb"][0]) + assert list(frame["boll_ub"][1:]) == [10.0] * 19 + assert list(frame["boll_lb"][1:]) == [10.0] * 19 + + +def test_flat_kdj_decays_geometrically_from_zero_rsv(): + frame = flat() + # RSV is 0 (high == low), so K = 2/3 * prev K, starting from 50. + assert frame["kdjk"][0] == pytest.approx(33.3333, rel=1e-3) + assert frame["kdjk"][1] == pytest.approx(22.2222, rel=1e-3) + assert frame["kdjd"][0] == pytest.approx(44.4444, rel=1e-3) + assert frame["kdjj"][0] == pytest.approx(11.1111, rel=1e-3) + + +def test_flat_momentum_indicators_are_neutral(): + frame = flat() + assert list(frame["trix"]) == [0.0] * 20 + assert list(frame["tema"]) == [10.0] * 20 + assert list(frame["cmo"]) == [0.0] * 20 + assert list(frame["bop"]) == [0.0] * 20 + assert list(frame["kst"]) == [0.0] * 20 + assert list(frame["chop"]) == [0.0] * 20 + assert list(frame["ao"]) == [0.0] * 20 + assert list(frame["pgo"]) == [0.0] * 20 + assert list(frame["psl"]) == [0.0] * 20 + assert list(frame["rvgi"]) == [0.0] * 20 + assert list(frame["rvgis"]) == [0.0] * 20 + + +def test_flat_mfi_is_half(): + assert list(flat()["mfi"]) == [0.5] * 20 + + +def test_flat_cr_is_all_nan_quirk(): + # Quirk, pinned as-is: with no price movement both CR sums are 0, + # and the division yields NaN instead of a guarded 0. + assert all(math.isnan(v) for v in flat()["cr"]) + + +def test_flat_qqe_ends_at_50(): + assert flat()["qqe"].iloc[-1] == pytest.approx(50.0, rel=1e-3) + + +def test_flat_supertrend_starts_at_zero_then_price(): + frame = flat() + assert frame["supertrend"][0] == 0.0 + assert list(frame["supertrend"][1:]) == [10.0] * 19 + + +# --- rising series ----------------------------------------------------- + +def test_rising_rsi_jumps_to_100(): + frame = rising() + assert frame["rsi"][0] == 50.0 + assert list(frame["rsi"][1:]) == [100.0] * 19 + assert frame["rsi_5"].iloc[-1] == 100.0 + + +def test_rising_kdj_first_and_last_values(): + frame = rising() + assert frame["kdjk"][0] == pytest.approx(50.0, rel=1e-3) + assert frame["kdjk"].iloc[-1] == pytest.approx(94.4017, rel=1e-3) + assert frame["kdjd"].iloc[-1] == pytest.approx(94.1728, rel=1e-3) + assert frame["kdjj"].iloc[-1] == pytest.approx(94.8594, rel=1e-3) + + +def test_rising_wr_first_and_last(): + frame = rising() + assert frame["wr"][0] == -50.0 + assert frame["wr"].iloc[-1] == pytest.approx(-3.5714, rel=1e-3) + + +def test_rising_bollinger_first_row_and_last(): + frame = rising() + assert frame["boll"][0] == 10.0 + assert math.isnan(frame["boll_ub"][0]) + assert frame["boll"].iloc[-1] == 19.5 + assert frame["boll_ub"].iloc[-1] == pytest.approx(31.3322, rel=1e-3) + assert frame["boll_lb"].iloc[-1] == pytest.approx(7.6678, rel=1e-3) + + +def test_rising_macd_values(): + frame = rising() + assert frame["macd"][0] == 0.0 + assert frame["macd"].iloc[-1] == pytest.approx(2.2709, rel=1e-3) + assert frame["macds"].iloc[-1] == pytest.approx(1.6666, rel=1e-3) + assert frame["macdh"].iloc[-1] == pytest.approx(0.6043, rel=1e-3) + + +def test_rising_ppo_last_and_constant_volume_pvo_zero(): + frame = rising() + assert frame["ppo"].iloc[-1] == pytest.approx(10.3396, rel=1e-3) + assert list(frame["pvo"]) == [0.0] * 20 + + +def test_rising_trix_tema_dma(): + frame = rising() + assert frame["trix"][0] == 0.0 + assert frame["trix"].iloc[-1] == pytest.approx(3.6812, rel=1e-3) + assert frame["tema"][0] == 10.0 + assert frame["tema"].iloc[-1] == pytest.approx(29.0198, rel=1e-3) + # dma default (10, 50): SMA10 - SMA50 at the last row. + assert frame["dma"].iloc[-1] == pytest.approx(5.0, rel=1e-3) + assert frame["close_5_dma"].iloc[-1] == pytest.approx(7.5, rel=1e-3) + + +def test_rising_cci_atr_tr(): + frame = rising() + assert frame["cci"].iloc[-1] == pytest.approx(123.8095, rel=1e-3) + assert frame["atr"][0] == 1.0 + assert frame["atr"].iloc[-1] == pytest.approx(1.4887, rel=1e-3) + assert frame["tr"][0] == 1.0 + assert list(frame["tr"][1:]) == [1.5] * 19 + + +def test_rising_directional_system(): + frame = rising() + assert frame["dx"].iloc[-1] == 100.0 + assert frame["adx"].iloc[-1] == pytest.approx(99.9521, rel=1e-3) + assert frame["adxr"].iloc[-1] == pytest.approx(99.6554, rel=1e-3) + assert frame["pdi"].iloc[-1] == pytest.approx(65.6542, rel=1e-3) + assert list(frame["ndi"]) == [0.0] * 20 + + +def test_rising_wavetrend_and_wt2_identity(): + frame = rising() + assert frame["wt1"][0] == 0.0 + assert frame["wt1"].iloc[-1] == pytest.approx(86.0289, rel=1e-3) + assert list(frame["wt2"]) == list(StockDataFrame.sma(frame["wt1"], 4)) + + +def test_rising_aroon_default_all_nan_short_series(): + # Window 25 over a 20-row series never fills. + assert all(math.isnan(v) for v in rising()["aroon"]) + assert rising()["aroon_5"].iloc[-1] == 80.0 + + +def test_rising_ssl_channels(): + frame = rising() + assert frame["ssl"][0] == 10.5 + assert frame["ssl_up"].iloc[-1] == 25.0 + assert frame["ssl_down"].iloc[-1] == 24.0 + + +def test_rising_ichimoku_is_zero_on_linear_series(): + assert list(rising()["ichimoku"]) == [0.0] * 20 + + +def test_rising_coppock_last(): + assert rising()["coppock"].iloc[-1] == pytest.approx(163.8754, rel=1e-3) + + +def test_rising_inertia_is_zero_for_short_series(): + # len 20 < 20 + 14, so the short-series path returns zeros. + assert list(rising()["inertia"]) == [0.0] * 20 + + +def test_rising_supertrend_qqe_ftr_rvgi_kst_pgo_psl_cti(): + frame = rising() + assert frame["supertrend"][0] == 0.0 + assert frame["qqe"].iloc[-1] == 100.0 + assert frame["qqes"].iloc[-1] == 0.0 + assert frame["ftr"][0] == 0.0 + assert frame["ftr"].iloc[-1] == pytest.approx(4.686, rel=1e-3) + assert frame["rvgi"].iloc[-1] == pytest.approx(0.1, rel=1e-3) + assert frame["rvgis"].iloc[-1] == pytest.approx(0.1, rel=1e-3) + assert frame["kst"].iloc[-1] == pytest.approx(198.6554, rel=1e-3) + assert frame["pgo"].iloc[-1] == pytest.approx(4.3468, rel=1e-3) + assert frame["psl"].iloc[-1] == 100.0 + assert frame["cti"].iloc[-1] == 1.0 + + +def test_rising_generic_moving_averages(): + frame = rising() + assert frame["close_10_lrma"].iloc[-1] == 28.0 + assert frame["close_5_mad"].iloc[-1] == pytest.approx(1.2, rel=1e-3) + assert frame["close_5_mstd"].iloc[-1] == pytest.approx(1.5811, rel=1e-3) + assert frame["close_5_mvar"].iloc[-1] == pytest.approx(2.5, rel=1e-3) + assert frame["close_5_sma"].iloc[-1] == 27.0 + assert frame["close_5_ema"].iloc[-1] == pytest.approx(27.006, rel=1e-3) + assert frame["close_5_wma"].iloc[-1] == pytest.approx(27.6667, rel=1e-3) + assert frame["close_5_hma"].iloc[-1] == pytest.approx(29.3333, rel=1e-3) + + +def test_rising_z_score_first_row_copies_second_quirk(): + frame = rising() + # Quirk, pinned as-is: _get_z overwrites row 0 with row 1. + assert frame["close_14_z"][0] == frame["close_14_z"][1] + assert frame["close_14_z"][0] == pytest.approx(0.7071, rel=1e-3) + + +def test_rising_roc_change_rate_logret(): + frame = rising() + assert frame["high_5_roc"].iloc[-1] == pytest.approx(20.4082, rel=1e-3) + assert frame["close_5_roc"].iloc[-1] == pytest.approx(20.8333, rel=1e-3) + assert frame["change"][0] == 0.0 + assert math.isnan(frame["rate"][0]) + assert frame["log-ret"][0] == 0.0 + assert list(frame["tp"]) == list(frame["middle"]) + + +def test_rising_num_is_zero(): + assert list(rising()["num"]) == [0.0] * 20 + + +# --- falling series ---------------------------------------------------- + +def test_falling_rsi_kdj_wr_psl_cci_cmo(): + frame = falling() + assert frame["rsi"][0] == 50.0 + assert list(frame["rsi"][1:]) == [0.0] * 19 + assert frame["kdjk"].iloc[-1] == pytest.approx(5.5983, rel=1e-3) + assert frame["wr"].iloc[-1] == pytest.approx(-96.4286, rel=1e-3) + assert list(frame["psl"]) == [0.0] * 20 + assert frame["cci"].iloc[-1] == pytest.approx(-123.8095, rel=1e-3) + assert frame["cmo"].iloc[-1] == -100.0 + + +# --- identities on a seeded random series -------------------------------- + +def test_macd_histogram_identity(): + frame = seeded() + _ = frame["macd"], frame["macds"], frame["macdh"] + diff = frame["macd"] - frame["macds"] - frame["macdh"] + assert float(np.abs(diff).max()) == 0.0 + + +def test_kdjj_identity(): + frame = seeded() + _ = frame["kdjk"], frame["kdjd"], frame["kdjj"] + diff = 3 * frame["kdjk"] - 2 * frame["kdjd"] - frame["kdjj"] + assert float(np.abs(diff).max()) == 0.0 + + +def test_bollinger_width_matches_std(): + frame = seeded() + _ = frame["boll"], frame["boll_ub"], frame["boll_lb"] + width_quarter = (frame["boll_ub"] - frame["boll_lb"]) / 4 + std = StockDataFrame.mov_std(frame["close"], 20) + assert float(np.nanmax(np.abs(width_quarter - std))) < 1e-9 + + +def test_ppo_histogram_identity_and_qqe_state(): + frame = seeded() + _ = frame["ppo"], frame["ppos"], frame["ppoh"] + diff = frame["ppo"] - frame["ppos"] - frame["ppoh"] + assert float(np.abs(diff).max()) == 0.0 + _ = frame["qqe"], frame["qqel"], frame["qqes"] + assert bool(((frame["qqe"] == frame["qqel"]) + | (frame["qqe"] == frame["qqes"])).all()) + + +def test_supertrend_equals_a_band_or_zero(): + frame = seeded() + _ = frame["supertrend"], frame["supertrend_ub"], frame["supertrend_lb"] + ok = ((frame["supertrend"] == frame["supertrend_ub"]) + | (frame["supertrend"] == frame["supertrend_lb"]) + | (frame["supertrend"] == 0)) + assert bool(ok.all()) + + +def test_eri_lines_match_adjust_false_ema(): + frame = seeded() + _ = frame["eribull"], frame["eribear"] + ema = StockDataFrame.ema(frame["close"], 13, adjust=False) + assert float(np.abs(frame["eribull"] - (frame["high"] - ema)).max()) == 0.0 + assert float(np.abs(frame["eribear"] - (frame["low"] - ema)).max()) == 0.0 + + +def test_dx_identity_from_pdi_ndi(): + frame = seeded() + _ = frame["pdi"], frame["ndi"], frame["dx"] + total = frame["pdi"] + frame["ndi"] + expected = np.where(total != 0, + abs(frame["pdi"] - frame["ndi"]) / total, 0.0) * 100 + assert float(np.abs(expected - frame["dx"].values).max()) == 0.0 + + +def test_dma_ema_sma_identities(): + frame = seeded() + _ = frame["dma"] + expected_dma = (StockDataFrame.sma(frame["close"], 10) + - StockDataFrame.sma(frame["close"], 50)) + assert float(np.abs(frame["dma"] - expected_dma).max()) == 0.0 + assert float(np.abs(frame["close_5_ema"] + - StockDataFrame.ema(frame["close"], 5)).max()) == 0.0 + assert float(np.abs(frame["close_5_sma"] + - StockDataFrame.sma(frame["close"], 5)).max()) == 0.0 + + +def test_bollinger_10_identity(): + frame = seeded() + _ = frame["boll_10"] + # name_ex("_ub") appends the suffix before the window: boll_ub_10. + expected_ub = (StockDataFrame.sma(frame["close"], 10) + + 2 * StockDataFrame.mov_std(frame["close"], 10)) + assert float(np.nanmax( + np.abs(frame["boll_ub_10"] - expected_ub))) < 1e-9 From 977afaf4053616b3221f63859430a717d90a5f1d Mon Sep 17 00:00:00 2001 From: wxs-lang <393456156@qq.com> Date: Sun, 25 Oct 2026 11:34:19 +0800 Subject: [PATCH 5/5] Add pytest coverage for indicator windows, column syntax and static helpers (129 tests) --- tests/test_columns_and_edges.py | 370 ++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 tests/test_columns_and_edges.py diff --git a/tests/test_columns_and_edges.py b/tests/test_columns_and_edges.py new file mode 100644 index 00000000..8e1b039f --- /dev/null +++ b/tests/test_columns_and_edges.py @@ -0,0 +1,370 @@ +"""Custom windows, column-syntax access, cross/compare columns and +edge cases (empty / single-row frames, frame helpers). + +Several accesses below raise ``KeyError`` for the requested name while +still creating side-effect columns. Those are real quirks of the +name-dispatch code, pinned as current behaviour rather than as ideal +behaviour: the requested column is never created, but its sibling +columns are. +""" + +import math + +import numpy as np +import pandas as pd +import pytest + +from stockstats import wrap + + +def make_frame(close, volume=1000.0): + close = np.asarray(close, dtype=float) + n = len(close) + return wrap(pd.DataFrame({ + "date": range(n), + "open": close - 0.1, + "high": close + 0.5, + "low": close - 0.5, + "close": close, + "volume": np.full(n, float(volume)), + })) + + +def flat(n=20, value=10.0): + # Truly flat candles: open == high == low == close. + return wrap(pd.DataFrame({ + "date": range(n), + "open": [value] * n, + "high": [value] * n, + "low": [value] * n, + "close": [value] * n, + "volume": np.full(n, 1000.0), + })) + + +def rising(n=20): + return make_frame(np.arange(10.0, 10.0 + n)) + + +def falling(n=20): + return make_frame(np.arange(29.0, 29.0 - n, -1.0)) + + +def seeded(n=40, seed=0): + rng = np.random.default_rng(seed) + return make_frame(10 + np.cumsum(rng.normal(0, 0.3, n))) + + +# --- custom windows ---------------------------------------------------- + +def test_custom_window_values_rsv_stochrsi_wr_cci_atr(): + frame = rising() + assert frame["rsv_5"][0] == 50.0 + assert frame["rsv_5"].iloc[-1] == 90.0 + assert frame["stochrsi_5"][1] == 100.0 + assert frame["stochrsi_5"].iloc[-1] == 0.0 + assert frame["wr_5"].iloc[-1] == -10.0 + assert frame["cci_5"].iloc[-1] == pytest.approx(111.1111, rel=1e-3) + assert frame["atr_5"].iloc[-1] == pytest.approx(1.4985, rel=1e-3) + + +def test_custom_window_values_trix_tema_kdjk_macd(): + frame = rising() + assert frame["trix_5"].iloc[-1] == pytest.approx(4.3936, rel=1e-3) + assert frame["high_5_trix"].iloc[-1] == pytest.approx(4.2965, rel=1e-3) + assert frame["tema_5"].iloc[-1] == pytest.approx(29.0198, rel=1e-3) + assert list(frame["close_5_tema"]) == list(frame["tema_5"]) + assert frame["kdjk_5"].iloc[-1] == pytest.approx(89.975, rel=1e-3) + assert frame["macd_5,10,3"].iloc[-1] == pytest.approx(2.1379, rel=1e-3) + assert frame["macds_5,10,3"].iloc[-1] == pytest.approx(2.0706, rel=1e-3) + assert frame["macdh_5,10,3"].iloc[-1] == pytest.approx(0.0673, rel=1e-3) + + +def test_custom_window_values_pdm_ndi_dx(): + frame = rising() + assert frame["pdm_14"][1] == pytest.approx(0.5185, rel=1e-3) + assert frame["pdm_14"].iloc[-1] == pytest.approx(0.9774, rel=1e-3) + assert list(frame["ndm_14"]) == [0.0] * 20 + assert frame["pdi_14"].iloc[-1] == pytest.approx(65.6542, rel=1e-3) + assert list(frame["ndi_14"]) == [0.0] * 20 + assert frame["dx_14"].iloc[-1] == 100.0 + + +def test_custom_window_values_qqe_ao_coppock_ichimoku_kama_ker(): + frame = rising() + assert frame["qqe_10,4"].iloc[-1] == 100.0 + assert frame["ao_5,10"].iloc[-1] == 2.5 + assert frame["coppock_5,10,15"].iloc[-1] == pytest.approx(176.5665, rel=1e-3) + assert frame["ichimoku_5,10,20"].iloc[-1] == 1.25 + assert frame["kama_5,3,10"].iloc[-1] == 29.0 + assert frame["ker_5"].iloc[-1] == 1.0 + assert list(frame["close_5_ker"]) == list(frame["ker_5"]) + + +def test_custom_window_values_ftr_rvgi_inertia_psl_pgo_cti(): + frame = rising() + assert frame["ftr_5"].iloc[-1] == pytest.approx(7.4183, rel=1e-3) + assert frame["rvgi_5"].iloc[-1] == pytest.approx(0.1, rel=1e-3) + assert frame["rvgis_5"].iloc[-1] == pytest.approx(0.1, rel=1e-3) + assert frame["inertia_10,5"].iloc[-1] == pytest.approx(0.1, rel=1e-3) + assert frame["psl_5"].iloc[-1] == 100.0 + assert frame["high_12_psl"].iloc[-1] == 100.0 + assert frame["pgo_5"].iloc[-1] == pytest.approx(1.3334, rel=1e-3) + assert frame["cti_5"].iloc[-1] == 1.0 + assert list(frame["close_5_cti"]) == list(frame["cti_5"]) + + +def test_custom_window_values_vr_mfi_chop_vwma(): + frame = rising() + # No down or flat days: both VR sums are 0, so VR is 0 by the + # guarded division (not NaN). + assert frame["vr_5"][0] == 100.0 + assert frame["vr_5"].iloc[-1] == 0.0 + assert frame["mfi_5"].iloc[-1] == 1.0 + assert frame["chop_5"].iloc[-1] == pytest.approx(25.193, rel=1e-3) + assert frame["vwma_5"].iloc[-1] == 27.0 + assert list(frame["close_5_vwma"]) == list(frame["vwma_5"]) + assert frame["open_5_sma"].iloc[-1] == pytest.approx(26.9, rel=1e-3) + + +# --- column-syntax access ---------------------------------------------- + +def test_count_columns_past_and_future(): + frame = rising() + assert list(frame["close_5_c"][:5]) == [1.0, 2.0, 3.0, 4.0, 5.0] + assert frame["close_5_c"].iloc[-1] == 5.0 + assert list(frame["close_5_fc"][-5:]) == [5.0, 4.0, 3.0, 2.0, 1.0] + + +def test_shift_columns_past_and_future(): + frame = rising() + # _s: negative pulls the past (first value repeated), positive pulls + # the future (last value repeated). + assert list(frame["close_-2_s"][:3]) == [10.0, 10.0, 10.0] + assert list(frame["close_2_s"][:3]) == [12.0, 13.0, 14.0] + assert list(frame["close_2_s"][-2:]) == [29.0, 29.0] + + +def test_range_max_min_columns(): + frame = rising() + assert list(frame["close_1~3_max"][:3]) == [13.0, 14.0, 15.0] + assert frame["close_1~3_max"].iloc[-1] == 29.0 + assert list(frame["close_1~3_min"][:3]) == [11.0, 12.0, 13.0] + assert frame["close_1~3_min"].iloc[-1] == 29.0 + + +def test_permutation_column(): + frame = rising() + col = frame["close_-2,-1_p"] + assert math.isnan(col[0]) and math.isnan(col[1]) + assert list(col[2:]) == [3.0] * 18 + + +def test_delta_column(): + frame = rising() + assert list(frame["close_delta"]) == [0.0] + [1.0] * 19 + + +def test_change_window_equals_roc_window(): + frame = rising() + assert list(frame["change_5"]) == list(frame["close_5_roc"]) + + +def test_cross_columns(): + frame = rising() + cross = frame["close_x_close_5_sma"] + assert list(cross) == [False, True] + [False] * 18 + assert list(frame["close_x_macd"]) == [False] * 20 + assert list(frame["close_xu_macd"]) == [False] * 20 + assert list(frame["close_xd_macd"]) == [False] * 20 + + +def test_compare_columns_against_macd(): + frame = rising() + assert list(frame["close_le_macd"]) == [False] * 20 + assert list(frame["close_ge_macd"]) == [True] * 20 + assert list(frame["close_lt_macd"]) == [False] * 20 + assert list(frame["close_gt_macd"]) == [True] * 20 + assert list(frame["close_eq_macd"]) == [False] * 20 + assert list(frame["close_ne_macd"]) == [True] * 20 + + +def test_list_access_initialises_all_columns(): + frame = seeded() + out = frame[["rsi", "macd"]] + assert list(out.columns) == ["rsi", "macd"] + + +# --- KeyError quirks ----------------------------------------------------- + +def test_bare_eri_and_wt_raise_keyerror(): + with pytest.raises(KeyError): + rising()["eri"] + with pytest.raises(KeyError): + rising()["wt"] + + +def test_bare_pdm_and_vwap_raise_user_warning(): + with pytest.raises(UserWarning): + rising()["pdm"] + with pytest.raises(UserWarning): + rising()["vwap"] + + +def test_custom_supertrend_raises_but_creates_bands(): + frame = rising() + with pytest.raises(KeyError): + frame["supertrend_10"] + assert "supertrend" in frame.columns + assert "supertrend_10_ub" in frame.columns + assert "supertrend_10_lb" in frame.columns + + +def test_custom_wt_raises_but_creates_wt1_wt2(): + frame = rising() + with pytest.raises(KeyError): + frame["wt_10,21"] + assert "wt1_10,21" in frame.columns + assert "wt2_10,21" in frame.columns + + +def test_custom_ppo_raises_but_creates_default_ppo_columns(): + frame = rising() + with pytest.raises(KeyError): + frame["ppo_5,10,3"] + assert "ppo" in frame.columns + assert "ppos" in frame.columns + assert "ppoh" in frame.columns + + +def test_custom_pvo_raises_but_creates_default_pvo_columns(): + frame = rising() + with pytest.raises(KeyError): + frame["pvo_5,10,3"] + assert "pvo" in frame.columns + assert "pvos" in frame.columns + assert "pvoh" in frame.columns + + +# --- empty and single-row frames ---------------------------------------- + +def test_empty_frame_indicators_are_empty(): + frame = wrap(pd.DataFrame({ + "open": [], "high": [], "low": [], "close": [], "volume": []})) + for col in ("rsi", "macd", "boll", "kdjk", "supertrend"): + assert list(frame[col]) == [] + + +def test_single_row_frame_values(): + frame = wrap(pd.DataFrame({ + "open": [10.0], "high": [11.0], "low": [9.0], "close": [10.5], + "volume": [100.0]})) + assert frame["rsi"][0] == 50.0 + assert frame["kdjk"][0] == pytest.approx(58.3333, rel=1e-3) + assert frame["wr"][0] == -25.0 + # Quirk: single-row CCI is NaN (mean absolute deviation over a + # window larger than the series is NaN, and the guard only covers a + # zero divisor, not NaN). + assert math.isnan(frame["cci"][0]) + assert frame["atr"][0] == 2.0 + assert frame["tr"][0] == 2.0 + assert frame["trix"][0] == 0.0 + assert frame["tema"][0] == 10.5 + assert frame["stochrsi"][0] == 0.0 + assert math.isnan(frame["aroon"][0]) + assert frame["boll"][0] == 10.5 + assert math.isnan(frame["boll_ub"][0]) + assert math.isnan(frame["boll_lb"][0]) + + +# --- frame helpers --------------------------------------------------------- + +def test_wrap_lowercases_price_columns_but_not_date(): + frame = wrap(pd.DataFrame({ + "Date": [0, 1], "Open": [10.0, 11.0], "High": [12.0, 13.0], + "Low": [9.0, 10.0], "Close": [11.0, 12.0], "Volume": [100.0, 200.0], + "Amount": [1100.0, 2400.0]})) + # Quirk: "date" is not in the lower-casing candidate list, so "Date" + # survives unchanged while the price columns are lower-cased. + assert "Date" in frame.columns + assert "open" in frame.columns + # Typical price uses amount / volume when amount is present. + assert list(frame["tp"]) == [11.0, 12.0] + assert list(frame["middle"]) == [11.0, 12.0] + + +def test_wrap_retype_index_column_and_passthrough(): + df = pd.DataFrame({ + "mydate": [0, 1], "open": [1.0, 2.0], "high": [2.0, 3.0], + "low": [0.0, 1.0], "close": [1.5, 2.5], "volume": [10.0, 20.0]}) + frame = wrap(df, index_column="mydate") + assert list(frame.index) == [0, 1] + assert list(frame["close"]) == [1.5, 2.5] + assert wrap(frame) is frame + + +def test_unwrap_returns_plain_dataframe(): + frame = rising() + from stockstats import unwrap + plain = unwrap(frame) + assert type(plain) is pd.DataFrame + + +def test_till_start_from_within(): + frame = seeded(20) + assert len(frame.till(9)) == 10 + assert len(frame.start_from(10)) == 10 + assert len(frame.within(5, 9)) == 5 + + +def test_copy_drop_head_tail(): + frame = seeded(20) + assert type(frame.copy()).__name__ == "StockDataFrame" + assert type(frame.drop_column(["open"])).__name__ == "StockDataFrame" + assert len(frame.drop_tail(3)) == 17 + assert len(frame.drop_head(3)) == 17 + + +def test_init_all_creates_core_indicators(): + frame = seeded(30) + frame.init_all() + for col in ("rsi", "macd", "boll", "kdjk", "atr", "cci"): + assert col in frame.columns + + +def test_set_dft_window_changes_macd_and_restores(): + import stockstats + old = stockstats.set_dft_window("macd", (5, 10, 3)) + try: + assert old == (12, 26, 9) + frame = seeded() + assert frame["macd"].iloc[-1] == pytest.approx(0.0581, rel=1e-3) + assert frame["macd_12,26,9"].iloc[-1] == pytest.approx(-0.1141, rel=1e-3) + finally: + stockstats.set_dft_window("macd", old) + assert stockstats.dft_windows("macd") == "12,26,9" + + +def test_zero_volume_vwma_and_zero_range_bop(): + frame = wrap(pd.DataFrame({ + "date": range(5), + "open": [10.0] * 5, + "high": [10.0] * 5, + "low": [10.0] * 5, + "close": [10.0] * 5, + "volume": [0.0] * 5, + })) + assert list(frame["vwma"]) == [0.0] * 5 + assert list(frame["bop"]) == [0.0] * 5 + + +def test_chop_window_one_is_zero_on_flat_series(): + # log10(1) == 0 in the denominator; the result is filled to 0. + assert list(flat()["chop_1"]) == [0.0] * 20 + + +def test_flat_and_falling_helpers_used(): + # Sanity: the shared builders behave as the other files assume. + assert list(flat()["close"]) == [10.0] * 20 + assert falling()["close"][0] == 29.0 + assert falling()["close"].iloc[-1] == 10.0