mirror of
https://github.com/freqtrade/freqtrade.git
synced 2024-11-16 05:03:55 +00:00
519 lines
19 KiB
Python
519 lines
19 KiB
Python
import logging
|
|
from typing import Any, Literal, Union
|
|
|
|
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config
|
|
from freqtrade.ft_types import BacktestResultType
|
|
from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats
|
|
from freqtrade.util import decimals_per_coin, fmt_coin, print_rich_table
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_line_floatfmt(stake_currency: str) -> list[str]:
|
|
"""
|
|
Generate floatformat (goes in line with _generate_result_line())
|
|
"""
|
|
return ["s", "d", ".2f", f".{decimals_per_coin(stake_currency)}f", ".2f", "d", "s", "s"]
|
|
|
|
|
|
def _get_line_header(
|
|
first_column: Union[str, list[str]], stake_currency: str, direction: str = "Trades"
|
|
) -> list[str]:
|
|
"""
|
|
Generate header lines (goes in line with _generate_result_line())
|
|
"""
|
|
return [
|
|
*([first_column] if isinstance(first_column, str) else first_column),
|
|
direction,
|
|
"Avg Profit %",
|
|
f"Tot Profit {stake_currency}",
|
|
"Tot Profit %",
|
|
"Avg Duration",
|
|
"Win Draw Loss Win%",
|
|
]
|
|
|
|
|
|
def generate_wins_draws_losses(wins, draws, losses):
|
|
if wins > 0 and losses == 0:
|
|
wl_ratio = "100"
|
|
elif wins == 0:
|
|
wl_ratio = "0"
|
|
else:
|
|
wl_ratio = f"{100.0 / (wins + draws + losses) * wins:.1f}" if losses > 0 else "100"
|
|
return f"{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}"
|
|
|
|
|
|
def text_table_bt_results(
|
|
pair_results: list[dict[str, Any]], stake_currency: str, title: str
|
|
) -> None:
|
|
"""
|
|
Generates and returns a text table for the given backtest data and the results dataframe
|
|
:param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
|
|
:param stake_currency: stake-currency - used to correctly name headers
|
|
:param title: Title of the table
|
|
"""
|
|
|
|
headers = _get_line_header("Pair", stake_currency, "Trades")
|
|
output = [
|
|
[
|
|
t["key"],
|
|
t["trades"],
|
|
t["profit_mean_pct"],
|
|
f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
|
|
t["profit_total_pct"],
|
|
t["duration_avg"],
|
|
generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
|
|
]
|
|
for t in pair_results
|
|
]
|
|
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
|
print_rich_table(output, headers, summary=title)
|
|
|
|
|
|
def text_table_tags(
|
|
tag_type: Literal["enter_tag", "exit_tag", "mix_tag"],
|
|
tag_results: list[dict[str, Any]],
|
|
stake_currency: str,
|
|
) -> None:
|
|
"""
|
|
Generates and returns a text table for the given backtest data and the results dataframe
|
|
:param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
|
|
:param stake_currency: stake-currency - used to correctly name headers
|
|
"""
|
|
floatfmt = _get_line_floatfmt(stake_currency)
|
|
fallback: str = ""
|
|
is_list = False
|
|
if tag_type == "enter_tag":
|
|
title = "Enter Tag"
|
|
headers = _get_line_header(title, stake_currency, "Entries")
|
|
elif tag_type == "exit_tag":
|
|
title = "Exit Reason"
|
|
headers = _get_line_header(title, stake_currency, "Exits")
|
|
fallback = "exit_reason"
|
|
else:
|
|
# Mix tag
|
|
title = "Mixed Tag"
|
|
headers = _get_line_header(["Enter Tag", "Exit Reason"], stake_currency, "Trades")
|
|
floatfmt.insert(0, "s")
|
|
is_list = True
|
|
|
|
output = [
|
|
[
|
|
*(
|
|
(
|
|
(t["key"] if isinstance(t["key"], list) else [t["key"], ""])
|
|
if is_list
|
|
else [t["key"]]
|
|
)
|
|
if t.get("key") is not None and len(str(t["key"])) > 0
|
|
else [t.get(fallback, "OTHER")]
|
|
),
|
|
t["trades"],
|
|
t["profit_mean_pct"],
|
|
f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
|
|
t["profit_total_pct"],
|
|
t.get("duration_avg"),
|
|
generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
|
|
]
|
|
for t in tag_results
|
|
]
|
|
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
|
print_rich_table(output, headers, summary=f"{title.upper()} STATS")
|
|
|
|
|
|
def text_table_periodic_breakdown(
|
|
days_breakdown_stats: list[dict[str, Any]], stake_currency: str, period: str
|
|
) -> None:
|
|
"""
|
|
Generate small table with Backtest results by days
|
|
:param days_breakdown_stats: Days breakdown metrics
|
|
:param stake_currency: Stakecurrency used
|
|
"""
|
|
headers = [
|
|
period.capitalize(),
|
|
f"Tot Profit {stake_currency}",
|
|
"Wins",
|
|
"Draws",
|
|
"Losses",
|
|
]
|
|
output = [
|
|
[
|
|
d["date"],
|
|
fmt_coin(d["profit_abs"], stake_currency, False),
|
|
d["wins"],
|
|
d["draws"],
|
|
d["loses"],
|
|
]
|
|
for d in days_breakdown_stats
|
|
]
|
|
print_rich_table(output, headers, summary=f"{period.upper()} BREAKDOWN")
|
|
|
|
|
|
def text_table_strategy(strategy_results, stake_currency: str, title: str):
|
|
"""
|
|
Generate summary table per strategy
|
|
:param strategy_results: Dict of <Strategyname: DataFrame> containing results for all strategies
|
|
:param stake_currency: stake-currency - used to correctly name headers
|
|
"""
|
|
headers = _get_line_header("Strategy", stake_currency, "Trades")
|
|
# _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless
|
|
# therefore we slip this column in only for strategy summary here.
|
|
headers.append("Drawdown")
|
|
|
|
# Align drawdown string on the center two space separator.
|
|
if "max_drawdown_account" in strategy_results[0]:
|
|
drawdown = [f'{t["max_drawdown_account"] * 100:.2f}' for t in strategy_results]
|
|
else:
|
|
# Support for prior backtest results
|
|
drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
|
|
|
|
dd_pad_abs = max([len(t["max_drawdown_abs"]) for t in strategy_results])
|
|
dd_pad_per = max([len(dd) for dd in drawdown])
|
|
drawdown = [
|
|
f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%'
|
|
for t, dd in zip(strategy_results, drawdown)
|
|
]
|
|
|
|
output = [
|
|
[
|
|
t["key"],
|
|
t["trades"],
|
|
f"{t['profit_mean_pct']:.2f}",
|
|
f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
|
|
t["profit_total_pct"],
|
|
t["duration_avg"],
|
|
generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
|
|
drawdown,
|
|
]
|
|
for t, drawdown in zip(strategy_results, drawdown)
|
|
]
|
|
print_rich_table(output, headers, summary=title)
|
|
|
|
|
|
def text_table_add_metrics(strat_results: dict) -> None:
|
|
if len(strat_results["trades"]) > 0:
|
|
best_trade = max(strat_results["trades"], key=lambda x: x["profit_ratio"])
|
|
worst_trade = min(strat_results["trades"], key=lambda x: x["profit_ratio"])
|
|
|
|
short_metrics = (
|
|
[
|
|
("", ""), # Empty line to improve readability
|
|
(
|
|
"Long / Short",
|
|
f"{strat_results.get('trade_count_long', 'total_trades')} / "
|
|
f"{strat_results.get('trade_count_short', 0)}",
|
|
),
|
|
("Total profit Long %", f"{strat_results['profit_total_long']:.2%}"),
|
|
("Total profit Short %", f"{strat_results['profit_total_short']:.2%}"),
|
|
(
|
|
"Absolute profit Long",
|
|
fmt_coin(
|
|
strat_results["profit_total_long_abs"], strat_results["stake_currency"]
|
|
),
|
|
),
|
|
(
|
|
"Absolute profit Short",
|
|
fmt_coin(
|
|
strat_results["profit_total_short_abs"], strat_results["stake_currency"]
|
|
),
|
|
),
|
|
]
|
|
if strat_results.get("trade_count_short", 0) > 0
|
|
else []
|
|
)
|
|
|
|
drawdown_metrics = []
|
|
if "max_relative_drawdown" in strat_results:
|
|
# Compatibility to show old hyperopt results
|
|
drawdown_metrics.append(
|
|
("Max % of account underwater", f"{strat_results['max_relative_drawdown']:.2%}")
|
|
)
|
|
drawdown_metrics.extend(
|
|
[
|
|
(
|
|
("Absolute Drawdown (Account)", f"{strat_results['max_drawdown_account']:.2%}")
|
|
if "max_drawdown_account" in strat_results
|
|
else ("Drawdown", f"{strat_results['max_drawdown']:.2%}")
|
|
),
|
|
(
|
|
"Absolute Drawdown",
|
|
fmt_coin(strat_results["max_drawdown_abs"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Drawdown high",
|
|
fmt_coin(strat_results["max_drawdown_high"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Drawdown low",
|
|
fmt_coin(strat_results["max_drawdown_low"], strat_results["stake_currency"]),
|
|
),
|
|
("Drawdown Start", strat_results["drawdown_start"]),
|
|
("Drawdown End", strat_results["drawdown_end"]),
|
|
]
|
|
)
|
|
|
|
entry_adjustment_metrics = (
|
|
[
|
|
("Canceled Trade Entries", strat_results.get("canceled_trade_entries", "N/A")),
|
|
("Canceled Entry Orders", strat_results.get("canceled_entry_orders", "N/A")),
|
|
("Replaced Entry Orders", strat_results.get("replaced_entry_orders", "N/A")),
|
|
]
|
|
if strat_results.get("canceled_entry_orders", 0) > 0
|
|
else []
|
|
)
|
|
|
|
trading_mode = (
|
|
(
|
|
[
|
|
(
|
|
"Trading Mode",
|
|
(
|
|
""
|
|
if not strat_results.get("margin_mode")
|
|
or strat_results.get("trading_mode", "spot") == "spot"
|
|
else f"{strat_results['margin_mode'].capitalize()} "
|
|
)
|
|
+ f"{strat_results['trading_mode'].capitalize()}",
|
|
)
|
|
]
|
|
)
|
|
if "trading_mode" in strat_results
|
|
else []
|
|
)
|
|
|
|
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
|
|
# command stores these results and newer version of freqtrade must be able to handle old
|
|
# results with missing new fields.
|
|
metrics = [
|
|
("Backtesting from", strat_results["backtest_start"]),
|
|
("Backtesting to", strat_results["backtest_end"]),
|
|
*trading_mode,
|
|
("Max open trades", strat_results["max_open_trades"]),
|
|
("", ""), # Empty line to improve readability
|
|
(
|
|
"Total/Daily Avg Trades",
|
|
f"{strat_results['total_trades']} / {strat_results['trades_per_day']}",
|
|
),
|
|
(
|
|
"Starting balance",
|
|
fmt_coin(strat_results["starting_balance"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Final balance",
|
|
fmt_coin(strat_results["final_balance"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Absolute profit ",
|
|
fmt_coin(strat_results["profit_total_abs"], 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 (Ratio)",
|
|
(
|
|
f"{strat_results['expectancy']:.2f} ({strat_results['expectancy_ratio']:.2f})"
|
|
if "expectancy_ratio" in strat_results
|
|
else "N/A"
|
|
),
|
|
),
|
|
(
|
|
"Avg. daily profit %",
|
|
f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}",
|
|
),
|
|
(
|
|
"Avg. stake amount",
|
|
fmt_coin(strat_results["avg_stake_amount"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Total trade volume",
|
|
fmt_coin(strat_results["total_volume"], strat_results["stake_currency"]),
|
|
),
|
|
*short_metrics,
|
|
("", ""), # Empty line to improve readability
|
|
(
|
|
"Best Pair",
|
|
f"{strat_results['best_pair']['key']} "
|
|
f"{strat_results['best_pair']['profit_total']:.2%}",
|
|
),
|
|
(
|
|
"Worst Pair",
|
|
f"{strat_results['worst_pair']['key']} "
|
|
f"{strat_results['worst_pair']['profit_total']:.2%}",
|
|
),
|
|
("Best trade", f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"),
|
|
("Worst trade", f"{worst_trade['pair']} {worst_trade['profit_ratio']:.2%}"),
|
|
(
|
|
"Best day",
|
|
fmt_coin(strat_results["backtest_best_day_abs"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Worst day",
|
|
fmt_coin(strat_results["backtest_worst_day_abs"], strat_results["stake_currency"]),
|
|
),
|
|
(
|
|
"Days win/draw/lose",
|
|
f"{strat_results['winning_days']} / "
|
|
f"{strat_results['draw_days']} / {strat_results['losing_days']}",
|
|
),
|
|
("Avg. Duration Winners", f"{strat_results['winner_holding_avg']}"),
|
|
("Avg. Duration Loser", f"{strat_results['loser_holding_avg']}"),
|
|
(
|
|
"Max Consecutive Wins / Loss",
|
|
(
|
|
(
|
|
f"{strat_results['max_consecutive_wins']} / "
|
|
f"{strat_results['max_consecutive_losses']}"
|
|
)
|
|
if "max_consecutive_losses" in strat_results
|
|
else "N/A"
|
|
),
|
|
),
|
|
("Rejected Entry signals", strat_results.get("rejected_signals", "N/A")),
|
|
(
|
|
"Entry/Exit Timeouts",
|
|
f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
|
|
f"{strat_results.get('timedout_exit_orders', 'N/A')}",
|
|
),
|
|
*entry_adjustment_metrics,
|
|
("", ""), # Empty line to improve readability
|
|
("Min balance", fmt_coin(strat_results["csum_min"], strat_results["stake_currency"])),
|
|
("Max balance", fmt_coin(strat_results["csum_max"], strat_results["stake_currency"])),
|
|
*drawdown_metrics,
|
|
("Market change", f"{strat_results['market_change']:.2%}"),
|
|
]
|
|
print_rich_table(metrics, ["Metric", "Value"], summary="SUMMARY METRICS", justify="left")
|
|
|
|
else:
|
|
start_balance = fmt_coin(strat_results["starting_balance"], strat_results["stake_currency"])
|
|
stake_amount = (
|
|
fmt_coin(strat_results["stake_amount"], strat_results["stake_currency"])
|
|
if strat_results["stake_amount"] != UNLIMITED_STAKE_AMOUNT
|
|
else "unlimited"
|
|
)
|
|
|
|
message = (
|
|
"No trades made. "
|
|
f"Your starting balance was {start_balance}, "
|
|
f"and your stake was {stake_amount}."
|
|
)
|
|
print(message)
|
|
|
|
|
|
def _show_tag_subresults(results: dict[str, Any], stake_currency: str):
|
|
"""
|
|
Print tag subresults (enter_tag, exit_reason_summary, mix_tag_stats)
|
|
"""
|
|
if (enter_tags := results.get("results_per_enter_tag")) is not None:
|
|
text_table_tags("enter_tag", enter_tags, stake_currency)
|
|
|
|
if (exit_reasons := results.get("exit_reason_summary")) is not None:
|
|
text_table_tags("exit_tag", exit_reasons, stake_currency)
|
|
|
|
if (mix_tag := results.get("mix_tag_stats")) is not None:
|
|
text_table_tags("mix_tag", mix_tag, stake_currency)
|
|
|
|
|
|
def show_backtest_result(
|
|
strategy: str, results: dict[str, Any], stake_currency: str, backtest_breakdown: list[str]
|
|
):
|
|
"""
|
|
Print results for one strategy
|
|
"""
|
|
# Print results
|
|
print(f"Result for strategy {strategy}")
|
|
text_table_bt_results(
|
|
results["results_per_pair"], stake_currency=stake_currency, title="BACKTESTING REPORT"
|
|
)
|
|
text_table_bt_results(
|
|
results["left_open_trades"], stake_currency=stake_currency, title="LEFT OPEN TRADES REPORT"
|
|
)
|
|
|
|
_show_tag_subresults(results, stake_currency)
|
|
|
|
for period in backtest_breakdown:
|
|
if period in results.get("periodic_breakdown", {}):
|
|
days_breakdown_stats = results["periodic_breakdown"][period]
|
|
else:
|
|
days_breakdown_stats = generate_periodic_breakdown_stats(
|
|
trade_list=results["trades"], period=period
|
|
)
|
|
text_table_periodic_breakdown(
|
|
days_breakdown_stats=days_breakdown_stats, stake_currency=stake_currency, period=period
|
|
)
|
|
|
|
text_table_add_metrics(results)
|
|
|
|
print()
|
|
|
|
|
|
def show_backtest_results(config: Config, backtest_stats: BacktestResultType):
|
|
stake_currency = config["stake_currency"]
|
|
|
|
for strategy, results in backtest_stats["strategy"].items():
|
|
show_backtest_result(
|
|
strategy, results, stake_currency, config.get("backtest_breakdown", [])
|
|
)
|
|
|
|
if len(backtest_stats["strategy"]) > 0:
|
|
# Print Strategy summary table
|
|
|
|
print(
|
|
f"Backtested {results['backtest_start']} -> {results['backtest_end']} |"
|
|
f" Max open trades : {results['max_open_trades']}"
|
|
)
|
|
text_table_strategy(
|
|
backtest_stats["strategy_comparison"], stake_currency, "STRATEGY SUMMARY"
|
|
)
|
|
|
|
|
|
def show_sorted_pairlist(config: Config, backtest_stats: BacktestResultType):
|
|
if config.get("backtest_show_pair_list", False):
|
|
for strategy, results in backtest_stats["strategy"].items():
|
|
print(f"Pairs for Strategy {strategy}: \n[")
|
|
for result in results["results_per_pair"]:
|
|
if result["key"] != "TOTAL":
|
|
print(f'"{result["key"]}", // {result["profit_mean"]:.2%}')
|
|
print("]")
|
|
|
|
|
|
def generate_edge_table(results: dict) -> None:
|
|
tabular_data = []
|
|
headers = [
|
|
"Pair",
|
|
"Stoploss",
|
|
"Win Rate",
|
|
"Risk Reward Ratio",
|
|
"Required Risk Reward",
|
|
"Expectancy",
|
|
"Total Number of Trades",
|
|
"Average Duration (min)",
|
|
]
|
|
|
|
for result in results.items():
|
|
if result[1].nb_trades > 0:
|
|
tabular_data.append(
|
|
[
|
|
result[0],
|
|
f"{result[1].stoploss:.10g}",
|
|
f"{result[1].winrate:.2f}",
|
|
f"{result[1].risk_reward_ratio:.2f}",
|
|
f"{result[1].required_risk_reward:.2f}",
|
|
f"{result[1].expectancy:.2f}",
|
|
result[1].nb_trades,
|
|
round(result[1].avg_trade_duration),
|
|
]
|
|
)
|
|
print_rich_table(tabular_data, headers, summary="EDGE TABLE")
|