• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

freqtrade / freqtrade / 4131167254

pending completion
4131167254

push

github-actions

GitHub
Merge pull request #7983 from stash86/bt-metrics

16866 of 17748 relevant lines covered (95.03%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.57
/freqtrade/optimize/optimize_reports.py
1
import logging
1✔
2
from copy import deepcopy
1✔
3
from datetime import datetime, timedelta, timezone
1✔
4
from pathlib import Path
1✔
5
from typing import Any, Dict, List, Union
1✔
6

7
from pandas import DataFrame, to_datetime
1✔
8
from tabulate import tabulate
1✔
9

10
from freqtrade.constants import (DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT,
1✔
11
                                 Config)
12
from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum,
1✔
13
                                    calculate_expectancy, calculate_market_change,
14
                                    calculate_max_drawdown, calculate_sharpe, calculate_sortino)
15
from freqtrade.misc import decimals_per_coin, file_dump_joblib, file_dump_json, round_coin_value
1✔
16
from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename
1✔
17

18

19
logger = logging.getLogger(__name__)
1✔
20

21

22
def store_backtest_stats(
1✔
23
        recordfilename: Path, stats: Dict[str, DataFrame], dtappendix: str) -> None:
24
    """
25
    Stores backtest results
26
    :param recordfilename: Path object, which can either be a filename or a directory.
27
        Filenames will be appended with a timestamp right before the suffix
28
        while for directories, <directory>/backtest-result-<datetime>.json will be used as filename
29
    :param stats: Dataframe containing the backtesting statistics
30
    :param dtappendix: Datetime to use for the filename
31
    """
32
    if recordfilename.is_dir():
1✔
33
        filename = (recordfilename / f'backtest-result-{dtappendix}.json')
1✔
34
    else:
35
        filename = Path.joinpath(
1✔
36
            recordfilename.parent, f'{recordfilename.stem}-{dtappendix}'
37
        ).with_suffix(recordfilename.suffix)
38

39
    # Store metadata separately.
40
    file_dump_json(get_backtest_metadata_filename(filename), stats['metadata'])
1✔
41
    del stats['metadata']
1✔
42

43
    file_dump_json(filename, stats)
1✔
44

45
    latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN)
1✔
46
    file_dump_json(latest_filename, {'latest_backtest': str(filename.name)})
1✔
47

48

49
def store_backtest_signal_candles(
1✔
50
        recordfilename: Path, candles: Dict[str, Dict], dtappendix: str) -> Path:
51
    """
52
    Stores backtest trade signal candles
53
    :param recordfilename: Path object, which can either be a filename or a directory.
54
        Filenames will be appended with a timestamp right before the suffix
55
        while for directories, <directory>/backtest-result-<datetime>_signals.pkl will be used
56
        as filename
57
    :param stats: Dict containing the backtesting signal candles
58
    :param dtappendix: Datetime to use for the filename
59
    """
60
    if recordfilename.is_dir():
1✔
61
        filename = (recordfilename / f'backtest-result-{dtappendix}_signals.pkl')
1✔
62
    else:
63
        filename = Path.joinpath(
1✔
64
            recordfilename.parent, f'{recordfilename.stem}-{dtappendix}_signals.pkl'
65
        )
66

67
    file_dump_joblib(filename, candles)
1✔
68

69
    return filename
1✔
70

71

72
def _get_line_floatfmt(stake_currency: str) -> List[str]:
1✔
73
    """
74
    Generate floatformat (goes in line with _generate_result_line())
75
    """
76
    return ['s', 'd', '.2f', '.2f', f'.{decimals_per_coin(stake_currency)}f',
1✔
77
            '.2f', 'd', 's', 's']
78

79

80
def _get_line_header(first_column: str, stake_currency: str,
1✔
81
                     direction: str = 'Entries') -> List[str]:
82
    """
83
    Generate header lines (goes in line with _generate_result_line())
84
    """
85
    return [first_column, direction, 'Avg Profit %', 'Cum Profit %',
1✔
86
            f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
87
            'Win  Draw  Loss  Win%']
88

89

90
def generate_wins_draws_losses(wins, draws, losses):
1✔
91
    if wins > 0 and losses == 0:
1✔
92
        wl_ratio = '100'
1✔
93
    elif wins == 0:
1✔
94
        wl_ratio = '0'
1✔
95
    else:
96
        wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100'
1✔
97
    return f'{wins:>4}  {draws:>4}  {losses:>4}  {wl_ratio:>4}'
1✔
98

99

100
def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict:
1✔
101
    """
102
    Generate one result dict, with "first_column" as key.
103
    """
104
    profit_sum = result['profit_ratio'].sum()
1✔
105
    # (end-capital - starting capital) / starting capital
106
    profit_total = result['profit_abs'].sum() / starting_balance
1✔
107

