Merge d3eb5fa5f4
into a1be6124f2
This commit is contained in:
commit
42498053b1
@ -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
|
||||
|
55
freqtrade/optimize/SortinoLossBalance.py
Normal file
55
freqtrade/optimize/SortinoLossBalance.py
Normal 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
|
@ -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())
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user