Files
mt5-quant-lib/Python_freqtrade/walkforward_optimisation.py
T
2025-07-12 15:53:31 +02:00

700 lines
26 KiB
Python

"""
MattC - 2025
This code is a working prototype and is intended for initial testing and development purposes. Some Python
standards, including but not limited to PEP 8 compliance, error handling, and code optimization, are yet to be fully
implemented. Further refactoring and enhancements are planned to improve readability, maintainability,
and efficiency.
"""
import os
from os import walk
import json
from datetime import datetime, timedelta
import pandas as pd
import shutil
from pathlib import Path
import logging
import matplotlib.pyplot as plt # pip install matplotlib
import matplotlib.dates as mdates
import warnings
import urllib
from urllib.request import urlopen
import time
warnings.simplefilter(action='ignore', category=FutureWarning)
logger = logging.getLogger(__name__)
# TODO sort out download data
class WalkForward(object):
def __init__(self, strategy_path, strategy, config, output_dir, wf_start, wf_finish, anchored_start, min_trades,
in_sample_days, out_sample_days, loss_function, cpu, epochs, wallet="2500", fee="0.002",
pre_live=False, re_opt=False, re_opt_t="5m 1h 1d"):
self.in_sample_days = in_sample_days
self.out_sample_days = out_sample_days
self.loss_function = loss_function
self.re_opt = re_opt
self.output_dir = self.create_run_dir(output_dir)
self.config = self.copy_input_config(config)
self.strategy_path = strategy_path
self.strategy_name = strategy
self.strategy = self.copy_input_strategy(strategy_path, self.strategy_name)
self.wf_start = wf_start
self.wf_finish = wf_finish
self.anchored_start = anchored_start
self.is_start = self.in_sample_start()
self.min_trades = min_trades
self.cpu = cpu
self.epochs = epochs
self.wallet = wallet
self.fee = fee
self.pre_live = pre_live
self.re_opt_t = re_opt_t
self.start_log()
def run_walk_forward(self):
stages = self.generate_wf_stages()
no_stages = len(stages)
start_wallet = self.wallet
start_time = datetime.now()
logger.info(f'Running Walk-forward for {no_stages} stages')
for count, value in enumerate(stages, 1):
stage_start_time = datetime.now()
logger.info(f'{"-" * 79}')
hyperopt_time = value[0]
backtest_time = value[1]
full_time_period = f"{hyperopt_time.split('-')[0]}-{backtest_time.split('-')[1]}"
logger.info(f'Walk-forward optimization for stage {count} of {no_stages}')
stage_dir = self.create_stage_dir(self.output_dir, count, full_time_period)
# Run Hyperopt and process required data
logger.info(f'Hyperopting for {hyperopt_time}')
cpu = self.set_cpu(hyperopt_time)
self.run_hyperopt(hyperopt_time, stage_dir, self.epochs, cpu)
hy_start = datetime.strptime(hyperopt_time.split('-')[0], "%Y%m%d")
hy_finish = datetime.strptime(hyperopt_time.split('-')[1], "%Y%m%d")
hy_delta = hy_start - hy_finish
hy_days = int(hy_delta.days)
logger.info(f'Running Hyperopt Backtest for period {hyperopt_time} ({hy_days} days)')
result_op, bt_file_op = self.run_backtest(hyperopt_time, "op_bt", start_wallet, stage_dir)
self.save_bt_file_data(stage_dir, bt_file_op, "op_bt")
df_op = self.update_df(result_op, "op_bt", count, hyperopt_time, start_wallet)
# Walk forward testing constant starting wallet:
logger.info(f'Running WF Backtest for period {backtest_time} ({self.out_sample_days} days)')
result_bt, bt_file_bt = self.run_backtest(backtest_time, "wf_bt", start_wallet, stage_dir)
self.save_bt_file_data(stage_dir, bt_file_bt, "wf_bt")
df_wf = self.update_df(result_bt, "wf_bt", count, backtest_time, start_wallet)
plot_equity_curve(df_wf, self.output_dir, save_fig=True)
self.combine_data(hyperopt_time, df_op, df_wf)
stage_run_time = str(datetime.now() - stage_start_time)
logger.info(f'Stage {count} walk-forward analysis Duration: {stage_run_time.split(".")[0]}')
if self.pre_live:
self.pre_live_optimise()
if self.re_opt:
self.re_optimise()
run_time = str(datetime.now() - start_time)
logger.info(f'Total walk-forward analysis Duration: {run_time.split(".")[0]}')
def re_optimise(self):
# Download data
# time_periods = "5m 15m 1h 4h 12h 1d"
time_periods = self.re_opt_t
self.download_data(time_periods)
end_date = datetime.strptime(self.wf_finish, "%Y%m%d")
start_date = end_date - timedelta(int(self.in_sample_days))
t1 = start_date.strftime("%Y%m%d")
t2 = end_date.strftime("%Y%m%d")
hyperopt_time = t1 + "-" + t2
full_time_period = f"{hyperopt_time}"
stage_dir = self.create_stage_dir(self.output_dir, "re_optimise", full_time_period)
epochs = f"{int(self.epochs)}"
self.run_hyperopt(hyperopt_time, stage_dir, epochs, self.cpu)
logger.info(f'Running Hyperopt Backtest for period {hyperopt_time} ({self.in_sample_days} days)')
_ = self.run_backtest(hyperopt_time, "re_optimise", self.wallet, stage_dir)
def save_bt_file_data(self, stage_dir, bt_file_op, file_id):
with open(f'{stage_dir}/{bt_file_op}') as f:
data1 = json.load(f)
df1 = pd.DataFrame.from_dict(data1["strategy"][self.strategy_name]["results_per_pair"])
csv_filepath = f"{stage_dir}/{file_id}_bt_results.csv"
df1.to_csv(csv_filepath)
def update_df(self, result, file_id, wf_stage, time_range, start_wallet):
h5_string = f"{self.output_dir}/{file_id}.h5"
if not os.path.exists(h5_string):
cols = ["profit_mean", "profit_mean_pct", "profit_sum", "profit_sum_pct", "profit_total_abs",
"profit_total_pct" "profit_total", "wins", "draws", "losses", "wf-stage", "bt_time_period",
"start-balance", "final-balance", "%_profit_pa", "acc-start", "acc-finish"]
df = pd.DataFrame(columns=cols)
df.to_hdf(h5_string, 'data')
df = pd.read_hdf(h5_string, 'data')
if file_id == "op_bt":
is_start = datetime.strptime(time_range.split('-')[0], "%Y%m%d")
is_finish = datetime.strptime(time_range.split('-')[1], "%Y%m%d")
delta = is_start - is_finish
sample_days = int(delta.days)
else:
sample_days = self.out_sample_days
data = result[0]
data.pop('key')
data["%_profit_pa"] = data["profit_total_pct"] / float(sample_days) * 365 # percent profit year
data["wf-stage"] = wf_stage
data["start-balance"] = start_wallet
data["final-balance"] = float(start_wallet) + float(data["profit_total_abs"])
data["bt_time_period"] = time_range
if len(df) == 0:
data["acc-start"] = float(self.wallet)
else:
data["acc-start"] = df["acc-finish"].iloc[-1]
data["acc-finish"] = (data["acc-start"] * data["profit_total_pct"] / 100) + data["acc-start"]
df2 = pd.DataFrame(data, index=[0])
df_new = df.copy()
df_new = df_new.append([df2], ignore_index=True)
csv_filepath = f"{self.output_dir}/{file_id}.csv"
df_new.to_csv(csv_filepath)
df_new.to_hdf(h5_string, 'data')
return df_new
def pre_live_optimise(self):
end_date = datetime.strptime(self.wf_finish, "%Y%m%d")
start_date = end_date - timedelta(int(self.in_sample_days))
t1 = start_date.strftime("%Y%m%d")
t2 = end_date.strftime("%Y%m%d")
hyperopt_time = t1 + "-" + t2
full_time_period = f"{hyperopt_time}"
stage_dir = self.create_stage_dir(self.output_dir, "pre_live", full_time_period)
epochs = f"{int(self.epochs) * 2}"
self.run_hyperopt(hyperopt_time, stage_dir, epochs, self.cpu)
logger.info(f'Running Hyperopt Backtest for period {hyperopt_time} ({self.in_sample_days} days)')
result_op = self.run_backtest(hyperopt_time, "pre_live", self.wallet, stage_dir)
def combine_data(self, hyp_time_range, df_op, df_wf):
is_start = datetime.strptime(hyp_time_range.split('-')[0], "%Y%m%d")
is_finish = datetime.strptime(hyp_time_range.split('-')[1], "%Y%m%d")
delta = is_start - is_finish
days_in = int(delta.days)
days_out = int(self.out_sample_days)
try:
a = pd.Series(df_op["profit_mean_pct"], name='op_profit_av')
b = pd.Series(df_wf["profit_mean_pct"], name='wf_profit_av')
c = pd.Series(df_op["wins"] / df_op["losses"], name='op_wl%')
d = pd.Series(df_wf["wins"] / df_wf["losses"], name='wf_wl%')
e = pd.Series(df_op['trades'].apply(lambda x: x / days_in), name='op_trades_per_day')
f = pd.Series(df_wf['trades'].apply(lambda x: x / days_out), name='wf_trades_per_day')
g = pd.Series(df_op["profit_total"].apply(lambda x: (x / days_in) * 365), name='op_ppa')
h = pd.Series(df_wf["profit_total"].apply(lambda x: (x / days_out) * 365), name='wf_ppa')
i = pd.Series(df_op["%_profit_pa"], name='op_%ppa')
j = pd.Series(df_wf["%_profit_pa"], name='wf_%ppa')
df_combined = pd.concat([a, b, c, d, e, f, g, h, i, j], axis=1)
filepath = f"{self.output_dir}/combined.csv"
df_combined.to_csv(filepath)
except:
pass
return
def generate_wf_stages(self):
is_start = datetime.strptime(self.is_start, "%Y%m%d")
oos_start = datetime.strptime(self.wf_start, "%Y%m%d")
end_date = datetime.strptime(self.wf_finish, "%Y%m%d")
oos_end = oos_start + timedelta(int(self.out_sample_days))
stages = []
while True:
t1 = is_start.strftime("%Y%m%d")
t2 = oos_start.strftime("%Y%m%d")
t3 = oos_end.strftime("%Y%m%d")
# define stage:
insample_timframe = t1 + "-" + t2
outsample_timframe = t2 + "-" + t3
stage = [insample_timframe, outsample_timframe]
stages.append(stage)
oos_start = oos_start + timedelta(int(self.out_sample_days))
oos_end = oos_start + timedelta(int(self.out_sample_days))
if not self.in_sample_days == "anchored":
is_start = is_start + timedelta(int(self.out_sample_days))
if oos_end > end_date:
break
return stages
def run_hyperopt(self, time_range, stage_dir, epochs, cpu):
"""
:param stage_number:
:param time_range:
:param epochs:
:param loss_function: SortinoHyperOptLoss,
:param fee:
:param cpu:
:return:
"""
self.wait_for_internet_connection()
start_time = datetime.now()
os.system(
"freqtrade hyperopt" +
" --min-trades " + self.min_trades +
" -j " + cpu +
" -e " + epochs +
" --spaces buy " +
" --fee " + self.fee +
" --logfile " + stage_dir + "/op_log" +
" --timerange " + time_range +
" --hyperopt-loss " + self.loss_function +
" --strategy " + self.strategy +
" --strategy-path " + self.output_dir +
" --config " + self.config +
" --dry-run-wallet " + self.wallet
)
self.wait_for_internet_connection()
os.system(
"freqtrade hyperopt-list" +
" --no-details " +
" --export-csv " + stage_dir + "/op.csv"
)
# # Copy the best optimisation results to "stage output directory":
src = Path(f"{self.output_dir}/{self.strategy}.json")
dst = f"{stage_dir}/op_result.json"
shutil.copyfile(str(src), dst)
run_time = str(datetime.now() - start_time)
logger.info(f'Hyperopt Duration: {run_time.split(".")[0]}')
with open(dst) as f:
data = json.load(f)
results = data["params"]["buy"]
for i in results:
logger.info(f'Hyperopt result: {i}: {results[i]}')
def run_backtest(self, time_range, file_id, wallet, stage_dir):
self.wait_for_internet_connection()
start_time = datetime.now()
os.system(
"freqtrade backtesting" +
" --export trades " +
" --fee " + self.fee +
f" --logfile {stage_dir}/{file_id}_log.txt"
" --timerange " + time_range +
" --strategy " + self.strategy +
" --strategy-path " + self.output_dir +
" --config " + self.config +
" --dry-run-wallet " + wallet +
f" --export-filename {stage_dir}/{file_id}_result.json"
)
result, bt_file = self.get_backtest_data(stage_dir, file_id)
run_time = str(datetime.now() - start_time)
if not file_id == "wf_acc_bt":
logger.info(f'Backtest Duration: {run_time.split(".")[0]}')
self.log_bt_results(time_range, result, wallet, file_id)
self.plot_bt_profit(time_range, stage_dir, bt_file, file_id)
return result, bt_file
def download_data(self, time_periods, days="4000"):
self.wait_for_internet_connection()
logger.info(f'downloading data')
os.system(
"freqtrade download-data" +
" -t " + time_periods +
" --exchange binance " +
" --pairs .*/USDT " +
" --new-pairs-days " + days +
" --include-inactive-pairs "
)
logger.info(f'finished downloading data')
return
@staticmethod
def wait_for_internet_connection():
start_time = datetime.now()
switch = True
while True:
try:
urlopen('https://www.google.com', timeout=1)
if not switch:
offline_time = str(datetime.now() - start_time)
logger.warning(f"Disconnected time: {offline_time.split('.')[0]}")
logger.warning("#############################")
return
except urllib.error.URLError:
if switch:
logger.warning("#############################")
logger.warning("NO INTERNET")
switch = False
time.sleep(2)
pass
def log_bt_results(self, time_range, result, wallet, file_id):
data = result[0].copy()
final_balance = float(wallet) + data["profit_total_abs"]
w_start = round(float(wallet), 2)
w_finish = round(final_balance, 2)
percent_prof = round(data["profit_total_pct"], 1)
if file_id == "op_bt":
is_start = datetime.strptime(time_range.split('-')[0], "%Y%m%d")
is_finish = datetime.strptime(time_range.split('-')[1], "%Y%m%d")
delta = is_start - is_finish
sample_days = int(delta.days)
else:
sample_days = self.out_sample_days
pppa = round((percent_prof / float(sample_days) * 365), 2) # percent profit per year
logger.info(f'Backtest result - Balance £{w_start} --> £{w_finish} ({percent_prof}%): {pppa} %profit pa')
# Trade stats
win = data['wins']
loss = data['losses']
draw = data['draws']
trades = data['trades']
logger.info(f"Backtest result - Wins: {win}, Draws: {draw}, Losses: {loss}, trades: {trades}")
# Average tade profits:
mean_p = round(float(data['profit_mean']), 2)
mp_percent = round(float(data['profit_mean_pct']), 2)
logger.info(f"Backtest result - mean trade profit £{mean_p}, {mp_percent}%")
# Account draw-down:
dd_percent = round((data['max_drawdown_account'] * 100), 2)
dd_abs = round(float(data['max_drawdown_abs']), 2)
logger.info(f"Backtest result - Max dd: {dd_percent}%, £{dd_abs}")
def plot_bt_profit(self, time_range, stage_dir, bt_file, file_id):
os.system(
"freqtrade plot-profit "
" --timeframe 1d "
" --timerange " + time_range +
" --strategy " + self.strategy +
" --strategy-path " + self.output_dir +
" --config " + self.config +
f" --export-filename {stage_dir}/{bt_file}"
)
# TODO relative path required:
src = Path(f"/home/matt/freqtrade/user_data/plot/freqtrade-profit-plot.html")
dst = f"{stage_dir}/{file_id}_profit-plot.html"
shutil.copyfile(str(src), dst)
@staticmethod
def get_backtest_data(stage_dir, file_id):
f = []
for (dirpath, dirnames, filenames) in walk(stage_dir):
f.extend(filenames)
break
# Find the backtest file for wf stage
for file in f:
# not meta.json:
if file[-9:-5] != "meta":
my_file = file_id + "_result"
if file.split("-")[0] == my_file:
# change to while open:
f = open(stage_dir + "/" + file)
data = json.load(f)
result = data["strategy_comparison"]
backtest_file = file
return result, backtest_file
def create_run_dir(self, path):
_time = datetime.now().strftime('%Y%m%d_%I:%M%p')
if self.re_opt:
directory = f"{path}/{_time}_{self.loss_function}_re-optimise_{self.in_sample_days}"
else:
directory = f"{path}/{_time}_{self.loss_function}_in_{self.in_sample_days}_out_{self.out_sample_days}"
if not os.path.exists(path):
os.makedirs(path)
try:
os.makedirs(directory)
except:
pass
return directory
@staticmethod
def create_stage_dir(path, stage, full_time_period):
dir_string = f"{path}/stage_{stage}_{full_time_period}"
if not os.path.exists(path):
os.makedirs(path)
try:
os.makedirs(dir_string)
except Exception as e:
pass
return dir_string
def copy_input_config(self, in_config):
# Copy config:
config = f"{in_config}"
dst_config = f"{self.output_dir}/{config.split('/')[-1]}"
shutil.copyfile(config, dst_config)
return dst_config
def copy_input_strategy(self, in_strategy_path, strategy):
# Copy Strategy
src = f"{in_strategy_path}/{strategy}.py"
dst_strategy = f"{self.output_dir}/{strategy}.py"
shutil.copyfile(src, dst_strategy)
return strategy
def in_sample_start(self):
oos_start = datetime.strptime(self.wf_start, "%Y%m%d")
if self.in_sample_days == "anchored":
is_start = datetime.strptime(self.anchored_start, "%Y%m%d") # + oos days
else:
is_start = oos_start - timedelta(int(self.in_sample_days))
is_start = is_start.strftime("%Y%m%d")
return is_start
def set_cpu(self, hyperopt_time):
is_start = datetime.strptime(hyperopt_time.split('-')[0], "%Y%m%d")
is_finish = datetime.strptime(hyperopt_time.split('-')[1], "%Y%m%d")
delta = is_finish - is_start
days = int(delta.days)
if self.in_sample_days == "anchored":
if days < 50:
cpu = "-1"
if days > 50:
cpu = "-2"
if days > 100:
cpu = "-4"
if days > 150:
cpu = "-6"
if days > 200:
cpu = "-8"
if days > 250:
cpu = "-10"
if days > 300:
cpu = "-11"
if days > 350:
cpu = "-12"
if days > 400:
cpu = "-13"
if days > 450:
cpu = "-14"
if days > 500:
cpu = "-14"
if days > 550:
cpu = "-16"
if days > 600:
cpu = "-16"
if days > 650:
cpu = "-17"
if days > 700:
cpu = "-17"
if days > 750:
cpu = "-17"
if days > 800:
cpu = "-18"
if days > 850:
cpu = "-18"
if days > 900:
cpu = "-18"
if days > 950:
cpu = "-19"
if days > 1000:
cpu = "-19"
if days > 1100:
cpu = "-20"
else:
d1 = datetime.strptime("20220601", "%Y%m%d")
d2 = datetime.strptime("20210101", "%Y%m%d")
d3 = datetime.strptime("20200101", "%Y%m%d")
d4 = datetime.strptime("20190101", "%Y%m%d")
d5 = datetime.strptime("20180101", "%Y%m%d")
if is_finish > d1:
cpu = self.cpu
if d2 < is_finish < d1:
cpu = int(self.cpu) + 1
if d3 < is_finish < d2:
cpu = int(self.cpu) + 2
if d4 < is_finish < d3:
cpu = int(self.cpu) + 3
if d5 < is_finish < d4:
cpu = int(self.cpu) + 4
if is_finish < d5:
cpu = int(self.cpu) + 5
return str(cpu)
def start_log(self):
# Set format and level:
logger.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - WF - %(levelname)s - %(message)s')
# Remove old handlers:
while logger.handlers:
logger.handlers.pop()
# Define file handler:
file_handler = logging.FileHandler(f'{self.output_dir}/wf.log')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Define console handler:
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Startup logs:
_cpu = 20 + int(self.cpu) + 1
start_date = datetime.strptime(self.is_start, '%Y%m%d').date()
end_date = datetime.strptime(self.wf_finish, '%Y%m%d').date()
run_time = str(end_date - start_date).split(",")[0]
logger.info('Code Initiated')
logger.info(f'Strategy:{self.strategy_path}/{self.strategy}')
logger.info(f'Config:{self.config}')
logger.info(f'Loss Function: {self.loss_function}')
logger.info(f'Time Frame:{self.is_start}-{self.wf_finish} ({run_time})')
logger.info(f'IS-days:{self.in_sample_days}, OOS-days:{self.out_sample_days}')
logger.info(f'CPUs:{_cpu}, Epochs:{self.epochs}, Wallet:{"2500"}, Fee:{"0.002"}, Min-trades:{self.min_trades}')
def plot_equity_curve(df, output_dir, save_fig=False):
df = df.copy()
pd.set_option('display.max_columns', None)
fig, axs = plt.subplots(figsize=(7, 4))
axs.xaxis.set_major_formatter(mdates.DateFormatter("%d %b"))
df['dates'] = df["bt_time_period"].apply(lambda i: i.split("-")[1])
df['dt'] = df['dates'].apply(lambda i: datetime.strptime(i, '%Y%m%d'))
df.set_index('dt')
df.plot(ax=axs, x="dt", y="acc-finish")
dela_y = int(df["acc-finish"].max()) - int(df["acc-finish"].min())
y_min = int(df["acc-finish"].min()) - (dela_y * 0.05)
y_max = int(df["acc-finish"].max()) + (dela_y * 0.05)
axs.set_title('Walk-Forward equity curve')
axs.set_ylim(y_min, y_max)
axs.set_ylabel("")
axs.set_xlabel("")
axs.grid(color='grey', alpha=0.5, linestyle='dashed', linewidth=0.5)
axs.yaxis.set_major_formatter("£" + '{x:1.0f}')
# plt.show()
if save_fig:
try:
plt.savefig(f"{output_dir}/wf_equity_curve.png")
except:
pass
def walk_forward(path, strategy, config, output_dir, wf_start, wf_finish, anchored_start, pre_live=False, re_opt=False):
is_list = ["730"]
oos_list = ["30"]
n_trades = ["100"]
cpu_list = ["-15"]
ep = "100"
loss_f = "SharpeHyperOptLoss"
for count, is_days in enumerate(is_list):
oos_days = oos_list[count]
nt = n_trades[count]
cores = cpu_list[count]
wf = WalkForward(strategy_path=path, strategy=strategy, config=config, output_dir=output_dir,
wf_start=wf_start, wf_finish=wf_finish, anchored_start=anchored_start, epochs=ep,
loss_function=loss_f, in_sample_days=is_days, out_sample_days=oos_days, min_trades=nt,
cpu=cores, pre_live=pre_live, re_opt=re_opt)
wf.run_walk_forward()
return
def re_optimise(path, strategy, config, output_dir, cpu="-19"):
today = datetime.now().strftime("%Y%m%d")
loss_f = "SortinoHyperOptLoss"
in_sample_days = "730"
n_trades = "100"
ep = "200"
download_data_t = "5m 1h 1d"
wf = WalkForward(strategy_path=path, strategy=strategy, config=config, output_dir=output_dir, wf_start=today,
wf_finish=today, anchored_start=today, epochs=ep, loss_function=loss_f,
in_sample_days=in_sample_days, out_sample_days="1", min_trades=n_trades, cpu=cpu,
pre_live=False, re_opt=True, re_opt_t=download_data_t)
wf.re_optimise()
if __name__ == "__main__":
WF_START = "20200101" # 2023-03-25
WF_END = "20230910"
ANCHORED_START = "20200101" # only used if in_sample_days == "anchored"
STRATEGY_PATH_THOR = "/home/matt/freqtrade/user_data/strategies/Thor"
STRATEGY_THOR = "Optimise_Thor_BuySig_RiskReward"
CONFIG_THOR = "/home/matt/freqtrade/user_data/strategies/Thor/config_Thor_WF.json"
OUTPUT_DIR_THOR = "/home/matt/freqtrade/user_data/strategies/Thor/walk_forward"
# -------------------------------------------------------------
walk_forward(STRATEGY_PATH_THOR, STRATEGY_THOR, CONFIG_THOR, OUTPUT_DIR_THOR, WF_START, WF_END, ANCHORED_START)
# -------------------------------------------------------------
re_optimise(STRATEGY_PATH_THOR, STRATEGY_THOR, CONFIG_THOR, OUTPUT_DIR_THOR)