108
    return {
1✔
109
        'key': first_column,
110
        'trades': len(result),
111
        'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0,
112
        'profit_mean_pct': result['profit_ratio'].mean() * 100.0 if len(result) > 0 else 0.0,
113
        'profit_sum': profit_sum,
114
        'profit_sum_pct': round(profit_sum * 100.0, 2),
115
        'profit_total_abs': result['profit_abs'].sum(),
116
        'profit_total': profit_total,
117
        'profit_total_pct': round(profit_total * 100.0, 2),
118
        'duration_avg': str(timedelta(
119
                            minutes=round(result['trade_duration'].mean()))
120
                            ) if not result.empty else '0:00',
121
        # 'duration_max': str(timedelta(
122
        #                     minutes=round(result['trade_duration'].max()))
123
        #                     ) if not result.empty else '0:00',
124
        # 'duration_min': str(timedelta(
125
        #                     minutes=round(result['trade_duration'].min()))
126
        #                     ) if not result.empty else '0:00',
127
        'wins': len(result[result['profit_abs'] > 0]),
128
        'draws': len(result[result['profit_abs'] == 0]),
129
        'losses': len(result[result['profit_abs'] < 0]),
130
    }
131

132

133
def generate_pair_metrics(pairlist: List[str], stake_currency: str, starting_balance: int,
1✔
134
                          results: DataFrame, skip_nan: bool = False) -> List[Dict]:
135
    """
136
    Generates and returns a list  for the given backtest data and the results dataframe
137
    :param pairlist: Pairlist used
138
    :param stake_currency: stake-currency - used to correctly name headers
139
    :param starting_balance: Starting balance
140
    :param results: Dataframe containing the backtest results
141
    :param skip_nan: Print "left open" open trades
142
    :return: List of Dicts containing the metrics per pair
143
    """
144

145
    tabular_data = []
1✔
146

147
    for pair in pairlist:
1✔
148
        result = results[results['pair'] == pair]
1✔
149
        if skip_nan and result['profit_abs'].isnull().all():
1✔
150
            continue
1✔
151

152
        tabular_data.append(_generate_result_line(result, starting_balance, pair))
1✔
153

154
    # Sort by total profit %:
155
    tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True)
1✔
156

157
    # Append Total
158
    tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL'))
1✔
159
    return tabular_data
1✔
160

161

162
def generate_tag_metrics(tag_type: str,
1✔
163
                         starting_balance: int,
164
                         results: DataFrame,
165
                         skip_nan: bool = False) -> List[Dict]:
166
    """
167
    Generates and returns a list of metrics for the given tag trades and the results dataframe
168
    :param starting_balance: Starting balance
169
    :param results: Dataframe containing the backtest results
170
    :param skip_nan: Print "left open" open trades
171
    :return: List of Dicts containing the metrics per pair
172
    """
173

174
    tabular_data = []
1✔
175

176
    if tag_type in results.columns:
1✔
177
        for tag, count in results[tag_type].value_counts().items():
1✔
178
            result = results[results[tag_type] == tag]
1✔
179
            if skip_nan and result['profit_abs'].isnull().all():
1✔
180
                continue
×
181

182
            tabular_data.append(_generate_result_line(result, starting_balance, tag))
1✔
183

184
        # Sort by total profit %:
185
        tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True)
1✔
186

187
        # Append Total
188
        tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL'))
1✔
189
        return tabular_data
1✔
190
    else:
191
        return []
1✔
192

193

194
def generate_exit_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]:
1✔
195
    """
196
    Generate small table outlining Backtest results
197
    :param max_open_trades: Max_open_trades parameter
198
    :param results: Dataframe containing the backtest result for one strategy
199
    :return: List of Dicts containing the metrics per Sell reason
200
    """
201
    tabular_data = []
1✔
202

203
    for reason, count in results['exit_reason'].value_counts().items():
1✔
204
        result = results.loc[results['exit_reason'] == reason]
1✔
205

206
        profit_mean = result['profit_ratio'].mean()
1✔
207
        profit_sum = result['profit_ratio'].sum()
1✔
208
        profit_total = profit_sum / max_open_trades
1✔
209

210
        tabular_data.append(
1✔
211
            {
212
                'exit_reason': reason,
213
                'trades': count,
214
                'wins': len(result[result['profit_abs'] > 0]),
215
                'draws': len(result[result['profit_abs'] == 0]),
216
                'losses': len(result[result['profit_abs'] < 0]),
217
                'profit_mean': profit_mean,
218
                'profit_mean_pct': round(profit_mean * 100, 2),
219
                'profit_sum': profit_sum,
220
                'profit_sum_pct': round(profit_sum * 100, 2),
221
                'profit_total_abs': result['profit_abs'].sum(),
222
                'profit_total': profit_total,
223
                'profit_total_pct': round(profit_total * 100, 2),
224
            }
225
        )
226
    return tabular_data
1✔
227

228

229
def generate_strategy_comparison(bt_stats: Dict) -> List[Dict]:
1✔
230
    """
231
    Generate summary per strategy
232
    :param bt_stats: Dict of <Strategyname: DataFrame> containing results for all strategies
233
    :return: List of Dicts containing the metrics per Strategy
234
    """
235

236
    tabular_data = []
1✔
237
    for strategy, result in bt_stats.items():
1✔
238
        tabular_data.append(deepcopy(result['results_per_pair'][-1]))
1✔
239
        # Update "key" to strategy (results_per_pair has it as "Total").
240
        tabular_data[-1]['key'] = strategy
1✔
241
        tabular_data[-1]['max_drawdown_account'] = result['max_drawdown_account']
1✔
242
        tabular_data[-1]['max_drawdown_abs'] = round_coin_value(
1✔
243
            result['max_drawdown_abs'], result['stake_currency'], False)
