Skip to content

Commit 185405a

Browse files
committed
Add multi df dataframe divergence support
1 parent 35099b1 commit 185405a

File tree

4 files changed

+298
-5
lines changed

4 files changed

+298
-5
lines changed

pyindicators/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
has_values_above_threshold, has_values_below_threshold, is_down_trend, \
77
is_up_trend, up_and_downtrends, detect_peaks, \
88
bearish_divergence, bullish_divergence, stochastic_oscillator, \
9-
bearish_divergence_multi_dataframe
9+
bearish_divergence_multi_dataframe, bullish_divergence_multi_dataframe
1010
from .exceptions import PyIndicatorException
1111
from .date_range import DateRange
1212

@@ -43,5 +43,6 @@
4343
'bullish_divergence',
4444
'is_divergence',
4545
'stochastic_oscillator',
46-
'bearish_divergence_multi_dataframe'
46+
'bearish_divergence_multi_dataframe',
47+
'bullish_divergence_multi_dataframe'
4748
]

pyindicators/indicators/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from .is_up_trend import is_up_trend
1717
from .up_and_down_trends import up_and_downtrends
1818
from .divergence import detect_peaks, bearish_divergence, \
19-
bullish_divergence, bearish_divergence_multi_dataframe
19+
bullish_divergence, bearish_divergence_multi_dataframe, \
20+
bullish_divergence_multi_dataframe
2021
from .stochastic_oscillator import stochastic_oscillator
2122

2223
__all__ = [
@@ -50,5 +51,6 @@
5051
'bullish_divergence',
5152
'is_divergence',
5253
'stochastic_oscillator',
53-
'bearish_divergence_multi_dataframe'
54+
'bearish_divergence_multi_dataframe',
55+
'bullish_divergence_multi_dataframe'
5456
]

