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      | | | Absolute profit             | 0.00762792 BTC      | | ||||||
| | Total profit %              | 76.2%               | | | Total profit %              | 76.2%               | | ||||||
| | CAGR %                      | 460.87%             | | | CAGR %                      | 460.87%             | | ||||||
|  | | Sortino                     | 1.88                | | ||||||
|  | | Sharpe                      | 2.97                | | ||||||
|  | | Calmar                      | 6.29                | | ||||||
| | Profit factor               | 1.11                | | | Profit factor               | 1.11                | | ||||||
|  | | Expectancy                  | -0.15               | | ||||||
| | Avg. stake amount           | 0.001      BTC      | | | Avg. stake amount           | 0.001      BTC      | | ||||||
| | Total trade volume          | 0.429      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      | | | Absolute profit             | 0.00762792 BTC      | | ||||||
| | Total profit %              | 76.2%               | | | Total profit %              | 76.2%               | | ||||||
| | CAGR %                      | 460.87%             | | | CAGR %                      | 460.87%             | | ||||||
|  | | Sortino                     | 1.88                | | ||||||
|  | | Sharpe                      | 2.97                | | ||||||
|  | | Calmar                      | 6.29                | | ||||||
| | Profit factor               | 1.11                | | | Profit factor               | 1.11                | | ||||||
|  | | Expectancy                  | -0.15               | | ||||||
| | Avg. stake amount           | 0.001      BTC      | | | Avg. stake amount           | 0.001      BTC      | | ||||||
| | Total trade volume          | 0.429      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. | - `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`. | - `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. | - `CAGR %`: Compound annual growth rate. | ||||||
|  | - `Sortino`: Annualized Sortino ratio. | ||||||
|  | - `Sharpe`: Annualized Sharpe ratio. | ||||||
|  | - `Calmar`: Annualized Calmar ratio. | ||||||
| - `Profit factor`: profit / loss. | - `Profit factor`: profit / loss. | ||||||
| - `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount. | - `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. | - `Total trade volume`: Volume generated on the exchange to reach the above profit. | ||||||
|   | |||||||
| @@ -1,4 +1,6 @@ | |||||||
| import logging | import logging | ||||||
|  | import math | ||||||
|  | from datetime import datetime | ||||||
| from typing import Dict, Tuple | from typing import Dict, Tuple | ||||||
|  |  | ||||||
| import numpy as np | import numpy as np | ||||||
| @@ -190,3 +192,119 @@ def calculate_cagr(days_passed: int, starting_balance: float, final_balance: flo | |||||||
|     :return: CAGR |     :return: CAGR | ||||||
|     """ |     """ | ||||||
|     return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1 |     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, | from freqtrade.constants import (DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT, | ||||||
|                                  Config) |                                  Config) | ||||||
| from freqtrade.data.metrics import (calculate_cagr, calculate_csum, calculate_market_change, | from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, | ||||||
|                                     calculate_max_drawdown) |                                     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.misc import decimals_per_coin, file_dump_joblib, file_dump_json, round_coin_value | ||||||
| from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename | 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_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), | ||||||
|         'profit_total_short_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']), |         '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, |         'profit_factor': profit_factor, | ||||||
|         'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT), |         'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT), | ||||||
|         'backtest_start_ts': int(min_date.timestamp() * 1000), |         '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'])), |                                                   strat_results['stake_currency'])), | ||||||
|             ('Total profit %', f"{strat_results['profit_total']:.2%}"), |             ('Total profit %', f"{strat_results['profit_total']:.2%}"), | ||||||
|             ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'), |             ('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' |             ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor' | ||||||
|                               in strat_results else 'N/A'), |                               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']), |             ('Trades per day', strat_results['trades_per_day']), | ||||||
|             ('Avg. daily profit %', |             ('Avg. daily profit %', | ||||||
|              f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), |              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, |                                        get_latest_hyperopt_file, load_backtest_data, | ||||||
|                                        load_backtest_metadata, load_trades, load_trades_from_db) |                                        load_backtest_metadata, load_trades, load_trades_from_db) | ||||||
| from freqtrade.data.history import load_data, load_pair_history | from freqtrade.data.history import load_data, load_pair_history | ||||||
| from freqtrade.data.metrics import (calculate_cagr, calculate_csum, calculate_market_change, | from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, | ||||||
|                                     calculate_max_drawdown, calculate_underwater, |                                     calculate_expectancy, calculate_market_change, | ||||||
|                                     combine_dataframes_with_mean, create_cum_profit) |                                     calculate_max_drawdown, calculate_sharpe, calculate_sortino, | ||||||
|  |                                     calculate_underwater, combine_dataframes_with_mean, | ||||||
|  |                                     create_cum_profit) | ||||||
| from freqtrade.exceptions import OperationalException | from freqtrade.exceptions import OperationalException | ||||||
| from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades | from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades | ||||||
| from tests.conftest_trades import MOCK_TRADE_COUNT | from tests.conftest_trades import MOCK_TRADE_COUNT | ||||||
| @@ -336,6 +338,69 @@ def test_calculate_csum(testdatadir): | |||||||
|         csum_min, csum_max = calculate_csum(DataFrame()) |         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', [ | @pytest.mark.parametrize('start,end,days, expected', [ | ||||||
|     (64900, 176000, 3 * 365, 0.3945), |     (64900, 176000, 3 * 365, 0.3945), | ||||||
|     (64900, 176000, 365, 1.7119), |     (64900, 176000, 365, 1.7119), | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user