Merge pull request #10417 from freqtrade/feat/rich_tables_bt

Backtest tables -> Rich
This commit is contained in:
Matthias 2024-07-10 06:48:26 +02:00 committed by GitHub
commit dcedc1c652
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 69 additions and 94 deletions

View File

@ -52,4 +52,4 @@ class EdgeCli:
result = self.edge.calculate(self.config["exchange"]["pair_whitelist"]) result = self.edge.calculate(self.config["exchange"]["pair_whitelist"])
if result: if result:
print("") # blank line for readability print("") # blank line for readability
print(generate_edge_table(self.edge._cached_pairs)) generate_edge_table(self.edge._cached_pairs)

View File

@ -1,12 +1,10 @@
import logging import logging
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Literal, Union
from tabulate import tabulate
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config
from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats
from freqtrade.types import BacktestResultType from freqtrade.types import BacktestResultType
from freqtrade.util import decimals_per_coin, fmt_coin from freqtrade.util import decimals_per_coin, fmt_coin, print_rich_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -46,22 +44,23 @@ def generate_wins_draws_losses(wins, draws, losses):
return f"{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}" 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) -> str: 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 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 pair_results: List of Dictionaries - one entry per pair + final TOTAL row
:param stake_currency: stake-currency - used to correctly name headers :param stake_currency: stake-currency - used to correctly name headers
:return: pretty printed table with tabulate as string :param title: Title of the table
""" """
headers = _get_line_header("Pair", stake_currency, "Trades") headers = _get_line_header("Pair", stake_currency, "Trades")
floatfmt = _get_line_floatfmt(stake_currency)
output = [ output = [
[ [
t["key"], t["key"],
t["trades"], t["trades"],
t["profit_mean_pct"], t["profit_mean_pct"],
t["profit_total_abs"], f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
t["profit_total_pct"], t["profit_total_pct"],
t["duration_avg"], t["duration_avg"],
generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]), generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
@ -69,26 +68,32 @@ def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: st
for t in pair_results for t in pair_results
] ]
# Ignore type as floatfmt does allow tuples but mypy does not know that # Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") print_rich_table(output, headers, summary=title)
def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_currency: str) -> str: 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 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 pair_results: List of Dictionaries - one entry per pair + final TOTAL row
:param stake_currency: stake-currency - used to correctly name headers :param stake_currency: stake-currency - used to correctly name headers
:return: pretty printed table with tabulate as string
""" """
floatfmt = _get_line_floatfmt(stake_currency) floatfmt = _get_line_floatfmt(stake_currency)
fallback: str = "" fallback: str = ""
is_list = False is_list = False
if tag_type == "enter_tag": if tag_type == "enter_tag":
headers = _get_line_header("Enter Tag", stake_currency, "Entries") title = "Enter Tag"
headers = _get_line_header(title, stake_currency, "Entries")
elif tag_type == "exit_tag": elif tag_type == "exit_tag":
headers = _get_line_header("Exit Reason", stake_currency, "Exits") title = "Exit Reason"
headers = _get_line_header(title, stake_currency, "Exits")
fallback = "exit_reason" fallback = "exit_reason"
else: else:
# Mix tag # Mix tag
title = "Mixed Tag"
headers = _get_line_header(["Enter Tag", "Exit Reason"], stake_currency, "Trades") headers = _get_line_header(["Enter Tag", "Exit Reason"], stake_currency, "Trades")
floatfmt.insert(0, "s") floatfmt.insert(0, "s")
is_list = True is_list = True
@ -106,7 +111,7 @@ def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_curr
), ),
t["trades"], t["trades"],
t["profit_mean_pct"], t["profit_mean_pct"],
t["profit_total_abs"], f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
t["profit_total_pct"], t["profit_total_pct"],
t.get("duration_avg"), t.get("duration_avg"),
generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]), generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
@ -114,17 +119,16 @@ def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_curr
for t in tag_results for t in tag_results
] ]
# Ignore type as floatfmt does allow tuples but mypy does not know that # Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") print_rich_table(output, headers, summary=f"{title.upper()} STATS")
def text_table_periodic_breakdown( def text_table_periodic_breakdown(
days_breakdown_stats: List[Dict[str, Any]], stake_currency: str, period: str days_breakdown_stats: List[Dict[str, Any]], stake_currency: str, period: str
) -> str: ) -> None:
""" """
Generate small table with Backtest results by days Generate small table with Backtest results by days
:param days_breakdown_stats: Days breakdown metrics :param days_breakdown_stats: Days breakdown metrics
:param stake_currency: Stakecurrency used :param stake_currency: Stakecurrency used
:return: pretty printed table with tabulate as string
""" """
headers = [ headers = [
period.capitalize(), period.capitalize(),
@ -143,17 +147,15 @@ def text_table_periodic_breakdown(
] ]
for d in days_breakdown_stats for d in days_breakdown_stats
] ]
return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") print_rich_table(output, headers, summary=f"{period.upper()} BREAKDOWN")
def text_table_strategy(strategy_results, stake_currency: str) -> str: def text_table_strategy(strategy_results, stake_currency: str, title: str):
""" """
Generate summary table per strategy Generate summary table per strategy
:param strategy_results: Dict of <Strategyname: DataFrame> containing results for all strategies :param strategy_results: Dict of <Strategyname: DataFrame> containing results for all strategies
:param stake_currency: stake-currency - used to correctly name headers :param stake_currency: stake-currency - used to correctly name headers
:return: pretty printed table with tabulate as string
""" """
floatfmt = _get_line_floatfmt(stake_currency)
headers = _get_line_header("Strategy", stake_currency, "Trades") headers = _get_line_header("Strategy", stake_currency, "Trades")
# _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless # _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. # therefore we slip this column in only for strategy summary here.
@ -177,8 +179,8 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
[ [
t["key"], t["key"],
t["trades"], t["trades"],
t["profit_mean_pct"], f"{t['profit_mean_pct']:.2f}",
t["profit_total_abs"], f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
t["profit_total_pct"], t["profit_total_pct"],
t["duration_avg"], t["duration_avg"],
generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]), generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
@ -186,11 +188,10 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
] ]
for t, drawdown in zip(strategy_results, drawdown) for t, drawdown in zip(strategy_results, drawdown)
] ]
# Ignore type as floatfmt does allow tuples but mypy does not know that print_rich_table(output, headers, summary=title)
return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
def text_table_add_metrics(strat_results: Dict) -> str: def text_table_add_metrics(strat_results: Dict) -> None:
if len(strat_results["trades"]) > 0: if len(strat_results["trades"]) > 0:
best_trade = max(strat_results["trades"], key=lambda x: x["profit_ratio"]) best_trade = max(strat_results["trades"], key=lambda x: x["profit_ratio"])
worst_trade = min(strat_results["trades"], key=lambda x: x["profit_ratio"]) worst_trade = min(strat_results["trades"], key=lambda x: x["profit_ratio"])
@ -372,8 +373,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
*drawdown_metrics, *drawdown_metrics,
("Market change", f"{strat_results['market_change']:.2%}"), ("Market change", f"{strat_results['market_change']:.2%}"),
] ]
print_rich_table(metrics, ["Metric", "Value"], summary="SUMMARY METRICS", justify="left")
return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl")
else: else:
start_balance = fmt_coin(strat_results["starting_balance"], strat_results["stake_currency"]) start_balance = fmt_coin(strat_results["starting_balance"], strat_results["stake_currency"])
stake_amount = ( stake_amount = (
@ -387,7 +388,7 @@ def text_table_add_metrics(strat_results: Dict) -> str:
f"Your starting balance was {start_balance}, " f"Your starting balance was {start_balance}, "
f"and your stake was {stake_amount}." f"and your stake was {stake_amount}."
) )
return message print(message)
def _show_tag_subresults(results: Dict[str, Any], stake_currency: str): def _show_tag_subresults(results: Dict[str, Any], stake_currency: str):
@ -395,25 +396,13 @@ def _show_tag_subresults(results: Dict[str, Any], stake_currency: str):
Print tag subresults (enter_tag, exit_reason_summary, mix_tag_stats) Print tag subresults (enter_tag, exit_reason_summary, mix_tag_stats)
""" """
if (enter_tags := results.get("results_per_enter_tag")) is not None: if (enter_tags := results.get("results_per_enter_tag")) is not None:
table = text_table_tags("enter_tag", enter_tags, stake_currency) text_table_tags("enter_tag", enter_tags, stake_currency)
if isinstance(table, str) and len(table) > 0:
print(" ENTER TAG STATS ".center(len(table.splitlines()[0]), "="))
print(table)
if (exit_reasons := results.get("exit_reason_summary")) is not None: if (exit_reasons := results.get("exit_reason_summary")) is not None:
table = text_table_tags("exit_tag", exit_reasons, stake_currency) text_table_tags("exit_tag", exit_reasons, stake_currency)
if isinstance(table, str) and len(table) > 0:
print(" EXIT REASON STATS ".center(len(table.splitlines()[0]), "="))
print(table)
if (mix_tag := results.get("mix_tag_stats")) is not None: if (mix_tag := results.get("mix_tag_stats")) is not None:
table = text_table_tags("mix_tag", mix_tag, stake_currency) text_table_tags("mix_tag", mix_tag, stake_currency)
if isinstance(table, str) and len(table) > 0:
print(" MIXED TAG STATS ".center(len(table.splitlines()[0]), "="))
print(table)
def show_backtest_result( def show_backtest_result(
@ -424,15 +413,12 @@ def show_backtest_result(
""" """
# Print results # Print results
print(f"Result for strategy {strategy}") print(f"Result for strategy {strategy}")
table = text_table_bt_results(results["results_per_pair"], stake_currency=stake_currency) text_table_bt_results(
if isinstance(table, str): results["results_per_pair"], stake_currency=stake_currency, title="BACKTESTING REPORT"
print(" BACKTESTING REPORT ".center(len(table.splitlines()[0]), "=")) )
print(table) text_table_bt_results(
results["left_open_trades"], stake_currency=stake_currency, title="LEFT OPEN TRADES REPORT"
table = text_table_bt_results(results["left_open_trades"], stake_currency=stake_currency) )
if isinstance(table, str) and len(table) > 0:
print(" LEFT OPEN TRADES REPORT ".center(len(table.splitlines()[0]), "="))
print(table)
_show_tag_subresults(results, stake_currency) _show_tag_subresults(results, stake_currency)
@ -443,20 +429,11 @@ def show_backtest_result(
days_breakdown_stats = generate_periodic_breakdown_stats( days_breakdown_stats = generate_periodic_breakdown_stats(
trade_list=results["trades"], period=period trade_list=results["trades"], period=period
) )
table = text_table_periodic_breakdown( text_table_periodic_breakdown(
days_breakdown_stats=days_breakdown_stats, stake_currency=stake_currency, period=period days_breakdown_stats=days_breakdown_stats, stake_currency=stake_currency, period=period
) )
if isinstance(table, str) and len(table) > 0:
print(f" {period.upper()} BREAKDOWN ".center(len(table.splitlines()[0]), "="))
print(table)
table = text_table_add_metrics(results) text_table_add_metrics(results)
if isinstance(table, str) and len(table) > 0:
print(" SUMMARY METRICS ".center(len(table.splitlines()[0]), "="))
print(table)
if isinstance(table, str) and len(table) > 0:
print("=" * len(table.splitlines()[0]))
print() print()
@ -472,15 +449,13 @@ def show_backtest_results(config: Config, backtest_stats: BacktestResultType):
if len(backtest_stats["strategy"]) > 0: if len(backtest_stats["strategy"]) > 0:
# Print Strategy summary table # Print Strategy summary table
table = text_table_strategy(backtest_stats["strategy_comparison"], stake_currency)
print( print(
f"Backtested {results['backtest_start']} -> {results['backtest_end']} |" f"Backtested {results['backtest_start']} -> {results['backtest_end']} |"
f" Max open trades : {results['max_open_trades']}" f" Max open trades : {results['max_open_trades']}"
) )
print(" STRATEGY SUMMARY ".center(len(table.splitlines()[0]), "=")) text_table_strategy(
print(table) backtest_stats["strategy_comparison"], stake_currency, "STRATEGY SUMMARY"
print("=" * len(table.splitlines()[0])) )
print("\nFor more details, please look at the detail tables above")
def show_sorted_pairlist(config: Config, backtest_stats: BacktestResultType): def show_sorted_pairlist(config: Config, backtest_stats: BacktestResultType):
@ -493,8 +468,7 @@ def show_sorted_pairlist(config: Config, backtest_stats: BacktestResultType):
print("]") print("]")
def generate_edge_table(results: dict) -> str: def generate_edge_table(results: dict) -> None:
floatfmt = ("s", ".10g", ".2f", ".2f", ".2f", ".2f", "d", "d", "d")
tabular_data = [] tabular_data = []
headers = [ headers = [
"Pair", "Pair",
@ -512,17 +486,13 @@ def generate_edge_table(results: dict) -> str:
tabular_data.append( tabular_data.append(
[ [
result[0], result[0],
result[1].stoploss, f"{result[1].stoploss:.10g}",
result[1].winrate, f"{result[1].winrate:.2f}",
result[1].risk_reward_ratio, f"{result[1].risk_reward_ratio:.2f}",
result[1].required_risk_reward, f"{result[1].required_risk_reward:.2f}",
result[1].expectancy, f"{result[1].expectancy:.2f}",
result[1].nb_trades, result[1].nb_trades,
round(result[1].avg_trade_duration), round(result[1].avg_trade_duration),
] ]
) )
print_rich_table(tabular_data, headers, summary="EDGE TABLE")
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(
tabular_data, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right"
)

View File

@ -15,10 +15,11 @@ def print_rich_table(
headers: Sequence[str], headers: Sequence[str],
summary: Optional[str] = None, summary: Optional[str] = None,
*, *,
justify="right",
table_kwargs: Optional[Dict[str, Any]] = None, table_kwargs: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
table = Table( table = Table(
*[c if isinstance(c, Column) else Column(c, justify="right") for c in headers], *[c if isinstance(c, Column) else Column(c, justify=justify) for c in headers],
title=summary, title=summary,
**(table_kwargs or {}), **(table_kwargs or {}),
) )

View File

@ -59,7 +59,7 @@ def _backup_file(file: Path, copy_file: bool = False) -> None:
copyfile(file_swp, file) copyfile(file_swp, file)
def test_text_table_bt_results(): def test_text_table_bt_results(capsys):
results = pd.DataFrame( results = pd.DataFrame(
{ {
"pair": ["ETH/BTC", "ETH/BTC", "ETH/BTC"], "pair": ["ETH/BTC", "ETH/BTC", "ETH/BTC"],
@ -72,7 +72,8 @@ def test_text_table_bt_results():
pair_results = generate_pair_metrics( pair_results = generate_pair_metrics(
["ETH/BTC"], stake_currency="BTC", starting_balance=4, results=results ["ETH/BTC"], stake_currency="BTC", starting_balance=4, results=results
) )
text = text_table_bt_results(pair_results, stake_currency="BTC") text_table_bt_results(pair_results, stake_currency="BTC", title="title")
text = capsys.readouterr().out
re.search( re.search(
r".* Pair .* Trades .* Avg Profit % .* Tot Profit BTC .* Tot Profit % .* " r".* Pair .* Trades .* Avg Profit % .* Tot Profit BTC .* Tot Profit % .* "
r"Avg Duration .* Win Draw Loss Win% .*", r"Avg Duration .* Win Draw Loss Win% .*",
@ -435,7 +436,7 @@ def test_calc_streak(testdatadir):
assert calc_streak(bt_data) == (7, 18) assert calc_streak(bt_data) == (7, 18)
def test_text_table_exit_reason(): def test_text_table_exit_reason(capsys):
results = pd.DataFrame( results = pd.DataFrame(
{ {
"pair": ["ETH/BTC", "ETH/BTC", "ETH/BTC"], "pair": ["ETH/BTC", "ETH/BTC", "ETH/BTC"],
@ -452,7 +453,8 @@ def test_text_table_exit_reason():
exit_reason_stats = generate_tag_metrics( exit_reason_stats = generate_tag_metrics(
"exit_reason", starting_balance=22, results=results, skip_nan=False "exit_reason", starting_balance=22, results=results, skip_nan=False
) )
text = text_table_tags("exit_tag", exit_reason_stats, "BTC") text_table_tags("exit_tag", exit_reason_stats, "BTC")
text = capsys.readouterr().out
assert re.search( assert re.search(
r".* Exit Reason .* Exits .* Avg Profit % .* Tot Profit BTC .* Tot Profit % .* " r".* Exit Reason .* Exits .* Avg Profit % .* Tot Profit BTC .* Tot Profit % .* "
@ -460,11 +462,11 @@ def test_text_table_exit_reason():
text, text,
) )
assert re.search( assert re.search(
r".* roi .* 2 .* 15.00 .* 0.60000000 .* 2.73 .* 0:20:00 .* 2 0 0 100 .*", r".* roi .* 2 .* 15.0 .* 0.60000000 .* 2.73 .* 0:20:00 .* 2 0 0 100 .*",
text, text,
) )
assert re.search( assert re.search(
r".* stop_loss .* 1 .* -10.00 .* -0.20000000 .* -0.91 .* 0:10:00 .* 0 0 1 0 .*", r".* stop_loss .* 1 .* -10.0 .* -0.20000000 .* -0.91 .* 0:10:00 .* 0 0 1 0 .*",
text, text,
) )
assert re.search( assert re.search(
@ -507,7 +509,7 @@ def test_generate_sell_reason_stats():
assert stop_result["profit_mean_pct"] == round(stop_result["profit_mean"] * 100, 2) assert stop_result["profit_mean_pct"] == round(stop_result["profit_mean"] * 100, 2)
def test_text_table_strategy(testdatadir): def test_text_table_strategy(testdatadir, capsys):
filename = testdatadir / "backtest_results/backtest-result_multistrat.json" filename = testdatadir / "backtest_results/backtest-result_multistrat.json"
bt_res_data = load_backtest_stats(filename) bt_res_data = load_backtest_stats(filename)
@ -515,8 +517,10 @@ def test_text_table_strategy(testdatadir):
strategy_results = generate_strategy_comparison(bt_stats=bt_res_data["strategy"]) strategy_results = generate_strategy_comparison(bt_stats=bt_res_data["strategy"])
assert strategy_results == bt_res_data_comparison assert strategy_results == bt_res_data_comparison
text = text_table_strategy(strategy_results, "BTC") text_table_strategy(strategy_results, "BTC", "STRATEGY SUMMARY")
captured = capsys.readouterr()
text = captured.out
assert re.search( assert re.search(
r".* Strategy .* Trades .* Avg Profit % .* Tot Profit BTC .* Tot Profit % .* " r".* Strategy .* Trades .* Avg Profit % .* Tot Profit BTC .* Tot Profit % .* "
r"Avg Duration .* Win Draw Loss Win% .* Drawdown .*", r"Avg Duration .* Win Draw Loss Win% .* Drawdown .*",
@ -534,12 +538,12 @@ def test_text_table_strategy(testdatadir):
) )
def test_generate_edge_table(): def test_generate_edge_table(capsys):
results = {} results = {}
results["ETH/BTC"] = PairInfo(-0.01, 0.60, 2, 1, 3, 10, 60) results["ETH/BTC"] = PairInfo(-0.01, 0.60, 2, 1, 3, 10, 60)
text = generate_edge_table(results) generate_edge_table(results)
assert text.count("+") == 7 text = capsys.readouterr().out
assert text.count("| ETH/BTC |") == 1 assert re.search(r".* ETH/BTC .*", text)
assert re.search(r".* Risk Reward Ratio .* Required Risk Reward .* Expectancy .*", text) assert re.search(r".* Risk Reward Ratio .* Required Risk Reward .* Expectancy .*", text)