Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backtrader/indicators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 *
Expand Down
6 changes: 3 additions & 3 deletions backtrader/indicators/cci.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def next(self):
self.buy()
"""

from . import Indicator, MeanDev, MovAv
from . import DivByZero, Indicator, MeanDev, MovAv


class CommodityChannelIndex(Indicator):
Expand Down Expand Up @@ -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)
94 changes: 94 additions & 0 deletions backtrader/indicators/obv.py
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions tests/unit/indicators/test_ind_obv.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tests/unit/test_light_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading