Skip to content

Commit

Permalink
Merge pull request #10212 from freqtrade/refactor/max_drawdown
Browse files Browse the repository at this point in the history
Refactor calculate_max_drawdown
  • Loading branch information
xmatthias committed May 15, 2024
2 parents 3b00363 + 702ac14 commit c91e1d8
Show file tree
Hide file tree
Showing 8 changed files with 86 additions and 93 deletions.
32 changes: 22 additions & 10 deletions freqtrade/data/metrics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Tuple

Expand Down Expand Up @@ -160,21 +161,31 @@ def calculate_underwater(
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(
trades: pd.DataFrame,
*,
date_col: str = "close_date",
value_col: str = "profit_abs",
starting_balance: float = 0,
relative: bool = False,
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]:
) -> DrawDownResult:
"""
Calculate max drawdown and the corresponding close dates
: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 value_col: Column in DataFrame to use for values (defaults to 'profit_abs')
: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,
and the relative account drawdown
:raise: ValueError if trade-dataframe was found empty.
Expand All @@ -201,13 +212,13 @@ def calculate_max_drawdown(
low_val = max_drawdown_df.loc[idxmin, "cumulative"]
max_drawdown_rel = max_drawdown_df.loc[idxmin, "drawdown_relative"]

return (
abs(max_drawdown_df.loc[idxmin, "drawdown"]),
high_date,
low_date,
high_val,
low_val,
max_drawdown_rel,
return DrawDownResult(
drawdown_abs=abs(max_drawdown_df.loc[idxmin, "drawdown"]),
high_date=high_date,
low_date=low_date,
high_value=high_val,
low_value=low_val,
relative_account_drawdown=max_drawdown_rel,
)


Expand Down Expand Up @@ -350,9 +361,10 @@ def calculate_calmar(

# calculate max drawdown
try:
_, _, _, _, _, max_drawdown = calculate_max_drawdown(
drawdown = calculate_max_drawdown(
trades, value_col="profit_abs", starting_balance=starting_balance
)
max_drawdown = drawdown.relative_account_drawdown
except ValueError:
max_drawdown = 0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ def hyperopt_loss_function(
except ValueError:
# No losing trade, therefore no drawdown.
return -total_profit
return -total_profit / max_drawdown[0]
return -total_profit / max_drawdown.drawdown_abs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ def hyperopt_loss_function(results: DataFrame, trade_count: int, *args, **kwargs
total_profit = results["profit_abs"].sum()

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:
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))
30 changes: 14 additions & 16 deletions freqtrade/optimize/optimize_reports/optimize_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,29 +497,27 @@ def generate_strategy_stats(
}

try:
max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown(
results, value_col="profit_ratio"
)
(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_drawdown_legacy = calculate_max_drawdown(results, value_col="profit_ratio")
drawdown = calculate_max_drawdown(
results, value_col="profit_abs", starting_balance=start_balance
)
# 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
)

strat_stats.update(
{
"max_drawdown": max_drawdown_legacy, # Deprecated - do not use
"max_drawdown_account": max_drawdown,
"max_relative_drawdown": max_relative_drawdown,
"max_drawdown_abs": drawdown_abs,
"drawdown_start": drawdown_start.strftime(DATETIME_PRINT_FORMAT),
"drawdown_start_ts": drawdown_start.timestamp() * 1000,
"drawdown_end": drawdown_end.strftime(DATETIME_PRINT_FORMAT),
"drawdown_end_ts": drawdown_end.timestamp() * 1000,
"max_drawdown_low": low_val,
"max_drawdown_high": high_val,
"max_drawdown": max_drawdown_legacy.drawdown_abs, # Deprecated - do not use
"max_drawdown_account": drawdown.relative_account_drawdown,
"max_relative_drawdown": underwater.relative_account_drawdown,
"max_drawdown_abs": drawdown.drawdown_abs,
"drawdown_start": drawdown.high_date.strftime(DATETIME_PRINT_FORMAT),
"drawdown_start_ts": drawdown.high_date.timestamp() * 1000,
"drawdown_end": drawdown.low_date.strftime(DATETIME_PRINT_FORMAT),
"drawdown_end_ts": drawdown.low_date.timestamp() * 1000,
"max_drawdown_low": drawdown.low_value,
"max_drawdown_high": drawdown.high_value,
}
)

Expand Down
14 changes: 6 additions & 8 deletions freqtrade/plot/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,17 @@ def add_max_drawdown(
Add scatter points indicating max drawdown
"""
try:
_, highdate, lowdate, _, _, max_drawdown = calculate_max_drawdown(
trades, starting_balance=starting_balance
)
drawdown = calculate_max_drawdown(trades, starting_balance=starting_balance)

