diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index 40c98973355..8f4a8d6c56c 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -388,8 +388,10 @@ def refresh_backtest_ohlcv_data( for timeframe in timeframes: # Get fast candles via parallel method on first loop through per timeframe # and candle type. Downloads all the pairs in the list and stores them. + # Also skips if only 1 pair/timeframe combination is scheduled for download. if ( not no_parallel_download + and (len(pairs) + len(timeframes)) > 2 and exchange.get_option("download_data_parallel_quick", True) and ( ((pair, timeframe, candle_type) not in fast_candles) @@ -474,7 +476,7 @@ def _download_all_pairs_history_parallel( :return: Candle pairs with timeframes """ candles: dict[PairWithTimeframe, DataFrame] = {} - since = 0 + since: int | None = None if timerange: if timerange.starttype == "date": since = timerange.startts * 1000 @@ -482,10 +484,12 @@ def _download_all_pairs_history_parallel( candle_limit = exchange.ohlcv_candle_limit(timeframe, candle_type) one_call_min_time_dt = dt_ts(date_minus_candles(timeframe, candle_limit)) # check if we can get all candles in one go, if so then we can download them in parallel - if since > one_call_min_time_dt: + if since is None or since > one_call_min_time_dt: logger.info( - f"Downloading parallel candles for {timeframe} for all pairs " - f"since {format_ms_time(since)}" + f"Downloading parallel candles for {timeframe} for all pairs" + f" since {format_ms_time(since)}" + if since + else "." ) needed_pairs: ListPairsWithTimeframes = [ (p, timeframe, candle_type) for p in [p for p in pairs] diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 075e6eb0e2f..22e457eef49 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -47,6 +47,7 @@ dt_ts, dt_ts_def, format_date, + format_pct, shorten_date, ) from freqtrade.wallets import PositionWallet, Wallet @@ -302,7 +303,7 @@ def _rpc_status_table( fiat_total_profit_sum = nan for trade in self._rpc_trade_status(): # Format profit as a string with the right sign - profit = f"{trade['profit_ratio']:.2%}" + profit = f"{format_pct(trade['profit_ratio'])}" fiat_profit = trade.get("profit_fiat", None) if fiat_profit is None or isnan(fiat_profit): fiat_profit = trade.get("profit_abs", 0.0) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 69c76b77687..151d6daecb1 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -48,6 +48,7 @@ fmt_coin, fmt_coin2, format_date, + format_pct, round_value, ) @@ -481,7 +482,7 @@ def _format_exit_msg(self, msg: RPCExitMsg) -> str: if is_final_exit: profit_prefix = "Sub " cp_extra = ( - f"*Final Profit:* `{msg['final_profit_ratio']:.2%} " + f"*Final Profit:* `{format_pct(msg['final_profit_ratio'])} " f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n" ) else: @@ -497,7 +498,7 @@ def _format_exit_msg(self, msg: RPCExitMsg) -> str: f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n" f"{self._add_analyzed_candle(msg['pair'])}" f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* " - f"`{msg['profit_ratio']:.2%}{profit_extra}`\n" + f"`{format_pct(msg['profit_ratio'])}{profit_extra}`\n" f"{cp_extra}" f"{enter_tag}" f"*Exit Reason:* `{msg['exit_reason']}`\n" @@ -677,7 +678,7 @@ def _prepare_order_details(self, filled_orders: list, quote_currency: str, is_op ) lines.append( f"*Average {wording} Price:* {round_value(cur_entry_average, 8)} " - f"({price_to_1st_entry:.2%} from 1st entry rate)" + f"({format_pct(price_to_1st_entry)} from 1st entry rate)" ) lines.append(f"*Order Filled:* {order['order_filled_date']}") @@ -800,21 +801,23 @@ async def _status_msg(self, update: Update, context: CallbackContext) -> None: else "" ), ("*Unrealized Profit:* " if r["is_open"] else "*Close Profit: *") - + f"`{r['profit_ratio']:.2%}` `({r['profit_abs_r']})`", + + f"`{format_pct(r['profit_ratio'])}` `({r['profit_abs_r']})`", ] ) if r["is_open"]: - if r.get("realized_profit"): - lines.extend( - [ - f"*Realized Profit:* `{r['realized_profit_ratio']:.2%} " - f"({r['realized_profit_r']})`", - ( - f"*Total Profit:* `{r['total_profit_ratio']:.2%} " - f"({r['total_profit_abs_r']})`" - ), - ] + if ( + r.get("realized_profit") is not None + and r.get("realized_profit_ratio") is not None + ): + lines.append( + f"*Realized Profit:* `{format_pct(r['realized_profit_ratio'])} " + f"({r['realized_profit_r']})`" + ) + if r.get("total_profit_ratio") is not None: + lines.append( + f"*Total Profit:* `{format_pct(r['total_profit_ratio'])} " + f"({r['total_profit_abs_r']})`" ) # Append empty line to improve readability @@ -830,17 +833,17 @@ async def _status_msg(self, update: Update, context: CallbackContext) -> None: # Adding initial stoploss only if it is different from stoploss lines.append( f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` " - f"`({r['initial_stop_loss_ratio']:.2%})`" + f"`({format_pct(r['initial_stop_loss_ratio'])})`" ) # Adding stoploss and stoploss percentage only if it is not None lines.append( f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` " - + (f"`({r['stop_loss_ratio']:.2%})`" if r["stop_loss_ratio"] else "") + + (f"`({format_pct(r['stop_loss_ratio'])})`" if r["stop_loss_ratio"] else "") ) lines.append( f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` " - f"`({r['stoploss_current_dist_ratio']:.2%})`" + f"`({format_pct(r['stoploss_current_dist_ratio'])})`" ) if open_orders := r.get("open_orders"): lines.append( @@ -951,7 +954,7 @@ async def _timeunit_stats(self, update: Update, context: CallbackContext, unit: f"{period['date']:{val.dateformat}} ({period['trade_count']})", f"{fmt_coin(period['abs_profit'], stats['stake_currency'])}", f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}", - f"{period['rel_profit']:.2%}", + f"{format_pct(period['rel_profit'])}", ] for period in stats["data"] ], @@ -1067,7 +1070,7 @@ def _format_profit_message( markdown_msg = ( f"{closed_roi_label}\n" f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " - f"({profit_closed_ratio_mean:.2%}) " + f"({format_pct(profit_closed_ratio_mean)}) " f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" f"{fiat_closed_trades}" ) @@ -1080,7 +1083,7 @@ def _format_profit_message( markdown_msg += ( f"{all_roi_label}\n" f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " - f"({profit_all_ratio_mean:.2%}) " + f"({format_pct(profit_all_ratio_mean)}) " f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" f"{fiat_all_trades}" f"*Total Trade Count:* `{trade_count}`\n" @@ -1089,7 +1092,7 @@ def _format_profit_message( f"`{first_trade_date}`\n" f"*Latest Trade opened:* `{latest_trade_date}`\n" f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n" - f"*Winrate:* `{winrate:.2%}`\n" + f"*Winrate:* `{format_pct(winrate)}`\n" f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`" ) @@ -1097,16 +1100,16 @@ def _format_profit_message( markdown_msg += ( f"\n*Avg. Duration:* `{avg_duration}`\n" f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} " - f"({best_pair_profit_ratio:.2%})`\n" + f"({format_pct(best_pair_profit_ratio)})`\n" f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n" f"*Profit factor:* `{stats['profit_factor']:.2f}`\n" - f"*Max Drawdown:* `{stats['max_drawdown']:.2%} " + f"*Max Drawdown:* `{format_pct(stats['max_drawdown'])} " f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n" f" from `{stats['max_drawdown_start']} " f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n" f" to `{stats['max_drawdown_end']} " f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n" - f"*Current Drawdown:* `{stats['current_drawdown']:.2%} " + f"*Current Drawdown:* `{format_pct(stats['current_drawdown'])} " f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n" f" from `{stats['current_drawdown_start']} " f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n" @@ -1559,7 +1562,7 @@ async def _trades(self, update: Update, context: CallbackContext) -> None: dt_humanize_delta(dt_from_ts(trade["close_timestamp"])), f"{trade['pair']} (#{trade['trade_id']}" f"{(' ' + ('S' if trade['is_short'] else 'L')) if nonspot else ''})", - f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})", + f"{format_pct(trade['close_profit'])} ({trade['close_profit_abs']})", ] for trade in trades["trades"] ], @@ -1623,7 +1626,7 @@ async def _performance(self, update: Update, context: CallbackContext) -> None: stat_line = ( f"{i + 1}.\t {trade['pair']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_ratio']:.2%}) " + f"({format_pct(trade['profit_ratio'])}) " f"({trade['count']})\n" ) @@ -1660,7 +1663,7 @@ async def _enter_tag_performance(self, update: Update, context: CallbackContext) stat_line = ( f"{i + 1}.\t `{trade['enter_tag']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_ratio']:.2%}) " + f"({format_pct(trade['profit_ratio'])}) " f"({trade['count']})`\n" ) @@ -1697,7 +1700,7 @@ async def _exit_reason_performance(self, update: Update, context: CallbackContex stat_line = ( f"{i + 1}.\t `{trade['exit_reason']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_ratio']:.2%}) " + f"({format_pct(trade['profit_ratio'])}) " f"({trade['count']})`\n" ) @@ -1734,7 +1737,7 @@ async def _mix_tag_performance(self, update: Update, context: CallbackContext) - stat_line = ( f"{i + 1}.\t `{trade['mix_tag']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_ratio']:.2%}) " + f"({format_pct(trade['profit_ratio'])}) " f"({trade['count']})`\n" ) diff --git a/freqtrade/util/__init__.py b/freqtrade/util/__init__.py index 98fda93cd1e..de892338956 100644 --- a/freqtrade/util/__init__.py +++ b/freqtrade/util/__init__.py @@ -18,6 +18,7 @@ fmt_coin, fmt_coin2, format_duration, + format_pct, round_value, ) from freqtrade.util.ft_precise import FtPrecise @@ -44,6 +45,7 @@ "format_date", "format_ms_time", "format_ms_time_det", + "format_pct", "get_dry_run_wallet", "FtPrecise", "PeriodicCache", diff --git a/freqtrade/util/formatters.py b/freqtrade/util/formatters.py index caac5a68b7e..3d7493a2a88 100644 --- a/freqtrade/util/formatters.py +++ b/freqtrade/util/formatters.py @@ -1,5 +1,7 @@ from datetime import timedelta +from numpy import isnan + from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN @@ -29,6 +31,8 @@ def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str: :param keep_trailing_zeros: Keep trailing zeros "222.200" vs. "222.2" :return: Rounded value as string """ + if isnan(value): + return "N/A" val = f"{value:.{decimals}f}" if not keep_trailing_zeros: val = strip_trailing_zeros(val) @@ -80,3 +84,15 @@ def format_duration(td: timedelta) -> str: h, r = divmod(td.seconds, 3600) m, _ = divmod(r, 60) return f"{d}d {h:02d}:{m:02d}" + + +def format_pct(value: float | None) -> str: + """ + Format a float value as percentage string with 2 decimals + None and NaN values are formatted as "N/A" + :param value: Float value to format + :return: Formatted percentage string + """ + if value is None or isnan(value): + return "N/A" + return f"{value:.2%}" diff --git a/tests/data/test_history.py b/tests/data/test_history.py index de216eec9ab..2dc3204db0d 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -582,6 +582,23 @@ def parallel_mock(pairs, timeframe, candle_type, **kwargs): assert log_has_re(r"Downloading pair ETH/BTC, funding_rate, interval 8h\.", caplog) assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 4h\.", caplog) + # Test with only one pair - no parallel download should happen 1 pair/timeframe combination + # doesn't justify parallelization + parallel_mock.reset_mock() + dl_mock.reset_mock() + refresh_backtest_ohlcv_data( + exchange=ex, + pairs=[ + "ETH/BTC", + ], + timeframes=["5m"], + datadir=testdatadir, + timerange=timerange, + erase=False, + trading_mode=trademode, + ) + assert parallel_mock.call_count == 0 + def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir): dl_mock = mocker.patch( @@ -780,6 +797,7 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt): exchange.refresh_latest_ohlcv.reset_mock() # Test without timerange + # expected to call refresh_latest_ohlcv - as we can't know how much will be required. result3 = _download_all_pairs_history_parallel( exchange=exchange, pairs=pairs, @@ -787,8 +805,8 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt): candle_type=candle_type, timerange=None, ) - assert result3 == {} - assert exchange.refresh_latest_ohlcv.call_count == 0 + assert result3 == expected + assert exchange.refresh_latest_ohlcv.call_count == 1 def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path, caplog) -> None: diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index d2d666cbc8a..40e2b826648 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -310,7 +310,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker, time_machine) -> No ) assert "now" == result[0][2] assert "ETH/BTC" in result[0][1] - assert "nan%" == result[0][3] + assert "N/A" == result[0][3] assert isnan(fiat_profit_sum) diff --git a/tests/util/test_formatters.py b/tests/util/test_formatters.py index 8872137a006..a884c07506c 100644 --- a/tests/util/test_formatters.py +++ b/tests/util/test_formatters.py @@ -1,6 +1,13 @@ from datetime import timedelta -from freqtrade.util import decimals_per_coin, fmt_coin, fmt_coin2, format_duration, round_value +from freqtrade.util import ( + decimals_per_coin, + fmt_coin, + fmt_coin2, + format_duration, + format_pct, + round_value, +) def test_decimals_per_coin(): @@ -25,6 +32,7 @@ def test_fmt_coin(): assert fmt_coin(0.1274512123, "BTC", False) == "0.12745121" assert fmt_coin(0.1274512123, "ETH", False) == "0.12745" assert fmt_coin(222.2, "USDT", False, True) == "222.200" + assert fmt_coin(float("nan"), "USDT", False, True) == "N/A" def test_fmt_coin2(): @@ -35,6 +43,7 @@ def test_fmt_coin2(): assert fmt_coin2(0.1274512123, "BTC") == "0.12745121 BTC" assert fmt_coin2(0.1274512123, "ETH") == "0.12745121 ETH" assert fmt_coin2(0.00001245, "PEPE") == "0.00001245 PEPE" + assert fmt_coin2(float("nan"), "PEPE") == "N/A PEPE" def test_round_value(): @@ -46,6 +55,8 @@ def test_round_value(): assert round_value(0.1274512123, 5) == "0.12745" assert round_value(222.2, 3, True) == "222.200" assert round_value(222.2, 0, True) == "222" + assert round_value(float("nan"), 0, True) == "N/A" + assert round_value(float("nan"), 10, True) == "N/A" def test_format_duration(): @@ -55,3 +66,13 @@ def test_format_duration(): assert format_duration(timedelta(minutes=1445)) == "1d 00:05" assert format_duration(timedelta(minutes=11445)) == "7d 22:45" assert format_duration(timedelta(minutes=101445)) == "70d 10:45" + + +def test_format_pct(): + assert format_pct(0.1234) == "12.34%" + assert format_pct(0.1) == "10.00%" + assert format_pct(0.0) == "0.00%" + assert format_pct(-0.0567) == "-5.67%" + assert format_pct(-1.5567) == "-155.67%" + assert format_pct(None) == "N/A" + assert format_pct(float("nan")) == "N/A"