diff --git a/freqtrade/data/metrics.py b/freqtrade/data/metrics.py index b766f461d..e4e618d1c 100644 --- a/freqtrade/data/metrics.py +++ b/freqtrade/data/metrics.py @@ -400,6 +400,30 @@ def calculate_sortino( return _calculate_annualized_ratio(expected_returns_mean, down_stdev) +def calculate_sortino_from_balance( + balance_history: pd.DataFrame, + date_col: str = "date", + balance_col: str = "total_quote", +) -> float: + """ + Calculate sortino ratio 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 + :return: sortino + """ + daily_returns = _calculate_daily_returns_from_balance(balance_history, date_col, balance_col) + + if len(daily_returns) == 0: + return 0.0 + + expected_returns_mean = daily_returns.mean() + downside_returns = daily_returns[daily_returns < 0] + down_stdev = downside_returns.std(ddof=0) + return _calculate_annualized_ratio(expected_returns_mean, down_stdev) + + def calculate_sharpe( trades: pd.DataFrame, min_date: datetime | None, diff --git a/tests/data/test_metrics.py b/tests/data/test_metrics.py index 38ce57aff..53f8ef0be 100644 --- a/tests/data/test_metrics.py +++ b/tests/data/test_metrics.py @@ -19,6 +19,7 @@ from freqtrade.data.metrics import ( calculate_sharpe, calculate_sharpe_from_balance, calculate_sortino, + calculate_sortino_from_balance, calculate_sqn, calculate_underwater, combine_dataframes_with_mean, @@ -202,6 +203,53 @@ def test_calculate_sortino(testdatadir): assert pytest.approx(sortino) == 35.17722 +def test_calculate_sortino_from_balance(): + balance_history = DataFrame( + { + "date": to_datetime( + [ + "2025-01-01 00:00:00+00:00", + "2025-01-02 00:00:00+00:00", + "2025-01-03 00:00:00+00:00", + "2025-01-04 00:00:00+00:00", + "2025-01-05 00:00:00+00:00", + ], + utc=True, + ), + "total_quote": [100.0, 110.0, 104.5, 125.4, 112.86], + } + ) + + sortino = calculate_sortino_from_balance(balance_history) + expected_returns = np.array([0.1, -0.05, 0.2, -0.1]) + expected_sortino = expected_returns.mean() / np.std(expected_returns[expected_returns < 0]) + expected_sortino *= np.sqrt(365) + + assert isinstance(sortino, float) + assert pytest.approx(sortino) == expected_sortino + # Explicit assert + assert pytest.approx(sortino) == 28.6574597 + + +def test_calculate_sortino_from_balance_empty_or_no_downside(): + assert calculate_sortino_from_balance(DataFrame()) == 0.0 + + positive_balance_history = DataFrame( + { + "date": to_datetime( + [ + "2025-01-01 00:00:00+00:00", + "2025-01-02 00:00:00+00:00", + "2025-01-03 00:00:00+00:00", + ], + utc=True, + ), + "total_quote": [100.0, 110.0, 121.0], + } + ) + assert calculate_sortino_from_balance(positive_balance_history) == -100 + + def test_calculate_sharpe(testdatadir): filename = testdatadir / "backtest_results/backtest-result.json" bt_data = load_backtest_data(filename)