244
    return tabular_data
1✔
245

246

247
def generate_edge_table(results: dict) -> str:
1✔
248
    floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', 'd', 'd')
1✔
249
    tabular_data = []
1✔
250
    headers = ['Pair', 'Stoploss', 'Win Rate', 'Risk Reward Ratio',
1✔
251
               'Required Risk Reward', 'Expectancy', 'Total Number of Trades',
252
               'Average Duration (min)']
253

254
    for result in results.items():
1✔
255
        if result[1].nb_trades > 0:
1✔
256
            tabular_data.append([
1✔
257
                result[0],
258
                result[1].stoploss,
259
                result[1].winrate,
260
                result[1].risk_reward_ratio,
261
                result[1].required_risk_reward,
262
                result[1].expectancy,
263
                result[1].nb_trades,
264
                round(result[1].avg_trade_duration)
265
            ])
266

267
    # Ignore type as floatfmt does allow tuples but mypy does not know that
268
    return tabulate(tabular_data, headers=headers,
1✔
269
                    floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
270

271

272
def _get_resample_from_period(period: str) -> str:
1✔
273
    if period == 'day':
1✔
274
        return '1d'
1✔
275
    if period == 'week':
1✔
276
        return '1w'
1✔
277
    if period == 'month':
1✔
278
        return '1M'
1✔
279
    raise ValueError(f"Period {period} is not supported.")
1✔
280

281

282
def generate_periodic_breakdown_stats(trade_list: List, period: str) -> List[Dict[str, Any]]:
1✔
283
    results = DataFrame.from_records(trade_list)
1✔
284
    if len(results) == 0:
1✔
285
        return []
1✔
286
    results['close_date'] = to_datetime(results['close_date'], utc=True)
1✔
287
    resample_period = _get_resample_from_period(period)
1✔
288
    resampled = results.resample(resample_period, on='close_date')
1✔
289
    stats = []
1✔
290
    for name, day in resampled:
1✔
291
        profit_abs = day['profit_abs'].sum().round(10)
1✔
292
        wins = sum(day['profit_abs'] > 0)
1✔
293
        draws = sum(day['profit_abs'] == 0)
1✔
294
        loses = sum(day['profit_abs'] < 0)
1✔
295
        stats.append(
1✔
296
            {
297
                'date': name.strftime('%d/%m/%Y'),
298
                'profit_abs': profit_abs,
299
                'wins': wins,
300
                'draws': draws,
301
                'loses': loses
302
            }
303
        )
304
    return stats
1✔
305

306

307
def generate_trading_stats(results: DataFrame) -> Dict[str, Any]:
1✔
308
    """ Generate overall trade statistics """
309
    if len(results) == 0:
1✔
310
        return {
1✔
311
            'wins': 0,
312
            'losses': 0,
313
            'draws': 0,
314
            'holding_avg': timedelta(),
315
            'winner_holding_avg': timedelta(),
316
            'loser_holding_avg': timedelta(),
317
        }
318

319
    winning_trades = results.loc[results['profit_ratio'] > 0]
1✔
320
    draw_trades = results.loc[results['profit_ratio'] == 0]
1✔
321
    losing_trades = results.loc[results['profit_ratio'] < 0]
1✔
322

323
    holding_avg = (timedelta(minutes=round(results['trade_duration'].mean()))
1✔
324
                   if not results.empty else timedelta())
325
    winner_holding_avg = (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
1✔
326
                          if not winning_trades.empty else timedelta())
327
    loser_holding_avg = (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
1✔
328
                         if not losing_trades.empty else timedelta())
329

330
    return {
1✔
331
        'wins': len(winning_trades),
332
        'losses': len(losing_trades),
333
        'draws': len(draw_trades),
334
        'holding_avg': holding_avg,
335
        'holding_avg_s': holding_avg.total_seconds(),
336
        'winner_holding_avg': winner_holding_avg,
337
        'winner_holding_avg_s': winner_holding_avg.total_seconds(),
338
        'loser_holding_avg': loser_holding_avg,
339
        'loser_holding_avg_s': loser_holding_avg.total_seconds(),
340
    }
341

342

343
def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
1✔
344
    """ Generate daily statistics """
345
    if len(results) == 0:
1✔
346
        return {
1✔
347
            'backtest_best_day': 0,
348
            'backtest_worst_day': 0,
349
            'backtest_best_day_abs': 0,
350
            'backtest_worst_day_abs': 0,
351
            'winning_days': 0,
352
            'draw_days': 0,
353
            'losing_days': 0,
354
            'daily_profit_list': [],
355
        }
356
    daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum()
1✔
357
    daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10)
1✔
358
    worst_rel = min(daily_profit_rel)
1✔
359
    best_rel = max(daily_profit_rel)
1✔
360
    worst = min(daily_profit)
1✔
361
    best = max(daily_profit)
1✔
362
    winning_days = sum(daily_profit > 0)
1✔
363
    draw_days = sum(daily_profit == 0)
1✔
364
    losing_days = sum(daily_profit < 0)
1✔
365
    daily_profit_list = [(str(idx.date()), val) for idx, val in daily_profit.items()]
1✔
366

367
    return {
1✔
368
        'backtest_best_day': best_rel,
369
        'backtest_worst_day': worst_rel,
370
        'backtest_best_day_abs': best,
371
        'backtest_worst_day_abs': worst,
372
        'winning_days': winning_days,
373
        'draw_days': draw_days,
374
        'losing_days': losing_days,
375
        'daily_profit': daily_profit_list,
376
    }
377

378

379
def generate_strategy_stats(pairlist: List[str],
1✔
380
                            strategy: str,
381
                            content: Dict[str, Any],
382
                            min_date: datetime, max_date: datetime,
383
                            market_change: float
384
                            ) -> Dict[str, Any]:
385
    """
386
    :param pairlist: List of pairs to backtest
387
    :param strategy: Strategy name
388
    :param content: Backtest result data in the format:
389
                    {'results: results, 'config: config}}.
390
    :param min_date: Backtest start date
391
    :param max_date: Backtest end date
392
    :param market_change: float indicating the market change
393
    :return: Dictionary containing results per strategy and a strategy summary.
394
    """
395
    results: Dict[str, DataFrame] = content['results']
1✔
396
    if not isinstance(results, DataFrame):
1✔
397
        return {}
×
398
    config = content['config']
1✔
399
    max_open_trades = min(config['max_open_trades'], len(pairlist))
1✔
400
    start_balance = config['dry_run_wallet']
1✔
401
    stake_currency = config['stake_currency']
1✔
402

403
    pair_results = generate_pair_metrics(pairlist, stake_currency=stake_currency,
1✔
404
                                         starting_balance=start_balance,
405
                                         results=results, skip_nan=False)
406

407
    enter_tag_results = generate_tag_metrics("enter_tag", starting_balance=start_balance,
1✔
408
                                             results=results, skip_nan=False)
409

410
    exit_reason_stats = generate_exit_reason_stats(max_open_trades=max_open_trades,
1✔
411
                                                   results=results)
412
    left_open_results = generate_pair_metrics(
1✔
413
        pairlist, stake_currency=stake_currency, starting_balance=start_balance,
414
        results=results.loc[results['exit_reason'] == 'force_exit'], skip_nan=True)
415

416
    daily_stats = generate_daily_stats(results)
1✔
417
    trade_stats = generate_trading_stats(results)
1✔
418
    best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
1✔
419
                    key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
420
    worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
1✔
421
                     key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
422
    winning_profit = results.loc[results['profit_abs'] > 0, 'profit_abs'].sum()
1✔
423
    losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum()
1✔
424
    profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0
1✔
425

426
    backtest_days = (max_date - min_date).days or 1
1✔
427
    strat_stats = {
1✔
428
        'trades': results.to_dict(orient='records'),
429
        'locks': [lock.to_json() for lock in content['locks']],
430
        'best_pair': best_pair,
431
        'worst_pair': worst_pair,
432
        'results_per_pair': pair_results,
433
        'results_per_enter_tag': enter_tag_results,
434
        'exit_reason_summary': exit_reason_stats,
435
        'left_open_trades': left_open_results,
436
        # 'days_breakdown_stats': days_breakdown_stats,
437

438
        'total_trades': len(results),
439
        'trade_count_long': len(results.loc[~results['is_short']]),
440
        'trade_count_short': len(results.loc[results['is_short']]),
441
        'total_volume': float(results['stake_amount'].sum()),
442
        'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0,
443
        'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
444
        'profit_median': results['profit_ratio'].median() if len(results) > 0 else 0,
445
        'profit_total': results['profit_abs'].sum() / start_balance,
446
        'profit_total_long': results.loc[~results['is_short'], 'profit_abs'].sum() / start_balance,
447
        'profit_total_short': results.loc[results['is_short'], 'profit_abs'].sum() / start_balance,
448
        'profit_total_abs': results['profit_abs'].sum(),
449
        'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(),
450
        'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(),
451
        'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']),
452
        'expectancy': calculate_expectancy(results),
453
        'sortino': calculate_sortino(results, min_date, max_date, start_balance),
454
        'sharpe': calculate_sharpe(results, min_date, max_date, start_balance),
455
        'calmar': calculate_calmar(results, min_date, max_date, start_balance),
456
        'profit_factor': profit_factor,
457
        'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT),
458
        'backtest_start_ts': int(min_date.timestamp() * 1000),
459
        'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT),
460
        'backtest_end_ts': int(max_date.timestamp() * 1000),
461
        'backtest_days': backtest_days,
462

463
        'backtest_run_start_ts': content['backtest_start_time'],
464
        'backtest_run_end_ts': content['backtest_end_time'],
465

466
        'trades_per_day': round(len(results) / backtest_days, 2),
467
        'market_change': market_change,
468
        'pairlist': pairlist,
469
        'stake_amount': config['stake_amount'],
470
        'stake_currency': config['stake_currency'],
471
        'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
472
        'starting_balance': start_balance,
473
        'dry_run_wallet': start_balance,
474
        'final_balance': content['final_balance'],
475
        'rejected_signals': content['rejected_signals'],
476
        'timedout_entry_orders': content['timedout_entry_orders'],
477
        'timedout_exit_orders': content['timedout_exit_orders'],
478
        'canceled_trade_entries': content['canceled_trade_entries'],
479
        'canceled_entry_orders': content['canceled_entry_orders'],
480
        'replaced_entry_orders': content['replaced_entry_orders'],
