Triangle is one of the most common chart patterns. There are three types of this pattern: ascending (maximums of prices are at the same level, minimums increase), descending (opposite to ascending), and symmetrical triangles (formed as a consolidation pattern, when a wide range of prices is gradually reduced from both sides under pressure from buyers and sellers).
We highly recommend you to search triangle method in the internet before reading this template. The main idea of triangle chart pattern one can find here. A dozen movies can describe the basic concept of this method. On the other hand, no one will tell you exactly how he finds triangles in real trading and when exactly it is worth to hold a long or short position. You need to select all parameters by yourself, based on historical data. We will do it now.
# standart libraries
import xarray as xr
import numpy as np
import pandas as pd
import qnt.data as qndata # data loading and manipulation
import qnt.stats as qnstats # key statistics
import qnt.graph as qngraph # graphical tools
import qnt.forward_looking as qnfl # forward looking checking
import qnt.xr_talib as xrtl # technical analysis indicators
import qnt.ta as qnta # for weighted moving average
from IPython.display import display # display function for fancy displaying
import plotly.graph_objs as go # lib for charts
import datetime as dt # work with date and time
import re # to find a substring in a string
from scipy.signal import argrelextrema # to find local extrema
# ignore warnings
import warnings
warnings.filterwarnings('ignore')
# load crypto data
data = qndata.load_cryptocurrency_data(min_date = "2013-05-01",
max_date = None,
dims = ("time", "field", "asset"),
forward_order = True)
# we will consider triangle on a bitcoin example
crypt_name = 'BTC'
btc_close = data.loc[:,"close",:].to_pandas()[crypt_name]
btc_high = data.loc[:,"high",:].to_pandas()[crypt_name]
btc_low = data.loc[:,"low",:].to_pandas()[crypt_name]
vol = data.loc[:,"vol",:].to_pandas()[crypt_name]
# we can see historical data on a chart
trend_fig = [
go.Scatter(
x = btc_close.index,
y = btc_close.values,
name="BTC Close",
line = dict(width=1,color='black')),
go.Scatter(
x = btc_high.index,
y = btc_high.values,
name="BTC High",
line = dict(width=1,color='green')),
go.Scatter(
x = btc_low.index,
y = btc_low.values,
name="BTC Low",
line = dict(width=1,color='orange'))]
# draw chart
fig = go.Figure(data = trend_fig)
fig.update_yaxes(fixedrange=False) # unlock vertical scrolling
fig.show()
We will try to find a triangles by the following algorithm:
The next three cells are simply functions that perform items 1-3
def min_max_points(low, high, window_range = 10):
"""
:param low: low values of the instrument
:param high: high values of the instrument
:param window_range: look back days
:return: dataframe with selected extrema
notion: there is always 1 look forward day
"""
# make a list of local maximums and local minimums
local_max = argrelextrema(high.values, np.greater)[0]
local_min = argrelextrema(low.values, np.less)[0]
# we look back in a "window_range" days and select "global extremum" for these days
# maximum
price_local_max_dt = []
for i in local_max:
if (i>window_range) and (i<len(high)-window_range):
loop_ind = local_max[(local_max>=i-window_range) & (local_max<=i)]
price_local_max_dt.append(high.iloc[loop_ind].idxmax())
maxima = pd.DataFrame(high.loc[price_local_max_dt])
# mark them as maximum with "1"
maxima_lbls = pd.DataFrame(np.ones(shape = (len(price_local_max_dt),)),index = maxima.index, columns = ['extremum'])
maxima = pd.concat([maxima, maxima_lbls],axis = 1).sort_index()
# minimum
price_local_min_dt = []
for i in local_min:
if (i>window_range) and (i<len(low)-window_range):
loop_ind = local_min[(local_min>=i-window_range) & (local_min<=i)]
price_local_min_dt.append(low.iloc[loop_ind].idxmin())
minima = pd.DataFrame(low.loc[price_local_min_dt] )
# mark them as minimum with "0"
minima_lbls = pd.DataFrame(np.zeros(shape = (len(price_local_min_dt),)),index = minima.index, columns = ['extremum'])
minima = pd.concat([minima, minima_lbls],axis = 1).sort_index()
# combine, sort, and remove duplicated
max_min = pd.concat([maxima, minima]).sort_index()
max_min.index.name = 'date'
max_min = max_min.reset_index()
max_min = max_min[~max_min.date.duplicated()]
return max_min
def find_triangle(start_hour, extrema, local_pattern):
"""
:param start_hour: reference (start) date of trading
:param extrema: dataframe with all extrema and date
:param local_pattern: desired sequence of extrema
:return triangle: list of selected triangles with detection date, index and trade signal
:return V1,V2: list of linear equation coefficients for line 1 and 2 for all triangles
:return V1_val,V2_val: point coordinates for each line for all triangles
"""
triangle = []
V1,V1_val = [[],[]]
V2,V2_val = [[],[]]
# find a substring in a string (local pattern in a list of extrema)
move_string = ''.join([str(int(i)) for i in extrema['extremum'].values])
indexis = [(m.start(1), m.end(1)) for m in re.finditer(r'(?=(' + local_pattern + '))',move_string)]
point_index = [np.linspace(i[0],i[1]-1,len(local_pattern),dtype='int32') for i in indexis]
# for detected triangles in history:
for i in range(len(point_index)):
# Define the line equetion
# first line index
fli = [n.start() for n in re.finditer("1",local_pattern)]
# second line index
sli = [n.start() for n in re.finditer("0",local_pattern)]
# for the first line
local_y1 = extrema.iloc[point_index[i]][crypt_name].iloc[fli]
local_x1 = [(i - start_hour).seconds//3600 + (i - start_hour).days*24 for i in extrema.iloc[point_index[i]].date.iloc[fli].values]
A = np.vstack([np.ones(shape = (len(fli),)),local_x1]).T
v_1 = np.linalg.inv(A.T@A)@A.T@local_y1
V1.append(v_1)
V1_val.append(np.vstack([local_x1,local_y1.values]))
# for the second line
local_y2 = extrema.iloc[point_index[i]][crypt_name].iloc[sli]
local_x2 = [(i - start_hour).seconds//3600 + (i - start_hour).days*24 for i in extrema.iloc[point_index[i]].date.iloc[sli].values]
A = np.vstack([np.ones(shape = (len(sli),)),local_x2]).T
v_2 = np.linalg.inv(A.T@A)@A.T@local_y2
V2.append(v_2)
V2_val.append(np.vstack([local_x2,local_y2.values]))
# Hour of triangle detection
detection_hour = extrema.iloc[point_index[i]].date.iloc[-1]
# Check triangle parameters and recieve trade decision
triangle_flag, trade_signal = triangle_check(v_1,v_2,local_x1,local_x2,local_y1,local_y2)
# If triangle parameters are appropriate - save it
if triangle_flag == True:
triangle.append([trade_signal,detection_hour,i])
return triangle, V1, V1_val, V2, V2_val
def triangle_check(line1,line2,points_x1,points_x2,points_y1,points_y2):
"""
:param line1, line2: linear equation coefficients for line 1 and 2
:param points_x1, points_x2: extrema coordinates on X-axis
:param points_y1, points_y2: extrema coordinates on Y-axis
:return triangle_flag: True if the triangle fits all conditions
:return trade_signal: +1 if it is a signal to buy; -1 for sell
"""
#
vals = np.hstack([points_y1.values,points_y2.values])
cors = np.hstack([points_x1,points_x2])
# Triangle must converge and converge fast
x0 = (line2[0] - line1[0])/(line1[1] - line2[1])
y0 = line1[1]*x0 + line1[0]
# 4 is an imperical parameter. You can choose another one
if (x0 - max(cors))>0 and (x0 - max(cors))<(max(cors) - min(cors))*4:
converge = True
else:
converge = False
# triangle should be big enough
triangle_height = abs(points_y1.iloc[0] - line2[0] - line2[1]*min(points_x2))
if triangle_height/points_y1.iloc[0] < 0.001: # it is an imperical parameter. You can choose another one
hflag = False
else:
hflag = True
# one line should be more or less horisontal
zero_flag = True
if abs(y0/max(vals) - 1) < 0.002: # You can choose another one.
# If the top line is horizontal, it is a signal to buy
trade_signal = 1
elif abs(y0/min(vals) - 1) < 0.002: # You can choose another one.
# If the bottom line is horisontal, it is a signal to sell
trade_signal = -1
else:
trade_signal = 0
zero_flag = False
hflag = True
return converge*hflag*zero_flag, trade_signal
We can come up with a list of parameters that the triangle must satisfy (see triangle_check function):
# first we can choose "window range" for selecting extrema (in this case in hours)
wr = 10
# dataframe with extrema list
min_max = min_max_points(btc_low, btc_high, wr)
# For instance, we will search triangle based on the following pattern:
pattern = '1010' # [maximum, minimum, maximum, minimum]
# We need to set a reference day for all triangles to make their equations consistent
start_date = btc_close.index[0]
# triangles that we found
tr_param, v1, val1, v2, val2 = find_triangle(start_date, min_max, pattern)
# Info
print(f"All 'triangles' found in a history: {len(v1)}")
print(f"Triangles that satisfy all parameters: {len(tr_param)}")
In the cell below you can analize selected traingles in order to tune parameters in the triangle_check function.
# triangle number we look at (among all 'triangles'):
tr_look = 0
# prepare desired timeframe
local_date = start_date + dt.timedelta(hours = val1[tr_look][0,0])
time_temp = btc_close[local_date - dt.timedelta(hours = 100) :local_date + dt.timedelta(hours = 100)].index
# just print an example
# min max points
Min_max =[
go.Scatter(
x = min_max['date'],
y = min_max[crypt_name],
name="Selected extremum",
mode="markers",
hovertext='stop',
marker_size=9,
line = dict(width=1,color='green')
)]
# triangle extrema
local_triangle = [
go.Scatter(
x = [start_date + dt.timedelta(hours = i) for i in np.hstack([val1[tr_look][0],val2[tr_look][0]])],
y = np.hstack([val1[tr_look][1],val2[tr_look][1]]),
name="Triangle example",
mode="markers",
hovertext='stop',
marker_size=9,
line = dict(width=1,color='blue')
), # two lines - triangle edges
go.Scatter(
x = time_temp,
y = v1[tr_look][0] + v1[tr_look][1]*((time_temp-start_date).days*24 + (time_temp-start_date).seconds//3600),
name="First trading line",
line = dict(width=1,color='green')
),
go.Scatter(
x = time_temp,
y = v2[tr_look][0] + v2[tr_look][1]*((time_temp-start_date).days*24 + (time_temp-start_date).seconds//3600),
name="Second trading line",
line = dict(width=1,color='orange')
)]
# We print whether it pass all filters
if sum(np.array(tr_param)[:,2] == tr_look):
print("This triangle pass all filters")
else:
print("This is not a triangle")
# draw chart
fig = go.Figure(data = trend_fig + Min_max + local_triangle)
fig.update_yaxes(fixedrange=False) # unlock vertical scrolling
fig.show()
Here we make the assumption that it is reasonable to trade long if the last extremum in the pattern is a minimum and vice versa.
You can create and implement any other approach as you wish.
In a more details. For a long position we will wait when price hit the high limit twice and each time price rebound smaller than the previous one. We can set a long position after the second price minimum in this triangle (1 hour after to avoid forward looking):
For a short position, everything happens the other way around:
The trade function is simply a rule that make a trade decision based on the triangle.
def trade(close_val, triangle, val1, val2, trade_hours, percent, local_pattern):
"""
:param close_val: close values of the instrument
:param triangle: a list of selected triangles with trade signals and detection dates
:param val1, val2: extrema coordinates on Y-axis
:param trade_hours: the maximum trade hours after triangle detection
:param percent: profit we want to gain on each triangle in percents
:param local_pattern: the triangle pattern we use right now
:return sold_buy: a list of trade decisions in format: [weigth, date IN, date OUT]
:return trade_index: indexis of trade triangles (for debag)
"""
sold_buy = []
trade_index = []
# convert the parameter into fractions of units
percent = percent/100
# for detected triangles
for i in triangle:
zero_line = i[0]
det_hour = i[1]
# As we discussed already we set long position in the only one case:
# the upper line is horisontal and last extremum is minimum
# and vice versa for a short position
if (zero_line == 1 and local_pattern[-1] == '0') or (zero_line == -1 and local_pattern[-1] == '1'):
[flag1,flag2,flag3] = [False,False,False]
in_flag = False
# in available trading hours...
for k in range(trade_hours):
try:
day_number = det_hour + dt.timedelta(hours=int(k + 1)) # "+1 hour" avoids forward looking
loop_price = close_val[day_number]
# IN -----------------------------------------------------------------------------
# we want ot go IN inside the triangle to make the best profit
if in_flag==False:
in_flag = True
IN = [zero_line,day_number]
in_price = loop_price
# OUT ----------------------------------------------------------------------------
# fisrt calsulations
wma_price = qnta.wma(close_val,15)
progress_sign = np.sign(wma_price[wma_price.index==day_number] - wma_price[wma_price.index<day_number][-1])
loop_height = (loop_price - in_price)
# Three indicators we are lookinf for to go OUT:
# 1. to much fall for a worse case
if (loop_price<np.min(val2[i[2]])) and (IN[0]>0):
flag1 = True
if (loop_price>np.max(val1[i[2]])) and (IN[0]<0):
flag1 = True
# 2. goal is complite
if (loop_height/in_price>percent) or (progress_sign[0]<=0):
if (IN[0]>0):
flag2 = True
if (loop_height/in_price<-percent) or (progress_sign[0]>=0):
if (IN[0]<0):
flag2 = True
# 3. to many days waiting
if (k > trade_hours-2):
flag3 = True
# Now check conditions for this hour
if flag1 or flag2 or flag3:
IN.append(day_number)
sold_buy.append(IN)
trade_index.append(i[2])
# print([flag1,flag2,flag3, day_number],i)
[flag1,flag2,flag3] = [False,False,False]
break
except:
# raise
continue
return sold_buy, trade_index
# parameters
percent = 5 # profit we want to gain on each triangle
trade_hours = 60 # the maximum number of hours we can trade on one triangle
# just to remind that our pattern suppose long position
pattern = '1010'
buy_sold, trade_index = trade(btc_close, tr_param, np.array(val1)[:,1], np.array(val2)[:,1], trade_hours, percent, pattern)
We can visualize our trade decisions. You can analize it in order to tune parameters in the trade function.
# Info
print(f"The number of trade triangles = {len(buy_sold)}")
# triangle number we look at (among trade triangles):
tr_look = 0
# prepare desired timeframe
tr_index = trade_index[tr_look]
local_date = start_date + dt.timedelta(hours = val1[tr_index][0,0])
time_temp = btc_close[local_date - dt.timedelta(hours = 100) :local_date + dt.timedelta(hours = 100)].index
# just print an example
local_triangle =[
go.Scatter(
x = [start_date + dt.timedelta(hours = i) for i in np.hstack([val1[tr_index][0],val2[tr_index][0]])],
y = np.hstack([val1[tr_index][1],val2[tr_index][1]]),
name="Triangle example",
mode="markers",
hovertext='stop',
marker_size=9,
line = dict(width=1,color='blue')
), # two lines - triangle edges
go.Scatter(
x = time_temp,
y = v1[tr_index][0] + v1[tr_index][1]*((time_temp-start_date).days*24 + (time_temp-start_date).seconds//3600),
name="First trading line",
line = dict(width=1,color='green')
),
go.Scatter(
x = time_temp,
y = v2[tr_index][0] + v2[tr_index][1]*((time_temp-start_date).days*24 + (time_temp-start_date).seconds//3600),
name="Second trading line",
line = dict(width=1,color='orange')
)]
# In and OUT
in_out_fig = [
go.Scatter(
x = buy_sold[tr_look][1:3:1],
y = btc_close[buy_sold[tr_look][1:3:1]].values,
name="IN and OUT",
mode="markers",
hovertext='stop',
marker_size=9,
line = dict(width=1,color='grey')
)]
# draw chart
fig = go.Figure(data = trend_fig + local_triangle + in_out_fig)
fig.update_yaxes(fixedrange=False) # unlock vertical scrolling
fig.show()
Now we are ready to form the strategy implementing all the above functions step by step:
# select a window range
wr = 10
# we want patterns both for long and short positions:
pattern_list = ["1010","0101"]
# parameters
percent = 5 # profit we want to gain on each triangle
trade_hours = 60 # the maximum number of hours we can trade on one triangle
# trade decision for a loop
buy_sold_temp = []
# for pattern in pattern list...
for pattern in pattern_list:
# we repeat all above steps for each pattern
min_max = min_max_points(btc_low, btc_high, wr)
tr_param, v1, val1, v2, val2 = find_triangle(start_date, min_max, pattern)
# append trade decisions
temp, _ = trade(btc_close, tr_param, np.array(val1)[:,1], np.array(val2)[:,1], trade_hours, percent, pattern)
buy_sold_temp.append(temp)
# combine decisions
buy_sold = np.vstack([buy_sold_temp[0],buy_sold_temp[1]])
The cell below is just a standart function for the algorithm evaluation.
def print_stat(stat):
"""Prints selected statistical key indicators:
- the global Sharpe ratio of the strategy;
- the global mean profit;
- the global volatility;
- the maximum drawdown.
Note that Sharpe ratio, mean profit and volatility
apply to max simulation period, and not to the
rolling basis of 3 years.
"""
days = len(stat.coords["time"])
returns = stat.loc[:, "relative_return"]
equity = stat.loc[:, "equity"]
sharpe_ratio = qnstats.calc_sharpe_ratio_annualized(
returns,
max_periods=days,
min_periods=days).to_pandas().values[-1]
profit = (qnstats.calc_mean_return_annualized(
returns,
max_periods=days,
min_periods=days).to_pandas().values[-1])*100.0
volatility = (qnstats.calc_volatility_annualized(
returns,
max_periods=days,
min_periods=days).to_pandas().values[-1])*100.0
max_ddown = (qnstats.calc_max_drawdown(
qnstats.calc_underwater(equity)).to_pandas().values[-1])*100.0
print("Sharpe Ratio : ", "{0:.3f}".format(sharpe_ratio))
print("Mean Return [%] : ", "{0:.3f}".format(profit))
print("Volatility [%] : ", "{0:.3f}".format(volatility))
print("Maximum Drawdown [%] : ", "{0:.3f}".format(-max_ddown))
# form weights of the portfolio
weights = data.loc[:,"vol",:].to_pandas().copy(deep = True)
weights[:] = 0
# for trade decisions in a buy_sold list...
for j in buy_sold:
index = (weights.index>=j[1])*(weights.index<=j[2])
weights[crypt_name][index] = weights[crypt_name][index] + j[0]
# output
output = xr.DataArray(weights.values, dims = ["time","asset"], coords= {"time":weights.index,"asset":weights.columns} )
# calculate statistics
stat = qnstats.calc_stat(data, output, slippage_factor=0.05)
print_stat(stat)
# show plot with profit and losses:
performance = stat.to_pandas()["equity"]
qngraph.make_plot_filled(performance.index, performance, name="PnL (Equity)", type="log")
Everything. Here is a suggested list of what can be improved:
def strategy():
data = qndata.load_cryptocurrency_data(min_date = "2013-05-01",
max_date = None,
dims = ("time", "field", "asset"),
forward_order = True)
crypt_name = 'BTC'
btc_close = data.loc[:,"close",:].to_pandas()[crypt_name]
btc_high = data.loc[:,"high",:].to_pandas()[crypt_name]
btc_low = data.loc[:,"low",:].to_pandas()[crypt_name]
start_date = start_date = btc_close.index[0]
wr = 10
percent = 5 # profit we want to gain on each triangle
trade_hours = 60 # the maximum number of hours we can trade on one triangle
pattern_list = ["1010","0101"]
buy_sold_temp = []
for pattern in pattern_list:
min_max = min_max_points(btc_low,btc_high,wr)
tr_param, v1, val1, v2, val2 = find_triangle(start_date, min_max, pattern)
temp, _ = trade(btc_close, tr_param, np.array(val1)[:,1], np.array(val2)[:,1], trade_hours, percent, pattern)
buy_sold_temp.append(temp)
buy_sold = np.vstack([buy_sold_temp[0],buy_sold_temp[1]])
weights = data.loc[:,"vol",:].to_pandas().copy(deep = True)
weights[:] = 0
for j in buy_sold:
index = (weights.index>=j[1])*(weights.index<=j[2])
weights[crypt_name][index] = weights[crypt_name][index] + j[0]
output = xr.DataArray(weights.values, dims = ["time","asset"], coords= {"time":weights.index,"asset":weights.columns} )
return output
output_final = qnfl.load_data_calc_output_and_check_forward_looking(strategy)