Merge pull request #7810 from stash86/bt-metrics
Add more calculations for backtest metrics
This commit is contained in:
		| @@ -300,7 +300,11 @@ A backtesting result will look like that: | ||||
| | Absolute profit             | 0.00762792 BTC      | | ||||
| | Total profit %              | 76.2%               | | ||||
| | CAGR %                      | 460.87%             | | ||||
| | Sortino                     | 1.88                | | ||||
| | Sharpe                      | 2.97                | | ||||
| | Calmar                      | 6.29                | | ||||
| | Profit factor               | 1.11                | | ||||
| | Expectancy                  | -0.15               | | ||||
| | Avg. stake amount           | 0.001      BTC      | | ||||
| | Total trade volume          | 0.429      BTC      | | ||||
| |                             |                     | | ||||
| @@ -400,7 +404,11 @@ It contains some useful key metrics about performance of your strategy on backte | ||||
| | Absolute profit             | 0.00762792 BTC      | | ||||
| | Total profit %              | 76.2%               | | ||||
| | CAGR %                      | 460.87%             | | ||||
| | Sortino                     | 1.88                | | ||||
| | Sharpe                      | 2.97                | | ||||
| | Calmar                      | 6.29                | | ||||
| | Profit factor               | 1.11                | | ||||
| | Expectancy                  | -0.15               | | ||||
| | Avg. stake amount           | 0.001      BTC      | | ||||
| | Total trade volume          | 0.429      BTC      | | ||||
| |                             |                     | | ||||
| @@ -447,6 +455,9 @@ It contains some useful key metrics about performance of your strategy on backte | ||||
| - `Absolute profit`: Profit made in stake currency. | ||||
| - `Total profit %`: Total profit. Aligned to the `TOTAL` row's `Tot Profit %` from the first table. Calculated as `(End capital − Starting capital) / Starting capital`. | ||||
| - `CAGR %`: Compound annual growth rate. | ||||
| - `Sortino`: Annualized Sortino ratio. | ||||
| - `Sharpe`: Annualized Sharpe ratio. | ||||
| - `Calmar`: Annualized Calmar ratio. | ||||
| - `Profit factor`: profit / loss. | ||||
| - `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount. | ||||
| - `Total trade volume`: Volume generated on the exchange to reach the above profit. | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| import logging | ||||
| import math | ||||
| from datetime import datetime | ||||
| from typing import Dict, Tuple | ||||
|  | ||||
| import numpy as np | ||||
| @@ -190,3 +192,119 @@ def calculate_cagr(days_passed: int, starting_balance: float, final_balance: flo | ||||
|     :return: CAGR | ||||
|     """ | ||||
|     return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1 | ||||
|  | ||||
|  | ||||
| def calculate_expectancy(trades: pd.DataFrame) -> float: | ||||
|     """ | ||||
|     Calculate expectancy | ||||
|     :param trades: DataFrame containing trades (requires columns close_date and profit_ratio) | ||||
|     :return: expectancy | ||||
|     """ | ||||
|     if len(trades) == 0: | ||||
|         return 0 | ||||
|  | ||||
|     expectancy = 1 | ||||
|  | ||||
|     profit_sum = trades.loc[trades['profit_abs'] > 0, 'profit_abs'].sum() | ||||
|     loss_sum = abs(trades.loc[trades['profit_abs'] < 0, 'profit_abs'].sum()) | ||||
|     nb_win_trades = len(trades.loc[trades['profit_abs'] > 0]) | ||||
|     nb_loss_trades = len(trades.loc[trades['profit_abs'] < 0]) | ||||
|  | ||||
|     if (nb_win_trades > 0) and (nb_loss_trades > 0): | ||||
|         average_win = profit_sum / nb_win_trades | ||||
|         average_loss = loss_sum / nb_loss_trades | ||||
|         risk_reward_ratio = average_win / average_loss | ||||
|         winrate = nb_win_trades / len(trades) | ||||
|         expectancy = ((1 + risk_reward_ratio) * winrate) - 1 | ||||
|     elif nb_win_trades == 0: | ||||
|         expectancy = 0 | ||||
|  | ||||
|     return expectancy | ||||
|  | ||||
|  | ||||
| def calculate_sortino(trades: pd.DataFrame, min_date: datetime, max_date: datetime, | ||||
|                       starting_balance: float) -> float: | ||||
|     """ | ||||
|     Calculate sortino | ||||
|     :param trades: DataFrame containing trades (requires columns profit_abs) | ||||
|     :return: sortino | ||||
|     """ | ||||
|     if (len(trades) == 0) or (min_date is None) or (max_date is None) or (min_date == max_date): | ||||
|         return 0 | ||||
|  | ||||
|     total_profit = trades['profit_abs'] / starting_balance | ||||
|     days_period = max(1, (max_date - min_date).days) | ||||
|  | ||||
|     expected_returns_mean = total_profit.sum() / days_period | ||||
|  | ||||
|     down_stdev = np.std(trades.loc[trades['profit_abs'] < 0, 'profit_abs'] / starting_balance) | ||||
|  | ||||
|     if down_stdev != 0: | ||||
|         sortino_ratio = expected_returns_mean / down_stdev * np.sqrt(365) | ||||
|     else: | ||||
|         # Define high (negative) sortino ratio to be clear that this is NOT optimal. | ||||
|         sortino_ratio = -100 | ||||
|  | ||||
|     # print(expected_returns_mean, down_stdev, sortino_ratio) | ||||
|     return sortino_ratio | ||||
|  | ||||
|  | ||||
| def calculate_sharpe(trades: pd.DataFrame, min_date: datetime, max_date: datetime, | ||||
|                      starting_balance: float) -> float: | ||||
|     """ | ||||
|     Calculate sharpe | ||||
|     :param trades: DataFrame containing trades (requires column profit_abs) | ||||
|     :return: sharpe | ||||
|     """ | ||||
|     if (len(trades) == 0) or (min_date is None) or (max_date is None) or (min_date == max_date): | ||||
|         return 0 | ||||
|  | ||||
|     total_profit = trades['profit_abs'] / starting_balance | ||||
|     days_period = max(1, (max_date - min_date).days) | ||||
|  | ||||
|     expected_returns_mean = total_profit.sum() / days_period | ||||
|     up_stdev = np.std(total_profit) | ||||
|  | ||||
|     if up_stdev != 0: | ||||
|         sharp_ratio = expected_returns_mean / up_stdev * np.sqrt(365) | ||||
|     else: | ||||
|         # Define high (negative) sharpe ratio to be clear that this is NOT optimal. | ||||
|         sharp_ratio = -100 | ||||
|  | ||||
|     # print(expected_returns_mean, up_stdev, sharp_ratio) | ||||
|     return sharp_ratio | ||||
|  | ||||
|  | ||||
| def calculate_calmar(trades: pd.DataFrame, min_date: datetime, max_date: datetime, | ||||
|                      starting_balance: float) -> float: | ||||
|     """ | ||||
|     Calculate calmar | ||||
|     :param trades: DataFrame containing trades (requires columns close_date and profit_abs) | ||||
|     :return: calmar | ||||
|     """ | ||||
|     if (len(trades) == 0) or (min_date is None) or (max_date is None) or (min_date == max_date): | ||||
|         return 0 | ||||
|  | ||||
|     total_profit = trades['profit_abs'].sum() / starting_balance | ||||
|     days_period = max(1, (max_date - min_date).days) | ||||
|  | ||||
|     # adding slippage of 0.1% per trade | ||||
|     # total_profit = total_profit - 0.0005 | ||||
|     expected_returns_mean = total_profit / days_period * 100 | ||||
|  | ||||
|     # calculate max drawdown | ||||
|     try: | ||||
|         _, _, _, _, _, max_drawdown = calculate_max_drawdown( | ||||
|             trades, value_col="profit_abs", starting_balance=starting_balance | ||||
|         ) | ||||
|     except ValueError: | ||||
|         max_drawdown = 0 | ||||
|  | ||||
|     if max_drawdown != 0: | ||||
|         calmar_ratio = expected_returns_mean / max_drawdown * math.sqrt(365) | ||||
|     else: | ||||
|         # Define high (negative) calmar ratio to be clear that this is NOT optimal. | ||||
|         calmar_ratio = -100 | ||||
|  | ||||
|     # print(expected_returns_mean, max_drawdown, calmar_ratio) | ||||
|     return calmar_ratio | ||||
|   | ||||
| @@ -9,8 +9,9 @@ from tabulate import tabulate | ||||
|  | ||||
| from freqtrade.constants import (DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT, | ||||
|                                  Config) | ||||
| from freqtrade.data.metrics import (calculate_cagr, calculate_csum, calculate_market_change, | ||||
|                                     calculate_max_drawdown) | ||||
| from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, | ||||
|                                     calculate_expectancy, calculate_market_change, | ||||
|                                     calculate_max_drawdown, calculate_sharpe, calculate_sortino) | ||||
| from freqtrade.misc import decimals_per_coin, file_dump_joblib, file_dump_json, round_coin_value | ||||
| from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename | ||||
|  | ||||
| @@ -448,6 +449,10 @@ def generate_strategy_stats(pairlist: List[str], | ||||
|         'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), | ||||
|         'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(), | ||||
|         'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']), | ||||
|         'expectancy': calculate_expectancy(results), | ||||
|         'sortino': calculate_sortino(results, min_date, max_date, start_balance), | ||||
|         'sharpe': calculate_sharpe(results, min_date, max_date, start_balance), | ||||
|         'calmar': calculate_calmar(results, min_date, max_date, start_balance), | ||||
|         'profit_factor': profit_factor, | ||||
|         'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT), | ||||
|         'backtest_start_ts': int(min_date.timestamp() * 1000), | ||||
| @@ -785,8 +790,13 @@ def text_table_add_metrics(strat_results: Dict) -> str: | ||||
|                                                   strat_results['stake_currency'])), | ||||
|             ('Total profit %', f"{strat_results['profit_total']:.2%}"), | ||||
|             ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'), | ||||
|             ('Sortino', f"{strat_results['sortino']:.2f}" if 'sortino' in strat_results else 'N/A'), | ||||
|             ('Sharpe', f"{strat_results['sharpe']:.2f}" if 'sharpe' in strat_results else 'N/A'), | ||||
|             ('Calmar', f"{strat_results['calmar']:.2f}" if 'calmar' in strat_results else 'N/A'), | ||||
|             ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor' | ||||
|                               in strat_results else 'N/A'), | ||||
|             ('Expectancy', f"{strat_results['expectancy']:.2f}" if 'expectancy' | ||||
|                            in strat_results else 'N/A'), | ||||
|             ('Trades per day', strat_results['trades_per_day']), | ||||
|             ('Avg. daily profit %', | ||||
|              f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), | ||||
|   | ||||
| @@ -12,9 +12,11 @@ from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, analyze_trade_parallelis | ||||
|                                        get_latest_hyperopt_file, load_backtest_data, | ||||
|                                        load_backtest_metadata, load_trades, load_trades_from_db) | ||||
| from freqtrade.data.history import load_data, load_pair_history | ||||
| from freqtrade.data.metrics import (calculate_cagr, calculate_csum, calculate_market_change, | ||||
|                                     calculate_max_drawdown, calculate_underwater, | ||||
|                                     combine_dataframes_with_mean, create_cum_profit) | ||||
| from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, | ||||
|                                     calculate_expectancy, calculate_market_change, | ||||
|                                     calculate_max_drawdown, calculate_sharpe, calculate_sortino, | ||||
|                                     calculate_underwater, combine_dataframes_with_mean, | ||||
|                                     create_cum_profit) | ||||
| from freqtrade.exceptions import OperationalException | ||||
| from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades | ||||
| from tests.conftest_trades import MOCK_TRADE_COUNT | ||||
| @@ -336,6 +338,69 @@ def test_calculate_csum(testdatadir): | ||||
|         csum_min, csum_max = calculate_csum(DataFrame()) | ||||
|  | ||||
|  | ||||
| def test_calculate_expectancy(testdatadir): | ||||
|     filename = testdatadir / "backtest_results/backtest-result.json" | ||||
|     bt_data = load_backtest_data(filename) | ||||
|  | ||||
|     expectancy = calculate_expectancy(DataFrame()) | ||||
|     assert expectancy == 0.0 | ||||
|  | ||||
|     expectancy = calculate_expectancy(bt_data) | ||||
|     assert isinstance(expectancy, float) | ||||
|     assert pytest.approx(expectancy) == 0.07151374226574791 | ||||
|  | ||||
|  | ||||
| def test_calculate_sortino(testdatadir): | ||||
|     filename = testdatadir / "backtest_results/backtest-result.json" | ||||
|     bt_data = load_backtest_data(filename) | ||||
|  | ||||
|     sortino = calculate_sortino(DataFrame(), None, None, 0) | ||||
|     assert sortino == 0.0 | ||||
|  | ||||
|     sortino = calculate_sortino( | ||||
|         bt_data, | ||||
|         bt_data['open_date'].min(), | ||||
|         bt_data['close_date'].max(), | ||||
|         0.01, | ||||
|         ) | ||||
|     assert isinstance(sortino, float) | ||||
|     assert pytest.approx(sortino) == 35.17722 | ||||
|  | ||||
|  | ||||
| def test_calculate_sharpe(testdatadir): | ||||
|     filename = testdatadir / "backtest_results/backtest-result.json" | ||||
|     bt_data = load_backtest_data(filename) | ||||
|  | ||||
|     sharpe = calculate_sharpe(DataFrame(), None, None, 0) | ||||
|     assert sharpe == 0.0 | ||||
|  | ||||
|     sharpe = calculate_sharpe( | ||||
|         bt_data, | ||||
|         bt_data['open_date'].min(), | ||||
|         bt_data['close_date'].max(), | ||||
|         0.01, | ||||
|         ) | ||||
|     assert isinstance(sharpe, float) | ||||
|     assert pytest.approx(sharpe) == 44.5078669 | ||||
|  | ||||
|  | ||||
| def test_calculate_calmar(testdatadir): | ||||
|     filename = testdatadir / "backtest_results/backtest-result.json" | ||||
|     bt_data = load_backtest_data(filename) | ||||
|  | ||||
|     calmar = calculate_calmar(DataFrame(), None, None, 0) | ||||
|     assert calmar == 0.0 | ||||
|  | ||||
|     calmar = calculate_calmar( | ||||
|         bt_data, | ||||
|         bt_data['open_date'].min(), | ||||
|         bt_data['close_date'].max(), | ||||
|         0.01, | ||||
|         ) | ||||
|     assert isinstance(calmar, float) | ||||
|     assert pytest.approx(calmar) == 559.040508 | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize('start,end,days, expected', [ | ||||
|     (64900, 176000, 3 * 365, 0.3945), | ||||
|     (64900, 176000, 365, 1.7119), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user