diff --git a/freqtrade/data/metrics.py b/freqtrade/data/metrics.py index de8ebac4a..d2ae2d64b 100644 --- a/freqtrade/data/metrics.py +++ b/freqtrade/data/metrics.py @@ -375,3 +375,32 @@ def calculate_calmar( # print(expected_returns_mean, max_drawdown, calmar_ratio) return calmar_ratio + + +def calculate_sqn(trades: pd.DataFrame, starting_balance: float) -> float: + """ + Calculate System Quality Number (SQN) - Van K. Tharp. + SQN measures systematic trading quality and takes into account both + the number of trades and their standard deviation. + + :param trades: DataFrame containing trades (requires column profit_abs) + :param starting_balance: Starting balance of the trading system + :return: SQN value + """ + if len(trades) == 0: + return 0.0 + + total_profit = trades["profit_abs"] / starting_balance + number_of_trades = len(trades) + + # Calculate average trade and standard deviation + average_profits = total_profit.mean() + profits_std = total_profit.std() + + if profits_std != 0 and not np.isnan(profits_std): + sqn = math.sqrt(number_of_trades) * (average_profits / profits_std) + else: + # Define negative SQN to indicate this is NOT optimal + sqn = -100.0 + + return round(sqn, 4) diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 1c901bc16..eec3ac4e4 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -30,6 +30,7 @@ from freqtrade.data.metrics import ( calculate_max_drawdown, calculate_sharpe, calculate_sortino, + calculate_sqn, calculate_underwater, combine_dataframes_with_mean, combined_dataframes_with_rel_mean, @@ -457,6 +458,42 @@ def test_calculate_calmar(testdatadir): assert pytest.approx(calmar) == 559.040508 +def test_calculate_sqn(testdatadir): + filename = testdatadir / "backtest_results/backtest-result.json" + bt_data = load_backtest_data(filename) + + sqn = calculate_sqn(DataFrame(), 0) + assert sqn == 0.0 + + sqn = calculate_sqn( + bt_data, + 0.01, + ) + assert isinstance(sqn, float) + assert pytest.approx(sqn) == 3.2991 + + +@pytest.mark.parametrize( + "profits,starting_balance,expected_sqn,description", + [ + ([1.0, -0.5, 2.0, -1.0, 0.5, 1.5, -0.5, 1.0], 100, 1.3229, "Mixed profits/losses"), + ([], 100, 0.0, "Empty dataframe"), + ([1.0, 0.5, 2.0, 1.5, 0.8], 100, 4.3657, "All winning trades"), + ([-1.0, -0.5, -2.0, -1.5, -0.8], 100, -4.3657, "All losing trades"), + ([1.0], 100, -100, "Single trade"), + ], +) +def test_calculate_sqn_cases(profits, starting_balance, expected_sqn, description): + """ + Test SQN calculation with various scenarios: + """ + trades = DataFrame({"profit_abs": profits}) + sqn = calculate_sqn(trades, starting_balance=starting_balance) + + assert isinstance(sqn, float) + assert pytest.approx(sqn, rel=1e-4) == expected_sqn + + @pytest.mark.parametrize( "start,end,days, expected", [