From 3479f7d986a044de4db94c6d1c9f3ac02b4544f6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Mar 2020 07:13:11 +0100 Subject: [PATCH 1/5] Add max_drawdown function --- freqtrade/data/btanalysis.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index c28e462ba..9407a3139 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -3,7 +3,7 @@ Helpers when analyzing backtest data """ import logging from pathlib import Path -from typing import Dict, Union +from typing import Dict, Union, Tuple import numpy as np import pandas as pd @@ -188,3 +188,23 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, # FFill to get continuous df[col_name] = df[col_name].ffill() return df + + +def calculate_max_drawdown(trades: pd.DataFrame) -> Tuple[float, pd.Timestamp, pd.Timestamp]: + """ + Calculate max drawdown and the corresponding close dates + :param trades: DataFrame containing trades (requires columns close_time and profitperc) + :return: Tuple (float, highdate, lowdate) with absolute max drawdown, high and low time + :raise: ValueError if trade-dataframe was found empty. + """ + if len(trades) == 0: + raise ValueError("Trade dataframe empty") + profit_results = trades.sort_values('close_time') + max_drawdown_df = pd.DataFrame() + max_drawdown_df['cumulative'] = profit_results['profitperc'].cumsum() + max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax() + max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value'] + high_date = profit_results.loc[max_drawdown_df['high_value'].idxmax(), 'close_time'] + low_date = profit_results.loc[max_drawdown_df['drawdown'].idxmin(), 'close_time'] + + return abs(min(max_drawdown_df['drawdown'])), high_date, low_date From e050511ddcd295841590cfd99c4897e97dc678e3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Mar 2020 07:20:41 +0100 Subject: [PATCH 2/5] Add test for max_drawdown calculation --- freqtrade/data/btanalysis.py | 2 +- tests/data/test_btanalysis.py | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 9407a3139..799f15011 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -198,7 +198,7 @@ def calculate_max_drawdown(trades: pd.DataFrame) -> Tuple[float, pd.Timestamp, p :raise: ValueError if trade-dataframe was found empty. """ if len(trades) == 0: - raise ValueError("Trade dataframe empty") + raise ValueError("Trade dataframe empty.") profit_results = trades.sort_values('close_time') max_drawdown_df = pd.DataFrame() max_drawdown_df['cumulative'] = profit_results['profitperc'].cumsum() diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 60d9c3ea5..7e3c1f077 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -2,15 +2,17 @@ from unittest.mock import MagicMock import pytest from arrow import Arrow -from pandas import DataFrame, DateOffset, to_datetime +from pandas import DataFrame, DateOffset, to_datetime, Timestamp from freqtrade.configuration import TimeRange from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, + analyze_trade_parallelism, + calculate_max_drawdown, combine_tickers_with_mean, create_cum_profit, extract_trades_of_period, load_backtest_data, load_trades, - load_trades_from_db, analyze_trade_parallelism) + load_trades_from_db) from freqtrade.data.history import load_data, load_pair_history from tests.test_persistence import create_mock_trades @@ -163,3 +165,17 @@ def test_create_cum_profit1(testdatadir): assert "cum_profits" in cum_profits.columns assert cum_profits.iloc[0]['cum_profits'] == 0 assert cum_profits.iloc[-1]['cum_profits'] == 0.0798005 + + +def test_calculate_max_drawdown(testdatadir): + filename = testdatadir / "backtest-result_test.json" + bt_data = load_backtest_data(filename) + drawdown, h, low = calculate_max_drawdown(bt_data) + assert isinstance(drawdown, float) + assert pytest.approx(drawdown) == 0.21142322 + assert isinstance(h, Timestamp) + assert isinstance(low, Timestamp) + assert h == Timestamp('2018-01-24 14:25:00', tz='UTC') + assert low == Timestamp('2018-01-30 04:45:00', tz='UTC') + with pytest.raises(ValueError, match='Trade dataframe empty.'): + drawdown, h, low = calculate_max_drawdown(DataFrame()) From 88e7cab5b99873d3f5af59ca61333013d8e2234b Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Mar 2020 07:21:14 +0100 Subject: [PATCH 3/5] Add max_drawdown to profit plot --- freqtrade/plot/plotting.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 4a892792a..2ce4f1501 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -5,7 +5,8 @@ from typing import Any, Dict, List import pandas as pd from freqtrade.configuration import TimeRange -from freqtrade.data.btanalysis import (combine_tickers_with_mean, +from freqtrade.data.btanalysis import (calculate_max_drawdown, + combine_tickers_with_mean, create_cum_profit, extract_trades_of_period, load_trades) from freqtrade.data.converter import trim_dataframe @@ -111,6 +112,36 @@ def add_profit(fig, row, data: pd.DataFrame, column: str, name: str) -> make_sub return fig +def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame) -> make_subplots: + """ + Add scatter points indicating max drawdown + """ + try: + max_drawdown, highdate, lowdate = calculate_max_drawdown(trades) + + drawdown = go.Scatter( + x=[highdate, lowdate], + y=[ + df_comb.loc[highdate, 'cum_profit'], + df_comb.loc[lowdate, 'cum_profit'], + ], + mode='markers', + name='Max Drawdown', + text=f"Max drawdown {max_drawdown}", + marker=dict( + symbol='square-open', + size=9, + line=dict(width=2), + color='green' + + ) + ) + fig.add_trace(drawdown, row, 1) + except ValueError: + logger.warning("No trades found - not plotting max drawdown.") + return fig + + def plot_trades(fig, trades: pd.DataFrame) -> make_subplots: """ Add trades to "fig" @@ -364,6 +395,7 @@ def generate_profit_graph(pairs: str, tickers: Dict[str, pd.DataFrame], fig.add_trace(avgclose, 1, 1) fig = add_profit(fig, 2, df_comb, 'cum_profit', 'Profit') + fig = add_max_drawdown(fig, 2, trades, df_comb) for pair in pairs: profit_col = f'cum_profit_{pair}' From 33a63562cbacdeb858a29b09c4a11b9d3f31ae7b Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Mar 2020 07:23:38 +0100 Subject: [PATCH 4/5] make drawdown function less restrictive --- freqtrade/data/btanalysis.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 799f15011..394c40112 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -190,21 +190,26 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, return df -def calculate_max_drawdown(trades: pd.DataFrame) -> Tuple[float, pd.Timestamp, pd.Timestamp]: +def calculate_max_drawdown(trades: pd.DataFrame, date_col: str = 'close_time', + value_col: str = 'profitperc' + ) -> Tuple[float, pd.Timestamp, pd.Timestamp]: """ Calculate max drawdown and the corresponding close dates :param trades: DataFrame containing trades (requires columns close_time and profitperc) + :param date_col: Column in DataFrame to use for dates (defaults to 'close_time') + :param value_col: Column in DataFrame to use for values (defaults to 'profitperc') :return: Tuple (float, highdate, lowdate) with absolute max drawdown, high and low time :raise: ValueError if trade-dataframe was found empty. """ if len(trades) == 0: raise ValueError("Trade dataframe empty.") - profit_results = trades.sort_values('close_time') + profit_results = trades.sort_values(date_col) max_drawdown_df = pd.DataFrame() - max_drawdown_df['cumulative'] = profit_results['profitperc'].cumsum() + max_drawdown_df['cumulative'] = profit_results[value_col].cumsum() max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax() max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value'] - high_date = profit_results.loc[max_drawdown_df['high_value'].idxmax(), 'close_time'] - low_date = profit_results.loc[max_drawdown_df['drawdown'].idxmin(), 'close_time'] + + high_date = profit_results.loc[max_drawdown_df['high_value'].idxmax(), date_col] + low_date = profit_results.loc[max_drawdown_df['drawdown'].idxmin(), date_col] return abs(min(max_drawdown_df['drawdown'])), high_date, low_date From 9d8970a76bdff307482bdf82dffe2279af3fa731 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Mar 2020 20:18:38 +0100 Subject: [PATCH 5/5] Add test and formatting to drawdown --- docs/plotting.md | 1 + freqtrade/data/btanalysis.py | 2 +- freqtrade/plot/plotting.py | 4 ++-- tests/test_plotting.py | 8 ++++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/plotting.md b/docs/plotting.md index ecd5e1603..3eef8f8e7 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -196,6 +196,7 @@ The first graph is good to get a grip of how the overall market progresses. The second graph will show if your algorithm works or doesn't. Perhaps you want an algorithm that steadily makes small profits, or one that acts less often, but makes big swings. +This graph will also highlight the start (and end) of the Max drawdown period. The third graph can be useful to spot outliers, events in pairs that cause profit spikes. diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 394c40112..7972c6333 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -190,7 +190,7 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, return df -def calculate_max_drawdown(trades: pd.DataFrame, date_col: str = 'close_time', +def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_time', value_col: str = 'profitperc' ) -> Tuple[float, pd.Timestamp, pd.Timestamp]: """ diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 2ce4f1501..d979a40e0 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -126,8 +126,8 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame) -> m df_comb.loc[lowdate, 'cum_profit'], ], mode='markers', - name='Max Drawdown', - text=f"Max drawdown {max_drawdown}", + name=f"Max drawdown {max_drawdown:.2f}%", + text=f"Max drawdown {max_drawdown:.2f}%", marker=dict( symbol='square-open', size=9, diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 34d1f2b0c..dd04035b7 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -3,15 +3,16 @@ from copy import deepcopy from pathlib import Path from unittest.mock import MagicMock +import pandas as pd import plotly.graph_objects as go import pytest from plotly.subplots import make_subplots +from freqtrade.commands import start_plot_dataframe, start_plot_profit from freqtrade.configuration import TimeRange from freqtrade.data import history from freqtrade.data.btanalysis import create_cum_profit, load_backtest_data from freqtrade.exceptions import OperationalException -from freqtrade.commands import start_plot_dataframe, start_plot_profit from freqtrade.plot.plotting import (add_indicators, add_profit, create_plotconfig, generate_candlestick_graph, @@ -266,6 +267,7 @@ def test_generate_profit_graph(testdatadir): trades = load_backtest_data(filename) timerange = TimeRange.parse_timerange("20180110-20180112") pairs = ["TRX/BTC", "ADA/BTC"] + trades = trades[trades['close_time'] < pd.Timestamp('2018-01-12', tz='UTC')] tickers = history.load_data(datadir=testdatadir, pairs=pairs, @@ -283,13 +285,15 @@ def test_generate_profit_graph(testdatadir): assert fig.layout.yaxis3.title.text == "Profit" figure = fig.layout.figure - assert len(figure.data) == 4 + assert len(figure.data) == 5 avgclose = find_trace_in_fig_data(figure.data, "Avg close price") assert isinstance(avgclose, go.Scatter) profit = find_trace_in_fig_data(figure.data, "Profit") assert isinstance(profit, go.Scatter) + profit = find_trace_in_fig_data(figure.data, "Max drawdown 0.00%") + assert isinstance(profit, go.Scatter) for pair in pairs: profit_pair = find_trace_in_fig_data(figure.data, f"Profit {pair}")