481
        'max_open_trades': max_open_trades,
482
        'max_open_trades_setting': (config['max_open_trades']
483
                                    if config['max_open_trades'] != float('inf') else -1),
484
        'timeframe': config['timeframe'],
485
        'timeframe_detail': config.get('timeframe_detail', ''),
486
        'timerange': config.get('timerange', ''),
487
        'enable_protections': config.get('enable_protections', False),
488
        'strategy_name': strategy,
489
        # Parameters relevant for backtesting
490
        'stoploss': config['stoploss'],
491
        'trailing_stop': config.get('trailing_stop', False),
492
        'trailing_stop_positive': config.get('trailing_stop_positive'),
493
        'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
494
        'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
495
        'use_custom_stoploss': config.get('use_custom_stoploss', False),
496
        'minimal_roi': config['minimal_roi'],
497
        'use_exit_signal': config['use_exit_signal'],
498
        'exit_profit_only': config['exit_profit_only'],
499
        'exit_profit_offset': config['exit_profit_offset'],
500
        'ignore_roi_if_entry_signal': config['ignore_roi_if_entry_signal'],
501
        **daily_stats,
502
        **trade_stats
503
    }
504

505
    try:
1✔
506
        max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown(
1✔
507
            results, value_col='profit_ratio')
508
        (drawdown_abs, drawdown_start, drawdown_end, high_val, low_val,
1✔
509
         max_drawdown) = calculate_max_drawdown(
510
             results, value_col='profit_abs', starting_balance=start_balance)
511
        # max_relative_drawdown = Underwater
512
        (_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown(
1✔
513
             results, value_col='profit_abs', starting_balance=start_balance, relative=True)
514

515
        strat_stats.update({
1✔
516
            'max_drawdown': max_drawdown_legacy,  # Deprecated - do not use
517
            'max_drawdown_account': max_drawdown,
518
            'max_relative_drawdown': max_relative_drawdown,
519
            'max_drawdown_abs': drawdown_abs,
520
            'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT),
521
            'drawdown_start_ts': drawdown_start.timestamp() * 1000,
522
            'drawdown_end': drawdown_end.strftime(DATETIME_PRINT_FORMAT),
523
            'drawdown_end_ts': drawdown_end.timestamp() * 1000,
524

525
            'max_drawdown_low': low_val,
526
            'max_drawdown_high': high_val,
527
        })
528

529
        csum_min, csum_max = calculate_csum(results, start_balance)
1✔
530
        strat_stats.update({
1✔
531
            'csum_min': csum_min,
532
            'csum_max': csum_max
533
        })
534

535
    except ValueError:
1✔
536
        strat_stats.update({
1✔
537
            'max_drawdown': 0.0,
538
            'max_drawdown_account': 0.0,
539
            'max_relative_drawdown': 0.0,
540
            'max_drawdown_abs': 0.0,
541
            'max_drawdown_low': 0.0,
542
            'max_drawdown_high': 0.0,
543
            'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
544
            'drawdown_start_ts': 0,
545
            'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
546
            'drawdown_end_ts': 0,
547
            'csum_min': 0,
548
            'csum_max': 0
549
        })
550

551
    return strat_stats
1✔
552

553

554
def generate_backtest_stats(btdata: Dict[str, DataFrame],
1✔
555
                            all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]],
556
                            min_date: datetime, max_date: datetime
557
                            ) -> Dict[str, Any]:
558
    """
559
    :param btdata: Backtest data
560
    :param all_results: backtest result - dictionary in the form:
561
                     { Strategy: {'results: results, 'config: config}}.
562
    :param min_date: Backtest start date
563
    :param max_date: Backtest end date
564
    :return: Dictionary containing results per strategy and a strategy summary.
565
    """
566
    result: Dict[str, Any] = {
1✔
567
        'metadata': {},
568
        'strategy': {},
569
        'strategy_comparison': [],
570
    }
571
    market_change = calculate_market_change(btdata, 'close')
1✔
572
    metadata = {}
1✔
573
    pairlist = list(btdata.keys())
1✔
574
    for strategy, content in all_results.items():
1✔
575
        strat_stats = generate_strategy_stats(pairlist, strategy, content,
1✔
576
                                              min_date, max_date, market_change=market_change)
577
        metadata[strategy] = {
1✔
578
            'run_id': content['run_id'],
579
            'backtest_start_time': content['backtest_start_time'],
580
        }
581
        result['strategy'][strategy] = strat_stats
1✔
582

583
    strategy_results = generate_strategy_comparison(bt_stats=result['strategy'])
1✔
584

585
    result['metadata'] = metadata
1✔
586
    result['strategy_comparison'] = strategy_results
1✔
587

588
    return result
1✔
589

590

591
###
592
# Start output section
593
###
594

595
def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: str) -> str:
1✔
596
    """
597
    Generates and returns a text table for the given backtest data and the results dataframe
598
    :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
599
    :param stake_currency: stake-currency - used to correctly name headers
600
    :return: pretty printed table with tabulate as string
601
    """
602

603
    headers = _get_line_header('Pair', stake_currency)
1✔
604
    floatfmt = _get_line_floatfmt(stake_currency)
1✔
605
    output = [[
1✔
606
        t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
607
        t['profit_total_pct'], t['duration_avg'],
608
        generate_wins_draws_losses(t['wins'], t['draws'], t['losses'])
609
    ] for t in pair_results]
