This commit is contained in:
orehunt 2021-10-07 12:58:53 -05:00 committed by GitHub
commit 42498053b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 217 additions and 19 deletions

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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