Merge pull request #10212 from freqtrade/refactor/max_drawdown

Refactor calculate_max_drawdown
This commit is contained in:
Matthias
2024-05-15 18:04:21 +02:00
committed by GitHub
8 changed files with 86 additions and 93 deletions

View File

@@ -1,5 +1,6 @@
import logging import logging
import math import math
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Dict, Tuple from typing import Dict, Tuple
@@ -160,6 +161,16 @@ def calculate_underwater(
return max_drawdown_df return max_drawdown_df
@dataclass()
class DrawDownResult:
drawdown_abs: float = 0.0
high_date: pd.Timestamp = None
low_date: pd.Timestamp = None
high_value: float = 0.0
low_value: float = 0.0
relative_account_drawdown: float = 0.0
def calculate_max_drawdown( def calculate_max_drawdown(
trades: pd.DataFrame, trades: pd.DataFrame,
*, *,
@@ -167,14 +178,14 @@ def calculate_max_drawdown(
value_col: str = "profit_abs", value_col: str = "profit_abs",
starting_balance: float = 0, starting_balance: float = 0,
relative: bool = False, relative: bool = False,
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]: ) -> DrawDownResult:
""" """
Calculate max drawdown and the corresponding close dates Calculate max drawdown and the corresponding close dates
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio) :param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date') :param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
:param value_col: Column in DataFrame to use for values (defaults to 'profit_abs') :param value_col: Column in DataFrame to use for values (defaults to 'profit_abs')
:param starting_balance: Portfolio starting balance - properly calculate relative drawdown. :param starting_balance: Portfolio starting balance - properly calculate relative drawdown.
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue, relative_drawdown) :return: DrawDownResult object
with absolute max drawdown, high and low time and high and low value, with absolute max drawdown, high and low time and high and low value,
and the relative account drawdown and the relative account drawdown
:raise: ValueError if trade-dataframe was found empty. :raise: ValueError if trade-dataframe was found empty.
@@ -201,13 +212,13 @@ def calculate_max_drawdown(
low_val = max_drawdown_df.loc[idxmin, "cumulative"] low_val = max_drawdown_df.loc[idxmin, "cumulative"]
max_drawdown_rel = max_drawdown_df.loc[idxmin, "drawdown_relative"] max_drawdown_rel = max_drawdown_df.loc[idxmin, "drawdown_relative"]
return ( return DrawDownResult(
abs(max_drawdown_df.loc[idxmin, "drawdown"]), drawdown_abs=abs(max_drawdown_df.loc[idxmin, "drawdown"]),
high_date, high_date=high_date,
low_date, low_date=low_date,
high_val, high_value=high_val,
low_val, low_value=low_val,
max_drawdown_rel, relative_account_drawdown=max_drawdown_rel,
) )
@@ -350,9 +361,10 @@ def calculate_calmar(
# calculate max drawdown # calculate max drawdown
try: try:
_, _, _, _, _, max_drawdown = calculate_max_drawdown( drawdown = calculate_max_drawdown(
trades, value_col="profit_abs", starting_balance=starting_balance trades, value_col="profit_abs", starting_balance=starting_balance
) )
max_drawdown = drawdown.relative_account_drawdown
except ValueError: except ValueError:
max_drawdown = 0 max_drawdown = 0

View File

@@ -42,4 +42,4 @@ class MaxDrawDownHyperOptLoss(IHyperOptLoss):
except ValueError: except ValueError:
# No losing trade, therefore no drawdown. # No losing trade, therefore no drawdown.
return -total_profit return -total_profit
return -total_profit / max_drawdown[0] return -total_profit / max_drawdown.drawdown_abs

View File

@@ -24,8 +24,9 @@ class ProfitDrawDownHyperOptLoss(IHyperOptLoss):
total_profit = results["profit_abs"].sum() total_profit = results["profit_abs"].sum()
try: try:
max_drawdown_abs = calculate_max_drawdown(results, value_col="profit_abs")[5] drawdown = calculate_max_drawdown(results, value_col="profit_abs")
relative_account_drawdown = drawdown.relative_account_drawdown
except ValueError: except ValueError:
max_drawdown_abs = 0 relative_account_drawdown = 0
return -1 * (total_profit * (1 - max_drawdown_abs * DRAWDOWN_MULT)) return -1 * (total_profit * (1 - relative_account_drawdown * DRAWDOWN_MULT))

View File

@@ -497,29 +497,27 @@ def generate_strategy_stats(
} }
try: try:
max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown( max_drawdown_legacy = calculate_max_drawdown(results, value_col="profit_ratio")
results, value_col="profit_ratio" drawdown = calculate_max_drawdown(
) results, value_col="profit_abs", starting_balance=start_balance
(drawdown_abs, drawdown_start, drawdown_end, high_val, low_val, max_drawdown) = (
calculate_max_drawdown(results, value_col="profit_abs", starting_balance=start_balance)
) )
# max_relative_drawdown = Underwater # max_relative_drawdown = Underwater
(_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown( underwater = calculate_max_drawdown(
results, value_col="profit_abs", starting_balance=start_balance, relative=True results, value_col="profit_abs", starting_balance=start_balance, relative=True
) )
strat_stats.update( strat_stats.update(
{ {
"max_drawdown": max_drawdown_legacy, # Deprecated - do not use "max_drawdown": max_drawdown_legacy.drawdown_abs, # Deprecated - do not use
"max_drawdown_account": max_drawdown, "max_drawdown_account": drawdown.relative_account_drawdown,
"max_relative_drawdown": max_relative_drawdown, "max_relative_drawdown": underwater.relative_account_drawdown,
"max_drawdown_abs": drawdown_abs, "max_drawdown_abs": drawdown.drawdown_abs,
"drawdown_start": drawdown_start.strftime(DATETIME_PRINT_FORMAT), "drawdown_start": drawdown.high_date.strftime(DATETIME_PRINT_FORMAT),
"drawdown_start_ts": drawdown_start.timestamp() * 1000, "drawdown_start_ts": drawdown.high_date.timestamp() * 1000,
"drawdown_end": drawdown_end.strftime(DATETIME_PRINT_FORMAT), "drawdown_end": drawdown.low_date.strftime(DATETIME_PRINT_FORMAT),
"drawdown_end_ts": drawdown_end.timestamp() * 1000, "drawdown_end_ts": drawdown.low_date.timestamp() * 1000,
"max_drawdown_low": low_val, "max_drawdown_low": drawdown.low_value,
"max_drawdown_high": high_val, "max_drawdown_high": drawdown.high_value,
} }
) )

View File

@@ -179,19 +179,17 @@ def add_max_drawdown(
Add scatter points indicating max drawdown Add scatter points indicating max drawdown
""" """
try: try:
_, highdate, lowdate, _, _, max_drawdown = calculate_max_drawdown( drawdown = calculate_max_drawdown(trades, starting_balance=starting_balance)
trades, starting_balance=starting_balance
)
drawdown = go.Scatter( drawdown = go.Scatter(
x=[highdate, lowdate], x=[drawdown.high_date, drawdown.low_date],
y=[ y=[
df_comb.loc[timeframe_to_prev_date(timeframe, highdate), "cum_profit"], df_comb.loc[timeframe_to_prev_date(timeframe, drawdown.high_date), "cum_profit"],
df_comb.loc[timeframe_to_prev_date(timeframe, lowdate), "cum_profit"], df_comb.loc[timeframe_to_prev_date(timeframe, drawdown.low_date), "cum_profit"],
], ],
mode="markers", mode="markers",
name=f"Max drawdown {max_drawdown:.2%}", name=f"Max drawdown {drawdown.relative_account_drawdown:.2%}",
text=f"Max drawdown {max_drawdown:.2%}", text=f"Max drawdown {drawdown.relative_account_drawdown:.2%}",
marker=dict(symbol="square-open", size=9, line=dict(width=2), color="green"), marker=dict(symbol="square-open", size=9, line=dict(width=2), color="green"),
) )
fig.add_trace(drawdown, row, 1) fig.add_trace(drawdown, row, 1)

View File

@@ -59,7 +59,8 @@ class MaxDrawdown(IProtection):
# Drawdown is always positive # Drawdown is always positive
try: try:
# TODO: This should use absolute profit calculation, considering account balance. # TODO: This should use absolute profit calculation, considering account balance.
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col="close_profit") drawdown_obj = calculate_max_drawdown(trades_df, value_col="close_profit")
drawdown = drawdown_obj.drawdown_abs
except ValueError: except ValueError:
return None return None

View File

@@ -19,7 +19,7 @@ from freqtrade import __version__
from freqtrade.configuration.timerange import TimeRange from freqtrade.configuration.timerange import TimeRange
from freqtrade.constants import CANCEL_REASON, DEFAULT_DATAFRAME_COLUMNS, Config from freqtrade.constants import CANCEL_REASON, DEFAULT_DATAFRAME_COLUMNS, Config
from freqtrade.data.history import load_data from freqtrade.data.history import load_data
from freqtrade.data.metrics import calculate_expectancy, calculate_max_drawdown from freqtrade.data.metrics import DrawDownResult, calculate_expectancy, calculate_max_drawdown
from freqtrade.enums import ( from freqtrade.enums import (
CandleType, CandleType,
ExitCheckTuple, ExitCheckTuple,
@@ -592,21 +592,10 @@ class RPC:
expectancy, expectancy_ratio = calculate_expectancy(trades_df) expectancy, expectancy_ratio = calculate_expectancy(trades_df)
max_drawdown_abs = 0.0 drawdown = DrawDownResult()
max_drawdown = 0.0
drawdown_start: Optional[datetime] = None
drawdown_end: Optional[datetime] = None
dd_high_val = dd_low_val = 0.0
if len(trades_df) > 0: if len(trades_df) > 0:
try: try:
( drawdown = calculate_max_drawdown(
max_drawdown_abs,
drawdown_start,
drawdown_end,
dd_high_val,
dd_low_val,
max_drawdown,
) = calculate_max_drawdown(
trades_df, trades_df,
value_col="profit_abs", value_col="profit_abs",
date_col="close_date_dt", date_col="close_date_dt",
@@ -663,14 +652,14 @@ class RPC:
"winrate": winrate, "winrate": winrate,
"expectancy": expectancy, "expectancy": expectancy,
"expectancy_ratio": expectancy_ratio, "expectancy_ratio": expectancy_ratio,
"max_drawdown": max_drawdown, "max_drawdown": drawdown.relative_account_drawdown,
"max_drawdown_abs": max_drawdown_abs, "max_drawdown_abs": drawdown.drawdown_abs,
"max_drawdown_start": format_date(drawdown_start), "max_drawdown_start": format_date(drawdown.high_date),
"max_drawdown_start_timestamp": dt_ts_def(drawdown_start), "max_drawdown_start_timestamp": dt_ts_def(drawdown.high_date),
"max_drawdown_end": format_date(drawdown_end), "max_drawdown_end": format_date(drawdown.low_date),
"max_drawdown_end_timestamp": dt_ts_def(drawdown_end), "max_drawdown_end_timestamp": dt_ts_def(drawdown.low_date),
"drawdown_high": dd_high_val, "drawdown_high": drawdown.high_value,
"drawdown_low": dd_low_val, "drawdown_low": drawdown.low_value,
"trading_volume": trading_volume, "trading_volume": trading_volume,
"bot_start_timestamp": dt_ts_def(bot_start, 0), "bot_start_timestamp": dt_ts_def(bot_start, 0),
"bot_start_date": format_date(bot_start), "bot_start_date": format_date(bot_start),

View File

@@ -343,17 +343,15 @@ def test_create_cum_profit1(testdatadir):
def test_calculate_max_drawdown(testdatadir): def test_calculate_max_drawdown(testdatadir):
filename = testdatadir / "backtest_results/backtest-result.json" filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_data(filename) bt_data = load_backtest_data(filename)
_, hdate, lowdate, hval, lval, drawdown = calculate_max_drawdown( drawdown = calculate_max_drawdown(bt_data, value_col="profit_abs")
bt_data, value_col="profit_abs" assert isinstance(drawdown.relative_account_drawdown, float)
) assert pytest.approx(drawdown.relative_account_drawdown) == 0.29753914
assert isinstance(drawdown, float) assert isinstance(drawdown.high_date, Timestamp)
assert pytest.approx(drawdown) == 0.29753914 assert isinstance(drawdown.low_date, Timestamp)
assert isinstance(hdate, Timestamp) assert isinstance(drawdown.high_value, float)
assert isinstance(lowdate, Timestamp) assert isinstance(drawdown.low_value, float)
assert isinstance(hval, float) assert drawdown.high_date == Timestamp("2018-01-16 19:30:00", tz="UTC")
assert isinstance(lval, float) assert drawdown.low_date == Timestamp("2018-01-16 22:25:00", tz="UTC")
assert hdate == Timestamp("2018-01-16 19:30:00", tz="UTC")
assert lowdate == Timestamp("2018-01-16 22:25:00", tz="UTC")
underwater = calculate_underwater(bt_data) underwater = calculate_underwater(bt_data)
assert isinstance(underwater, DataFrame) assert isinstance(underwater, DataFrame)
@@ -509,19 +507,17 @@ def test_calculate_max_drawdown2():
# sort by profit and reset index # sort by profit and reset index
df = df.sort_values("profit").reset_index(drop=True) df = df.sort_values("profit").reset_index(drop=True)
df1 = df.copy() df1 = df.copy()
drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown( drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
df, date_col="open_date", value_col="profit"
)
# Ensure df has not been altered. # Ensure df has not been altered.
assert df.equals(df1) assert df.equals(df1)
assert isinstance(drawdown, float) assert isinstance(drawdown.drawdown_abs, float)
assert isinstance(drawdown_rel, float) assert isinstance(drawdown.relative_account_drawdown, float)
# High must be before low # High must be before low
assert hdate < ldate assert drawdown.high_date < drawdown.low_date
# High value must be higher than low value # High value must be higher than low value
assert hval > lval assert drawdown.high_value > drawdown.low_value
assert drawdown == 0.091755 assert drawdown.drawdown_abs == 0.091755
df = DataFrame(zip(values[:5], dates[:5]), columns=["profit", "open_date"]) df = DataFrame(zip(values[:5], dates[:5]), columns=["profit", "open_date"])
with pytest.raises(ValueError, match="No losing trade, therefore no drawdown."): with pytest.raises(ValueError, match="No losing trade, therefore no drawdown."):
@@ -530,10 +526,8 @@ def test_calculate_max_drawdown2():
df1 = DataFrame(zip(values[:5], dates[:5]), columns=["profit", "open_date"]) df1 = DataFrame(zip(values[:5], dates[:5]), columns=["profit", "open_date"])
df1.loc[:, "profit"] = df1["profit"] * -1 df1.loc[:, "profit"] = df1["profit"] * -1
# No winning trade ... # No winning trade ...
drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown( drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
df1, date_col="open_date", value_col="profit" assert drawdown.drawdown_abs == 0.043965
)
assert drawdown == 0.043965
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -555,20 +549,20 @@ def test_calculate_max_drawdown_abs(profits, relative, highd, lowdays, result, r
# sort by profit and reset index # sort by profit and reset index
df = df.sort_values("profit_abs").reset_index(drop=True) df = df.sort_values("profit_abs").reset_index(drop=True)
df1 = df.copy() df1 = df.copy()
drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown( drawdown = calculate_max_drawdown(
df, date_col="open_date", starting_balance=1000, relative=relative df, date_col="open_date", starting_balance=1000, relative=relative
) )
# Ensure df has not been altered. # Ensure df has not been altered.
assert df.equals(df1) assert df.equals(df1)
assert isinstance(drawdown, float) assert isinstance(drawdown.drawdown_abs, float)
assert isinstance(drawdown_rel, float) assert isinstance(drawdown.relative_account_drawdown, float)
assert hdate == init_date + timedelta(days=highd) assert drawdown.high_date == init_date + timedelta(days=highd)
assert ldate == init_date + timedelta(days=lowdays) assert drawdown.low_date == init_date + timedelta(days=lowdays)
# High must be before low # High must be before low
assert hdate < ldate assert drawdown.high_date < drawdown.low_date
# High value must be higher than low value # High value must be higher than low value
assert hval > lval assert drawdown.high_value > drawdown.low_value
assert drawdown == result assert drawdown.drawdown_abs == result
assert pytest.approx(drawdown_rel) == result_rel assert pytest.approx(drawdown.relative_account_drawdown) == result_rel