610
    # Ignore type as floatfmt does allow tuples but mypy does not know that
611
    return tabulate(output, headers=headers,
1✔
612
                    floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
613

614

615
def text_table_exit_reason(exit_reason_stats: List[Dict[str, Any]], stake_currency: str) -> str:
1✔
616
    """
617
    Generate small table outlining Backtest results
618
    :param sell_reason_stats: Exit reason metrics
619
    :param stake_currency: Stakecurrency used
620
    :return: pretty printed table with tabulate as string
621
    """
622
    headers = [
1✔
623
        'Exit Reason',
624
        'Exits',
625
        'Win  Draws  Loss  Win%',
626
        'Avg Profit %',
627
        'Cum Profit %',
628
        f'Tot Profit {stake_currency}',
629
        'Tot Profit %',
630
    ]
631

632
    output = [[
1✔
633
        t.get('exit_reason', t.get('sell_reason')), t['trades'],
634
        generate_wins_draws_losses(t['wins'], t['draws'], t['losses']),
635
        t['profit_mean_pct'], t['profit_sum_pct'],
636
        round_coin_value(t['profit_total_abs'], stake_currency, False),
637
        t['profit_total_pct'],
638
    ] for t in exit_reason_stats]
639
    return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right")
1✔
640

641

642
def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_currency: str) -> str:
1✔
643
    """
644
    Generates and returns a text table for the given backtest data and the results dataframe
645
    :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
646
    :param stake_currency: stake-currency - used to correctly name headers
647
    :return: pretty printed table with tabulate as string
648
    """
649
    if (tag_type == "enter_tag"):
1✔
650
        headers = _get_line_header("TAG", stake_currency)
1✔
651
    else:
652
        headers = _get_line_header("TAG", stake_currency, 'Exits')
×
653
    floatfmt = _get_line_floatfmt(stake_currency)
1✔
654
    output = [
1✔
655
        [
656
            t['key'] if t['key'] is not None and len(
657
                t['key']) > 0 else "OTHER",
658
            t['trades'],
659
            t['profit_mean_pct'],
660
            t['profit_sum_pct'],
661
            t['profit_total_abs'],
662
            t['profit_total_pct'],
663
            t['duration_avg'],
664
            generate_wins_draws_losses(
665
                t['wins'],
666
                t['draws'],
667
                t['losses'])] for t in tag_results]
668
    # Ignore type as floatfmt does allow tuples but mypy does not know that
669
    return tabulate(output, headers=headers,
1✔
670
                    floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
671

672

673
def text_table_periodic_breakdown(days_breakdown_stats: List[Dict[str, Any]],
1✔
674
                                  stake_currency: str, period: str) -> str:
675
    """
676
    Generate small table with Backtest results by days
677
    :param days_breakdown_stats: Days breakdown metrics
678
    :param stake_currency: Stakecurrency used
679
    :return: pretty printed table with tabulate as string
680
    """
681
    headers = [
1✔
682
        period.capitalize(),
683
        f'Tot Profit {stake_currency}',
684
        'Wins',
685
        'Draws',
686
        'Losses',
687
    ]
688
    output = [[
1✔
689
        d['date'], round_coin_value(d['profit_abs'], stake_currency, False),
690
        d['wins'], d['draws'], d['loses'],
691
    ] for d in days_breakdown_stats]
692
    return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right")
1✔
693

694

695
def text_table_strategy(strategy_results, stake_currency: str) -> str:
1✔
696
    """
697
    Generate summary table per strategy
698
    :param strategy_results: Dict of <Strategyname: DataFrame> containing results for all strategies
699
    :param stake_currency: stake-currency - used to correctly name headers
700
    :return: pretty printed table with tabulate as string
701
    """
702
    floatfmt = _get_line_floatfmt(stake_currency)
1✔
703
    headers = _get_line_header('Strategy', stake_currency)
1✔
704
    # _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless
705
    # therefore we slip this column in only for strategy summary here.
706
    headers.append('Drawdown')
1✔
707

708
    # Align drawdown string on the center two space separator.
709
    if 'max_drawdown_account' in strategy_results[0]:
1✔
710
        drawdown = [f'{t["max_drawdown_account"] * 100:.2f}' for t in strategy_results]
1✔
711
    else:
712
        # Support for prior backtest results
713
        drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
×
714

715
    dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results])
1✔
716
    dd_pad_per = max([len(dd) for dd in drawdown])
1✔
717
    drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency}  {dd:>{dd_pad_per}}%'
1✔
718
                for t, dd in zip(strategy_results, drawdown)]
719

720
    output = [[
1✔
721
        t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
722
        t['profit_total_pct'], t['duration_avg'],
723
        generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown]
724
        for t, drawdown in zip(strategy_results, drawdown)]
725
    # Ignore type as floatfmt does allow tuples but mypy does not know that
