diff --git a/docs/backtesting.md b/docs/backtesting.md index abaf00a53..ccd4ed4ac 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -324,6 +324,7 @@ A backtesting result will look like that: | Days win/draw/lose | 12 / 82 / 25 | | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | +| Max Consecutive Wins / Loss | 3 / 4 | | Rejected Entry signals | 3089 | | Entry/Exit Timeouts | 0 / 0 | | Canceled Trade Entries | 34 | @@ -428,6 +429,7 @@ It contains some useful key metrics about performance of your strategy on backte | Days win/draw/lose | 12 / 82 / 25 | | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | +| Max Consecutive Wins / Loss | 3 / 4 | | Rejected Entry signals | 3089 | | Entry/Exit Timeouts | 0 / 0 | | Canceled Trade Entries | 34 | @@ -467,6 +469,7 @@ It contains some useful key metrics about performance of your strategy on backte - `Best day` / `Worst day`: Best and worst day based on daily profit. - `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade). - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. +- `Max Consecutive Wins / Loss`: Maximum consecutive wins/losses in a row. - `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached. - `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Canceled Trade Entries`: Number of trades that have been canceled by user request via `adjust_entry_price`. diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index d33ae4070..eb30d0c97 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -270,6 +270,9 @@ def text_table_add_metrics(strat_results: Dict) -> str: f"{strat_results['draw_days']} / {strat_results['losing_days']}"), ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), + ('Max Consecutive Wins / Loss', + f"{strat_results['max_consecutive_wins']} / {strat_results['max_consecutive_losses']}" + if 'max_consecutive_losses' in strat_results else 'N/A'), ('Rejected Entry signals', strat_results.get('rejected_signals', 'N/A')), ('Entry/Exit Timeouts', f"{strat_results.get('timedout_entry_orders', 'N/A')} / " diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index 91c2fa750..f24e30318 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -1,9 +1,10 @@ import logging from copy import deepcopy from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Tuple, Union -from pandas import DataFrame, concat, to_datetime +import numpy as np +from pandas import DataFrame, Series, concat, to_datetime from freqtrade.constants import BACKTEST_BREAKDOWNS, DATETIME_PRINT_FORMAT, IntOrInf from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, @@ -252,6 +253,23 @@ def generate_all_periodic_breakdown_stats(trade_list: List) -> Dict[str, List]: return result +def calc_streak(dataframe: DataFrame) -> Tuple[int, int]: + """ + Calculate consecutive win and loss streaks + :param dataframe: Dataframe containing the trades dataframe, with profit_ratio column + :return: Tuple containing consecutive wins and losses + """ + + df = Series(np.where(dataframe['profit_ratio'] > 0, 'win', 'loss')).to_frame('result') + df['streaks'] = df['result'].ne(df['result'].shift()).cumsum().rename('streaks') + df['counter'] = df['streaks'].groupby(df['streaks']).cumcount() + 1 + res = df.groupby(df['result']).max() + # + cons_wins = int(res.loc['win', 'counter']) if 'win' in res.index else 0 + cons_losses = int(res.loc['loss', 'counter']) if 'loss' in res.index else 0 + return cons_wins, cons_losses + + def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: """ Generate overall trade statistics """ if len(results) == 0: @@ -263,6 +281,8 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: 'holding_avg': timedelta(), 'winner_holding_avg': timedelta(), 'loser_holding_avg': timedelta(), + 'max_consecutive_wins': 0, + 'max_consecutive_losses': 0, } winning_trades = results.loc[results['profit_ratio'] > 0] @@ -275,6 +295,7 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: if not winning_trades.empty else timedelta()) loser_holding_avg = (timedelta(minutes=round(losing_trades['trade_duration'].mean())) if not losing_trades.empty else timedelta()) + winstreak, loss_streak = calc_streak(results) return { 'wins': len(winning_trades), @@ -287,6 +308,8 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: 'winner_holding_avg_s': winner_holding_avg.total_seconds(), 'loser_holding_avg': loser_holding_avg, 'loser_holding_avg_s': loser_holding_avg.total_seconds(), + 'max_consecutive_wins': winstreak, + 'max_consecutive_losses': loss_streak, } diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 7b85e7978..2ea6a33a4 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -23,7 +23,8 @@ from freqtrade.optimize.optimize_reports import (generate_backtest_stats, genera store_backtest_analysis_results, store_backtest_stats, text_table_bt_results, text_table_exit_reason, text_table_strategy) -from freqtrade.optimize.optimize_reports.optimize_reports import _get_resample_from_period +from freqtrade.optimize.optimize_reports.optimize_reports import (_get_resample_from_period, + calc_streak) from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.util import dt_ts from freqtrade.util.datetime_helpers import dt_from_ts, dt_utc @@ -348,6 +349,32 @@ def test_generate_trading_stats(testdatadir): assert res['losses'] == 0 +def test_calc_streak(testdatadir): + df = pd.DataFrame({ + 'profit_ratio': [0.05, -0.02, -0.03, -0.05, 0.01, 0.02, 0.03, 0.04, -0.02, -0.03], + }) + # 4 consecutive wins, 3 consecutive losses + res = calc_streak(df) + assert res == (4, 3) + assert isinstance(res[0], int) + assert isinstance(res[1], int) + + # invert situation + df1 = df.copy() + df1['profit_ratio'] = df1['profit_ratio'] * -1 + assert calc_streak(df1) == (3, 4) + + df_empty = pd.DataFrame({ + 'profit_ratio': [], + }) + assert df_empty.empty + assert calc_streak(df_empty) == (0, 0) + + filename = testdatadir / "backtest_results/backtest-result.json" + bt_data = load_backtest_data(filename) + assert calc_streak(bt_data) == (7, 18) + + def test_text_table_exit_reason(): results = pd.DataFrame(