Skip to content

Commit 82d7660

Browse files
committed
Add reversal override code and tests
1 parent 8a21aab commit 82d7660

File tree

5 files changed

+126
-24
lines changed

5 files changed

+126
-24
lines changed

src/mplfinance/_utils.py

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,9 @@ def _valid_pnf_kwargs():
389389
'box_size' : { 'Default' : 'atr',
390390
'Validator' : lambda value: isinstance(value,(float,int)) or value == 'atr' },
391391
'atr_length' : { 'Default' : 14,
392-
'Validator' : lambda value: isinstance(value,int) or value == 'total' },
392+
'Validator' : lambda value: isinstance(value,int) or value == 'total' },
393+
'reversal' : { 'Default' : 2,
394+
'Validator' : lambda value: isinstance(value,int) }
393395
}
394396

395397
_validate_vkwargs_dict(vkwargs)
@@ -882,10 +884,11 @@ def _construct_pointnfig_collections(dates, highs, lows, volumes, config_pointnf
882884
first to ensure every time there is a trend change (ex. previous box is
883885
an X, current brick is a O) we draw one less box to account for the price
884886
having to move the previous box's amount before creating a box in the
885-
opposite direction. Next we adjust volume and dates to combine volume into
886-
non 0 box indexes and to only use dates from non 0 box indexes. We then
887-
remove all 0s from the boxes array and once again combine adjacent similarly
888-
signed differences in boxes.
887+
opposite direction. During this same step we also combine like signed elements
888+
and associated volume/date data ignoring any zero values that are created by
889+
subtracting 1 from the box value. Next we recreate the box array utilizing a
890+
rolling_change and volume_cache to store and sum the changes that don't break
891+
the reversal threshold.
889892
890893
Lastly, we enumerate through the boxes to populate the line_seg and circle_patches
891894
arrays. line_seg holds the / and \ line segments that make up an X and
@@ -929,20 +932,28 @@ def _construct_pointnfig_collections(dates, highs, lows, volumes, config_pointnf
929932

930933
box_size = pointnfig_params['box_size']
931934
atr_length = pointnfig_params['atr_length']
935+
reversal = pointnfig_params['reversal']
936+
937+
# box_size upper limit (also used for calculating upper limit of reversal)
938+
upper_limit = (max(closes) - min(closes)) / 2
932939

933940
if box_size == 'atr':
934941
if atr_length == 'total':
935942
box_size = _calculate_atr(len(closes)-1, highs, lows, closes)
936943
else:
937944
box_size = _calculate_atr(atr_length, highs, lows, closes)
938945
else: # is an integer or float
939-
upper_limit = (max(closes) - min(closes)) / 2
940946
lower_limit = 0.01 * _calculate_atr(len(closes)-1, highs, lows, closes)
941947
if box_size > upper_limit:
942948
raise ValueError("Specified box_size may not be larger than (50% of the close price range of the dataset) which has value: "+ str(upper_limit))
943949
elif box_size < lower_limit:
944950
raise ValueError("Specified box_size may not be smaller than (0.01* the Average True Value of the dataset) which has value: "+ str(lower_limit))
945951

952+
if reversal < 2:
953+
raise ValueError("Specified reversal may not be smaller than 2")
954+
elif reversal*box_size > upper_limit*0.6:
955+
raise ValueError("Product of specified box_size and reversal which has value: "+ str(reversal*box_size) + " may not exceed (30% of the close price range of the dataset) which has value: "+ str(upper_limit*0.6))
956+
946957
alpha = marketcolors['alpha']
947958

948959
uc = mcolors.to_rgba(marketcolors['ohlc'][ 'up' ], alpha)
@@ -972,27 +983,70 @@ def _construct_pointnfig_collections(dates, highs, lows, volumes, config_pointnf
972983
boxes, indexes = combine_adjacent(boxes)
973984
new_volumes, new_dates = coalesce_volume_dates(temp_volumes, temp_dates, indexes)
974985

975-
#subtract 1 from the abs of each diff except the first to account for the first box using the last box in the opposite direction
976-
first_elem = boxes[0]
977-
boxes = [boxes[i]- int((boxes[i]/abs(boxes[i]))) for i in range(1, len(boxes))]
978-
boxes.insert(0, first_elem)
979-
980-
# adjust volume and dates to make sure volume is combined into non 0 box indexes and only use dates from non 0 box indexes
981-
temp_volumes, temp_dates = [], []
982-
for i in range(len(boxes)):
983-
if boxes[i] == 0:
984-
volume_cache += new_volumes[i]
985-
else:
986+
adjusted_boxes = [boxes[0]]
987+
temp_volumes, temp_dates = [new_volumes[0]], [new_dates[0]]
988+
volume_cache = 0
989+
990+
# Clean data to subtract 1 from all box # not including the first boxes element and combine like signed adjacent values (after ignoring zeros)
991+
for i in range(1, len(boxes)):
992+
adjusted_value = boxes[i]- int((boxes[i]/abs(boxes[i])))
993+
994+
# not equal to 0 and different signs
995+
if adjusted_value != 0 and adjusted_boxes[-1]*adjusted_value < 0:
996+
997+
# Append adjusted_value, volumes, and date to associated lists
998+
adjusted_boxes.append(adjusted_value)
986999
temp_volumes.append(new_volumes[i] + volume_cache)
987-
volume_cache = 0
9881000
temp_dates.append(new_dates[i])
989-
990-
#remove 0s from boxes
991-
boxes = list(filter(lambda diff: diff != 0, boxes))
9921001

993-
# combine adjacent similarly signed differences again after 0s removed
994-
boxes, indexes = combine_adjacent(boxes)
995-
new_volumes, new_dates = coalesce_volume_dates(temp_volumes, temp_dates, indexes)
1002+
# reset volume_cache once we use it
1003+
volume_cache = 0
1004+
1005+
# not equal to 0 and same signs
1006+
elif adjusted_value != 0 and adjusted_boxes[-1]*adjusted_value > 0:
1007+
1008+
# Add adjusted_value and volume values to last added elements
1009+
adjusted_boxes[-1] += adjusted_value
1010+
temp_volumes[-1] += new_volumes[i] + volume_cache
1011+
1012+
# reset volume_cache once we use it
1013+
volume_cache = 0
1014+
1015+
else: # adjusted_value == 0
1016+
volume_cache += new_volumes[i]
1017+
1018+
boxes = [adjusted_boxes[0]]
1019+
new_volumes = [temp_volumes[0]]
1020+
new_dates = [temp_dates[0]]
1021+
1022+
rolling_change = 0
1023+
volume_cache = 0
1024+
1025+
#Clean data to account for reversal size (added to allow overriding the default reversal of 2)
1026+
for i in range(1, len(adjusted_boxes)):
1027+
1028+
# Add to rolling_change and volume_cache which stores the box and volume values
1029+
rolling_change += adjusted_boxes[i]
1030+
volume_cache += temp_volumes[i]
1031+
1032+
# Add to new list if the rolling change is >= the reversal - 1
1033+
# The -1 is because we have already subtracted 1 from the box values in the previous loop
1034+
if abs(rolling_change) >= reversal-1:
1035+
1036+
# if rolling_change is the same sign as the previous # of boxes then combine
1037+
if rolling_change*boxes[-1] > 0:
1038+
boxes[-1] += rolling_change
1039+
new_volumes[-1] += volume_cache
1040+
1041+
# otherwise add new box
1042+
else: # < 0 (== 0 can't happen since neither rolling_change or boxes[-1] can be 0)
1043+
boxes.append(rolling_change)
1044+
new_volumes.append(volume_cache)
1045+
new_dates.append(temp_dates[i])
1046+
1047+
# reset rolling_change and volume_cache once we've used them
1048+
rolling_change = 0
1049+
volume_cache = 0
9961050

9971051
curr_price = closes[0]
9981052
box_values = [] # y values for the boxes

tests/reference_images/pnf05.png

57.9 KB
Loading

tests/reference_images/pnf06.png

54.4 KB
Loading

tests/test_exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,11 @@ def test_figratio_bounds(bolldata):
8181
with pytest.raises(ValueError) as ex:
8282
mpf.plot(df,volume=True,figratio=(10,51),savefig=buf)
8383
assert '"figratio" (aspect ratio) must be between' in str(ex.value)
84+
85+
def test_reversal_box_size_bounds(bolldata):
86+
df = bolldata
87+
buf = io.BytesIO()
88+
mpf.plot(df,type='pnf',pnf_params=dict(box_size=3, reversal=3), volume=True, savefig=buf)
89+
with pytest.raises(ValueError) as ex:
90+
mpf.plot(df,type='pnf',pnf_params=dict(box_size=3, reversal=4), volume=True, savefig=buf)
91+
assert 'Product of specified box_size and reversal which has value: 12 may not exceed (30% of the close price range of the dataset)' in str(ex.value)

tests/test_pnf.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,43 @@ def test_pnf04(bolldata):
109109
if result is not None:
110110
print('result=',result)
111111
assert result is None
112+
113+
def test_pnf05(bolldata):
114+
df = bolldata
115+
116+
fname = base+'05.png'
117+
tname = os.path.join(tdir,fname)
118+
rname = os.path.join(refd,fname)
119+
120+
mpf.plot(df,type='pnf',pnf_params=dict(box_size=2, reversal=3),mav=(4,6,8),volume=True,savefig=tname)
121+
122+
tsize = os.path.getsize(tname)
123+
print(glob.glob(tname),'[',tsize,'bytes',']')
124+
125+
rsize = os.path.getsize(rname)
126+
print(glob.glob(rname),'[',rsize,'bytes',']')
127+
128+
result = compare_images(rname,tname,tol=IMGCOMP_TOLERANCE)
129+
if result is not None:
130+
print('result=',result)
131+
assert result is None
132+
133+
def test_pnf06(bolldata):
134+
df = bolldata
135+
136+
fname = base+'06.png'
137+
tname = os.path.join(tdir,fname)
138+
rname = os.path.join(refd,fname)
139+
140+
mpf.plot(df,type='pnf',pnf_params=dict(box_size='atr',atr_length='total', reversal=3),mav=(4,6,8),volume=True,savefig=tname)
141+
142+
tsize = os.path.getsize(tname)
143+
print(glob.glob(tname),'[',tsize,'bytes',']')
144+
145+
rsize = os.path.getsize(rname)
146+
print(glob.glob(rname),'[',rsize,'bytes',']')
147+
148+
result = compare_images(rname,tname,tol=IMGCOMP_TOLERANCE)
149+
if result is not None:
150+
print('result=',result)
151+
assert result is None

0 commit comments

Comments
 (0)