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 1/2] =?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 2/2] =?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