726
    return tabulate(output, headers=headers,
1✔
727
                    floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
728

729

730
def text_table_add_metrics(strat_results: Dict) -> str:
1✔
731
    if len(strat_results['trades']) > 0:
1✔
732
        best_trade = max(strat_results['trades'], key=lambda x: x['profit_ratio'])
1✔
733
        worst_trade = min(strat_results['trades'], key=lambda x: x['profit_ratio'])
1✔
734

735
        short_metrics = [
1✔
736
            ('', ''),  # Empty line to improve readability
737
            ('Long / Short',
738
             f"{strat_results.get('trade_count_long', 'total_trades')} / "
739
             f"{strat_results.get('trade_count_short', 0)}"),
740
            ('Total profit Long %', f"{strat_results['profit_total_long']:.2%}"),
741
            ('Total profit Short %', f"{strat_results['profit_total_short']:.2%}"),
742
            ('Absolute profit Long', round_coin_value(strat_results['profit_total_long_abs'],
743
                                                      strat_results['stake_currency'])),
744
            ('Absolute profit Short', round_coin_value(strat_results['profit_total_short_abs'],
745
                                                       strat_results['stake_currency'])),
746
        ] if strat_results.get('trade_count_short', 0) > 0 else []
747

748
        drawdown_metrics = []
1✔
749
        if 'max_relative_drawdown' in strat_results:
1✔
750
            # Compatibility to show old hyperopt results
751
            drawdown_metrics.append(
1✔
752
                ('Max % of account underwater', f"{strat_results['max_relative_drawdown']:.2%}")
753
            )
754
        drawdown_metrics.extend([
1✔
755
            ('Absolute Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}")
756
            if 'max_drawdown_account' in strat_results else (
757
                'Drawdown', f"{strat_results['max_drawdown']:.2%}"),
758
            ('Absolute Drawdown', round_coin_value(strat_results['max_drawdown_abs'],
759
                                                   strat_results['stake_currency'])),
760
            ('Drawdown high', round_coin_value(strat_results['max_drawdown_high'],
761
                                               strat_results['stake_currency'])),
762
            ('Drawdown low', round_coin_value(strat_results['max_drawdown_low'],
763
                                              strat_results['stake_currency'])),
764
            ('Drawdown Start', strat_results['drawdown_start']),
765
            ('Drawdown End', strat_results['drawdown_end']),
766
        ])
767

768
        entry_adjustment_metrics = [
1✔
769
            ('Canceled Trade Entries', strat_results.get('canceled_trade_entries', 'N/A')),
770
            ('Canceled Entry Orders', strat_results.get('canceled_entry_orders', 'N/A')),
771
            ('Replaced Entry Orders', strat_results.get('replaced_entry_orders', 'N/A')),
772
        ] if strat_results.get('canceled_entry_orders', 0) > 0 else []
773

774
        # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
775
        # command stores these results and newer version of freqtrade must be able to handle old
776
        # results with missing new fields.
777
        metrics = [
1✔
778
            ('Backtesting from', strat_results['backtest_start']),
779
            ('Backtesting to', strat_results['backtest_end']),
780
            ('Max open trades', strat_results['max_open_trades']),
781
            ('', ''),  # Empty line to improve readability
782
            ('Total/Daily Avg Trades',
783
                f"{strat_results['total_trades']} / {strat_results['trades_per_day']}"),
784

785
            ('Starting balance', round_coin_value(strat_results['starting_balance'],
786
                                                  strat_results['stake_currency'])),
787
            ('Final balance', round_coin_value(strat_results['final_balance'],
788
                                               strat_results['stake_currency'])),
789
            ('Absolute profit ', round_coin_value(strat_results['profit_total_abs'],
790
                                                  strat_results['stake_currency'])),
791
            ('Total profit %', f"{strat_results['profit_total']:.2%}"),
792
            ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'),
793
            ('Sortino', f"{strat_results['sortino']:.2f}" if 'sortino' in strat_results else 'N/A'),
794
            ('Sharpe', f"{strat_results['sharpe']:.2f}" if 'sharpe' in strat_results else 'N/A'),
795
            ('Calmar', f"{strat_results['calmar']:.2f}" if 'calmar' in strat_results else 'N/A'),
796
            ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor'
797
                              in strat_results else 'N/A'),
798
            ('Expectancy', f"{strat_results['expectancy']:.2f}" if 'expectancy'
799
                           in strat_results else 'N/A'),
800
            ('Trades per day', strat_results['trades_per_day']),
801
            ('Avg. daily profit %',
802
             f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"),
803
            ('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'],
804
                                                   strat_results['stake_currency'])),
805
            ('Total trade volume', round_coin_value(strat_results['total_volume'],
806
                                                    strat_results['stake_currency'])),
807
            *short_metrics,
808
            ('', ''),  # Empty line to improve readability
809
            ('Best Pair', f"{strat_results['best_pair']['key']} "
810
                          f"{strat_results['best_pair']['profit_sum']:.2%}"),
811
            ('Worst Pair', f"{strat_results['worst_pair']['key']} "
812
                           f"{strat_results['worst_pair']['profit_sum']:.2%}"),
813
            ('Best trade', f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"),
814
            ('Worst trade', f"{worst_trade['pair']} "
815
                            f"{worst_trade['profit_ratio']:.2%}"),
816

817
            ('Best day', round_coin_value(strat_results['backtest_best_day_abs'],
818
                                          strat_results['stake_currency'])),
819
            ('Worst day', round_coin_value(strat_results['backtest_worst_day_abs'],
820
                                           strat_results['stake_currency'])),
821
            ('Days win/draw/lose', f"{strat_results['winning_days']} / "
822
                f"{strat_results['draw_days']} / {strat_results['losing_days']}"),
823
            ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"),
824
            ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"),
825
            ('Rejected Entry signals', strat_results.get('rejected_signals', 'N/A')),
826
            ('Entry/Exit Timeouts',
827
             f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
828
             f"{strat_results.get('timedout_exit_orders', 'N/A')}"),
829
            *entry_adjustment_metrics,
830
            ('', ''),  # Empty line to improve readability
831

832
            ('Min balance', round_coin_value(strat_results['csum_min'],
833
                                             strat_results['stake_currency'])),
834
            ('Max balance', round_coin_value(strat_results['csum_max'],
835
                                             strat_results['stake_currency'])),
836

837
            *drawdown_metrics,
838
            ('Market change', f"{strat_results['market_change']:.2%}"),
839
        ]
840

841
        return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl")
1✔
842
    else:
843
        start_balance = round_coin_value(strat_results['starting_balance'],
1✔
844
                                         strat_results['stake_currency'])
845
        stake_amount = round_coin_value(
1✔
846
            strat_results['stake_amount'], strat_results['stake_currency']
847
        ) if strat_results['stake_amount'] != UNLIMITED_STAKE_AMOUNT else 'unlimited'
848

849
        message = ("No trades made. "
1✔
850
                   f"Your starting balance was {start_balance}, "
851
                   f"and your stake was {stake_amount}."
852
                   )
853
        return message
1✔
854

855

856
def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str,
1✔
857
                         backtest_breakdown=[]):