drawdown = go.Scatter(
x=[highdate, lowdate],
x=[drawdown.high_date, drawdown.low_date],
y=[
df_comb.loc[timeframe_to_prev_date(timeframe, highdate), "cum_profit"],
df_comb.loc[timeframe_to_prev_date(timeframe, lowdate), "cum_profit"],
df_comb.loc[timeframe_to_prev_date(timeframe, drawdown.high_date), "cum_profit"],
df_comb.loc[timeframe_to_prev_date(timeframe, drawdown.low_date), "cum_profit"],
],
mode="markers",
name=f"Max drawdown {max_drawdown:.2%}",
text=f"Max drawdown {max_drawdown:.2%}",
name=f"Max drawdown {drawdown.relative_account_drawdown:.2%}",
text=f"Max drawdown {drawdown.relative_account_drawdown:.2%}",
marker=dict(symbol="square-open", size=9, line=dict(width=2), color="green"),
)
fig.add_trace(drawdown, row, 1)
Expand Down
3 changes: 2 additions & 1 deletion freqtrade/plugins/protections/max_drawdown_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]:
# Drawdown is always positive
try:
# 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:
return None

Expand Down
33 changes: 11 additions & 22 deletions freqtrade/rpc/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from freqtrade.configuration.timerange import TimeRange
from freqtrade.constants import CANCEL_REASON, DEFAULT_DATAFRAME_COLUMNS, Config
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 (
CandleType,
ExitCheckTuple,
Expand Down Expand Up @@ -592,21 +592,10 @@ def _rpc_trade_statistics(

expectancy, expectancy_ratio = calculate_expectancy(trades_df)

max_drawdown_abs = 0.0
max_drawdown = 0.0
drawdown_start: Optional[datetime] = None
drawdown_end: Optional[datetime] = None
dd_high_val = dd_low_val = 0.0
drawdown = DrawDownResult()
if len(trades_df) > 0:
try:
(
max_drawdown_abs,
drawdown_start,
drawdown_end,
dd_high_val,
dd_low_val,
max_drawdown,
) = calculate_max_drawdown(
drawdown = calculate_max_drawdown(
trades_df,
value_col="profit_abs",
date_col="close_date_dt",
Expand Down Expand Up @@ -663,14 +652,14 @@ def _rpc_trade_statistics(
"winrate": winrate,
"expectancy": expectancy,
"expectancy_ratio": expectancy_ratio,
"max_drawdown": max_drawdown,
"max_drawdown_abs": max_drawdown_abs,
"max_drawdown_start": format_date(drawdown_start),
"max_drawdown_start_timestamp": dt_ts_def(drawdown_start),
"max_drawdown_end": format_date(drawdown_end),
"max_drawdown_end_timestamp": dt_ts_def(drawdown_end),
"drawdown_high": dd_high_val,
"drawdown_low": dd_low_val,
"max_drawdown": drawdown.relative_account_drawdown,
"max_drawdown_abs": drawdown.drawdown_abs,
"max_drawdown_start": format_date(drawdown.high_date),
"max_drawdown_start_timestamp": dt_ts_def(drawdown.high_date),
"max_drawdown_end": format_date(drawdown.low_date),
"max_drawdown_end_timestamp": dt_ts_def(drawdown.low_date),
"drawdown_high": drawdown.high_value,
"drawdown_low": drawdown.low_value,
"trading_volume": trading_volume,
"bot_start_timestamp": dt_ts_def(bot_start, 0),
"bot_start_date": format_date(bot_start),
Expand Down
58 changes: 26 additions & 32 deletions tests/data/test_btanalysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,17 +343,15 @@ def test_create_cum_profit1(testdatadir):
def test_calculate_max_drawdown(testdatadir):
filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_data(filename)
_, hdate, lowdate, hval, lval, drawdown = calculate_max_drawdown(
bt_data, value_col="profit_abs"
)
assert isinstance(drawdown, float)
assert pytest.approx(drawdown) == 0.29753914
assert isinstance(hdate, Timestamp)
assert isinstance(lowdate, Timestamp)
assert isinstance(hval, float)
assert isinstance(lval, float)
assert hdate == Timestamp("2018-01-16 19:30:00", tz="UTC")
assert lowdate == Timestamp("2018-01-16 22:25:00", tz="UTC")
drawdown = calculate_max_drawdown(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.high_date, Timestamp)
assert isinstance(drawdown.low_date, Timestamp)
assert isinstance(drawdown.high_value, float)
assert isinstance(drawdown.low_value, float)
assert drawdown.high_date == Timestamp("2018-01-16 19:30:00", tz="UTC")
assert drawdown.low_date == Timestamp("2018-01-16 22:25:00", tz="UTC")

underwater = calculate_underwater(bt_data)
assert isinstance(underwater, DataFrame)
Expand Down Expand Up @@ -509,19 +507,17 @@ def test_calculate_max_drawdown2():
# sort by profit and reset index
df = df.sort_values("profit").reset_index(drop=True)
df1 = df.copy()
drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown(
df, date_col="open_date", value_col="profit"
)
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
# Ensure df has not been altered.
assert df.equals(df1)

assert isinstance(drawdown, float)
assert isinstance(drawdown_rel, float)
assert isinstance(drawdown.drawdown_abs, float)
assert isinstance(drawdown.relative_account_drawdown, float)
# High must be before low
assert hdate < ldate
assert drawdown.high_date < drawdown.low_date
# High value must be higher than low value
assert hval > lval
assert drawdown == 0.091755
assert drawdown.high_value > drawdown.low_value
assert drawdown.drawdown_abs == 0.091755

df = DataFrame(zip(values[:5], dates[:5]), columns=["profit", "open_date"])
with pytest.raises(ValueError, match="No losing trade, therefore no drawdown."):
Expand All @@ -530,10 +526,8 @@ def test_calculate_max_drawdown2():
df1 = DataFrame(zip(values[:5], dates[:5]), columns=["profit", "open_date"])
df1.loc[:, "profit"] = df1["profit"] * -1
# No winning trade ...
drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown(
df1, date_col="open_date", value_col="profit"
)
assert drawdown == 0.043965
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
assert drawdown.drawdown_abs == 0.043965


@pytest.mark.parametrize(
Expand All @@ -555,20 +549,20 @@ def test_calculate_max_drawdown_abs(profits, relative, highd, lowdays, result, r
# sort by profit and reset index
df = df.sort_values("profit_abs").reset_index(drop=True)
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
)
# Ensure df has not been altered.
assert df.equals(df1)

assert isinstance(drawdown, float)
assert isinstance(drawdown_rel, float)
assert hdate == init_date + timedelta(days=highd)
assert ldate == init_date + timedelta(days=lowdays)
assert isinstance(drawdown.drawdown_abs, float)
assert isinstance(drawdown.relative_account_drawdown, float)
assert drawdown.high_date == init_date + timedelta(days=highd)
assert drawdown.low_date == init_date + timedelta(days=lowdays)

# High must be before low
assert hdate < ldate
assert drawdown.high_date < drawdown.low_date
# High value must be higher than low value
assert hval > lval
assert drawdown == result
assert pytest.approx(drawdown_rel) == result_rel
assert drawdown.high_value > drawdown.low_value
assert drawdown.drawdown_abs == result
assert pytest.approx(drawdown.relative_account_drawdown) == result_rel

0 comments on commit c91e1d8

Please sign in to comment.