diff --git a/freqtrade/data/metrics.py b/freqtrade/data/metrics.py index 8573505c7..5aaf6fac2 100644 --- a/freqtrade/data/metrics.py +++ b/freqtrade/data/metrics.py @@ -493,6 +493,43 @@ def calculate_sharpe_from_balance( return _calculate_annualized_ratio(expected_returns_mean, up_stdev) +def calculate_max_drawdown_from_balance( + balance_history: pd.DataFrame, + date_col: str = "date", + balance_col: str = "total_quote", + relative: bool = False, +) -> DrawDownResult: + """ + Calculate max drawdown from historical balance snapshots. + + :param balance_history: DataFrame containing at least date and balance columns + :param date_col: Column containing timestamps + :param balance_col: Column containing historical balance values + :param relative: If True, use relative drawdown for max calculation instead of absolute + :return: DrawDownResult object + :raise: ValueError if balance-history dataframe was found empty. + """ + wallet = _prepare_balance_history( + balance_history=balance_history, + date_col=date_col, + balance_col=balance_col, + ) + + if len(wallet) < 2: + raise ValueError("Balance-history dataframe empty.") + + starting_balance = float(wallet[balance_col].iloc[0]) + wallet.loc[:, "total_balance"] = wallet[balance_col].diff().fillna(0.0) + + return calculate_max_drawdown( + wallet, + date_col=date_col, + value_col="total_balance", + starting_balance=starting_balance, + relative=relative, + ) + + def calculate_calmar( trades: pd.DataFrame, min_date: datetime | None, diff --git a/tests/data/test_metrics.py b/tests/data/test_metrics.py index 53f8ef0be..610cbd8ee 100644 --- a/tests/data/test_metrics.py +++ b/tests/data/test_metrics.py @@ -16,6 +16,7 @@ from freqtrade.data.metrics import ( calculate_expectancy, calculate_market_change, calculate_max_drawdown, + calculate_max_drawdown_from_balance, calculate_sharpe, calculate_sharpe_from_balance, calculate_sortino, @@ -145,6 +146,48 @@ def test_calculate_max_drawdown(testdatadir): calculate_underwater(DataFrame()) +def test_calculate_max_drawdown_from_balance(): + balance_history = DataFrame( + { + "date": to_datetime( + [ + "2025-01-01 00:00:00+00:00", + "2025-01-01 12:00:00+00:00", + "2025-01-01 18:00:00+00:00", + "2025-01-04 00:00:00+00:00", + ], + utc=True, + ), + "total_quote": [100.0, 120.0, 80.0, 110.0], + } + ) + + drawdown = calculate_max_drawdown_from_balance(balance_history) + assert isinstance(drawdown.relative_account_drawdown, float) + assert pytest.approx(drawdown.relative_account_drawdown) == 1 / 3 + assert pytest.approx(drawdown.drawdown_abs) == 40 + assert pytest.approx(drawdown.current_high_value) == 20 + assert pytest.approx(drawdown.low_value) == -20 + assert pytest.approx(drawdown.high_value) == 20 + + assert drawdown.high_date == Timestamp("2025-01-01 12:00:00", tz="UTC") + assert drawdown.low_date == Timestamp("2025-01-01 18:00:00", tz="UTC") + + +def test_calculate_max_drawdown_from_balance_empty_or_short(): + with pytest.raises(ValueError, match=r"Balance-history dataframe empty\."): + calculate_max_drawdown_from_balance(DataFrame()) + + one_point = DataFrame( + { + "date": to_datetime(["2025-01-01 00:00:00+00:00"], utc=True), + "total_quote": [100.0], + } + ) + with pytest.raises(ValueError, match=r"Balance-history dataframe empty\."): + calculate_max_drawdown_from_balance(one_point) + + def test_calculate_csum(testdatadir): filename = testdatadir / "backtest_results/backtest-result.json" bt_data = load_backtest_data(filename)