pyindicators/indicators/divergence.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,13 @@ def bearish_divergence_multi_dataframe(
478478
"""
479479
Detect bearish divergence between two different DataFrames.
480480
481+
Given that the highs columns are selected for both columns; For
482+
a bearish divergence:
483+
* First Column of the first dataframe: Look for a lower
484+
high (-1) within the window.
485+
* Second Column of the second dataframe: Look for a higher
486+
high (1) within the window.
487+
481488
Args:
482489
first_df: DataFrame containing the indicator data (e.g., RSI).
483490
second_df: DataFrame containing the price data.
@@ -581,3 +588,127 @@ def bearish_divergence_multi_dataframe(
581588
result_df[result_column] = merged_df[result_column]
582589

583590
return pl.DataFrame(result_df) if is_polars else result_df
591+
592+
593+
def bullish_divergence_multi_dataframe(
594+
first_df: Union[pd.DataFrame, pl.DataFrame],
595+
second_df: Union[pd.DataFrame, pl.DataFrame],
596+
result_df: Union[pd.DataFrame, pl.DataFrame],
597+
first_column: str,
598+
second_column: str,
599+
window_size: int = 1,
600+
result_column: str = "bearish_divergence",
601+
number_of_neighbors_to_compare: int = 5,
602+
min_consecutive: int = 2
603+
) -> Union[pd.DataFrame, pl.DataFrame]:
604+
"""
605+
Detect bullish divergence between two different DataFrames.
606+
607+
Given that the low columns are selected for both columns; For
608+
a bullish divergence:
609+
* First Column: Look for a higher low (-1) within the window.
610+
* Second Column: Look for a lower low (1) within the window.
611+
612+
Args:
613+
first_df: DataFrame containing the indicator data (e.g., RSI).
614+
second_df: DataFrame containing the price data.
615+
result_df: DataFrame used to store results. Must be aligned in time.
616+
first_column: Column in first_df (e.g., RSI).
617+
second_column: Column in second_df (e.g., price).
618+
window_size: Number of bars to consider for pattern.
619+
result_column: Output column name.
620+
number_of_neighbors_to_compare: For peak detection.
621+
min_consecutive: Minimum consecutive peaks required.
622+
623+
Returns:
624+
A DataFrame with a new column indicating bullish divergence.
625+
"""
626+
is_polars = isinstance(first_df, pl.DataFrame)
627+
628+
if is_polars:
629+
first_df = first_df.to_pandas()
630+
second_df = second_df.to_pandas()
631+
result_df = result_df.to_pandas()
632+
633+
# Validate columns
634+
for df, col, label in [
635+
(first_df, first_column, "first_df"),
636+
(second_df, second_column, "second_df")
637+
]:
638+
if col not in df.columns:
639+
raise PyIndicatorException(f"{col} column is missing in {label}")
640+
641+
# Determine which df has more granular datetime index
642+
first_freq = first_df.index.to_series().diff().median()
643+
second_freq = second_df.index.to_series().diff().median()
644+
645+
if first_freq < second_freq:
646+
align_index = first_df.index
647+
else:
648+
align_index = second_df.index
649+
650+
if len(result_df) != len(align_index):
651+
raise PyIndicatorException(
652+
"result_df must have the same length as the aligned index"
653+
)
654+
655+
# Reindex all DataFrames to the most granular one
656+
first_df = first_df.reindex(align_index)
657+
second_df = second_df.reindex(align_index)
658+
659+
# Peak detection
660+
first_lows_col = f"{first_column}_lows"
661+
second_lows_col = f"{second_column}_lows"
662+
663+
if first_lows_col not in first_df.columns:
664+
first_df = detect_peaks(
665+
first_df,
666+
source_column=first_column,
667+
number_of_neighbors_to_compare=number_of_neighbors_to_compare,
668+
min_consecutive=min_consecutive
669+
)
670+
671+
if second_lows_col not in second_df.columns:
672+
second_df = detect_peaks(
673+
second_df,
674+
source_column=second_column,
675+
number_of_neighbors_to_compare=number_of_neighbors_to_compare,
676+
min_consecutive=min_consecutive
677+
)
678+
679+
# Now align and merge
680+
merged_df = pd.concat([
681+
first_df[[first_lows_col]],
682+
second_df[[second_lows_col]],
683+
result_df.copy()
684+
], axis=1, join='inner')
685+
686+
# Validate enough data
687+
if len(merged_df) < window_size:
688+
raise PyIndicatorException(
689+
f"Not enough data points (need at least {window_size}, "
690+
f"got {len(merged_df)})"
691+
)
692+
693+
# Apply divergence detection
694+
indicator_lows = merged_df[first_lows_col].values
695+
price_lows = merged_df[second_lows_col].values
696+
result = [False] * len(merged_df)
697+
698+
i = window_size - 1
699+
while i < len(merged_df):
700+
win_a = indicator_lows[i - window_size + 1:i + 1]
701+
win_b = price_lows[i - window_size + 1:i + 1]
702+
703+
if check_divergence_pattern(win_a, win_b):
704+
result[i] = True
705+
i += window_size # Skip forward to avoid overlap
706+
else:
707+
i += 1
708+
709+
merged_df[result_column] = result
710+
711+
# Merge back result column to result_df using the original index
712+
result_df[result_column] = merged_df[result_column]
713+
714+
return pl.DataFrame(result_df) if is_polars else result_df

tests/indicators/test_divergence.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import numpy as np
22
from unittest import TestCase
33
from pyindicators import is_divergence, bearish_divergence_multi_dataframe, \
4-
PyIndicatorException
4+
PyIndicatorException, bullish_divergence_multi_dataframe
55

66
import pandas as pd
77

@@ -261,3 +261,162 @@ def test_different_timeframes_align_correctly(self):
261261
)
262262
self.assertIn("bearish_divergence", result.columns)
263263
self.assertTrue(any(result["bearish_divergence"]))
264+
265+
266+
class TestBullishDivergenceMultiDataFrame(TestCase):
267+
268+
def test_bullish_divergence_detected(self):
269+
# Setup indicator (e.g., RSI) and price (e.g., Close) with divergence
270+
indicator = pd.DataFrame({
271+
"RSI": [50, 60, 70, 65, 60, 58, 55],
272+
}, index=pd.date_range("2022-01-01", periods=7))
273+
price = pd.DataFrame({
274+
"Close": [100, 102, 105, 108, 110, 112, 115], # Higher highs
275+
}, index=pd.date_range("2022-01-01", periods=7))
276+
277+
result = pd.DataFrame(index=indicator.index)
278+
279+
# Force peaks manually for deterministic test
280+
indicator["RSI_lows"] = [0, 0, 0, 0, -1, 0, 0] # Two indicator highs
281+
price["Close_lows"] = [0, 0, 0, 0, 1, 0, 0] # Two price highs
282+
283+
out = bullish_divergence_multi_dataframe(
284+
first_df=indicator,
285+
second_df=price,
286+
result_df=result,
287+
first_column="RSI",
288+
second_column="Close",
289+
window_size=2,
290+
result_column="bullish_divergence"
291+
)
292+
293+
self.assertIn("bullish_divergence", out.columns)
294+
self.assertTrue(any(out["bullish_divergence"]))
295+
296+
indicator = pd.DataFrame({
297+
"RSI": [50, 60, 70, 65, 60, 58, 55],
298+
}, index=pd.date_range("2022-01-01", periods=7))
299+
price = pd.DataFrame({
300+
"Close": [100, 102, 105, 108, 110, 112, 115], # Higher highs
301+
}, index=pd.date_range("2022-01-01", periods=7))
302+
303+
result = pd.DataFrame(index=indicator.index)
304+
305+
# Force peaks manually for deterministic test
306+
indicator["RSI_lows"] = [0, 0, 0, 0, 1, 0, 0] # Two indicator highs
307+
price["Close_lows"] = [0, 0, 0, 0, 1, 0, 0] # Two price highs
308+
309+
out = bullish_divergence_multi_dataframe(
310+
first_df=indicator,
311+
second_df=price,
312+
result_df=result,
313+
first_column="RSI",
314+
second_column="Close",
315+
window_size=2,
316+
result_column="bullish_divergence"
317+
)
318+
319+
self.assertIn("bullish_divergence", out.columns)
320+
self.assertFalse(any(out["bullish_divergence"]))
321+
322+
indicator = pd.DataFrame({
323+
"RSI": [50, 60, 70, 65, 60, 58, 55],
324+
}, index=pd.date_range("2022-01-01", periods=7))
325+
price = pd.DataFrame({
326+
"Close": [100, 102, 105, 108, 110, 112, 115], # Higher highs
327+
}, index=pd.date_range("2022-01-01", periods=7))
328+
329+
result = pd.DataFrame(index=indicator.index)
330+
331+
# Force peaks manually for deterministic test
332+
indicator["RSI_lows"] = [0, 0, 0, -1, 0, 0, 0] # Two indicator highs
333+
price["Close_lows"] = [0, 0, 0, 0, 0, 1, 0] # Two price highs
334+
335+
out = bullish_divergence_multi_dataframe(
336+
first_df=indicator,
337+
second_df=price,
338+
result_df=result,
339+
first_column="RSI",
340+
second_column="Close",
341+
window_size=2,
342+
result_column="bullish_divergence"
343+
)
344+
345+
self.assertIn("bullish_divergence", out.columns)
346+
self.assertFalse(any(out["bullish_divergence"]))
347+
348+
out = bullish_divergence_multi_dataframe(
349+
first_df=indicator,
350+
second_df=price,
351+
result_df=result,
352+
first_column="RSI",
353+
second_column="Close",
354+
window_size=3,
355+
result_column="bullish_divergence"
356+
)
357+
358+
self.assertIn("bullish_divergence", out.columns)
359+
self.assertTrue(any(out["bullish_divergence"]))
360+
361+
def test_missing_column_exception(self):
362+
df1 = pd.DataFrame({"RSI": [50, 60]}, index=pd.date_range("2022-01-01", periods=2))
363+
df2 = pd.DataFrame({"Close": [100, 110]}, index=pd.date_range("2022-01-01", periods=2))
364+
result = pd.DataFrame(index=df1.index)
365+
366+
with self.assertRaises(PyIndicatorException):
367+
bullish_divergence_multi_dataframe(
368+
first_df=df1.drop("RSI", axis=1),
369+
second_df=df2,
370+
result_df=result,
371+
first_column="RSI",
372+
second_column="Close"
373+
)
374+
375+
def test_not_enough_data_exception(self):
376+
df1 = pd.DataFrame({"RSI": [50]}, index=pd.date_range("2022-01-01", periods=1))
377+
df2 = pd.DataFrame({"Close": [100]}, index=pd.date_range("2022-01-01", periods=1))
378+
result = pd.DataFrame(index=df1.index)
379+
380+
# Assume detect_peaks adds _highs column
381+
df1["RSI_lows"] = [1]
382+
df2["Close_lows"] = [1]
383+
384+
with self.assertRaises(PyIndicatorException):
385+
bullish_divergence_multi_dataframe(
386+
first_df=df1,
387+
second_df=df2,
388+
result_df=result,
389+
first_column="RSI",
390+
second_column="Close",
391+
window_size=3
392+
)
393+
394+
def test_different_timeframes_align_correctly(self):
395+
daily_index = pd.date_range("2022-01-01", periods=2, freq="D")
396+
indicator_df = pd.DataFrame({
397+
"RSI": [65, 60],
398+
}, index=daily_index)
399+
400+
# 2-hour close prices — only some times will match the daily timestamps
401+
two_hour_index = pd.date_range("2022-01-01", periods=12, freq="2h")
402+
price_df = pd.DataFrame({
403+
"Close": [100, 102, 105, 108, 110, 112, 115, 117, 120, 122, 125, 130]
404+
}, index=two_hour_index)
405+
406+
result_df = pd.DataFrame(index=price_df.index)
407+
408+
# Inject fake peaks
409+
indicator_df["RSI_lows"] = [-1, 0]
410+
price_df["Close_lows"] = [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
411+
412+
result = bullish_divergence_multi_dataframe(
413+
first_df=indicator_df,
414+
second_df=price_df,
415+
result_df=result_df,
416+
first_column="RSI",
417+
second_column="Close",
418+
window_size=2,
419+
result_column="bullish_divergence"
420+
)
421+
self.assertIn("bullish_divergence", result.columns)
422+
self.assertTrue(any(result["bullish_divergence"]))

0 commit comments

Comments
 (0)