├── .gitattributes ├── README.md ├── perf_TradingPair(452516 [eth_btc]).csv ├── perf_analysis_pyfolio.ipynb ├── pyrenko.py ├── renko_trend_following.py └── renko_trend_following_optimizer.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # renko_trend_following_strategy_catalyst 2 | Example of adaptive trend following strategy based on Renko. This article describes the strategy https://medium.com/@sermal/adaptive-trend-following-trading-strategy-based-on-renko-9248bf83554 3 | 4 | Article about optimizer script 5 | https://towardsdatascience.com/bayesian-optimization-in-trading-77202ffed530 6 | 7 | This strategy uses Catalyst framework for backtesting https://enigma.co/catalyst/beginner-tutorial.html 8 | 9 | ### Project contains: 10 | 11 | renko_trend_following.py - main file. You should execute this file by python in Catalyst environment. 12 | 13 | perf_TradingPair(452516 [eth_btc]).csv - you get this file when the main script is executed. The file contains basic stats of performance. 14 | 15 | perf_analysis_pyfolio.ipynb - this ipython-notebook carries out an advanced analytics using csv-file. 16 | 17 | pyrenko.py - necessary file to analysis. You can find the latest version here https://github.com/quantroom-pro/pyrenko 18 | -------------------------------------------------------------------------------- /pyrenko.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | import matplotlib.patches as patches 4 | import talib 5 | 6 | class renko: 7 | def __init__(self): 8 | self.source_prices = [] 9 | self.renko_prices = [] 10 | self.renko_directions = [] 11 | 12 | # Setting brick size. Auto mode is preferred, it uses history 13 | def set_brick_size(self, HLC_history = None, auto = True, brick_size = 10.0): 14 | if auto == True: 15 | self.brick_size = self.__get_optimal_brick_size(HLC_history.iloc[:, [0, 1, 2]]) 16 | else: 17 | self.brick_size = brick_size 18 | return self.brick_size 19 | 20 | def __renko_rule(self, last_price): 21 | # Get the gap between two prices 22 | gap_div = int(float(last_price - self.renko_prices[-1]) / self.brick_size) 23 | is_new_brick = False 24 | start_brick = 0 25 | num_new_bars = 0 26 | 27 | # When we have some gap in prices 28 | if gap_div != 0: 29 | # Forward any direction (up or down) 30 | if (gap_div > 0 and (self.renko_directions[-1] > 0 or self.renko_directions[-1] == 0)) or (gap_div < 0 and (self.renko_directions[-1] < 0 or self.renko_directions[-1] == 0)): 31 | num_new_bars = gap_div 32 | is_new_brick = True 33 | start_brick = 0 34 | # Backward direction (up -> down or down -> up) 35 | elif np.abs(gap_div) >= 2: # Should be double gap at least 36 | num_new_bars = gap_div 37 | num_new_bars -= np.sign(gap_div) 38 | start_brick = 2 39 | is_new_brick = True 40 | self.renko_prices.append(self.renko_prices[-1] + 2 * self.brick_size * np.sign(gap_div)) 41 | self.renko_directions.append(np.sign(gap_div)) 42 | #else: 43 | #num_new_bars = 0 44 | 45 | if is_new_brick: 46 | # Add each brick 47 | for d in range(start_brick, np.abs(gap_div)): 48 | self.renko_prices.append(self.renko_prices[-1] + self.brick_size * np.sign(gap_div)) 49 | self.renko_directions.append(np.sign(gap_div)) 50 | 51 | return num_new_bars 52 | 53 | # Getting renko on history 54 | def build_history(self, prices): 55 | if len(prices) > 0: 56 | # Init by start values 57 | self.source_prices = prices 58 | self.renko_prices.append(prices.iloc[0]) 59 | self.renko_directions.append(0) 60 | 61 | # For each price in history 62 | for p in self.source_prices[1:]: 63 | self.__renko_rule(p) 64 | 65 | return len(self.renko_prices) 66 | 67 | # Getting next renko value for last price 68 | def do_next(self, last_price): 69 | if len(self.renko_prices) == 0: 70 | self.source_prices.append(last_price) 71 | self.renko_prices.append(last_price) 72 | self.renko_directions.append(0) 73 | return 1 74 | else: 75 | self.source_prices.append(last_price) 76 | return self.__renko_rule(last_price) 77 | 78 | # Simple method to get optimal brick size based on ATR 79 | def __get_optimal_brick_size(self, HLC_history, atr_timeperiod = 14): 80 | brick_size = 0.0 81 | 82 | # If we have enough of data 83 | if HLC_history.shape[0] > atr_timeperiod: 84 | brick_size = np.median(talib.ATR(high = np.double(HLC_history.iloc[:, 0]), 85 | low = np.double(HLC_history.iloc[:, 1]), 86 | close = np.double(HLC_history.iloc[:, 2]), 87 | timeperiod = atr_timeperiod)[atr_timeperiod:]) 88 | 89 | return brick_size 90 | 91 | def evaluate(self, method = 'simple'): 92 | balance = 0 93 | sign_changes = 0 94 | price_ratio = len(self.source_prices) / len(self.renko_prices) 95 | 96 | if method == 'simple': 97 | for i in range(2, len(self.renko_directions)): 98 | if self.renko_directions[i] == self.renko_directions[i - 1]: 99 | balance = balance + 1 100 | else: 101 | balance = balance - 2 102 | sign_changes = sign_changes + 1 103 | 104 | if sign_changes == 0: 105 | sign_changes = 1 106 | 107 | score = balance / sign_changes 108 | if score >= 0 and price_ratio >= 1: 109 | score = np.log(score + 1) * np.log(price_ratio) 110 | else: 111 | score = -1.0 112 | 113 | return {'balance': balance, 'sign_changes:': sign_changes, 114 | 'price_ratio': price_ratio, 'score': score} 115 | 116 | def get_renko_prices(self): 117 | return self.renko_prices 118 | 119 | def get_renko_directions(self): 120 | return self.renko_directions 121 | 122 | def plot_renko(self, col_up = 'g', col_down = 'r'): 123 | fig, ax = plt.subplots(1, figsize=(20, 10)) 124 | ax.set_title('Renko chart') 125 | ax.set_xlabel('Renko bars') 126 | ax.set_ylabel('Price') 127 | 128 | # Calculate the limits of axes 129 | ax.set_xlim(0.0, 130 | len(self.renko_prices) + 1.0) 131 | ax.set_ylim(np.min(self.renko_prices) - 3.0 * self.brick_size, 132 | np.max(self.renko_prices) + 3.0 * self.brick_size) 133 | 134 | # Plot each renko bar 135 | for i in range(1, len(self.renko_prices)): 136 | # Set basic params for patch rectangle 137 | col = col_up if self.renko_directions[i] == 1 else col_down 138 | x = i 139 | y = self.renko_prices[i] - self.brick_size if self.renko_directions[i] == 1 else self.renko_prices[i] 140 | height = self.brick_size 141 | 142 | # Draw bar with params 143 | ax.add_patch( 144 | patches.Rectangle( 145 | (x, y), # (x,y) 146 | 1.0, # width 147 | self.brick_size, # height 148 | facecolor = col 149 | ) 150 | ) 151 | 152 | plt.show() -------------------------------------------------------------------------------- /renko_trend_following.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import pandas as pd 4 | import pyrenko 5 | import scipy.optimize as opt 6 | from scipy.stats import iqr 7 | import talib 8 | 9 | from catalyst import run_algorithm 10 | from catalyst.api import (record, symbol, order_target, order_target_percent, get_datetime) 11 | 12 | # Function for optimization 13 | def evaluate_renko(brick, history, column_name): 14 | renko_obj = pyrenko.renko() 15 | renko_obj.set_brick_size(brick_size = brick, auto = False) 16 | renko_obj.build_history(prices = history) 17 | return renko_obj.evaluate()[column_name] 18 | 19 | def initialize(context): 20 | context.asset = symbol('eth_btc') 21 | 22 | context.leverage = 1.0 # 1.0 - no leverage 23 | context.n_history = 877 # Number of lookback bars for modelling 24 | context.tf = 99 # How many minutes in a timeframe 25 | context.diff_lag = 96 # Lag of differences to get returns 26 | context.model = pyrenko.renko() # Renko object 27 | context.part_cover_ratio = 0.67 # Partially cover position ratio 28 | context.last_brick_size = 0.0 # Last optimal brick size (just for storing) 29 | 30 | context.set_benchmark(context.asset) 31 | context.set_commission(maker = 0.001, taker = 0.002) 32 | context.set_slippage(slippage = 0.0005) 33 | 34 | def handle_data(context, data): 35 | current_time = get_datetime().time() 36 | if current_time.hour == 0 and current_time.minute == 0: 37 | print('Current date is ' + str(get_datetime().date())) 38 | 39 | # When model is empty 40 | if len(context.model.get_renko_prices()) == 0: 41 | context.model = pyrenko.renko() 42 | history = data.history(context.asset, 43 | 'price', 44 | bar_count = context.n_history, 45 | frequency = str(context.tf) + 'T' 46 | ) 47 | 48 | # Get absolute returns 49 | diffs = history.diff(context.diff_lag).abs() 50 | diffs = diffs[~np.isnan(diffs)] 51 | # Calculate IQR of daily returns 52 | iqr_diffs = np.percentile(diffs, [25, 75]) 53 | 54 | # Find the optimal brick size 55 | opt_bs = opt.fminbound(lambda x: -evaluate_renko(brick = x, 56 | history = history, column_name = 'score'), 57 | iqr_diffs[0], iqr_diffs[1], disp=0) 58 | 59 | # Build the model 60 | print('REBUILDING RENKO: ' + str(opt_bs)) 61 | context.last_brick_size = opt_bs 62 | context.model.set_brick_size(brick_size = opt_bs, auto = False) 63 | context.model.build_history(prices = history) 64 | 65 | # Open a position 66 | order_target_percent(context.asset, context.leverage * context.model.get_renko_directions()[-1]) 67 | 68 | # Store some information 69 | record( 70 | rebuilding_status = 1, 71 | brick_size = context.last_brick_size, 72 | price = history[-1], 73 | renko_price = context.model.get_renko_prices()[-1], 74 | num_created_bars = 0, 75 | amount = context.portfolio.positions[context.asset].amount 76 | ) 77 | 78 | else: 79 | last_price = data.history(context.asset, 80 | 'price', 81 | bar_count = 1, 82 | frequency = '1440T', 83 | ) 84 | 85 | # Just for output and debug 86 | prev = context.model.get_renko_prices()[-1] 87 | prev_dir = context.model.get_renko_directions()[-1] 88 | num_created_bars = context.model.do_next(last_price) 89 | if num_created_bars != 0: 90 | print('New Renko bars created') 91 | print('last price: ' + str(last_price)) 92 | print('previous Renko price: ' + str(prev)) 93 | print('current Renko price: ' + str(context.model.get_renko_prices()[-1])) 94 | print('direction: ' + str(prev_dir)) 95 | print('brick size: ' + str(context.model.brick_size)) 96 | 97 | # Store some information 98 | record( 99 | rebuilding_status = 0, 100 | brick_size = context.last_brick_size, 101 | price = last_price, 102 | renko_price = context.model.get_renko_prices()[-1], 103 | num_created_bars = num_created_bars, 104 | amount = context.portfolio.positions[context.asset].amount 105 | ) 106 | 107 | # If the last price moves in the backward direction we should rebuild the model 108 | if np.sign(context.portfolio.positions[context.asset].amount * context.model.get_renko_directions()[-1]) == -1: 109 | order_target_percent(context.asset, 0.0) 110 | context.model = pyrenko.renko() 111 | # or we cover the part of the position 112 | elif context.part_cover_ratio > 0.0 and num_created_bars != 0: 113 | order_target(context.asset, context.portfolio.positions[context.asset].amount * (1.0 - context.part_cover_ratio)) 114 | 115 | def analyze(context, perf): 116 | # Summary output 117 | print('Total return: ' + str(perf.algorithm_period_return[-1])) 118 | print('Sortino ratio: ' + str(perf.sortino[-1])) 119 | print('Max drawdown: ' + str(np.min(perf.max_drawdown))) 120 | print('Alpha: ' + str(perf.alpha[-1])) 121 | print('Beta: ' + str(perf.beta[-1])) 122 | perf.to_csv('perf_' + str(context.asset) + '.csv') 123 | 124 | f = plt.figure(figsize = (7.2, 7.2)) 125 | 126 | # Plot performance 127 | ax1 = f.add_subplot(611) 128 | ax1.plot(perf.algorithm_period_return, 'blue') 129 | ax1.plot(perf.benchmark_period_return, 'red') 130 | ax1.set_title('Performance') 131 | ax1.set_xlabel('Time') 132 | ax1.set_ylabel('Return') 133 | 134 | # Plot price and renko price 135 | ax2 = f.add_subplot(612, sharex = ax1) 136 | ax2.plot(perf.price, 'grey') 137 | ax2.plot(perf.renko_price, 'yellow') 138 | ax2.set_title(context.asset) 139 | ax2.set_xlabel('Time') 140 | ax2.set_ylabel('Price') 141 | 142 | # Plot brick size 143 | ax3 = f.add_subplot(613, sharex = ax1) 144 | ax3.plot(perf.brick_size, 'blue') 145 | xcoords = perf.index[perf.rebuilding_status == 1] 146 | for xc in xcoords: 147 | ax3.axvline(x = xc, color = 'red') 148 | ax3.set_title('Brick size and rebuilding status') 149 | ax3.set_xlabel('Time') 150 | ax3.set_ylabel('Size and Status') 151 | 152 | # Plot renko_price 153 | ax4 = f.add_subplot(614, sharex = ax1) 154 | ax4.plot(perf.num_created_bars, 'green') 155 | ax4.set_title('Number of created Renko bars') 156 | ax4.set_xlabel('Time') 157 | ax4.set_ylabel('Amount') 158 | 159 | # Plot amount of asset in portfolio 160 | ax5 = f.add_subplot(615, sharex = ax1) 161 | ax5.plot(perf.amount, 'black') 162 | ax5.set_title('Asset amount in portfolio') 163 | ax5.set_xlabel('Time') 164 | ax5.set_ylabel('Amount') 165 | 166 | # Plot drawdown 167 | ax6 = f.add_subplot(616, sharex = ax1) 168 | ax6.plot(perf.max_drawdown, 'yellow') 169 | ax6.set_title('Max drawdown') 170 | ax6.set_xlabel('Time') 171 | ax6.set_ylabel('Drawdown') 172 | 173 | plt.show() 174 | 175 | #perf.returns.to_csv(str(context.asset) + '_returns.csv') 176 | 177 | run_algorithm( 178 | capital_base = 10, 179 | data_frequency = 'daily', 180 | initialize = initialize, 181 | handle_data = handle_data, 182 | analyze = analyze, 183 | exchange_name = 'bitfinex', 184 | quote_currency = 'btc', 185 | start = pd.to_datetime('2018-11-1', utc = True), 186 | end = pd.to_datetime('2018-11-30', utc = True)) 187 | 188 | -------------------------------------------------------------------------------- /renko_trend_following_optimizer.py: -------------------------------------------------------------------------------- 1 | from hyperopt import hp, tpe, fmin, Trials 2 | import numpy as np 3 | import pandas as pd 4 | import datetime 5 | import pyrenko 6 | import scipy.optimize as opt 7 | from scipy.stats import iqr 8 | 9 | from catalyst import run_algorithm 10 | from catalyst.api import (record, symbol, order_target, order_target_percent, get_datetime) 11 | 12 | period_weights = [0.2, 0.2, 0.2, 0.2, 0.2] 13 | 14 | start_date = datetime.datetime.strptime("1-6-2018", "%d-%m-%Y") 15 | end_date = datetime.datetime.strptime("31-10-2018", "%d-%m-%Y") 16 | total_days = (end_date - start_date).days + 1 17 | 18 | folds_start_date = [start_date] + [start_date + datetime.timedelta(days = round(total_days * x)) for x in np.cumsum(period_weights)[:-1]] 19 | folds_end_date = [start_date + datetime.timedelta(days = round(total_days * x) - 1) for x in np.cumsum(period_weights)] 20 | 21 | hyper_params_space = {'n_history': hp.quniform('n_history', 150, 1000, 1), 22 | 'tf': hp.quniform('tf', 10, 100, 1), 23 | 'diff_lag': hp.quniform('diff_lag', 1, 100, 1), 24 | 'part_cover_ratio': hp.uniform('part_cover_ratio', 0.0, 1.0)} 25 | 26 | def weighted_mean(values): 27 | return np.average(values, weights = list(range(1, len(values) + 1))) 28 | 29 | def score_func(params): 30 | # Function for Renko brick optimization 31 | def evaluate_renko(brick, history, column_name): 32 | renko_obj = pyrenko.renko() 33 | renko_obj.set_brick_size(brick_size = brick, auto = False) 34 | renko_obj.build_history(prices = history) 35 | return renko_obj.evaluate()[column_name] 36 | 37 | def initialize(context): 38 | context.asset = symbol('eth_btc') 39 | 40 | context.leverage = 1.0 # 1.0 - no leverage 41 | context.n_history = int(params['n_history']) # Number of lookback bars for modelling 42 | context.tf = str(int(params['tf'])) + 'T' # How many minutes in a timeframe 43 | context.diff_lag = int(params['diff_lag']) # Lag of differences to get returns 44 | context.model = pyrenko.renko() # Renko object 45 | context.part_cover_ratio = float(params['part_cover_ratio']) # Partially cover position ratio 46 | context.last_brick_size = 0.0 # Last optimal brick size (just for storing) 47 | 48 | context.set_benchmark(context.asset) 49 | context.set_commission(maker = 0.001, taker = 0.002) 50 | context.set_slippage(slippage = 0.0005) 51 | 52 | def handle_data(context, data): 53 | current_time = get_datetime().time() 54 | 55 | # When model is empty 56 | if len(context.model.get_renko_prices()) == 0: 57 | context.model = pyrenko.renko() 58 | history = data.history(context.asset, 59 | 'price', 60 | bar_count = context.n_history, 61 | frequency = context.tf 62 | ) 63 | 64 | # Get daily absolute returns 65 | diffs = history.diff(context.diff_lag).abs() 66 | diffs = diffs[~np.isnan(diffs)] 67 | # Calculate IQR of daily returns 68 | iqr_diffs = np.percentile(diffs, [25, 75]) 69 | 70 | # Find the optimal brick size 71 | opt_bs = opt.fminbound(lambda x: -evaluate_renko(brick = x, 72 | history = history, column_name = 'score'), 73 | iqr_diffs[0], iqr_diffs[1], disp=0) 74 | 75 | # Build the model 76 | context.last_brick_size = opt_bs 77 | context.model.set_brick_size(brick_size = opt_bs, auto = False) 78 | context.model.build_history(prices = history) 79 | 80 | # Open a position 81 | order_target_percent(context.asset, context.leverage * context.model.get_renko_directions()[-1]) 82 | 83 | else: 84 | last_price = data.history(context.asset, 85 | 'price', 86 | bar_count = 1, 87 | frequency = '1440T', 88 | ) 89 | 90 | # Just for output and debug 91 | prev = context.model.get_renko_prices()[-1] 92 | prev_dir = context.model.get_renko_directions()[-1] 93 | num_created_bars = context.model.do_next(last_price) 94 | 95 | # If the last price moves in the backward direction we should rebuild the model 96 | if np.sign(context.portfolio.positions[context.asset].amount * context.model.get_renko_directions()[-1]) == -1: 97 | order_target_percent(context.asset, 0.0) 98 | context.model = pyrenko.renko() 99 | # or we cover the part of the position 100 | elif context.part_cover_ratio > 0.0 and num_created_bars != 0: 101 | order_target(context.asset, context.portfolio.positions[context.asset].amount * (1.0 - context.part_cover_ratio)) 102 | 103 | def analyze(context, perf): 104 | pass 105 | 106 | # Run alfo and get the performance 107 | perf = run_algorithm( 108 | capital_base = 1000000, 109 | data_frequency = 'daily', 110 | initialize = initialize, 111 | handle_data = handle_data, 112 | analyze = analyze, 113 | exchange_name = 'bitfinex', 114 | quote_currency = 'btc', 115 | start = pd.to_datetime(params['start'], utc = True), 116 | end = pd.to_datetime(params['end'], utc = True)) 117 | 118 | # Invert the metric 119 | if pd.isnull(perf.sortino[-1]): 120 | return 0.0 121 | else: 122 | return (-1.0) * perf.sortino[-1] 123 | 124 | def objective(hyper_params): 125 | print(hyper_params) 126 | 127 | # Calculate metric for each fold 128 | metric_folds = [0.0] * (len(folds_start_date)) 129 | for p in range(len(folds_start_date)): 130 | hyper_params['start'] = folds_start_date[p] 131 | hyper_params['end'] = folds_end_date[p] 132 | 133 | metric_folds[p] = score_func(hyper_params) 134 | print('Fold #' + str(p) +' metric value: ' + str(metric_folds[p])) 135 | 136 | result = 0.0 137 | if np.max(metric_folds) >= 0.0: 138 | result = np.max(metric_folds) 139 | else: 140 | result = weighted_mean(metric_folds) 141 | 142 | print('Objective function value: ' + str(result)) 143 | return result 144 | 145 | tpe_trials = Trials() 146 | opt_params = fmin(fn = objective, 147 | space = hyper_params_space, 148 | algo = tpe.suggest, 149 | max_evals = 300, 150 | trials = tpe_trials, 151 | rstate = np.random.RandomState(100)) 152 | 153 | tpe_results = pd.DataFrame({'score': [x['loss'] for x in tpe_trials.results], 154 | 'n_history': tpe_trials.idxs_vals[1]['n_history'], 155 | 'tf': tpe_trials.idxs_vals[1]['tf'], 156 | 'diff_lag': tpe_trials.idxs_vals[1]['diff_lag'], 157 | 'part_cover_ratio': tpe_trials.idxs_vals[1]['part_cover_ratio']}) 158 | tpe_results.sort_values(by = ['score'], inplace = True) 159 | 160 | print(tpe_results.head(10)) 161 | print(opt_params) --------------------------------------------------------------------------------