diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 7d97661c4..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 @@ -188,6 +189,36 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non return df +def expand_trades_over_period(results: pd.DataFrame, timeframe: str, + timeframe_min: Optional[int] = None) -> pd.DataFrame: + """ + Expand trades DF to have one row per candle + :param results: Results Dataframe - can be loaded + :param timeframe: Timeframe used for backtest + :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 + """ + 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(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] + # expand and flatten 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') + 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 @@ -198,15 +229,10 @@ def analyze_trade_parallelism(results: pd.DataFrame, timeframe: str) -> pd.DataF """ from freqtrade.exchange import timeframe_to_minutes timeframe_min = timeframe_to_minutes(timeframe) - dates = [pd.Series(pd.date_range(row[1]['open_date'], 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] - dates = pd.Series(pd.concat(dates).values, name='date') - df2 = pd.DataFrame(np.repeat(results.values, deltas, axis=0), columns=results.columns) + df2 = expand_trades_over_period(results, timeframe, timeframe_min) - 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 @@ -408,3 +434,40 @@ def calculate_csum(trades: pd.DataFrame, starting_balance: float = 0) -> Tuple[f csum_max = csum_df['sum'].max() + starting_balance return csum_min, csum_max + + +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 hloc: historical DataFrame of each pair tested + :return: DataFrame of outstanding balance at each timeframe + """ + + from freqtrade.exchange import timeframe_to_minutes + timeframe_min = timeframe_to_minutes(timeframe) + 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 = 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) + + # 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 + + balance = pd.concat([df[['value']] for k, df in values.items()]) + # Combine multi-pair balances + balance = balance.resample(f"{timeframe_min}min").agg({"value": sum}) + return balance diff --git a/freqtrade/optimize/SortinoLossBalance.py b/freqtrade/optimize/SortinoLossBalance.py new file mode 100644 index 000000000..6a1cc8fa5 --- /dev/null +++ b/freqtrade/optimize/SortinoLossBalance.py @@ -0,0 +1,55 @@ +""" +SortinoHyperOptLoss +This module defines the alternative HyperOptLoss class which can be used for +Hyperoptimization. +""" +import logging +from datetime import datetime +from typing import Dict + +import numpy as np +from pandas import DataFrame, Timedelta + +from freqtrade.data.btanalysis import calculate_outstanding_balance +from freqtrade.optimize.hyperopt import IHyperOptLoss + + +logger = logging.getLogger(__name__) + +target = 0 +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, + config: Dict, processed: Dict[str, DataFrame], + *args, **kwargs) -> float: + """ + Objective function, returns smaller number for more optimal results. + Uses Sortino Ratio calculation. + """ + timeframe = SortinoLossBalance.timeframe + annualize = np.sqrt(365 * (Timedelta("1D") / Timedelta(timeframe))) + + 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(processed)) + + 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 diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 1dcd04a80..2b692e4ae 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 @@ -11,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, + 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): @@ -178,6 +181,69 @@ 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_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()) 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