2023-05-06 19:56:11 +00:00
|
|
|
import logging
|
|
|
|
import shutil
|
|
|
|
from copy import deepcopy
|
2023-09-12 10:54:25 +00:00
|
|
|
from datetime import datetime, timedelta
|
2023-06-17 06:40:09 +00:00
|
|
|
from pathlib import Path
|
2023-09-12 10:54:25 +00:00
|
|
|
from typing import Any, Dict, List
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-06-17 08:12:36 +00:00
|
|
|
from pandas import DataFrame
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
from freqtrade.data.history import get_timerange
|
|
|
|
from freqtrade.exchange import timeframe_to_minutes
|
2024-05-12 13:18:32 +00:00
|
|
|
from freqtrade.loggers.set_log_levels import (
|
|
|
|
reduce_verbosity_for_bias_tester,
|
|
|
|
restore_verbosity_for_bias_tester,
|
|
|
|
)
|
2023-05-06 19:56:11 +00:00
|
|
|
from freqtrade.optimize.backtesting import Backtesting
|
2023-09-12 10:57:16 +00:00
|
|
|
from freqtrade.optimize.base_analysis import BaseAnalysis, VarHolder
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class Analysis:
|
|
|
|
def __init__(self) -> None:
|
|
|
|
self.total_signals = 0
|
|
|
|
self.false_entry_signals = 0
|
|
|
|
self.false_exit_signals = 0
|
|
|
|
self.false_indicators: List[str] = []
|
|
|
|
self.has_bias = False
|
|
|
|
|
|
|
|
|
2023-09-12 10:11:19 +00:00
|
|
|
class LookaheadAnalysis(BaseAnalysis):
|
2023-05-20 09:02:13 +00:00
|
|
|
def __init__(self, config: Dict[str, Any], strategy_obj: Dict):
|
2023-09-12 10:11:19 +00:00
|
|
|
super().__init__(config, strategy_obj)
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
self.entry_varHolders: List[VarHolder] = []
|
|
|
|
self.exit_varHolders: List[VarHolder] = []
|
|
|
|
|
|
|
|
self.current_analysis = Analysis()
|
2024-05-12 15:16:55 +00:00
|
|
|
self.minimum_trade_amount = config["minimum_trade_amount"]
|
|
|
|
self.targeted_trade_amount = config["targeted_trade_amount"]
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2023-06-17 08:12:36 +00:00
|
|
|
def get_result(backtesting: Backtesting, processed: DataFrame):
|
2023-05-06 19:56:11 +00:00
|
|
|
min_date, max_date = get_timerange(processed)
|
|
|
|
|
|
|
|
result = backtesting.backtest(
|
2024-05-12 15:16:55 +00:00
|
|
|
processed=deepcopy(processed), start_date=min_date, end_date=max_date
|
2023-05-06 19:56:11 +00:00
|
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def report_signal(result: dict, column_name: str, checked_timestamp: datetime):
|
2024-05-12 15:16:55 +00:00
|
|
|
df = result["results"]
|
2023-05-06 19:56:11 +00:00
|
|
|
row_count = df[column_name].shape[0]
|
|
|
|
|
|
|
|
if row_count == 0:
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
df_cut = df[(df[column_name] == checked_timestamp)]
|
|
|
|
if df_cut[column_name].shape[0] == 0:
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
|
|
# analyzes two data frames with processed indicators and shows differences between them.
|
2023-06-17 12:25:46 +00:00
|
|
|
def analyze_indicators(self, full_vars: VarHolder, cut_vars: VarHolder, current_pair: str):
|
2023-05-06 19:56:11 +00:00
|
|
|
# extract dataframes
|
2023-06-17 08:12:36 +00:00
|
|
|
cut_df: DataFrame = cut_vars.indicators[current_pair]
|
|
|
|
full_df: DataFrame = full_vars.indicators[current_pair]
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
# cut longer dataframe to length of the shorter
|
2024-05-12 15:16:55 +00:00
|
|
|
full_df_cut = full_df[(full_df.date == cut_vars.compared_dt)].reset_index(drop=True)
|
|
|
|
cut_df_cut = cut_df[(cut_df.date == cut_vars.compared_dt)].reset_index(drop=True)
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-06-17 12:25:46 +00:00
|
|
|
# check if dataframes are not empty
|
|
|
|
if full_df_cut.shape[0] != 0 and cut_df_cut.shape[0] != 0:
|
|
|
|
# compare dataframes
|
|
|
|
compare_df = full_df_cut.compare(cut_df_cut)
|
|
|
|
|
|
|
|
if compare_df.shape[0] > 0:
|
|
|
|
for col_name, values in compare_df.items():
|
|
|
|
col_idx = compare_df.columns.get_loc(col_name)
|
|
|
|
compare_df_row = compare_df.iloc[0]
|
|
|
|
# compare_df now comprises tuples with [1] having either 'self' or 'other'
|
2024-05-12 15:16:55 +00:00
|
|
|
if "other" in col_name[1]:
|
2023-06-17 12:25:46 +00:00
|
|
|
continue
|
2023-10-30 17:26:25 +00:00
|
|
|
self_value = compare_df_row.iloc[col_idx]
|
|
|
|
other_value = compare_df_row.iloc[col_idx + 1]
|
2023-06-17 12:25:46 +00:00
|
|
|
|
|
|
|
# output differences
|
|
|
|
if self_value != other_value:
|
|
|
|
if not self.current_analysis.false_indicators.__contains__(col_name[0]):
|
|
|
|
self.current_analysis.false_indicators.append(col_name[0])
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(
|
|
|
|
f"=> found look ahead bias in indicator "
|
|
|
|
f"{col_name[0]}. "
|
|
|
|
f"{str(self_value)} != {str(other_value)}"
|
|
|
|
)
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-09-12 10:29:13 +00:00
|
|
|
def prepare_data(self, varholder: VarHolder, pairs_to_load: List[DataFrame]):
|
2024-05-12 15:16:55 +00:00
|
|
|
if "freqai" in self.local_config and "identifier" in self.local_config["freqai"]:
|
2023-09-12 10:29:13 +00:00
|
|
|
# purge previous data if the freqai model is defined
|
|
|
|
# (to be sure nothing is carried over from older backtests)
|
2024-05-12 15:16:55 +00:00
|
|
|
path_to_current_identifier = Path(
|
|
|
|
f"{self.local_config['user_data_dir']}/models/"
|
|
|
|
f"{self.local_config['freqai']['identifier']}"
|
|
|
|
).resolve()
|
2023-09-12 10:29:13 +00:00
|
|
|
# remove folder and its contents
|
|
|
|
if Path.exists(path_to_current_identifier):
|
|
|
|
shutil.rmtree(path_to_current_identifier)
|
|
|
|
|
|
|
|
prepare_data_config = deepcopy(self.local_config)
|
2024-05-12 15:16:55 +00:00
|
|
|
prepare_data_config["timerange"] = (
|
|
|
|
str(self.dt_to_timestamp(varholder.from_dt))
|
|
|
|
+ "-"
|
|
|
|
+ str(self.dt_to_timestamp(varholder.to_dt))
|
|
|
|
)
|
|
|
|
prepare_data_config["exchange"]["pair_whitelist"] = pairs_to_load
|
2023-09-12 10:29:13 +00:00
|
|
|
|
|
|
|
if self._fee is not None:
|
|
|
|
# Don't re-calculate fee per pair, as fee might differ per pair.
|
2024-05-12 15:16:55 +00:00
|
|
|
prepare_data_config["fee"] = self._fee
|
2023-09-12 10:29:13 +00:00
|
|
|
|
|
|
|
backtesting = Backtesting(prepare_data_config, self.exchange)
|
2023-06-09 04:45:34 +00:00
|
|
|
self.exchange = backtesting.exchange
|
2023-08-09 16:36:09 +00:00
|
|
|
self._fee = backtesting.fee
|
2023-09-12 10:29:13 +00:00
|
|
|
backtesting._set_strategy(backtesting.strategylist[0])
|
2023-05-08 20:35:13 +00:00
|
|
|
|
2023-09-12 10:29:13 +00:00
|
|
|
varholder.data, varholder.timerange = backtesting.load_bt_data()
|
|
|
|
backtesting.load_bt_data_detail()
|
|
|
|
varholder.timeframe = backtesting.timeframe
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-09-12 10:29:13 +00:00
|
|
|
varholder.indicators = backtesting.strategy.advise_all_indicators(varholder.data)
|
2023-06-08 18:13:28 +00:00
|
|
|
varholder.result = self.get_result(backtesting, varholder.indicators)
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-05-08 20:35:13 +00:00
|
|
|
def fill_entry_and_exit_varHolders(self, result_row):
|
2023-05-06 19:56:11 +00:00
|
|
|
# entry_varHolder
|
|
|
|
entry_varHolder = VarHolder()
|
|
|
|
self.entry_varHolders.append(entry_varHolder)
|
|
|
|
entry_varHolder.from_dt = self.full_varHolder.from_dt
|
2024-05-12 15:16:55 +00:00
|
|
|
entry_varHolder.compared_dt = result_row["open_date"]
|
2023-05-06 19:56:11 +00:00
|
|
|
# to_dt needs +1 candle since it won't buy on the last candle
|
2024-05-12 15:16:55 +00:00
|
|
|
entry_varHolder.to_dt = result_row["open_date"] + timedelta(
|
|
|
|
minutes=timeframe_to_minutes(self.full_varHolder.timeframe)
|
|
|
|
)
|
|
|
|
self.prepare_data(entry_varHolder, [result_row["pair"]])
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
# exit_varHolder
|
|
|
|
exit_varHolder = VarHolder()
|
|
|
|
self.exit_varHolders.append(exit_varHolder)
|
|
|
|
# to_dt needs +1 candle since it will always exit/force-exit trades on the last candle
|
|
|
|
exit_varHolder.from_dt = self.full_varHolder.from_dt
|
2024-05-12 15:16:55 +00:00
|
|
|
exit_varHolder.to_dt = result_row["close_date"] + timedelta(
|
|
|
|
minutes=timeframe_to_minutes(self.full_varHolder.timeframe)
|
|
|
|
)
|
|
|
|
exit_varHolder.compared_dt = result_row["close_date"]
|
|
|
|
self.prepare_data(exit_varHolder, [result_row["pair"]])
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
# now we analyze a full trade of full_varholder and look for analyze its bias
|
2023-08-09 16:36:09 +00:00
|
|
|
def analyze_row(self, idx: int, result_row):
|
2023-05-06 19:56:11 +00:00
|
|
|
# if force-sold, ignore this signal since here it will unconditionally exit.
|
|
|
|
if result_row.close_date == self.dt_to_timestamp(self.full_varHolder.to_dt):
|
|
|
|
return
|
|
|
|
|
|
|
|
# keep track of how many signals are processed at total
|
|
|
|
self.current_analysis.total_signals += 1
|
|
|
|
|
|
|
|
# fill entry_varHolder and exit_varHolder
|
2023-05-08 20:35:13 +00:00
|
|
|
self.fill_entry_and_exit_varHolders(result_row)
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-07-23 13:29:25 +00:00
|
|
|
# this will trigger a logger-message
|
|
|
|
buy_or_sell_biased: bool = False
|
|
|
|
|
2023-05-06 19:56:11 +00:00
|
|
|
# register if buy signal is broken
|
|
|
|
if not self.report_signal(
|
2024-05-12 15:16:55 +00:00
|
|
|
self.entry_varHolders[idx].result, "open_date", self.entry_varHolders[idx].compared_dt
|
|
|
|
):
|
2023-05-06 19:56:11 +00:00
|
|
|
self.current_analysis.false_entry_signals += 1
|
2023-07-23 13:29:25 +00:00
|
|
|
buy_or_sell_biased = True
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
# register if buy or sell signal is broken
|
|
|
|
if not self.report_signal(
|
2024-05-12 15:16:55 +00:00
|
|
|
self.exit_varHolders[idx].result, "close_date", self.exit_varHolders[idx].compared_dt
|
|
|
|
):
|
2023-05-06 19:56:11 +00:00
|
|
|
self.current_analysis.false_exit_signals += 1
|
2023-07-23 13:29:25 +00:00
|
|
|
buy_or_sell_biased = True
|
|
|
|
|
|
|
|
if buy_or_sell_biased:
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(
|
|
|
|
f"found lookahead-bias in trade "
|
|
|
|
f"pair: {result_row['pair']}, "
|
|
|
|
f"timerange:{result_row['open_date']} - {result_row['close_date']}, "
|
|
|
|
f"idx: {idx}"
|
|
|
|
)
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
# check if the indicators themselves contain biased data
|
2024-05-12 15:16:55 +00:00
|
|
|
self.analyze_indicators(self.full_varHolder, self.entry_varHolders[idx], result_row["pair"])
|
|
|
|
self.analyze_indicators(self.full_varHolder, self.exit_varHolders[idx], result_row["pair"])
|
2023-05-06 19:56:11 +00:00
|
|
|
|
|
|
|
def start(self) -> None:
|
2023-09-12 10:11:19 +00:00
|
|
|
super().start()
|
2023-05-06 19:56:11 +00:00
|
|
|
|
2023-06-09 05:13:45 +00:00
|
|
|
reduce_verbosity_for_bias_tester()
|
|
|
|
|
2023-05-06 19:56:11 +00:00
|
|
|
# check if requirements have been met of full_varholder
|
2024-05-12 15:16:55 +00:00
|
|
|
found_signals: int = self.full_varHolder.result["results"].shape[0] + 1
|
2023-05-06 19:56:11 +00:00
|
|
|
if found_signals >= self.targeted_trade_amount:
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(
|
|
|
|
f"Found {found_signals} trades, "
|
|
|
|
f"calculating {self.targeted_trade_amount} trades."
|
|
|
|
)
|
2023-05-06 19:56:11 +00:00
|
|
|
elif self.targeted_trade_amount >= found_signals >= self.minimum_trade_amount:
|
2023-05-20 17:58:14 +00:00
|
|
|
logger.info(f"Only found {found_signals} trades. Calculating all available trades.")
|
2023-05-06 19:56:11 +00:00
|
|
|
else:
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(
|
|
|
|
f"found {found_signals} trades "
|
|
|
|
f"which is less than minimum_trade_amount {self.minimum_trade_amount}. "
|
|
|
|
f"Cancelling this backtest lookahead bias test."
|
|
|
|
)
|
2023-05-06 19:56:11 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
# now we loop through all signals
|
|
|
|
# starting from the same datetime to avoid miss-reports of bias
|
2024-05-12 15:16:55 +00:00
|
|
|
for idx, result_row in self.full_varHolder.result["results"].iterrows():
|
2023-05-06 19:56:11 +00:00
|
|
|
if self.current_analysis.total_signals == self.targeted_trade_amount:
|
2023-07-23 09:23:02 +00:00
|
|
|
logger.info(f"Found targeted trade amount = {self.targeted_trade_amount} signals.")
|
2023-05-06 19:56:11 +00:00
|
|
|
break
|
2023-07-23 09:23:02 +00:00
|
|
|
if found_signals < self.minimum_trade_amount:
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(
|
|
|
|
f"only found {found_signals} "
|
|
|
|
f"which is smaller than "
|
|
|
|
f"minimum trade amount = {self.minimum_trade_amount}. "
|
|
|
|
f"Exiting this lookahead-analysis"
|
|
|
|
)
|
2023-07-23 09:23:02 +00:00
|
|
|
return None
|
2024-05-12 15:16:55 +00:00
|
|
|
if "force_exit" in result_row["exit_reason"]:
|
|
|
|
logger.info(
|
|
|
|
"found force-exit in pair: {result_row['pair']}, "
|
|
|
|
f"timerange:{result_row['open_date']}-{result_row['close_date']}, "
|
|
|
|
f"idx: {idx}, skipping this one to avoid a false-positive."
|
|
|
|
)
|
2023-07-23 11:48:54 +00:00
|
|
|
|
|
|
|
# just to keep the IDs of both full, entry and exit varholders the same
|
|
|
|
# to achieve a better debugging experience
|
|
|
|
self.entry_varHolders.append(VarHolder())
|
|
|
|
self.exit_varHolders.append(VarHolder())
|
2023-07-23 09:23:02 +00:00
|
|
|
continue
|
|
|
|
|
2023-05-06 19:56:11 +00:00
|
|
|
self.analyze_row(idx, result_row)
|
|
|
|
|
2023-07-23 09:23:02 +00:00
|
|
|
if len(self.entry_varHolders) < self.minimum_trade_amount:
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(
|
|
|
|
f"only found {found_signals} after skipping forced exits "
|
|
|
|
f"which is smaller than "
|
|
|
|
f"minimum trade amount = {self.minimum_trade_amount}. "
|
|
|
|
f"Exiting this lookahead-analysis"
|
|
|
|
)
|
2023-07-23 09:23:02 +00:00
|
|
|
|
2023-06-09 05:13:45 +00:00
|
|
|
# Restore verbosity, so it's not too quiet for the next strategy
|
|
|
|
restore_verbosity_for_bias_tester()
|
2023-05-06 19:56:11 +00:00
|
|
|
# check and report signals
|
2024-05-12 15:16:55 +00:00
|
|
|
if self.current_analysis.total_signals < self.local_config["minimum_trade_amount"]:
|
|
|
|
logger.info(
|
|
|
|
f" -> {self.local_config['strategy']} : too few trades. "
|
|
|
|
f"We only found {self.current_analysis.total_signals} trades. "
|
|
|
|
f"Hint: Extend the timerange "
|
|
|
|
f"to get at least {self.local_config['minimum_trade_amount']} "
|
|
|
|
f"or lower the value of minimum_trade_amount."
|
|
|
|
)
|
2023-05-28 18:52:58 +00:00
|
|
|
self.failed_bias_check = True
|
2024-05-12 15:16:55 +00:00
|
|
|
elif (
|
|
|
|
self.current_analysis.false_entry_signals > 0
|
|
|
|
or self.current_analysis.false_exit_signals > 0
|
|
|
|
or len(self.current_analysis.false_indicators) > 0
|
|
|
|
):
|
2023-05-28 18:52:58 +00:00
|
|
|
logger.info(f" => {self.local_config['strategy']} : bias detected!")
|
2023-05-06 19:56:11 +00:00
|
|
|
self.current_analysis.has_bias = True
|
2023-05-28 18:52:58 +00:00
|
|
|
self.failed_bias_check = False
|
2023-05-06 19:56:11 +00:00
|
|
|
else:
|
2024-05-12 15:16:55 +00:00
|
|
|
logger.info(self.local_config["strategy"] + ": no bias detected")
|
2023-05-28 18:52:58 +00:00
|
|
|
self.failed_bias_check = False
|