858
    """
859
    Print results for one strategy
860
    """
861
    # Print results
862
    print(f"Result for strategy {strategy}")
1✔
863
    table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
1✔
864
    if isinstance(table, str):
1✔
865
        print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
1✔
866
    print(table)
1✔
867

868
    if (results.get('results_per_enter_tag') is not None
1✔
869
            or results.get('results_per_buy_tag') is not None):
870
        # results_per_buy_tag is deprecated and should be removed 2 versions after short golive.
871
        table = text_table_tags(
1✔
872
            "enter_tag",
873
            results.get('results_per_enter_tag', results.get('results_per_buy_tag')),
874
            stake_currency=stake_currency)
875

876
        if isinstance(table, str) and len(table) > 0:
1✔
877
            print(' ENTER TAG STATS '.center(len(table.splitlines()[0]), '='))
1✔
878
        print(table)
1✔
879

880
    exit_reasons = results.get('exit_reason_summary', results.get('sell_reason_summary'))
1✔
881
    table = text_table_exit_reason(exit_reason_stats=exit_reasons,
1✔
882
                                   stake_currency=stake_currency)
883
    if isinstance(table, str) and len(table) > 0:
1✔
884
        print(' EXIT REASON STATS '.center(len(table.splitlines()[0]), '='))
1✔
885
    print(table)
1✔
886

887
    table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
1✔
888
    if isinstance(table, str) and len(table) > 0:
1✔
889
        print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
1✔
890
    print(table)
1✔
891

892
    for period in backtest_breakdown:
1✔
893
        days_breakdown_stats = generate_periodic_breakdown_stats(
1✔
894
            trade_list=results['trades'], period=period)
895
        table = text_table_periodic_breakdown(days_breakdown_stats=days_breakdown_stats,
1✔
896
                                              stake_currency=stake_currency, period=period)
897
        if isinstance(table, str) and len(table) > 0:
1✔
898
            print(f' {period.upper()} BREAKDOWN '.center(len(table.splitlines()[0]), '='))
1✔
899
        print(table)
1✔
900

901
    table = text_table_add_metrics(results)
1✔
902
    if isinstance(table, str) and len(table) > 0:
1✔
903
        print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
1✔
904
    print(table)
1✔
905

906
    if isinstance(table, str) and len(table) > 0:
1✔
907
        print('=' * len(table.splitlines()[0]))
1✔
908

909
    print()
1✔
910

911

912
def show_backtest_results(config: Config, backtest_stats: Dict):
1✔
913
    stake_currency = config['stake_currency']
1✔
914

915
    for strategy, results in backtest_stats['strategy'].items():
1✔
916
        show_backtest_result(
1✔
917
            strategy, results, stake_currency,
918
            config.get('backtest_breakdown', []))
919

920
    if len(backtest_stats['strategy']) > 1:
1✔
921
        # Print Strategy summary table
922

923
        table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency)
1✔
924
        print(f"{results['backtest_start']} -> {results['backtest_end']} |"
1✔
925
              f" Max open trades : {results['max_open_trades']}")
926
        print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '='))
1✔
927
        print(table)
1✔
928
        print('=' * len(table.splitlines()[0]))
1✔
929
        print('\nFor more details, please look at the detail tables above')
1✔
930

931

932
def show_sorted_pairlist(config: Config, backtest_stats: Dict):
1✔
933
    if config.get('backtest_show_pair_list', False):
1✔
934
        for strategy, results in backtest_stats['strategy'].items():
1✔
935
            print(f"Pairs for Strategy {strategy}: \n[")
1✔
936
            for result in results['results_per_pair']:
1✔
937
                if result["key"] != 'TOTAL':
1✔
938
                    print(f'"{result["key"]}",  // {result["profit_mean"]:.2%}')
1✔
939
            print("]")
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc