From dcd85e3e29c2d93f3edb6ff46b284711127ed683 Mon Sep 17 00:00:00 2001 From: orehunt Date: Thu, 12 Mar 2020 14:28:27 +0100 Subject: [PATCH 01/10] - outstanding balance function - comments for analyze_trade_parallelism --- freqtrade/data/btanalysis.py | 100 ++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 7972c6333..10cc575a1 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -7,7 +7,8 @@ from typing import Dict, Union, Tuple import numpy as np import pandas as pd -from datetime import timezone +from datetime import timezone, datetime +from scipy.ndimage.interpolation import shift from freqtrade import persistence from freqtrade.misc import json_load @@ -62,15 +63,22 @@ def analyze_trade_parallelism(results: pd.DataFrame, timeframe: str) -> pd.DataF """ from freqtrade.exchange import timeframe_to_minutes timeframe_min = timeframe_to_minutes(timeframe) + # compute how long each trade was left outstanding as date indexes dates = [pd.Series(pd.date_range(row[1].open_time, row[1].close_time, freq=f"{timeframe_min}min")) for row in results[['open_time', 'close_time']].iterrows()] + # track the lifetime of each trade in number of candles deltas = [len(x) for x in dates] + # concat expands and flattens the list of lists of dates dates = pd.Series(pd.concat(dates).values, name='date') + # trades are repeated (column wise) according to their lifetime df2 = pd.DataFrame(np.repeat(results.values, deltas, axis=0), columns=results.columns) + # the expanded dates list is added as a new column to the repeated trades (df2) df2 = pd.concat([dates, df2], axis=1) df2 = df2.set_index('date') + # duplicate dates entries represent trades on the same candle + # which resampling resolves through the applied function (count) df_final = df2.resample(f"{timeframe_min}min")[['pair']].count() df_final = df_final.rename({'pair': 'open_trades'}, axis=1) return df_final @@ -213,3 +221,93 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_time' low_date = profit_results.loc[max_drawdown_df['drawdown'].idxmin(), date_col] return abs(min(max_drawdown_df['drawdown'])), high_date, low_date + + +def calculate_outstanding_balance( + results: pd.DataFrame, + timeframe: str, + min_date: datetime, + max_date: datetime, + hloc: Dict[str, pd.DataFrame], + slippage=0, +) -> pd.DataFrame: + """ + Sums the value of each trade (both open and closed) on each candle + :param results: Results Dataframe + :param timeframe: Frequency used for the backtest + :param min_date: date of the first trade opened (results.open_time.min()) + :param max_date: date of the last trade closed (results.close_time.max()) + :param hloc: historical DataFrame of each pair tested + :slippage: optional profit value to subtract per trade + :return: DataFrame of outstanding balance at each timeframe + """ + timedelta = pd.Timedelta(timeframe) + + date_index: pd.DatetimeIndex = pd.date_range( + start=min_date, end=max_date, freq=timeframe, normalize=True + ) + balance_total = [] + for pair in hloc: + pair_candles = hloc[pair].set_index("date").reindex(date_index) + # index becomes open_time + pair_trades = ( + results.loc[results["pair"].values == pair] + .set_index("open_time") + .resample(timeframe) + .asfreq() + .reindex(date_index) + ) + open_rate = pair_trades["open_rate"].fillna(0).values + open_time = pair_trades.index.values + close_time = pair_trades["close_time"].values + close = pair_candles["close"].values + profits = pair_trades["profit_percent"].values - slippage + # at the open_time candle, the balance is matched to the close of the candle + pair_balance = np.where( + # only the rows with actual trades + (open_rate > 0) + # only if the trade is not also closed on the same candle + & (open_time != close_time), + 1 - open_rate / close - slippage, + 0, + ) + # at the close_time candle, the balance just uses the profits col + pair_balance = pair_balance + np.where( + # only rows with actual trades + (open_rate > 0) + # the rows where a close happens + & (open_time == close_time), + profits, + pair_balance, + ) + + # how much time each trade was open, close - open time + periods = close_time - open_time + # how many candles each trade was open, set as a counter at each trade open_time index + hops = np.nan_to_num(periods / timedelta).astype(int) + + # each loop update one timeframe forward, the balance on each timeframe + # where there is at least one hop left to do (>0) + for _ in range(1, hops.max() + 1): + # move hops and open_rate by one + hops = shift(hops, 1, cval=0) + open_rate = shift(open_rate, 1, cval=0) + pair_balance = np.where( + hops > 0, pair_balance + (1 - open_rate / close) - slippage, pair_balance + ) + hops -= 1 + + # same as above but one loop per pair + # trades_indexes = np.nonzero(hops)[0] + # for i in trades_indexes: + # # start from 1 because counters are set at the open_time balance + # # which was already added previously + # for c in range(1, hops[i]): + # offset = i + c + # # the open rate is always for the current date, not the offset + # pair_balance[offset] += 1 - open_rate[i] / close[offset] - slippage + + # add the pair balance to the total + balance_total.append(pair_balance) + balance_total = np.array(balance_total).sum(axis=0) + return pd.DataFrame({"balance": balance_total, "date": date_index}) From ceb697f54cb99d55eceb81f656fdb478693c2d0c Mon Sep 17 00:00:00 2001 From: orehunt Date: Thu, 30 Apr 2020 07:56:18 +0200 Subject: [PATCH 02/10] sortino loss balance --- contrib/loss_functions/SortinoLossBalance.py | 133 +++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 contrib/loss_functions/SortinoLossBalance.py diff --git a/contrib/loss_functions/SortinoLossBalance.py b/contrib/loss_functions/SortinoLossBalance.py new file mode 100644 index 000000000..5581ca5be --- /dev/null +++ b/contrib/loss_functions/SortinoLossBalance.py @@ -0,0 +1,133 @@ +""" +SortinoHyperOptLoss +This module defines the alternative HyperOptLoss class which can be used for +Hyperoptimization. +""" +from datetime import datetime +import os + + +import logging +from pandas import DataFrame, DatetimeIndex, Timedelta, date_range +from scipy.ndimage.interpolation import shift +import numpy as np + +from freqtrade.optimize.hyperopt import IHyperOptLoss + +logger = logging.getLogger(__name__) + +interval = os.getenv("FQT_TIMEFRAME") or "5m" +slippage = 0.0005 +target = 0 +annualize = np.sqrt(365 * (Timedelta("1D") / Timedelta(interval))) + +logger.info(f"SortinoLossBalance target is set to: {target}") + + +class SortinoLossBalance(IHyperOptLoss): + """ + Defines the loss function for hyperopt. + This implementation uses the Sortino Ratio calculation. + """ + + @staticmethod + def hyperopt_loss_function( + results: DataFrame, + trade_count: int, + min_date: datetime, + max_date: datetime, + *args, + **kwargs, + ) -> float: + """ + Objective function, returns smaller number for more optimal results. + Uses Sortino Ratio calculation. + """ + hloc = kwargs["processed"] + timeframe = SortinoLossBalance.ticker_interval + timedelta = Timedelta(timeframe) + + date_index: DatetimeIndex = date_range( + start=min_date, end=max_date, freq=timeframe, normalize=True + ) + balance_total: np.ndarray = [] + for pair in hloc: + pair_candles = hloc[pair].set_index("date").reindex(date_index) + # index becomes open_time + pair_trades = ( + results.loc[results["pair"].values == pair] + .set_index("open_time") + .resample(timeframe) + .asfreq() + .reindex(date_index) + ) + open_rate = pair_trades["open_rate"].fillna(0).values + open_time = pair_trades.index.values + close_time = pair_trades["close_time"].values + close = pair_candles["close"].values + profits = pair_trades["profit_percent"].values - slippage + # at the open_time candle, the balance is matched to the close of the candle + pair_balance = np.where( + # only the rows with actual trades + (open_rate > 0) + # only if the trade is not also closed on the same candle + & (open_time != close_time), + 1 - open_rate / close - slippage, + # or initialize to 0 + 0, + ) + # at the close_time candle, the balance just uses the profits col + pair_balance = pair_balance + np.where( + # only rows with actual trades + (open_rate > 0) + # the rows where a close happens + & (open_time == close_time), + # use to profits + profits, + # otherwise leave unchanged + pair_balance, + ) + + # how much time each trade was open, close - open time + periods = close_time - open_time + # how many candles each trade was open, set as a counter at each trade open_time index + hops = np.nan_to_num(periods / timedelta).astype(int) + + # each loop update one timeframe forward, the balance on each timeframe + # where there is at least one hop left to do (>0) + for _ in range(1, hops.max() + 1): + # move hops and open_rate by one + hops = shift(hops, 1, cval=0) + open_rate = shift(open_rate, 1, cval=0) + pair_balance = np.where( + hops > 0, pair_balance + (1 - open_rate / close) - slippage, pair_balance + ) + hops -= 1 + + # same as above but one loop per pair + # trades_indexes = np.nonzero(hops)[0] + # for i in trades_indexes: + # # start from 1 because counters are set at the open_time balance + # # which was already added previously + # for c in range(1, hops[i]): + # offset = i + c + # # the open rate is always for the current date, not the offset + # pair_balance[offset] += 1 - open_rate[i] / close[offset] - slippage + + # add the pair balance to the total + balance_total.append(pair_balance) + balance_total = np.array(balance_total).sum(axis=0) + + returns = balance_total.mean() + # returns = balance_total.values.mean() + + downside_returns = np.where(balance_total < 0, balance_total, 0) + downside_risk = np.sqrt((downside_returns ** 2).sum() / len(date_index)) + + if downside_risk != 0.0: + sortino_ratio = (returns - target) / downside_risk * annualize + else: + sortino_ratio = -np.iinfo(np.int32).max + + # print(expected_returns_mean, down_stdev, sortino_ratio) + return -sortino_ratio From 443a36dd62008eec9bfb41b2eed7f63966f96813 Mon Sep 17 00:00:00 2001 From: orehunt Date: Thu, 30 Apr 2020 12:11:08 +0200 Subject: [PATCH 03/10] rename to profitperc --- freqtrade/data/btanalysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 10cc575a1..966e12c78 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -261,7 +261,7 @@ def calculate_outstanding_balance( open_time = pair_trades.index.values close_time = pair_trades["close_time"].values close = pair_candles["close"].values - profits = pair_trades["profit_percent"].values - slippage + profits = pair_trades["profitperc"].values - slippage # at the open_time candle, the balance is matched to the close of the candle pair_balance = np.where( # only the rows with actual trades From b7a463539c455bd6b47bcc03deb90c9a974a6082 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 24 Dec 2020 11:18:25 +0100 Subject: [PATCH 04/10] Extract expand_trades_over_period to it's own function --- freqtrade/data/btanalysis.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index ac2f6dc4a..1b54a3d09 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -172,16 +172,19 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non return df -def analyze_trade_parallelism(results: pd.DataFrame, timeframe: str) -> pd.DataFrame: +def expand_trades_over_period(results: pd.DataFrame, timeframe: str, + timeframe_min: Optional[int] = None) -> pd.DataFrame: """ - Find overlapping trades by expanding each trade once per period it was open - and then counting overlaps. + Expand trades DF to have one row per candle :param results: Results Dataframe - can be loaded :param timeframe: Timeframe used for backtest - :return: dataframe with open-counts per time-period in timeframe + :param timeframe: Timeframe in minutes. calculated from timeframe if not available. + :return: dataframe with date index (nonunique) + with trades expanded for every row from trade.open_date til trade.close_date """ - from freqtrade.exchange import timeframe_to_minutes - timeframe_min = timeframe_to_minutes(timeframe) + if not timeframe_min: + from freqtrade.exchange import timeframe_to_minutes + timeframe_min = timeframe_to_minutes(timeframe) # compute how long each trade was left outstanding as date indexes dates = [pd.Series(pd.date_range(row[1]['open_date'], row[1]['close_date'], freq=f"{timeframe_min}min")) @@ -195,6 +198,21 @@ def analyze_trade_parallelism(results: pd.DataFrame, timeframe: str) -> pd.DataF # the expanded dates list is added as a new column to the repeated trades (df2) df2 = pd.concat([dates, df2], axis=1) df2 = df2.set_index('date') + return df2 + + +def analyze_trade_parallelism(results: pd.DataFrame, timeframe: str) -> pd.DataFrame: + """ + Find overlapping trades by expanding each trade once per period it was open + and then counting overlaps. + :param results: Results Dataframe - can be loaded + :param timeframe: Timeframe used for backtest + :return: dataframe with open-counts per time-period in timeframe + """ + from freqtrade.exchange import timeframe_to_minutes + timeframe_min = timeframe_to_minutes(timeframe) + df2 = expand_trades_over_period(results, timeframe, timeframe_min) + # duplicate dates entries represent trades on the same candle # which resampling resolves through the applied function (count) df_final = df2.resample(f"{timeframe_min}min")[['pair']].count() From 09659da4889e696b22a8c28034c282555fc5895e Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 24 Dec 2020 11:43:42 +0100 Subject: [PATCH 05/10] Speed up "outstanding balance" function --- contrib/loss_functions/SortinoLossBalance.py | 10 +- freqtrade/data/btanalysis.py | 104 +++++-------------- 2 files changed, 30 insertions(+), 84 deletions(-) diff --git a/contrib/loss_functions/SortinoLossBalance.py b/contrib/loss_functions/SortinoLossBalance.py index 5581ca5be..e0056fa37 100644 --- a/contrib/loss_functions/SortinoLossBalance.py +++ b/contrib/loss_functions/SortinoLossBalance.py @@ -3,17 +3,17 @@ SortinoHyperOptLoss This module defines the alternative HyperOptLoss class which can be used for Hyperoptimization. """ -from datetime import datetime -import os - - import logging +import os +from datetime import datetime + +import numpy as np from pandas import DataFrame, DatetimeIndex, Timedelta, date_range from scipy.ndimage.interpolation import shift -import numpy as np from freqtrade.optimize.hyperopt import IHyperOptLoss + logger = logging.getLogger(__name__) interval = os.getenv("FQT_TIMEFRAME") or "5m" diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 1b54a3d09..fc0d8bf60 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -2,13 +2,12 @@ Helpers when analyzing backtest data """ import logging -from datetime import datetime, timezone +from datetime import timezone from pathlib import Path from typing import Any, Dict, Optional, Tuple, Union import numpy as np import pandas as pd -from scipy.ndimage.interpolation import shift from freqtrade.constants import LAST_BT_RESULT_FN from freqtrade.misc import json_load @@ -407,91 +406,38 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date' return abs(min(max_drawdown_df['drawdown'])), high_date, low_date -def calculate_outstanding_balance( - results: pd.DataFrame, - timeframe: str, - min_date: datetime, - max_date: datetime, - hloc: Dict[str, pd.DataFrame], - slippage=0, -) -> pd.DataFrame: +def calculate_outstanding_balance(results: pd.DataFrame, timeframe: str, + hloc: Dict[str, pd.DataFrame]) -> pd.DataFrame: """ Sums the value of each trade (both open and closed) on each candle :param results: Results Dataframe :param timeframe: Frequency used for the backtest - :param min_date: date of the first trade opened (results.open_time.min()) - :param max_date: date of the last trade closed (results.close_time.max()) :param hloc: historical DataFrame of each pair tested - :slippage: optional profit value to subtract per trade :return: DataFrame of outstanding balance at each timeframe """ - timedelta = pd.Timedelta(timeframe) - date_index: pd.DatetimeIndex = pd.date_range( - start=min_date, end=max_date, freq=timeframe, normalize=True - ) - balance_total = [] + from freqtrade.exchange import timeframe_to_minutes + timeframe_min = timeframe_to_minutes(timeframe) + df3 = expand_trades_over_period(results, timeframe, timeframe_min) + + values = {} + # Iterate over every pair for pair in hloc: - pair_candles = hloc[pair].set_index("date").reindex(date_index) - # index becomes open_time - pair_trades = ( - results.loc[results["pair"].values == pair] - .set_index("open_time") - .resample(timeframe) - .asfreq() - .reindex(date_index) - ) - open_rate = pair_trades["open_rate"].fillna(0).values - open_time = pair_trades.index.values - close_time = pair_trades["close_time"].values - close = pair_candles["close"].values - profits = pair_trades["profit_percent"].values - slippage - # at the open_time candle, the balance is matched to the close of the candle - pair_balance = np.where( - # only the rows with actual trades - (open_rate > 0) - # only if the trade is not also closed on the same candle - & (open_time != close_time), - 1 - open_rate / close - slippage, - 0, - ) - # at the close_time candle, the balance just uses the profits col - pair_balance = pair_balance + np.where( - # only rows with actual trades - (open_rate > 0) - # the rows where a close happens - & (open_time == close_time), - profits, - pair_balance, - ) + ohlc = hloc[pair].set_index('date') + df_pair = df3.loc[df3['pair'] == pair] + # filter on pair and convert dateindex to utc + # * Temporary workaround + df_pair.index = pd.to_datetime(df_pair.index, utc=True) - # how much time each trade was open, close - open time - periods = close_time - open_time - # how many candles each trade was open, set as a counter at each trade open_time index - hops = np.nan_to_num(periods / timedelta).astype(int) + # Combine trades with ohlc data + df4 = df_pair.merge(ohlc, left_on=['date'], right_on=['date']) + # Calculate the value at each candle + df4['current_value'] = df4['amount'] * df4['open'] + # 0.002 -> slippage / fees + df4['value'] = df4['current_value'] - df4['current_value'] * 0.002 + values[pair] = df4 - # each loop update one timeframe forward, the balance on each timeframe - # where there is at least one hop left to do (>0) - for _ in range(1, hops.max() + 1): - # move hops and open_rate by one - hops = shift(hops, 1, cval=0) - open_rate = shift(open_rate, 1, cval=0) - pair_balance = np.where( - hops > 0, pair_balance + (1 - open_rate / close) - slippage, pair_balance - ) - hops -= 1 - - # same as above but one loop per pair - # trades_indexes = np.nonzero(hops)[0] - # for i in trades_indexes: - # # start from 1 because counters are set at the open_time balance - # # which was already added previously - # for c in range(1, hops[i]): - # offset = i + c - # # the open rate is always for the current date, not the offset - # pair_balance[offset] += 1 - open_rate[i] / close[offset] - slippage - - # add the pair balance to the total - balance_total.append(pair_balance) - balance_total = np.array(balance_total).sum(axis=0) - return pd.DataFrame({"balance": balance_total, "date": date_index}) + balance = pd.concat([df[['value']] for k, df in values.items()]) + # TODO: Does this resample make sense ... ? + balance = balance.resample(f"{timeframe_min}min").agg({"value": sum}) + return balance From 958ad7d4468f52589659cff8458edf75d051bdad Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 25 Mar 2021 06:35:40 +0100 Subject: [PATCH 06/10] Have SortinoLossBalance use the calculate_outstanding_balance method --- freqtrade/optimize/SortinoLossBalance.py | 80 ++---------------------- 1 file changed, 5 insertions(+), 75 deletions(-) diff --git a/freqtrade/optimize/SortinoLossBalance.py b/freqtrade/optimize/SortinoLossBalance.py index 35b23b9c5..effd34412 100644 --- a/freqtrade/optimize/SortinoLossBalance.py +++ b/freqtrade/optimize/SortinoLossBalance.py @@ -8,9 +8,9 @@ import os from datetime import datetime import numpy as np -from pandas import DataFrame, DatetimeIndex, Timedelta, date_range -from scipy.ndimage.interpolation import shift +from pandas import DataFrame, Timedelta +from freqtrade.data.btanalysis import calculate_outstanding_balance from freqtrade.optimize.hyperopt import IHyperOptLoss @@ -44,85 +44,15 @@ class SortinoLossBalance(IHyperOptLoss): Uses Sortino Ratio calculation. """ hloc = kwargs["processed"] - timeframe = SortinoLossBalance.ticker_interval - timedelta = Timedelta(timeframe) + timeframe = SortinoLossBalance.timeframe - date_index: DatetimeIndex = date_range( - start=min_date, end=max_date, freq=timeframe, normalize=True - ) - balance_total: np.ndarray = [] - for pair in hloc: - pair_candles = hloc[pair].set_index("date").reindex(date_index) - # index becomes open_time - pair_trades = ( - results.loc[results["pair"].values == pair] - .set_index("open_date") - .resample(timeframe) - .asfreq() - .reindex(date_index) - ) - open_rate = pair_trades["open_rate"].fillna(0).values - open_date = pair_trades.index.values - close_date = pair_trades["close_date"].values - close = pair_candles["close"].values - profits = pair_trades["profit_ratio"].values - slippage - # at the open_time candle, the balance is matched to the close of the candle - pair_balance = np.where( - # only the rows with actual trades - (open_rate > 0) - # only if the trade is not also closed on the same candle - & (open_date != close_date), - 1 - open_rate / close - slippage, - # or initialize to 0 - 0, - ) - # at the close_time candle, the balance just uses the profits col - pair_balance = pair_balance + np.where( - # only rows with actual trades - (open_rate > 0) - # the rows where a close happens - & (open_date == close_date), - # use to profits - profits, - # otherwise leave unchanged - pair_balance, - ) - - # how much time each trade was open, close - open date - periods = close_date - open_date - # how many candles each trade was open, set as a counter at each trade open_date index - hops = np.nan_to_num(periods / timedelta).astype(int) - - # each loop update one timeframe forward, the balance on each timeframe - # where there is at least one hop left to do (>0) - for _ in range(1, hops.max() + 1): - # move hops and open_rate by one - hops = shift(hops, 1, cval=0) - open_rate = shift(open_rate, 1, cval=0) - pair_balance = np.where( - hops > 0, pair_balance + (1 - open_rate / close) - slippage, pair_balance - ) - hops -= 1 - - # same as above but one loop per pair - # trades_indexes = np.nonzero(hops)[0] - # for i in trades_indexes: - # # start from 1 because counters are set at the open_time balance - # # which was already added previously - # for c in range(1, hops[i]): - # offset = i + c - # # the open rate is always for the current date, not the offset - # pair_balance[offset] += 1 - open_rate[i] / close[offset] - slippage - - # add the pair balance to the total - balance_total.append(pair_balance) - balance_total = np.array(balance_total).sum(axis=0) + balance_total = calculate_outstanding_balance(results, timeframe, hloc) returns = balance_total.mean() # returns = balance_total.values.mean() downside_returns = np.where(balance_total < 0, balance_total, 0) - downside_risk = np.sqrt((downside_returns ** 2).sum() / len(date_index)) + downside_risk = np.sqrt((downside_returns ** 2).sum() / len(hloc)) if downside_risk != 0.0: sortino_ratio = (returns - target) / downside_risk * annualize From 5aaa05f2f250faa791e88e3dafc9463174b13a0c Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 25 Mar 2021 06:49:19 +0100 Subject: [PATCH 07/10] Simplify SortinoLoss --- freqtrade/optimize/SortinoLossBalance.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/freqtrade/optimize/SortinoLossBalance.py b/freqtrade/optimize/SortinoLossBalance.py index effd34412..6a1cc8fa5 100644 --- a/freqtrade/optimize/SortinoLossBalance.py +++ b/freqtrade/optimize/SortinoLossBalance.py @@ -4,8 +4,8 @@ This module defines the alternative HyperOptLoss class which can be used for Hyperoptimization. """ import logging -import os from datetime import datetime +from typing import Dict import numpy as np from pandas import DataFrame, Timedelta @@ -16,11 +16,7 @@ from freqtrade.optimize.hyperopt import IHyperOptLoss logger = logging.getLogger(__name__) -interval = os.getenv("FQT_TIMEFRAME") or "5m" -slippage = 0.0005 target = 0 -annualize = np.sqrt(365 * (Timedelta("1D") / Timedelta(interval))) - logger.info(f"SortinoLossBalance target is set to: {target}") @@ -31,28 +27,24 @@ class SortinoLossBalance(IHyperOptLoss): """ @staticmethod - def hyperopt_loss_function( - results: DataFrame, - trade_count: int, - min_date: datetime, - max_date: datetime, - *args, - **kwargs, - ) -> float: + def hyperopt_loss_function(results: DataFrame, trade_count: int, + min_date: datetime, max_date: datetime, + config: Dict, processed: Dict[str, DataFrame], + *args, **kwargs) -> float: """ Objective function, returns smaller number for more optimal results. Uses Sortino Ratio calculation. """ - hloc = kwargs["processed"] timeframe = SortinoLossBalance.timeframe + annualize = np.sqrt(365 * (Timedelta("1D") / Timedelta(timeframe))) - balance_total = calculate_outstanding_balance(results, timeframe, hloc) + balance_total = calculate_outstanding_balance(results, timeframe, processed) returns = balance_total.mean() # returns = balance_total.values.mean() downside_returns = np.where(balance_total < 0, balance_total, 0) - downside_risk = np.sqrt((downside_returns ** 2).sum() / len(hloc)) + downside_risk = np.sqrt((downside_returns ** 2).sum() / len(processed)) if downside_risk != 0.0: sortino_ratio = (returns - target) / downside_risk * annualize From e2356c856d24e955f9eae419042417ad0e3eca54 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 2 Oct 2021 15:23:09 +0200 Subject: [PATCH 08/10] Improve outstanding balance --- freqtrade/data/btanalysis.py | 6 ++--- tests/optimize/test_hyperoptloss.py | 34 ++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 79b0c2801..8ba61aeed 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -446,13 +446,13 @@ def calculate_outstanding_balance(results: pd.DataFrame, timeframe: str, from freqtrade.exchange import timeframe_to_minutes timeframe_min = timeframe_to_minutes(timeframe) - df3 = expand_trades_over_period(results, timeframe, timeframe_min) + trades_over_period = expand_trades_over_period(results, timeframe, timeframe_min) values = {} # Iterate over every pair for pair in hloc: ohlc = hloc[pair].set_index('date') - df_pair = df3.loc[df3['pair'] == pair] + df_pair = trades_over_period.loc[trades_over_period['pair'] == pair] # filter on pair and convert dateindex to utc # * Temporary workaround df_pair.index = pd.to_datetime(df_pair.index, utc=True) @@ -466,6 +466,6 @@ def calculate_outstanding_balance(results: pd.DataFrame, timeframe: str, values[pair] = df4 balance = pd.concat([df[['value']] for k, df in values.items()]) - # TODO: Does this resample make sense ... ? + # Combine multi-pair balances balance = balance.resample(f"{timeframe_min}min").agg({"value": sum}) return balance diff --git a/tests/optimize/test_hyperoptloss.py b/tests/optimize/test_hyperoptloss.py index 923e3fc32..d35b45e12 100644 --- a/tests/optimize/test_hyperoptloss.py +++ b/tests/optimize/test_hyperoptloss.py @@ -6,6 +6,7 @@ import pytest from freqtrade.exceptions import OperationalException from freqtrade.optimize.hyperopt_loss_short_trade_dur import ShortTradeDurHyperOptLoss from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver +from tests.strategy.test_strategy_helpers import generate_test_data def test_hyperoptlossresolver_noname(default_conf): @@ -78,14 +79,16 @@ def test_loss_calculation_has_limited_profit(hyperopt_conf, hyperopt_results) -> assert under > correct -@pytest.mark.parametrize('lossfunction', [ - "OnlyProfitHyperOptLoss", - "SortinoHyperOptLoss", - "SortinoHyperOptLossDaily", - "SharpeHyperOptLoss", - "SharpeHyperOptLossDaily", +@pytest.mark.parametrize('lossfunction, needsdata', [ + ("OnlyProfitHyperOptLoss", False), + ("SortinoHyperOptLoss", False), + ("SortinoHyperOptLossDaily", False), + ("SharpeHyperOptLoss", False), + ("SharpeHyperOptLossDaily", False), + ("SortinoLossBalance", True), ]) -def test_loss_functions_better_profits(default_conf, hyperopt_results, lossfunction) -> None: +def test_loss_functions_better_profits(default_conf, hyperopt_results, + lossfunction, needsdata) -> None: results_over = hyperopt_results.copy() results_over['profit_abs'] = hyperopt_results['profit_abs'] * 2 results_over['profit_ratio'] = hyperopt_results['profit_ratio'] * 2 @@ -93,13 +96,24 @@ def test_loss_functions_better_profits(default_conf, hyperopt_results, lossfunct results_under['profit_abs'] = hyperopt_results['profit_abs'] / 2 results_under['profit_ratio'] = hyperopt_results['profit_ratio'] / 2 + if needsdata: + data = {'ETH/USDT': generate_test_data('5m', 1200, start='2019-01-01')} + else: + data = {} + default_conf.update({'hyperopt_loss': lossfunction}) hl = HyperOptLossResolver.load_hyperoptloss(default_conf) correct = hl.hyperopt_loss_function(hyperopt_results, len(hyperopt_results), - datetime(2019, 1, 1), datetime(2019, 5, 1)) + datetime(2019, 1, 1), datetime(2019, 5, 1), + config=default_conf, processed=data, + ) over = hl.hyperopt_loss_function(results_over, len(results_over), - datetime(2019, 1, 1), datetime(2019, 5, 1)) + datetime(2019, 1, 1), datetime(2019, 5, 1), + config=default_conf, processed=data, + ) under = hl.hyperopt_loss_function(results_under, len(results_under), - datetime(2019, 1, 1), datetime(2019, 5, 1)) + datetime(2019, 1, 1), datetime(2019, 5, 1), + config=default_conf, processed=data, + ) assert over < correct assert under > correct From 20ad18ad236f39d25a1ee35f382e9429cfabb16a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 2 Oct 2021 16:11:17 +0200 Subject: [PATCH 09/10] Add explicit test for expand_trades_over_period --- freqtrade/data/btanalysis.py | 4 ++- tests/data/test_btanalysis.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 8ba61aeed..931f182e1 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd from freqtrade.constants import LAST_BT_RESULT_FN +from freqtrade.exchange import timeframe_to_prev_date from freqtrade.misc import json_load from freqtrade.persistence import LocalTrade, Trade, init_db @@ -202,7 +203,8 @@ def expand_trades_over_period(results: pd.DataFrame, timeframe: str, from freqtrade.exchange import timeframe_to_minutes timeframe_min = timeframe_to_minutes(timeframe) # compute how long each trade was left outstanding as date indexes - dates = [pd.Series(pd.date_range(row[1]['open_date'], row[1]['close_date'], + dates = [pd.Series(pd.date_range(timeframe_to_prev_date(timeframe, row[1]['open_date']), + timeframe_to_prev_date(timeframe, row[1]['close_date']), freq=f"{timeframe_min}min")) for row in results[['open_date', 'close_date']].iterrows()] deltas = [len(x) for x in dates] diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 1dcd04a80..d7f99d608 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -1,3 +1,4 @@ +from datetime import datetime from math import isclose from pathlib import Path from unittest.mock import MagicMock @@ -12,9 +13,9 @@ from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, BT_DATA_COLUMNS_MID, BT_ analyze_trade_parallelism, calculate_csum, calculate_market_change, calculate_max_drawdown, combine_dataframes_with_mean, create_cum_profit, - extract_trades_of_period, get_latest_backtest_filename, - get_latest_hyperopt_file, load_backtest_data, load_trades, - load_trades_from_db) + expand_trades_over_period, extract_trades_of_period, + get_latest_backtest_filename, get_latest_hyperopt_file, + load_backtest_data, load_trades, load_trades_from_db) from freqtrade.data.history import load_data, load_pair_history from tests.conftest import create_mock_trades from tests.conftest_trades import MOCK_TRADE_COUNT @@ -178,6 +179,55 @@ def test_analyze_trade_parallelism(default_conf, mocker, testdatadir): assert res['open_trades'].min() == 0 +def test_expand_trades_over_period(testdatadir): + filename = testdatadir / "backtest-result_test.json" + bt_data = load_backtest_data(filename) + + res = expand_trades_over_period(bt_data, "5m") + assert isinstance(res, DataFrame) + assert res['pair'].str.contains('ADA/BTC').sum() == 1970 + pair_res = res[res['pair'] == 'ADA/BTC'] + assert all(pair_res.iloc[[0]].index == '2018-01-10 07:15:00') + assert all(pair_res.iloc[[1]].index == '2018-01-10 07:20:00') + + res = expand_trades_over_period(bt_data, "15m") + # Expanding over 15m should produce fewer rows. + assert isinstance(res, DataFrame) + assert res['pair'].str.contains('ADA/BTC').sum() == 672 + pair_res = res[res['pair'] == 'ADA/BTC'] + assert all(pair_res.iloc[[0]].index == '2018-01-10 07:15:00') + assert all(pair_res.iloc[[1]].index == '2018-01-10 07:30:00') + + trade_results = DataFrame( + { + 'pair': ['ETH/USDT', 'ETH/USDT', 'ETH/USDT', 'ETH/USDT'], + 'profit_ratio': [-0.1, 0.2, -0.1, 0.3], + 'profit_abs': [-0.2, 0.4, -0.2, 0.6], + 'trade_duration': [10, 30, 10, 10], + 'amount': [0.1, 0.1, 0.1, 0.1], + 'open_date': + [ + datetime(2019, 1, 1, 9, 15, 0), + datetime(2019, 1, 2, 8, 55, 0), + datetime(2019, 1, 3, 9, 15, 0), + datetime(2019, 1, 4, 9, 15, 0), + ], + 'close_date': + [ + datetime(2019, 1, 1, 9, 25, 0), + datetime(2019, 1, 2, 9, 25, 0), + datetime(2019, 1, 3, 9, 25, 0), + datetime(2019, 1, 4, 9, 25, 0), + ], + } + ) + + res = expand_trades_over_period(trade_results, "5m") + assert res['pair'].str.contains('ETH/USDT').sum() == 16 + assert all(res.iloc[[0]].index == '2019-01-01 09:15:00') + assert all(res.iloc[[1]].index == '2019-01-01 09:20:00') + + def test_load_trades(default_conf, mocker): db_mock = mocker.patch("freqtrade.data.btanalysis.load_trades_from_db", MagicMock()) bt_mock = mocker.patch("freqtrade.data.btanalysis.load_backtest_data", MagicMock()) From d3eb5fa5f451d0cc0227632851cf622532631169 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 2 Oct 2021 16:21:04 +0200 Subject: [PATCH 10/10] Add test for outstanding balance --- tests/data/test_btanalysis.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index d7f99d608..2b692e4ae 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -12,13 +12,15 @@ from freqtrade.constants import LAST_BT_RESULT_FN from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, BT_DATA_COLUMNS_MID, BT_DATA_COLUMNS_OLD, analyze_trade_parallelism, calculate_csum, calculate_market_change, calculate_max_drawdown, - combine_dataframes_with_mean, create_cum_profit, - expand_trades_over_period, extract_trades_of_period, - get_latest_backtest_filename, get_latest_hyperopt_file, - load_backtest_data, load_trades, load_trades_from_db) + calculate_outstanding_balance, combine_dataframes_with_mean, + create_cum_profit, expand_trades_over_period, + extract_trades_of_period, get_latest_backtest_filename, + get_latest_hyperopt_file, load_backtest_data, load_trades, + load_trades_from_db) from freqtrade.data.history import load_data, load_pair_history from tests.conftest import create_mock_trades from tests.conftest_trades import MOCK_TRADE_COUNT +from tests.strategy.test_strategy_helpers import generate_test_data def test_get_latest_backtest_filename(testdatadir, mocker): @@ -228,6 +230,20 @@ def test_expand_trades_over_period(testdatadir): assert all(res.iloc[[1]].index == '2019-01-01 09:20:00') +def test_calculate_outstanding_balance(testdatadir): + filename = testdatadir / "backtest-result_new.json" + bt_results = load_backtest_data(filename) + + data = {pair: generate_test_data('5m', 1200, start='2018-01-10') + for pair in bt_results['pair'].unique()} + + res = calculate_outstanding_balance(bt_results, "5m", data) + + assert isinstance(res, DataFrame) + assert len(res) == 1113 + assert 'value' in res.columns + + def test_load_trades(default_conf, mocker): db_mock = mocker.patch("freqtrade.data.btanalysis.load_trades_from_db", MagicMock()) bt_mock = mocker.patch("freqtrade.data.btanalysis.load_backtest_data", MagicMock())