From dc391b547086b9c0d488d84c113473b39ad10910 Mon Sep 17 00:00:00 2001 From: pp2024 <275885997@qq.com> Date: Fri, 14 Aug 2026 18:21:37 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86contrib=E4=B8=8B?= =?UTF-8?q?=E7=9A=84vortex=E6=96=87=E4=BB=B6=EF=BC=8C=E5=9B=A0=E5=92=8Cind?= =?UTF-8?q?icators=E7=9B=AE=E5=BD=95=E4=B8=8B=E5=8A=9F=E8=83=BD=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=E9=87=8D=E5=A4=8D=EF=BC=8C=E4=BC=9A=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=AF=BC=E5=85=A5=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backtrader/indicators/contrib/vortex.py | 79 ------------------------- 1 file changed, 79 deletions(-) delete mode 100644 backtrader/indicators/contrib/vortex.py diff --git a/backtrader/indicators/contrib/vortex.py b/backtrader/indicators/contrib/vortex.py deleted file mode 100644 index d403de1a..00000000 --- a/backtrader/indicators/contrib/vortex.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python -"""Vortex Indicator Module - Vortex Movement Indicator. - -This module provides the Vortex indicator, which measures trend movement -direction and identifies the start of a trend. - -Classes: - Vortex: Vortex Movement Indicator (Vortex). - -Example: - >>> class MyStrategy(bt.Strategy): - ... def __init__(self): - ... self.vortex = bt.indicators.Vortex(self.data, period=14) - ... - ... def next(self): - ... if self.vortex.vi_plus[0] > self.vortex.vi_minus[0]: - ... self.buy() -""" - -# -*- coding: utf-8; py-indent-offset:4 -*- -############################################################################### -# -# Copyright (C) 2015-2020 Daniel Rodriguez -# Copyright (C) 2015-2020 Daniel Rodriguez -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -############################################################################### -from .. import Indicator, Max, SumN - -__all__ = ["Vortex"] - - -class Vortex(Indicator): - """ - See: - - http://www.vortexindicator.com/VFX_VORTEX.PDF - - """ - - lines = ( - "vi_plus", - "vi_minus", - ) - - params = (("period", 14),) - - plotlines = {"vi_plus": {"_name": "+VI"}, "vi_minus": {"_name": "-VI"}} - - def __init__(self): - """Initialize the Vortex indicator. - - Calculates the Vortex Movement Indicator components. - """ - h0l1 = abs(self.data.high(0) - self.data.low(-1)) - vm_plus = SumN(h0l1, period=self.p.period) - - l0h1 = abs(self.data.low(0) - self.data.high(-1)) - vm_minus = SumN(l0h1, period=self.p.period) - - h0c1 = abs(self.data.high(0) - self.data.close(-1)) - l0c1 = abs(self.data.low(0) - self.data.close(-1)) - h0l0 = abs(self.data.high(0) - self.data.low(0)) - - tr = SumN(Max(h0l0, h0c1, l0c1), period=self.p.period) - - self.l.vi_plus = vm_plus / tr - self.l.vi_minus = vm_minus / tr From a04fd5b0fb0b313c1a64ad1bc903711eb2607e1e Mon Sep 17 00:00:00 2001 From: pp2024 <275885997@qq.com> Date: Mon, 17 Aug 2026 15:02:41 +0800 Subject: [PATCH 2/6] =?UTF-8?q?cci=E6=8C=87=E6=A0=87=E5=9C=A8=E5=B9=B3?= =?UTF-8?q?=E7=9B=98=E6=98=AF=E4=BC=9A=E5=87=BA=E7=8E=B0=E9=99=A40?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backtrader/indicators/cci.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backtrader/indicators/cci.py b/backtrader/indicators/cci.py index b39545aa..9df5efb1 100644 --- a/backtrader/indicators/cci.py +++ b/backtrader/indicators/cci.py @@ -19,7 +19,7 @@ def next(self): self.buy() """ -from . import Indicator, MeanDev, MovAv +from . import DivByZero, Indicator, MeanDev, MovAv class CommodityChannelIndex(Indicator): @@ -83,5 +83,5 @@ def __init__(self): # This matches master branch's behavior: SMA(|tp - tpmean|) where tpmean varies meandev = MeanDev(tp, tpmean, period=self.p.period) - # cci = dev / (factor * meandev) - self.lines.cci = dev / (self.p.factor * meandev) + # Return 0.0 when mean deviation is zero (for example, on flat prices). + self.lines.cci = DivByZero(dev, self.p.factor * meandev, zero=0.0) From 1cdf2db141be22de07e94f486c1c7c5f0cd3fab0 Mon Sep 17 00:00:00 2001 From: pp2024 <275885997@qq.com> Date: Mon, 17 Aug 2026 16:31:43 +0800 Subject: [PATCH 3/6] =?UTF-8?q?obv=E6=8C=87=E6=A0=87=E5=9C=A8backtrader\in?= =?UTF-8?q?dicators\=5F=5Finit=5F=5F.py=E4=B8=AD=E6=9C=89=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=8C=E4=BD=86=E6=9C=AA=E5=AE=9E=E7=8E=B0=E3=80=82?= =?UTF-8?q?=E6=95=85=E6=8F=90=E4=BA=A4=E4=B8=80=E7=89=88obv=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backtrader/indicators/__init__.py | 4 ++ backtrader/indicators/obv.py | 94 +++++++++++++++++++++++++++ tests/unit/indicators/test_ind_obv.py | 93 ++++++++++++++++++++++++++ tests/unit/test_light_import.py | 1 + 4 files changed, 192 insertions(+) create mode 100644 backtrader/indicators/obv.py create mode 100644 tests/unit/indicators/test_ind_obv.py diff --git a/backtrader/indicators/__init__.py b/backtrader/indicators/__init__.py index afb0f7e9..0a8212dc 100644 --- a/backtrader/indicators/__init__.py +++ b/backtrader/indicators/__init__.py @@ -54,6 +54,9 @@ from .directionalmove import PlusDirectionalIndicator as PlusDirectionalIndicator from .rsi import RSI as RSI from .rsi import RelativeStrengthIndex as RelativeStrengthIndex + from .obv import OnBalanceVolume as OnBalanceVolume + + OBV = OnBalanceVolume SimpleMovingAverage = MovingAverageSimple SMMA = SmoothedMovingAverage @@ -115,6 +118,7 @@ from .accdecoscillator import * from .priceops_ext import * from .moneyflow import * + from .obv import * from .demarker import * from .channels_ext import * from .trend_ext import * diff --git a/backtrader/indicators/obv.py b/backtrader/indicators/obv.py new file mode 100644 index 00000000..75ad4763 --- /dev/null +++ b/backtrader/indicators/obv.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +"""On-Balance Volume indicator. + +This module provides the On-Balance Volume (OBV) cumulative volume indicator. +OBV adds the current volume when the closing price rises, subtracts it when +it falls, and leaves the cumulative value unchanged when the closing price is +unchanged. + +Classes: + OnBalanceVolume: On-Balance Volume indicator (alias: OBV). + +Example: + class MyStrategy(bt.Strategy): + def __init__(self): + self.obv = bt.indicators.OBV(self.data) +""" + +from . import Indicator + + +class OnBalanceVolume(Indicator): + """Cumulative On-Balance Volume indicator. + + Formula: + - first value = volume + - close > previous close: obv = previous obv + volume + - close < previous close: obv = previous obv - volume + - close == previous close: obv = previous obv + """ + + alias = ("OBV",) + lines = ("obv",) + + def __init__(self): + """Initialize the indicator.""" + super().__init__() + + def nextstart(self): + """Seed OBV with the first available volume value.""" + self.lines.obv[0] = self.data.volume[0] + + def next(self): + """Update OBV for the current bar in event-driven mode.""" + previous = self.lines.obv[-1] + close = self.data.close[0] + previous_close = self.data.close[-1] + volume = self.data.volume[0] + + if close > previous_close: + self.lines.obv[0] = previous + volume + elif close < previous_close: + self.lines.obv[0] = previous - volume + else: + self.lines.obv[0] = previous + + def oncestart(self, start, end): + """Seed OBV in batch-processing mode.""" + dst = self.lines.obv.array + volume = self.data.volume.array + + while len(dst) < end: + dst.append(float("nan")) + + for i in range(start, min(end, len(volume))): + dst[i] = volume[i] + + def once(self, start, end): + """Calculate OBV values in batch-processing mode.""" + dst = self.lines.obv.array + close = self.data.close.array + volume = self.data.volume.array + actual_end = min(end, len(close), len(volume)) + + while len(dst) < end: + dst.append(float("nan")) + + if start >= actual_end: + return + + if start == 0: + dst[0] = volume[0] + start = 1 + + previous = dst[start - 1] + for i in range(start, actual_end): + if close[i] > close[i - 1]: + previous += volume[i] + elif close[i] < close[i - 1]: + previous -= volume[i] + + dst[i] = previous + + +OBV = OnBalanceVolume diff --git a/tests/unit/indicators/test_ind_obv.py b/tests/unit/indicators/test_ind_obv.py new file mode 100644 index 00000000..fbe2488a --- /dev/null +++ b/tests/unit/indicators/test_ind_obv.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +"""Tests for the On-Balance Volume indicator.""" + +import pandas as pd +import pytest + +import backtrader as bt +import backtrader.indicators as btind + + +def _run_obv(close, volume, runonce): + """Run OBV over synthetic OHLCV data and return calculated values.""" + index = pd.date_range("2020-01-01", periods=len(close), freq="D") + frame = pd.DataFrame( + { + "open": close, + "high": close, + "low": close, + "close": close, + "volume": volume, + "openinterest": 0.0, + }, + index=index, + ) + values = [] + lengths = {} + + class OBVStrategy(bt.Strategy): + def __init__(self): + self.obv = btind.OBV(self.data) + + def next(self): + values.append(float(self.obv[0])) + + def stop(self): + lengths["data"] = self.data.buflen() + lengths["indicator"] = self.obv.buflen() + lengths["line"] = len(self.obv.lines.obv.array) + + cerebro = bt.Cerebro(runonce=runonce, preload=True, stdstats=False) + cerebro.adddata(bt.feeds.PandasData(dataname=frame)) + cerebro.addstrategy(OBVStrategy) + cerebro.run() + return values, lengths + + +def test_obv_public_names_and_lifecycle_methods(): + """OBV is exported under both names and explicitly implements both modes.""" + assert btind.OBV is btind.OnBalanceVolume + + from backtrader.indicators.obv import OBV, OnBalanceVolume + + assert OBV is OnBalanceVolume + + lifecycle_methods = {"nextstart", "next", "oncestart", "once"} + assert lifecycle_methods <= btind.OnBalanceVolume.__dict__.keys() + + +@pytest.mark.parametrize("runonce", [False, True]) +def test_obv_calculation(runonce): + """OBV follows price direction and seeds with the first volume.""" + values, lengths = _run_obv( + close=[10.0, 11.0, 11.0, 9.0, 10.0], + volume=[100.0, 200.0, 300.0, 400.0, 500.0], + runonce=runonce, + ) + + assert values == pytest.approx([100.0, 300.0, 300.0, -100.0, 400.0]) + assert lengths == {"data": 5, "indicator": 5, "line": 5} + + +@pytest.mark.parametrize("runonce", [False, True]) +def test_obv_flat_prices_and_zero_volume(runonce): + """Unchanged prices preserve OBV and zero volume changes nothing.""" + values, lengths = _run_obv( + close=[10.0, 10.0, 11.0, 9.0], + volume=[100.0, 200.0, 0.0, 0.0], + runonce=runonce, + ) + + assert values == pytest.approx([100.0, 100.0, 100.0, 100.0]) + assert lengths == {"data": 4, "indicator": 4, "line": 4} + + +def test_obv_runonce_runnext_parity(): + """Batch and event-driven execution produce identical OBV output.""" + close = [10.0, 11.0, 9.0, 9.0, 12.0, 8.0, 13.0] + volume = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0] + + runnext_values, _ = _run_obv(close, volume, runonce=False) + runonce_values, _ = _run_obv(close, volume, runonce=True) + + assert runonce_values == pytest.approx(runnext_values) \ No newline at end of file diff --git a/tests/unit/test_light_import.py b/tests/unit/test_light_import.py index fc208ae5..dfb2db33 100644 --- a/tests/unit/test_light_import.py +++ b/tests/unit/test_light_import.py @@ -20,6 +20,7 @@ def test_light_import_exposes_live_runner_api_without_heavy_modules(): assert bt.indicators.CrossOver assert bt.indicators.BollingerBands assert bt.indicators.RelativeStrengthIndex + assert bt.indicators.OBV is bt.indicators.OnBalanceVolume assert bt.indicators.AverageDirectionalMovementIndex assert bt.indicators.PlusDirectionalIndicator assert bt.indicators.MinusDirectionalIndicator From da78c39eec4740579b309177ad2cf235c5be76f2 Mon Sep 17 00:00:00 2001 From: pp2024 <275885997@qq.com> Date: Wed, 19 Aug 2026 17:03:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=9C=A8exactbars>0=E6=97=B6=EF=BC=8Cliner?= =?UTF-8?q?oot.py:624=20=E4=B8=8E=20:610=EF=BC=9Areturn=20bool(value=20!?= =?UTF-8?q?=3D=200.0)=EF=BC=8C=E8=BF=94=E5=9B=9E=20np.bool=5F=20=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E6=8A=9B=20TypeError:=20=5F=5Fbool=5F=5F=20should=20r?= =?UTF-8?q?eturn=20bool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backtrader/lineroot.py | 26 +- tests/unit/core/test_lineroot_bool_numpy.py | 504 ++++++++++++++++++++ 2 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 tests/unit/core/test_lineroot_bool_numpy.py diff --git a/backtrader/lineroot.py b/backtrader/lineroot.py index 0197d53f..babe1dd8 100644 --- a/backtrader/lineroot.py +++ b/backtrader/lineroot.py @@ -245,7 +245,13 @@ def _makeoperationown(self, operation, _ownerskip=None): if not math.isfinite(value): return False - return value != 0.0 + # bool() for the same reason as in __nonzero__: + # numpy.float64 subclasses float, so it reaches + # this branch and `!= 0.0` yields numpy.bool_. + # This path returns a value rather than going + # through the __bool__ protocol, so a leak here + # would propagate silently instead of raising. + return bool(value != 0.0) return bool(value) return False except Exception: @@ -265,7 +271,9 @@ def _makeoperationown(self, operation, _ownerskip=None): if not math.isfinite(value): return False - return value != 0.0 + # See the note above: guard against numpy.bool_ leaking + # out of this non-__bool__ return path. + return bool(value != 0.0) return bool(value) return False except Exception: @@ -607,7 +615,14 @@ def __nonzero__(self): if not math.isfinite(value): return False - return value != 0.0 + # bool() is required, not cosmetic: numpy.float64 + # subclasses float, so numpy scalars reach this branch + # and `np.float64 != 0.0` yields numpy.bool_, which + # CPython rejects from __bool__. Such scalars come from + # PandasData and survive only in QBuffer/exactbars mode, + # where lines are backed by a deque rather than + # array.array("d") (which coerces them to float). + return bool(value != 0.0) return bool(value) return False if hasattr(self, "__getitem__") and hasattr(self, "__len__"): @@ -621,7 +636,10 @@ def __nonzero__(self): if not math.isfinite(value): return False - return value != 0.0 + # See the note above: bool() guards against numpy.float64 + # reaching the isinstance(value, float) branch and making + # `!=` return numpy.bool_. + return bool(value != 0.0) return bool(value) return False # Fallback: if no data available, return False diff --git a/tests/unit/core/test_lineroot_bool_numpy.py b/tests/unit/core/test_lineroot_bool_numpy.py new file mode 100644 index 00000000..484f0b87 --- /dev/null +++ b/tests/unit/core/test_lineroot_bool_numpy.py @@ -0,0 +1,504 @@ +"""Regression tests for LineRoot.__bool__ returning a strict bool. + +Background +---------- +``LineRoot.__nonzero__`` (aliased to ``__bool__`` at ``lineroot.py:635``) used to +end its float branch with a bare ``return value != 0.0``. That is unsafe: + +* ``numpy.float64`` is a **subclass** of ``float``, so numpy scalars satisfy the + ``isinstance(value, float)`` guard and fall into that branch. +* ``numpy.float64 != 0.0`` evaluates to ``numpy.bool_``, not ``bool``. +* CPython enforces that ``__bool__`` return a strict ``bool`` and otherwise raises + ``TypeError: __bool__ should return bool, returned numpy.bool_``. + +Why the failure only showed up with ``exactbars > 0``: numpy scalars enter the line +buffers from ``PandasData`` (``feeds/pandafeed.py`` does ``df.to_numpy(copy=False)``), +but in the default mode lines are stored in an ``array.array("d")``, a typed C buffer +that coerces every value back to a plain ``float`` on read -- silently laundering the +numpy type away. Under ``exactbars > 0`` (QBuffer mode) the storage becomes a +``collections.deque``, a generic object container that hands back the identical +``numpy.float64`` instance. The bug was always latent; QBuffer merely stopped hiding it. + +The consumer that trips it is ``lineiterator.py``'s ``_next``, where an ``or`` +expression forces a truth test on a line object. + +The fix wraps both returns in ``bool()`` (``lineroot.py:610`` and ``:624``). +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +import numpy as np +import pandas as pd +import pytest + +import backtrader as bt +import backtrader.indicators as btind +from backtrader import lineroot + +# ============================================================================ +# Helpers +# ============================================================================ + + +def make_pandas_feed(num_bars=60, seed=7): + """Build a PandasData feed whose lines carry numpy.float64 scalars. + + PandasData converts the frame via ``to_numpy()``, so the values written into + the line buffers are numpy scalars rather than Python floats. That is a + precondition for reproducing the bug. + """ + index = pd.date_range("2020-01-01", periods=num_bars, freq="D") + rng = np.random.RandomState(seed) + close = pd.Series(np.cumsum(rng.randn(num_bars)) * 2 + 100, index=index) + open_ = close.shift(1).fillna(close.iloc[0]) + high = pd.concat([open_, close], axis=1).max(axis=1) + np.abs(rng.randn(num_bars)) + low = pd.concat([open_, close], axis=1).min(axis=1) - np.abs(rng.randn(num_bars)) + frame = pd.DataFrame( + { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": rng.randint(100, 9999, num_bars).astype(float), + "openinterest": 0.0, + }, + index=index, + ) + return bt.feeds.PandasData(dataname=frame) + + +def run_indicator(make_indicator, **cerebro_kwargs): + """Run one indicator over the synthetic feed and collect its line values.""" + rows = [] + + class _Strategy(bt.Strategy): + def __init__(self): + self.ind = make_indicator(self) + + def next(self): + rows.append(tuple(float(line[0]) for line in self.ind.lines)) + + cerebro = bt.Cerebro(stdstats=False, **cerebro_kwargs) + cerebro.adddata(make_pandas_feed()) + cerebro.addstrategy(_Strategy) + cerebro.run() + return rows + + +@pytest.fixture +def unfixed_nonzero(): + """Temporarily restore the pre-fix ``__bool__`` to prove the repro is real. + + Without this, a test asserting "no TypeError" would pass even if the fix were + reverted, because nothing else in the suite exercises ``exactbars > 0``. + Re-introducing the exact old body lets us assert the bug *does* occur without + it, which is what makes these tests genuine regression guards. + """ + original = lineroot.LineRoot.__nonzero__ + + def legacy_nonzero(self): + # Verbatim pre-fix logic: no bool() around the != comparison. + try: + if hasattr(self, "lines") and self.lines: + if hasattr(self.lines, "__getitem__") and len(self.lines) > 0: + line = self.lines[0] + if hasattr(line, "__getitem__") and hasattr(line, "__len__"): + if len(line) > 0: + value = line[0] + if value is None: + return False + if isinstance(value, float): + import math + + if not math.isfinite(value): + return False + return value != 0.0 # the bug + return bool(value) + return False + if hasattr(self, "__getitem__") and hasattr(self, "__len__"): + if len(self) > 0: + value = self[0] + if value is None: + return False + if isinstance(value, float): + import math + + if not math.isfinite(value): + return False + return value != 0.0 # the bug + return bool(value) + return False + return False + except Exception: + return False + + lineroot.LineRoot.__nonzero__ = legacy_nonzero + lineroot.LineRoot.__bool__ = legacy_nonzero + try: + yield + finally: + lineroot.LineRoot.__nonzero__ = original + lineroot.LineRoot.__bool__ = original + + +@pytest.fixture +def unfixed_makeoperationown(): + """Temporarily restore the pre-fix ``_makeoperationown`` bool branches. + + Mirrors ``unfixed_nonzero``: only the ``bool`` fast path is reproduced, so the + tests can assert the numpy.bool_ leak really happened without the fix. + """ + original = lineroot.LineRoot._makeoperationown + + def legacy_makeoperationown(self, operation, _ownerskip=None): + if operation is not bool: + return original(self, operation, _ownerskip=_ownerskip) + # Verbatim pre-fix logic for the two bool branches. + if hasattr(self, "lines") and self.lines: + try: + if hasattr(self.lines, "__getitem__") and len(self.lines) > 0: + line = self.lines[0] + if hasattr(line, "__getitem__") and hasattr(line, "__len__"): + if len(line) > 0: + value = line[0] + if value is None: + return False + if isinstance(value, float): + import math + + if not math.isfinite(value): + return False + return value != 0.0 # the bug (:248) + return bool(value) + return False + except Exception: + return False + elif hasattr(self, "__getitem__") and hasattr(self, "__len__"): + try: + if len(self) > 0: + value = self[0] + if value is None: + return False + if isinstance(value, float): + import math + + if not math.isfinite(value): + return False + return value != 0.0 # the bug (:268) + return bool(value) + return False + except Exception: + return False + else: + return False + + lineroot.LineRoot._makeoperationown = legacy_makeoperationown + try: + yield + finally: + lineroot.LineRoot._makeoperationown = original + + +# ============================================================================ +# The precondition that makes the bug possible +# ============================================================================ + + +def test_numpy_float64_is_a_float_subclass_and_ne_yields_numpy_bool(): + """Document the language-level facts the bug rests on.""" + value = np.float64(1.5) + + # This is why numpy scalars reach the `isinstance(value, float)` branch. + assert isinstance(value, float) + + # And this is why the bare `return value != 0.0` violated the __bool__ contract. + assert type(value != 0.0) is np.bool_ + assert type(value != 0.0) is not bool + + +def test_qbuffer_mode_preserves_numpy_scalars_while_default_mode_coerces(): + """Show the storage asymmetry that confines the bug to ``exactbars > 0``.""" + import array + import collections + + value = np.float64(2.5) + + # Default mode: typed C buffer coerces on write/read. + typed = array.array("d") + typed.append(value) + assert type(typed[0]) is float + + # QBuffer mode: generic container hands the numpy scalar straight back. + queued = collections.deque(maxlen=4) + queued.append(value) + assert type(queued[0]) is np.float64 + + +# ============================================================================ +# Reproduction: the bug must actually occur without the fix +# ============================================================================ + + +@pytest.mark.parametrize("exactbars", [1, 2]) +def test_bug_reproduces_without_fix(unfixed_nonzero, exactbars): + """With the pre-fix body restored, ADX under ``exactbars > 0`` raises TypeError.""" + with pytest.raises(TypeError, match=r"__bool__ should return bool"): + run_indicator(lambda s: btind.ADX(s.data, period=14), exactbars=exactbars) + + +def test_bool_returns_numpy_bool_without_fix(unfixed_nonzero): + """Pin the defect directly on ``__bool__``, independent of any indicator.""" + + class _Wrapper: + """Minimal stand-in for a QBuffer-backed line holding a numpy scalar.""" + + __bool__ = lineroot.LineRoot.__nonzero__ + + def __len__(self): + return 1 + + def __getitem__(self, ago): + return np.float64(3.25) + + wrapper = _Wrapper() + + # The unbound pre-fix body leaks a numpy.bool_ ... + assert type(lineroot.LineRoot.__nonzero__(wrapper)) is np.bool_ + + # ... which CPython rejects when the bool protocol is actually invoked. + with pytest.raises(TypeError, match=r"__bool__ should return bool"): + bool(wrapper) + + +# ============================================================================ +# Verification: the fix resolves it +# ============================================================================ + + +# ---------------------------------------------------------------------------- +# _makeoperationown(bool) -- the sibling sites at lineroot.py:248 and :268 +# +# These share the identical `isinstance(value, float)` + `!= 0.0` shape, but they +# differ from __bool__ in one important way: they *return a value* rather than +# implementing the bool protocol. CPython therefore never validates the type, so a +# numpy.bool_ escaping here propagates silently instead of raising -- harder to +# notice, not easier. +# +# Honest scoping: instrumentation shows normal cerebro runs never invoke +# _makeoperationown with `bool` (see test_makeoperationown_bool_not_reached_in_ +# normal_runs below), so fixing these is defensive hardening rather than the repair +# of an observed failure. They are reachable by direct/derived-class use. +# ---------------------------------------------------------------------------- + + +class _OwnOpSingle(lineroot.LineRoot): + """LineSingle-shaped: no ``.lines``, so it takes the ``elif`` branch (:268).""" + + def __init__(self, value): + self._value = value + + def __len__(self): + return 1 + + def __getitem__(self, ago): + return self._value + + +class _OwnOpMultiple(lineroot.LineRoot): + """LineMultiple-shaped: has ``.lines``, so it takes the first branch (:248).""" + + class _Line: + def __init__(self, value): + self._value = value + + def __len__(self): + return 1 + + def __getitem__(self, ago): + return self._value + + class _Lines: + def __init__(self, value): + self._line = _OwnOpMultiple._Line(value) + + def __len__(self): + return 1 + + def __getitem__(self, idx): + return self._line + + def __init__(self, value): + self.lines = _OwnOpMultiple._Lines(value) + + +@pytest.mark.parametrize("holder", [_OwnOpSingle, _OwnOpMultiple], ids=["lines_branch", "elif"]) +def test_makeoperationown_bool_leaks_numpy_bool_without_fix(unfixed_makeoperationown, holder): + """Without the fix, both branches hand back a numpy.bool_.""" + result = lineroot.LineRoot._makeoperationown(holder(np.float64(3.25)), bool) + assert type(result) is np.bool_, "expected the pre-fix body to leak numpy.bool_" + + +@pytest.mark.parametrize("holder", [_OwnOpSingle, _OwnOpMultiple], ids=["lines_branch", "elif"]) +def test_makeoperationown_bool_returns_strict_bool_with_fix(holder): + """With the fix, both branches return a strict ``bool``.""" + result = lineroot.LineRoot._makeoperationown(holder(np.float64(3.25)), bool) + assert type(result) is bool, f"expected bool, got {type(result).__name__}" + assert result is True + + +@pytest.mark.parametrize("holder", [_OwnOpSingle, _OwnOpMultiple], ids=["lines_branch", "elif"]) +@pytest.mark.parametrize( + "value, expected", + [ + (np.float64(0.0), False), + (np.float64(-0.0), False), + (np.float64(4.5), True), + (np.float64(-4.5), True), + (np.float64(np.nan), False), + (np.float64(np.inf), False), + (0.0, False), + (7.0, True), + ], +) +def test_makeoperationown_bool_semantics_preserved(holder, value, expected): + """Truthiness must be unchanged by the fix -- only the returned type differs.""" + result = lineroot.LineRoot._makeoperationown(holder(value), bool) + assert type(result) is bool + assert result is expected + + +def test_makeoperationown_bool_not_reached_in_normal_runs(): + """Record the scope of the :248/:268 fix: normal runs never take this path. + + This documents *why* those two sites are hardening rather than an active bug + fix. If a future change starts routing ``bool`` through ``_operationown``, this + test fails and the sibling tests above become load-bearing. + """ + calls = [] + original = lineroot.LineRoot._makeoperationown + + def counting(self, operation, _ownerskip=None): + if operation is bool: + calls.append(type(self).__name__) + return original(self, operation, _ownerskip=_ownerskip) + + lineroot.LineRoot._makeoperationown = counting + try: + for exactbars in (0, 1): + run_indicator(lambda s: btind.ADX(s.data, period=14), exactbars=exactbars) + finally: + lineroot.LineRoot._makeoperationown = original + + assert calls == [], f"_makeoperationown(bool) unexpectedly reached: {calls}" + + +def test_bool_returns_strict_bool_with_fix(): + """``__bool__`` must return exactly ``bool`` even for numpy input.""" + + class _Wrapper: + __bool__ = lineroot.LineRoot.__nonzero__ + + def __len__(self): + return 1 + + def __getitem__(self, ago): + return np.float64(3.25) + + wrapper = _Wrapper() + result = lineroot.LineRoot.__nonzero__(wrapper) + + assert type(result) is bool, f"expected bool, got {type(result).__name__}" + assert result is True + assert bool(wrapper) is True # exercises the real protocol; must not raise + + +@pytest.mark.parametrize( + "value, expected", + [ + (np.float64(0.0), False), + (np.float64(-0.0), False), + (np.float64(1.5), True), + (np.float64(-1.5), True), + (np.float64(np.nan), False), # non-finite -> False + (np.float64(np.inf), False), + (np.float64(-np.inf), False), + (0.0, False), + (2.0, True), + ], +) +def test_bool_semantics_preserved_for_numpy_and_python_floats(value, expected): + """The fix must not change truthiness, only the returned type.""" + + class _Wrapper: + __bool__ = lineroot.LineRoot.__nonzero__ + + def __len__(self): + return 1 + + def __getitem__(self, ago): + return value + + result = lineroot.LineRoot.__nonzero__(_Wrapper()) + assert type(result) is bool + assert result is expected + + +@pytest.mark.parametrize("exactbars", [1, 2]) +def test_adx_runs_under_exactbars_with_fix(exactbars): + """The canonical victim: ADX must complete under ``exactbars > 0``.""" + rows = run_indicator(lambda s: btind.ADX(s.data, period=14), exactbars=exactbars) + assert rows, "ADX produced no output" + + +@pytest.mark.parametrize( + "name, factory", + [ + ("ADX", lambda s: btind.ADX(s.data, period=14)), + ("PlusDirectionalIndicator", lambda s: btind.PlusDirectionalIndicator(s.data)), + ("MinusDirectionalIndicator", lambda s: btind.MinusDirectionalIndicator(s.data)), + ("DirectionalIndicator", lambda s: btind.DirectionalIndicator(s.data)), + ("Stochastic", lambda s: btind.Stochastic(s.data)), + ("Vortex", lambda s: btind.Vortex(s.data)), + ("CommodityChannelIndex", lambda s: btind.CommodityChannelIndex(s.data)), + ], +) +def test_indicators_using_line_truth_tests_run_under_exactbars(name, factory): + """A sample of the ~60 indicators this single fix unblocked. + + These all build comparison/If expressions in ``__init__``, so the framework + performs a truth test on a line object during iteration. + """ + rows = run_indicator(factory, exactbars=1) + assert rows, f"{name} produced no output under exactbars=1" + + +def test_fix_does_not_alter_default_mode_results(): + """Guard against the fix changing any value in the default (non-QBuffer) mode.""" + baseline = run_indicator(lambda s: btind.ADX(s.data, period=14), runonce=False, preload=True) + vectorized = run_indicator(lambda s: btind.ADX(s.data, period=14), runonce=True, preload=True) + + assert len(baseline) == len(vectorized) + for bar, (left, right) in enumerate(zip(baseline, vectorized)): + for lhs, rhs in zip(left, right): + if lhs != lhs and rhs != rhs: # NaN == NaN for our purposes + continue + assert lhs == pytest.approx(rhs, abs=1e-9), f"divergence at bar {bar}" + + +def test_adx_values_match_between_exactbars_and_default_mode(): + """Beyond "does not crash": the values must agree with the default mode. + + ``exactbars`` is a memory-retention setting, so it must not change results. + """ + baseline = run_indicator(lambda s: btind.ADX(s.data, period=14), runonce=False, preload=True) + queued = run_indicator(lambda s: btind.ADX(s.data, period=14), exactbars=1) + + assert len(baseline) == len(queued) + mismatches = [] + for bar, (left, right) in enumerate(zip(baseline, queued)): + for idx, (lhs, rhs) in enumerate(zip(left, right)): + if lhs != lhs and rhs != rhs: + continue + if lhs != pytest.approx(rhs, abs=1e-9): + mismatches.append((bar, idx, lhs, rhs)) + assert not mismatches, f"exactbars changed ADX values: {mismatches[:5]}" From eab5fc4ae9979c3abaae3053df59cfec102770a5 Mon Sep 17 00:00:00 2001 From: pp2024 <275885997@qq.com> Date: Fri, 21 Aug 2026 15:13:19 +0800 Subject: [PATCH 5/6] =?UTF-8?q?LineBuffer.qbuffer=20=E8=AE=BE=20maxlen=20?= =?UTF-8?q?=3D=20max(1,=20self.=5Fminperiod)=EF=BC=88linebuffer.py:329?= =?UTF-8?q?=EF=BC=89=E3=80=82=20=E8=AF=BB=20line[-N]=20=E9=9C=80=E8=A6=81?= =?UTF-8?q?=20=5Fminperiod=20>=3D=20N+1=EF=BC=8C=E5=90=A6=E5=88=99=20QBuff?= =?UTF-8?q?er=20=E5=9B=9E=E7=BB=95=E5=88=B0=E5=BD=93=E5=89=8D=E5=B0=9A?= =?UTF-8?q?=E6=9C=AA=E5=86=99=E5=85=A5=E7=9A=84=E6=A7=BD=E4=BD=8D=EF=BC=8C?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E8=BF=94=E5=9B=9E=20NaN=20=E6=88=96=E9=94=99?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E4=B8=8D=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backtrader/lineiterator.py | 14 + .../core/test_qbuffer_minbuffer_retention.py | 374 ++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 tests/unit/core/test_qbuffer_minbuffer_retention.py diff --git a/backtrader/lineiterator.py b/backtrader/lineiterator.py index a37e346e..f9ce1b3d 100644 --- a/backtrader/lineiterator.py +++ b/backtrader/lineiterator.py @@ -2091,6 +2091,20 @@ def qbuffer(self, savemem=0): if savemem: for line in self.lines: line.qbuffer() + # LineBuffer.qbuffer sizes the ring buffer from the LINE's own + # _minperiod, which is frequently still 1: only addminperiod() / + # updateminperiod() propagate a value down to the lines, and many + # indicators never call either. An indicator that reads its own + # output recursively (self.lines.x[-1], as cumulative/stateful ones + # do) then finds maxlen == 1 and silently reads NaN instead of the + # previous bar. + # + # Retention must therefore cover this object's lookback needs, with a + # floor of 2 for the [-1] self-reference. minbuffer() only ever grows + # maxlen in QBuffer mode and is a no-op otherwise, so it cannot alter + # results -- unlike raising _minperiod, which is a semantic claim that + # would delay output and propagate to downstream consumers. + line.minbuffer(max(2, self._minperiod)) # If called, anything under it, must save for obj in self._lineiterators[self.IndType]: diff --git a/tests/unit/core/test_qbuffer_minbuffer_retention.py b/tests/unit/core/test_qbuffer_minbuffer_retention.py new file mode 100644 index 00000000..1262d183 --- /dev/null +++ b/tests/unit/core/test_qbuffer_minbuffer_retention.py @@ -0,0 +1,374 @@ +"""Regression tests for QBuffer retention of self-referential indicator lines. + +Background +---------- +``LineBuffer.qbuffer`` sizes the ring buffer from the **line's own** ``_minperiod``:: + + self.maxlen = max(1, self._minperiod) # linebuffer.py:329 + +but a line's ``_minperiod`` only ever leaves 1 if the indicator calls +``addminperiod()`` or ``line.updateminperiod()``. Many indicators never call either, +so their *object* ``_minperiod`` may be 40 while every *line* is still 1. + +Under ``exactbars > 0`` (QBuffer mode) that produced ``maxlen == 1``. Any indicator +that reads its own previous output -- ``self.lines.x[-1]``, which every cumulative or +stateful indicator does -- then read a slot that had already been overwritten and got +NaN back. No exception: the values were silently wrong. ``HeikinAshi`` for instance +returned NaN for ``ha_open`` on every bar after the first, which in turn made +``max(high, nan, ha_close)`` collapse ``ha_high``/``ha_low`` onto the raw high/low. + +The fix (``lineiterator.py``, in ``LineIterator.qbuffer``) asks each line to retain +enough history for the owning object's lookback, with a floor of 2 for the ``[-1]`` +self-reference:: + + line.minbuffer(max(2, self._minperiod)) + +Why ``minbuffer`` and not ``addminperiod``/``updateminperiod`` +-------------------------------------------------------------- +``_minperiod`` is a *semantic* claim ("no valid value before bar N") that downstream +indicators use to derive their own minperiod. Buffer retention is a separate concern. +Measured on ``HeikinAshi``: + +* ``addminperiod(2)`` -- fixes exactbars but **drops an output bar** (60 -> 59). +* ``updateminperiod(2)`` -- fixes exactbars, output unchanged, but **leaks**: a + downstream ``SMA(ha_close, period=5)`` had its minperiod pushed from 5 to 6. +* ``minbuffer(2)`` -- fixes exactbars, output unchanged, no leak. Chosen. + +``minbuffer`` only grows ``maxlen`` in QBuffer mode and returns immediately otherwise +(``linebuffer.py:355-356``), so it cannot affect the default execution path at all. + +These tests are companions to ``test_lineroot_bool_numpy.py``, which covers the other +``exactbars`` defect (``numpy.bool_`` escaping ``__bool__``). +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +import numpy as np +import pandas as pd +import pytest + +import backtrader as bt +import backtrader.indicators as btind +from backtrader import lineiterator + +# ============================================================================ +# Helpers +# ============================================================================ + +# Indicators that read their own line(s) recursively and therefore need at least +# two retained slots. Each was verified to produce wrong values before the fix. +SELF_REFERENTIAL = [ + ("HeikinAshi", lambda s: btind.HeikinAshi(s.data)), + ("Accum", lambda s: btind.Accum(s.data.volume)), + ("KST", lambda s: btind.KST(s.data.close)), + ("TrixSignal", lambda s: btind.TrixSignal(s.data.close)), + ("PPO", lambda s: btind.PPO(s.data.close)), + ("SuperTrendIndicator", lambda s: btind.SuperTrendIndicator(s.data)), + ("SupertrendIndicator", lambda s: btind.SupertrendIndicator(s.data)), + ("AdaptiveSuperTrendIndicator", lambda s: btind.AdaptiveSuperTrendIndicator(s.data)), + ("AccumulationDistributionLine", lambda s: btind.AccumulationDistributionLine(s.data)), +] + +# Indicators already correct before the fix (they call addminperiod, so their lines +# carried a real minperiod). Included to prove the fix changes nothing for them. +ALREADY_CORRECT = [ + ("MACD", lambda s: btind.MACD(s.data.close)), + ("SMA", lambda s: btind.SMA(s.data.close, period=20)), + ("EMA", lambda s: btind.EMA(s.data.close, period=20)), + ("ATR", lambda s: btind.ATR(s.data, period=14)), + ("ParabolicSAR", lambda s: btind.ParabolicSAR(s.data)), + ("BollingerBands", lambda s: btind.BollingerBands(s.data.close)), + ("Ichimoku", lambda s: btind.Ichimoku(s.data)), + ("SuperTrendBandsIndicator", lambda s: btind.SuperTrendBandsIndicator(s.data)), +] + + +def make_feed(num_bars=60, seed=7): + """Build a deterministic OHLCV feed.""" + index = pd.date_range("2020-01-01", periods=num_bars, freq="D") + rng = np.random.RandomState(seed) + close = pd.Series(np.cumsum(rng.randn(num_bars)) * 2 + 100, index=index) + open_ = close.shift(1).fillna(close.iloc[0]) + high = pd.concat([open_, close], axis=1).max(axis=1) + np.abs(rng.randn(num_bars)) + low = pd.concat([open_, close], axis=1).min(axis=1) - np.abs(rng.randn(num_bars)) + frame = pd.DataFrame( + { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": rng.randint(100, 9999, num_bars).astype(float), + "openinterest": 0.0, + }, + index=index, + ) + return bt.feeds.PandasData(dataname=frame) + + +def run_indicator(make_indicator, **cerebro_kwargs): + """Run one indicator and return (rows, introspection) for its lines.""" + rows = [] + info = {} + + class _Strategy(bt.Strategy): + def __init__(self): + self.ind = make_indicator(self) + + def next(self): + rows.append(tuple(float(line[0]) for line in self.ind.lines)) + + def stop(self): + info["object_minperiod"] = self.ind._minperiod + info["line_minperiods"] = [line._minperiod for line in self.ind.lines] + info["line_maxlens"] = [getattr(line, "maxlen", None) for line in self.ind.lines] + + cerebro = bt.Cerebro(stdstats=False, **cerebro_kwargs) + cerebro.adddata(make_feed()) + cerebro.addstrategy(_Strategy) + cerebro.run() + return rows, info + + +def count_mismatches(left, right): + """Count differing cells between two row lists, treating NaN == NaN.""" + if len(left) != len(right): + return [("length", len(left), len(right))] + bad = [] + for bar, (row_l, row_r) in enumerate(zip(left, right)): + for idx, (lhs, rhs) in enumerate(zip(row_l, row_r)): + nan_l, nan_r = lhs != lhs, rhs != rhs + if nan_l != nan_r: + bad.append((bar, idx, lhs, rhs)) + elif not nan_l and abs(lhs - rhs) > 1e-9 * max(1.0, abs(lhs), abs(rhs)): + bad.append((bar, idx, lhs, rhs)) + return bad + + +@pytest.fixture +def unfixed_qbuffer(): + """Restore the pre-fix ``LineIterator.qbuffer`` so the bug can be observed. + + Nothing else in the suite exercises ``exactbars > 0``, so without this a test + asserting "values agree" would still pass if the fix were reverted. + """ + original = lineiterator.LineIterator.qbuffer + + def legacy_qbuffer(self, savemem=0): + # Verbatim pre-fix body: no minbuffer() call. + if savemem: + for line in self.lines: + line.qbuffer() + for obj in self._lineiterators[self.IndType]: + obj.qbuffer(savemem=1) + for data in self.datas: + data.minbuffer(self._minperiod) + + lineiterator.LineIterator.qbuffer = legacy_qbuffer + try: + yield + finally: + lineiterator.LineIterator.qbuffer = original + + +# ============================================================================ +# The mechanism +# ============================================================================ + + +def test_line_minperiod_can_lag_behind_object_minperiod(): + """Document the root cause: lines do not inherit the object's minperiod. + + This asymmetry is what made ``qbuffer`` under-size the ring buffer. + """ + _, info = run_indicator(lambda s: btind.KST(s.data.close), runonce=False, preload=True) + + assert info["object_minperiod"] > 1, "KST should need substantial warmup" + assert info["line_minperiods"] == [1, 1], ( + "KST never calls addminperiod/updateminperiod, so its lines stay at 1 -- " + "this is precisely why qbuffer used to compute maxlen == 1" + ) + + +def test_minbuffer_is_a_noop_outside_qbuffer_mode(): + """``minbuffer`` cannot affect the default path, which is why it is safe here.""" + from backtrader.linebuffer import LineBuffer + + buffer = LineBuffer() + assert buffer.mode != LineBuffer.QBuffer + + buffer.minbuffer(64) + + assert buffer.mode != LineBuffer.QBuffer, "minbuffer must not switch storage mode" + assert buffer._minperiod == 1, "minbuffer must never touch _minperiod" + + +@pytest.mark.parametrize("name, factory", SELF_REFERENTIAL, ids=[n for n, _ in SELF_REFERENTIAL]) +def test_retention_covers_lookback_after_fix(name, factory): + """Every self-referential line must retain at least 2 bars under exactbars.""" + _, info = run_indicator(factory, exactbars=1) + + for idx, maxlen in enumerate(info["line_maxlens"]): + assert maxlen is not None, f"{name} line {idx} is not in QBuffer mode" + assert maxlen >= 2, f"{name} line {idx} retains only {maxlen} bar(s)" + + +@pytest.mark.parametrize("name, factory", SELF_REFERENTIAL, ids=[n for n, _ in SELF_REFERENTIAL]) +def test_fix_does_not_raise_line_minperiod(name, factory): + """The fix must grow retention only -- never the semantic ``_minperiod``. + + Guards against regressing to ``updateminperiod``, which over-delays consumers. + """ + _, baseline = run_indicator(factory, runonce=False, preload=True) + _, queued = run_indicator(factory, exactbars=1) + + assert queued["line_minperiods"] == baseline["line_minperiods"], ( + f"{name}: exactbars changed line minperiods from " + f"{baseline['line_minperiods']} to {queued['line_minperiods']}" + ) + assert queued["object_minperiod"] == baseline["object_minperiod"] + + +# ============================================================================ +# Reproduction: the bug must actually occur without the fix +# ============================================================================ + + +@pytest.mark.parametrize("name, factory", SELF_REFERENTIAL, ids=[n for n, _ in SELF_REFERENTIAL]) +def test_bug_reproduces_without_fix(unfixed_qbuffer, name, factory): + """Without the fix, exactbars silently changes these indicators' values.""" + baseline, _ = run_indicator(factory, runonce=False, preload=True) + queued, info = run_indicator(factory, exactbars=1) + + assert min(info["line_maxlens"]) == 1, ( + f"{name}: expected the pre-fix body to leave maxlen == 1 " f"(got {info['line_maxlens']})" + ) + assert count_mismatches( + baseline, queued + ), f"{name}: expected exactbars to corrupt values without the fix" + + +def test_heikinashi_ha_open_is_all_nan_without_fix(unfixed_qbuffer): + """Pin the concrete symptom: the recursive line degrades to NaN.""" + rows, _ = run_indicator(lambda s: btind.HeikinAshi(s.data), exactbars=1) + + # ha_open is lines[0]; bar 0 is seeded, every later bar reads ha_open[-1]. + later_ha_open = [row[0] for row in rows[1:]] + assert later_ha_open, "expected more than one bar of output" + assert all( + value != value for value in later_ha_open + ), "expected every post-seed ha_open to be NaN without the fix" + + +# ============================================================================ +# Verification: the fix resolves it +# ============================================================================ + + +@pytest.mark.parametrize("name, factory", SELF_REFERENTIAL, ids=[n for n, _ in SELF_REFERENTIAL]) +@pytest.mark.parametrize("exactbars", [1, 2]) +def test_exactbars_matches_default_mode_after_fix(name, factory, exactbars): + """``exactbars`` is a memory setting: results must be identical to the default.""" + baseline, _ = run_indicator(factory, runonce=False, preload=True) + queued, _ = run_indicator(factory, exactbars=exactbars) + + mismatches = count_mismatches(baseline, queued) + assert not mismatches, ( + f"{name}: exactbars={exactbars} changed {len(mismatches)} cell(s); " + f"first few: {mismatches[:5]}" + ) + + +def test_heikinashi_produces_real_values_after_fix(): + """The counterpart to the all-NaN reproduction above.""" + rows, _ = run_indicator(lambda s: btind.HeikinAshi(s.data), exactbars=1) + + later_ha_open = [row[0] for row in rows[1:]] + assert later_ha_open + assert all(value == value for value in later_ha_open), "ha_open still contains NaN" + + +def test_heikinashi_high_low_not_collapsed_onto_raw_bars(): + """A NaN ha_open used to make max()/min() silently fall back to the raw high/low. + + Verifying only ha_open would miss this knock-on corruption. + """ + rows, _ = run_indicator(lambda s: btind.HeikinAshi(s.data), exactbars=1) + baseline, _ = run_indicator(lambda s: btind.HeikinAshi(s.data), runonce=False, preload=True) + + # lines are (ha_open, ha_high, ha_low, ha_close) + assert not count_mismatches([r[1:3] for r in baseline], [r[1:3] for r in rows]) + + +@pytest.mark.parametrize("name, factory", ALREADY_CORRECT, ids=[n for n, _ in ALREADY_CORRECT]) +def test_previously_correct_indicators_are_unchanged(name, factory): + """Regression guard: indicators that already worked must be untouched.""" + baseline, _ = run_indicator(factory, runonce=False, preload=True) + queued, _ = run_indicator(factory, exactbars=1) + + mismatches = count_mismatches(baseline, queued) + assert not mismatches, f"{name}: fix perturbed a previously correct indicator: {mismatches[:5]}" + + +def test_downstream_consumer_minperiod_not_inflated(): + """The decisive difference from ``updateminperiod``. + + A consumer of a fixed indicator's line must keep its own natural minperiod; + an SMA of period 5 needs 5 bars, not 6. + """ + captured = {} + + class _Strategy(bt.Strategy): + def __init__(self): + self.ha = btind.HeikinAshi(self.data) + self.sma = btind.SMA(self.ha.lines.ha_close, period=5) + + def stop(self): + captured["sma_minperiod"] = self.sma._minperiod + + for kwargs in ({"runonce": False, "preload": True}, {"exactbars": 1}): + cerebro = bt.Cerebro(stdstats=False, **kwargs) + cerebro.adddata(make_feed()) + cerebro.addstrategy(_Strategy) + cerebro.run() + assert captured["sma_minperiod"] == 5, ( + f"SMA(period=5) minperiod became {captured['sma_minperiod']} with {kwargs}; " + "retention must not leak into minperiod semantics" + ) + + +def test_default_mode_results_are_untouched_by_the_fix(): + """Compare fixed vs pre-fix code in the DEFAULT mode: must be bit-identical. + + ``minbuffer`` returns early outside QBuffer mode, so the non-exactbars path + cannot change. This asserts that directly rather than assuming it. + """ + + def collect(): + return ( + run_indicator(lambda s: btind.HeikinAshi(s.data), runonce=False, preload=True)[0], + run_indicator(lambda s: btind.HeikinAshi(s.data), runonce=True, preload=True)[0], + ) + + # Capture the real (fixed) implementation BEFORE swapping anything in, so the + # comparison is genuinely fixed-vs-legacy rather than legacy-vs-legacy. + fixed_impl = lineiterator.LineIterator.qbuffer + fixed_next, fixed_once = collect() + + def legacy_qbuffer(self, savemem=0): + if savemem: + for line in self.lines: + line.qbuffer() + for obj in self._lineiterators[self.IndType]: + obj.qbuffer(savemem=1) + for data in self.datas: + data.minbuffer(self._minperiod) + + lineiterator.LineIterator.qbuffer = legacy_qbuffer + try: + assert lineiterator.LineIterator.qbuffer is not fixed_impl, "swap did not take effect" + legacy_next, legacy_once = collect() + finally: + lineiterator.LineIterator.qbuffer = fixed_impl + + assert not count_mismatches(legacy_next, fixed_next) + assert not count_mismatches(legacy_once, fixed_once) From aee30f83e52235842d6a62de49b1740253776d38 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Sat, 22 Aug 2026 19:05:34 +0800 Subject: [PATCH 6/6] fix(indicators): preserve undefined CCI on flat prices --- backtrader/indicators/cci.py | 6 +- tests/unit/indicators/test_cci_flat_prices.py | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/unit/indicators/test_cci_flat_prices.py diff --git a/backtrader/indicators/cci.py b/backtrader/indicators/cci.py index 9df5efb1..dcf2b897 100644 --- a/backtrader/indicators/cci.py +++ b/backtrader/indicators/cci.py @@ -83,5 +83,7 @@ def __init__(self): # This matches master branch's behavior: SMA(|tp - tpmean|) where tpmean varies meandev = MeanDev(tp, tpmean, period=self.p.period) - # Return 0.0 when mean deviation is zero (for example, on flat prices). - self.lines.cci = DivByZero(dev, self.p.factor * meandev, zero=0.0) + # A zero mean deviation makes CCI mathematically undefined. Preserve that + # state while avoiding a division-by-zero exception, rather than treating + # it as the neutral (and signal-bearing) value 0.0. + self.lines.cci = DivByZero(dev, self.p.factor * meandev, zero=float("nan")) diff --git a/tests/unit/indicators/test_cci_flat_prices.py b/tests/unit/indicators/test_cci_flat_prices.py new file mode 100644 index 00000000..66056b05 --- /dev/null +++ b/tests/unit/indicators/test_cci_flat_prices.py @@ -0,0 +1,58 @@ +"""Regression coverage for CCI when its mean-deviation denominator is zero.""" + +import math + +import pandas as pd +import pytest + +import backtrader as bt +import backtrader.indicators as btind + + +def _run_flat_cci(runonce): + """Run CCI over a flat OHLC series and collect every valid output.""" + prices = [100.0] * 8 + frame = pd.DataFrame( + { + "open": prices, + "high": prices, + "low": prices, + "close": prices, + "volume": [1.0] * len(prices), + "openinterest": [0.0] * len(prices), + }, + index=pd.date_range("2026-01-01", periods=len(prices), freq="D"), + ) + values = [] + + class CCIProbeStrategy(bt.Strategy): + def __init__(self): + self.cci = btind.CCI(self.data, period=3) + + def next(self): + values.append(float(self.cci[0])) + + cerebro = bt.Cerebro(runonce=runonce, preload=True, stdstats=False) + cerebro.adddata(bt.feeds.PandasData(dataname=frame)) + cerebro.addstrategy(CCIProbeStrategy) + cerebro.run() + return values + + +@pytest.mark.parametrize("runonce", [False, True]) +def test_cci_flat_prices_are_undefined_not_neutral(runonce): + """Flat prices do not raise and produce undefined, rather than neutral, CCI.""" + values = _run_flat_cci(runonce) + + assert values + assert all(math.isnan(value) for value in values) + + +def test_cci_flat_price_runonce_runnext_parity(): + """Batch and event-driven calculation preserve the same undefined values.""" + runnext_values = _run_flat_cci(runonce=False) + runonce_values = _run_flat_cci(runonce=True) + + assert len(runonce_values) == len(runnext_values) + assert all(math.isnan(value) for value in runnext_values) + assert all(math.isnan(value) for value in runonce_values)