From 6d3ed84807402ad671e8a5d67b755686a33654af Mon Sep 17 00:00:00 2001 From: mrpabloyeah Date: Sat, 17 May 2025 02:50:10 +0200 Subject: [PATCH 1/7] Add max trade duration to backtest results --- freqtrade/optimize/optimize_reports/bt_output.py | 1 + freqtrade/optimize/optimize_reports/optimize_reports.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index cc179410a..94dc966a5 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -370,6 +370,7 @@ def text_table_add_metrics(strat_results: dict) -> None: f"{strat_results['winning_days']} / " f"{strat_results['draw_days']} / {strat_results['losing_days']}", ), + ("Max trade duration", f"{strat_results['holding_max']}"), ("Avg. Duration Winners", f"{strat_results['winner_holding_avg']}"), ("Avg. Duration Loser", f"{strat_results['loser_holding_avg']}"), ( diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index 1478ee2be..f331c0709 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -344,6 +344,11 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: if not results.empty else timedelta() ) + holding_max = ( + timedelta(minutes=round(results["trade_duration"].max())) + if not results.empty + else timedelta() + ) winner_holding_avg = ( timedelta(minutes=round(winning_trades["trade_duration"].mean())) if not winning_trades.empty @@ -363,6 +368,8 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: "winrate": len(winning_trades) / len(results) if len(results) else 0.0, "holding_avg": holding_avg, "holding_avg_s": holding_avg.total_seconds(), + "holding_max": holding_max, + "holding_max_s": holding_max.total_seconds(), "winner_holding_avg": winner_holding_avg, "winner_holding_avg_s": winner_holding_avg.total_seconds(), "loser_holding_avg": loser_holding_avg, From 718efc828a993ad6dfe1ffe33cd8e2c4e2a83954 Mon Sep 17 00:00:00 2001 From: mrpabloyeah Date: Sat, 17 May 2025 14:22:25 +0200 Subject: [PATCH 2/7] Also add min trade duration and display the info horizontally --- .../optimize/optimize_reports/bt_output.py | 15 +++++-- .../optimize_reports/optimize_reports.py | 41 +++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index 94dc966a5..c305a7b0e 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -370,9 +370,18 @@ def text_table_add_metrics(strat_results: dict) -> None: f"{strat_results['winning_days']} / " f"{strat_results['draw_days']} / {strat_results['losing_days']}", ), - ("Max trade duration", f"{strat_results['holding_max']}"), - ("Avg. Duration Winners", f"{strat_results['winner_holding_avg']}"), - ("Avg. Duration Loser", f"{strat_results['loser_holding_avg']}"), + ( + "Min/Max/Avg. Duration Winners", + f"{strat_results.get('winner_holding_min', 'N/A')} / " + f"{strat_results.get('winner_holding_max', 'N/A')} / " + f"{strat_results.get('winner_holding_avg', 'N/A')}", + ), + ( + "Min/Max/Avg. Duration Losers", + f"{strat_results.get('loser_holding_min', 'N/A')} / " + f"{strat_results.get('loser_holding_max', 'N/A')} / " + f"{strat_results.get('loser_holding_avg', 'N/A')}", + ), ( "Max Consecutive Wins / Loss", ( diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index f331c0709..f857342b3 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -336,27 +336,44 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: } winning_trades = results.loc[results["profit_ratio"] > 0] + winning_duratios = winning_trades["trade_duration"] draw_trades = results.loc[results["profit_ratio"] == 0] losing_trades = results.loc[results["profit_ratio"] < 0] + losing_duratios = losing_trades["trade_duration"] holding_avg = ( timedelta(minutes=round(results["trade_duration"].mean())) if not results.empty else timedelta() ) - holding_max = ( - timedelta(minutes=round(results["trade_duration"].max())) - if not results.empty + winner_holding_min = ( + timedelta(minutes=round(winning_duratios[winning_duratios > 0].min())) + if not winning_duratios.empty + else timedelta() + ) + winner_holding_max = ( + timedelta(minutes=round(winning_duratios.max())) + if not winning_duratios.empty else timedelta() ) winner_holding_avg = ( - timedelta(minutes=round(winning_trades["trade_duration"].mean())) - if not winning_trades.empty + timedelta(minutes=round(winning_duratios.mean())) + if not winning_duratios.empty + else timedelta() + ) + loser_holding_min = ( + timedelta(minutes=round(losing_duratios[losing_duratios > 0].min())) + if not losing_duratios.empty + else timedelta() + ) + loser_holding_max = ( + timedelta(minutes=round(losing_duratios.max())) + if not losing_duratios.empty else timedelta() ) loser_holding_avg = ( - timedelta(minutes=round(losing_trades["trade_duration"].mean())) - if not losing_trades.empty + timedelta(minutes=round(losing_duratios.mean())) + if not losing_duratios.empty else timedelta() ) winstreak, loss_streak = calc_streak(results) @@ -368,10 +385,16 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: "winrate": len(winning_trades) / len(results) if len(results) else 0.0, "holding_avg": holding_avg, "holding_avg_s": holding_avg.total_seconds(), - "holding_max": holding_max, - "holding_max_s": holding_max.total_seconds(), + "winner_holding_min": winner_holding_min, + "winner_holding_min_s": winner_holding_min.total_seconds(), + "winner_holding_max": winner_holding_max, + "winner_holding_max_s": winner_holding_max.total_seconds(), "winner_holding_avg": winner_holding_avg, "winner_holding_avg_s": winner_holding_avg.total_seconds(), + "loser_holding_min": loser_holding_min, + "loser_holding_min_s": loser_holding_min.total_seconds(), + "loser_holding_max": loser_holding_max, + "loser_holding_max_s": loser_holding_max.total_seconds(), "loser_holding_avg": loser_holding_avg, "loser_holding_avg_s": loser_holding_avg.total_seconds(), "max_consecutive_wins": winstreak, From fbe38c362c639d000e9dd2ae25b556974201ccb3 Mon Sep 17 00:00:00 2001 From: mrpabloyeah Date: Sat, 17 May 2025 15:10:04 +0200 Subject: [PATCH 3/7] Fix typo --- .../optimize_reports/optimize_reports.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index f857342b3..a62a5144e 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -336,10 +336,10 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: } winning_trades = results.loc[results["profit_ratio"] > 0] - winning_duratios = winning_trades["trade_duration"] + winning_duration = winning_trades["trade_duration"] draw_trades = results.loc[results["profit_ratio"] == 0] losing_trades = results.loc[results["profit_ratio"] < 0] - losing_duratios = losing_trades["trade_duration"] + losing_duration = losing_trades["trade_duration"] holding_avg = ( timedelta(minutes=round(results["trade_duration"].mean())) @@ -347,33 +347,33 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: else timedelta() ) winner_holding_min = ( - timedelta(minutes=round(winning_duratios[winning_duratios > 0].min())) - if not winning_duratios.empty + timedelta(minutes=round(winning_duration[winning_duration > 0].min())) + if not winning_duration.empty else timedelta() ) winner_holding_max = ( - timedelta(minutes=round(winning_duratios.max())) - if not winning_duratios.empty + timedelta(minutes=round(winning_duration.max())) + if not winning_duration.empty else timedelta() ) winner_holding_avg = ( - timedelta(minutes=round(winning_duratios.mean())) - if not winning_duratios.empty + timedelta(minutes=round(winning_duration.mean())) + if not winning_duration.empty else timedelta() ) loser_holding_min = ( - timedelta(minutes=round(losing_duratios[losing_duratios > 0].min())) - if not losing_duratios.empty + timedelta(minutes=round(losing_duration[losing_duration > 0].min())) + if not losing_duration.empty else timedelta() ) loser_holding_max = ( - timedelta(minutes=round(losing_duratios.max())) - if not losing_duratios.empty + timedelta(minutes=round(losing_duration.max())) + if not losing_duration.empty else timedelta() ) loser_holding_avg = ( - timedelta(minutes=round(losing_duratios.mean())) - if not losing_duratios.empty + timedelta(minutes=round(losing_duration.mean())) + if not losing_duration.empty else timedelta() ) winstreak, loss_streak = calc_streak(results) From cbb0294cd2ad22d557b4a5a83ce7be760e2f0ce0 Mon Sep 17 00:00:00 2001 From: mrpabloyeah Date: Sun, 18 May 2025 13:02:47 +0200 Subject: [PATCH 4/7] Format duration to make results more readable --- .../optimize_reports/optimize_reports.py | 16 ++++++++-------- freqtrade/util/__init__.py | 9 ++++++++- freqtrade/util/formatters.py | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index a62a5144e..a220186ad 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -23,7 +23,7 @@ from freqtrade.ft_types import ( BacktestResultType, get_BacktestResultType_default, ) -from freqtrade.util import decimals_per_coin, fmt_coin, get_dry_run_wallet +from freqtrade.util import decimals_per_coin, fmt_coin, format_duration, get_dry_run_wallet logger = logging.getLogger(__name__) @@ -383,19 +383,19 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: "losses": len(losing_trades), "draws": len(draw_trades), "winrate": len(winning_trades) / len(results) if len(results) else 0.0, - "holding_avg": holding_avg, + "holding_avg": format_duration(holding_avg), "holding_avg_s": holding_avg.total_seconds(), - "winner_holding_min": winner_holding_min, + "winner_holding_min": format_duration(winner_holding_min), "winner_holding_min_s": winner_holding_min.total_seconds(), - "winner_holding_max": winner_holding_max, + "winner_holding_max": format_duration(winner_holding_max), "winner_holding_max_s": winner_holding_max.total_seconds(), - "winner_holding_avg": winner_holding_avg, + "winner_holding_avg": format_duration(winner_holding_avg), "winner_holding_avg_s": winner_holding_avg.total_seconds(), - "loser_holding_min": loser_holding_min, + "loser_holding_min": format_duration(loser_holding_min), "loser_holding_min_s": loser_holding_min.total_seconds(), - "loser_holding_max": loser_holding_max, + "loser_holding_max": format_duration(loser_holding_max), "loser_holding_max_s": loser_holding_max.total_seconds(), - "loser_holding_avg": loser_holding_avg, + "loser_holding_avg": format_duration(loser_holding_avg), "loser_holding_avg_s": loser_holding_avg.total_seconds(), "max_consecutive_wins": winstreak, "max_consecutive_losses": loss_streak, diff --git a/freqtrade/util/__init__.py b/freqtrade/util/__init__.py index 5a4857eea..98fda93cd 100644 --- a/freqtrade/util/__init__.py +++ b/freqtrade/util/__init__.py @@ -13,7 +13,13 @@ from freqtrade.util.datetime_helpers import ( shorten_date, ) from freqtrade.util.dry_run_wallet import get_dry_run_wallet -from freqtrade.util.formatters import decimals_per_coin, fmt_coin, fmt_coin2, round_value +from freqtrade.util.formatters import ( + decimals_per_coin, + fmt_coin, + fmt_coin2, + format_duration, + round_value, +) from freqtrade.util.ft_precise import FtPrecise from freqtrade.util.measure_time import MeasureTime from freqtrade.util.periodic_cache import PeriodicCache @@ -44,6 +50,7 @@ __all__ = [ "shorten_date", "decimals_per_coin", "round_value", + "format_duration", "fmt_coin", "fmt_coin2", "MeasureTime", diff --git a/freqtrade/util/formatters.py b/freqtrade/util/formatters.py index 82ac1cf66..fa656f52b 100644 --- a/freqtrade/util/formatters.py +++ b/freqtrade/util/formatters.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN @@ -66,3 +68,15 @@ def fmt_coin2( val = f"{val} {coin}" return val + + +def format_duration(td: timedelta) -> str: + """ + Format a timedelta object to "XXd HH:MM" format + :param td: Timedelta object to format + :return: Formatted time string + """ + d = td.days + h, r = divmod(td.seconds, 3600) + m, s = divmod(r, 60) + return f"{d}d {h:02d}:{m:02d}" From 60157606fe6550358843cec9fab52030dd5090aa Mon Sep 17 00:00:00 2001 From: mrpabloyeah Date: Sun, 18 May 2025 13:39:50 +0200 Subject: [PATCH 5/7] Format only winner/loser durations --- freqtrade/optimize/optimize_reports/optimize_reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index a220186ad..4242d7a9e 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -383,7 +383,7 @@ def generate_trading_stats(results: DataFrame) -> dict[str, Any]: "losses": len(losing_trades), "draws": len(draw_trades), "winrate": len(winning_trades) / len(results) if len(results) else 0.0, - "holding_avg": format_duration(holding_avg), + "holding_avg": holding_avg, "holding_avg_s": holding_avg.total_seconds(), "winner_holding_min": format_duration(winner_holding_min), "winner_holding_min_s": winner_holding_min.total_seconds(), From 882ca44816cf1de90d06c8ebcdb26604976ccbc0 Mon Sep 17 00:00:00 2001 From: mrpabloyeah Date: Sun, 18 May 2025 14:19:10 +0200 Subject: [PATCH 6/7] Fix test_optimize_reports --- tests/optimize/test_optimize_reports.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 8943a7467..4667f3e1c 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -40,7 +40,7 @@ from freqtrade.optimize.optimize_reports.optimize_reports import ( generate_tag_metrics, ) from freqtrade.resolvers.strategy_resolver import StrategyResolver -from freqtrade.util import dt_ts +from freqtrade.util import dt_ts, format_duration from freqtrade.util.datetime_helpers import dt_from_ts, dt_utc from tests.conftest import CURRENT_TEST_STRATEGY, log_has_re from tests.data.test_history import _clean_test_file @@ -482,8 +482,8 @@ def test_generate_trading_stats(testdatadir): bt_data = load_backtest_data(filename) res = generate_trading_stats(bt_data) assert isinstance(res, dict) - assert res["winner_holding_avg"] == timedelta(seconds=1440) - assert res["loser_holding_avg"] == timedelta(days=1, seconds=21420) + assert res["winner_holding_avg"] == format_duration(timedelta(seconds=1440)) + assert res["loser_holding_avg"] == format_duration(timedelta(days=1, seconds=21420)) assert "wins" in res assert "losses" in res assert "draws" in res From 947cbdd858f6647f6ba9f2a79bbad83d272c2d8e Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 May 2025 18:15:54 +0200 Subject: [PATCH 7/7] test: add format_duration test --- tests/util/test_formatters.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/util/test_formatters.py b/tests/util/test_formatters.py index ba77eb9a6..8872137a0 100644 --- a/tests/util/test_formatters.py +++ b/tests/util/test_formatters.py @@ -1,5 +1,6 @@ -from freqtrade.util import decimals_per_coin, fmt_coin, round_value -from freqtrade.util.formatters import fmt_coin2 +from datetime import timedelta + +from freqtrade.util import decimals_per_coin, fmt_coin, fmt_coin2, format_duration, round_value def test_decimals_per_coin(): @@ -45,3 +46,12 @@ def test_round_value(): assert round_value(0.1274512123, 5) == "0.12745" assert round_value(222.2, 3, True) == "222.200" assert round_value(222.2, 0, True) == "222" + + +def test_format_duration(): + assert format_duration(timedelta(minutes=5)) == "0d 00:05" + assert format_duration(timedelta(minutes=75)) == "0d 01:15" + assert format_duration(timedelta(minutes=1440)) == "1d 00:00" + assert format_duration(timedelta(minutes=1445)) == "1d 00:05" + assert format_duration(timedelta(minutes=11445)) == "7d 22:45" + assert format_duration(timedelta(minutes=101445)) == "70d 10:45"