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)