diff --git a/freqtrade/data/metrics.py b/freqtrade/data/metrics.py index cf59c330a21..6d4e405cbad 100644 --- a/freqtrade/data/metrics.py +++ b/freqtrade/data/metrics.py @@ -334,7 +334,10 @@ def calculate_expectancy(trades: pd.DataFrame) -> tuple[float, float]: def calculate_sortino( - trades: pd.DataFrame, min_date: datetime, max_date: datetime, starting_balance: float + trades: pd.DataFrame, + min_date: datetime | None, + max_date: datetime | None, + starting_balance: float, ) -> float: """ Calculate sortino @@ -362,7 +365,10 @@ def calculate_sortino( def calculate_sharpe( - trades: pd.DataFrame, min_date: datetime, max_date: datetime, starting_balance: float + trades: pd.DataFrame, + min_date: datetime | None, + max_date: datetime | None, + starting_balance: float, ) -> float: """ Calculate sharpe @@ -389,7 +395,10 @@ def calculate_sharpe( def calculate_calmar( - trades: pd.DataFrame, min_date: datetime, max_date: datetime, starting_balance: float + trades: pd.DataFrame, + min_date: datetime | None, + max_date: datetime | None, + starting_balance: float, ) -> float: """ Calculate calmar diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 0dcd95edf0c..1783c9ebf90 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -157,6 +157,11 @@ class Profit(BaseModel): winrate: float expectancy: float expectancy_ratio: float + sharpe: float + sortino: float + sqn: float + calmar: float + cagr: float max_drawdown: float max_drawdown_abs: float max_drawdown_start: str diff --git a/freqtrade/rpc/fiat_convert.py b/freqtrade/rpc/fiat_convert.py index 16d64dd502b..4ae5842d575 100644 --- a/freqtrade/rpc/fiat_convert.py +++ b/freqtrade/rpc/fiat_convert.py @@ -5,7 +5,6 @@ import logging from datetime import datetime -from typing import Any from requests.exceptions import RequestException @@ -13,6 +12,7 @@ from freqtrade.mixins.logging_mixin import LoggingMixin from freqtrade.util import FtTTLCache from freqtrade.util.coin_gecko import FtCoinGeckoApi +from freqtrade.util.singleton import SingletonMeta logger = logging.getLogger(__name__) @@ -32,26 +32,16 @@ } -class CryptoToFiatConverter(LoggingMixin): +class CryptoToFiatConverter(LoggingMixin, metaclass=SingletonMeta): """ Main class to initiate Crypto to FIAT. This object contains a list of pair Crypto, FIAT This object is also a Singleton """ - __instance = None - _coinlistings: list[dict] = [] _backoff: float = 0.0 - def __new__(cls, *args: Any, **kwargs: Any) -> Any: - """ - Singleton pattern to ensure only one instance is created. - """ - if not cls.__instance: - cls.__instance = super().__new__(cls) - return cls.__instance - def __init__(self, config: Config) -> None: # Timeout: 6h self._pair_price: FtTTLCache = FtTTLCache(maxsize=500, ttl=6 * 60 * 60) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 81bb52ce5c9..fc15cfda021 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -19,7 +19,16 @@ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DEFAULT_DATAFRAME_COLUMNS, Config from freqtrade.data.history import load_data -from freqtrade.data.metrics import DrawDownResult, calculate_expectancy, calculate_max_drawdown +from freqtrade.data.metrics import ( + DrawDownResult, + calculate_cagr, + calculate_calmar, + calculate_expectancy, + calculate_max_drawdown, + calculate_sharpe, + calculate_sortino, + calculate_sqn, +) from freqtrade.enums import ( CandleType, ExitCheckTuple, @@ -689,6 +698,34 @@ def _rpc_trade_statistics( last_date = trades[-1].open_date_utc if trades else None num = float(len(durations) or 1) bot_start = KeyValueStore.get_datetime_value("bot_start_time") + + sharpe = calculate_sharpe( + trades=trades_df, + min_date=first_date, + max_date=last_date, + starting_balance=starting_balance, + ) + sortino = calculate_sortino( + trades=trades_df, + min_date=first_date, + max_date=last_date, + starting_balance=starting_balance, + ) + sqn = calculate_sqn(trades=trades_df, starting_balance=starting_balance) + calmar = calculate_calmar( + trades=trades_df, + min_date=first_date, + max_date=last_date, + starting_balance=starting_balance, + ) + current_balance = self._freqtrade.wallets.get_total_stake_amount() + days_passed = max(1, (last_date - first_date).days) if first_date and last_date else 1 + cagr = calculate_cagr( + starting_balance=starting_balance, + final_balance=current_balance, + days_passed=days_passed, + ) + return { "profit_closed_coin": profit_closed_coin_sum, "profit_closed_percent_mean": round(profit_closed_ratio_mean * 100, 2), @@ -725,6 +762,11 @@ def _rpc_trade_statistics( "winrate": winrate, "expectancy": expectancy, "expectancy_ratio": expectancy_ratio, + "sharpe": sharpe, + "sortino": sortino, + "sqn": sqn, + "calmar": calmar, + "cagr": cagr, "max_drawdown": drawdown.relative_account_drawdown, "max_drawdown_abs": drawdown.drawdown_abs, "max_drawdown_start": format_date(drawdown.high_date), diff --git a/freqtrade/util/singleton.py b/freqtrade/util/singleton.py new file mode 100644 index 00000000000..91841cdecfc --- /dev/null +++ b/freqtrade/util/singleton.py @@ -0,0 +1,16 @@ +from typing import Any + + +class SingletonMeta(type): + """ + A thread-safe implementation of Singleton. + Use as metaclass to create singleton classes. + """ + + _instances: dict = {} + + def __call__(cls, *args: Any, **kwargs: Any) -> Any: + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] diff --git a/tests/rpc/test_fiat_convert.py b/tests/rpc/test_fiat_convert.py index 4ae7441c8e5..8d58113f099 100644 --- a/tests/rpc/test_fiat_convert.py +++ b/tests/rpc/test_fiat_convert.py @@ -12,6 +12,14 @@ from tests.conftest import log_has, log_has_re +@pytest.fixture(autouse=True) +def reset_singleton_instance(): + # Reset the singleton instance before each test + CryptoToFiatConverter._instances = {} + yield + CryptoToFiatConverter._instances = {} + + def test_fiat_convert_is_singleton(): fiat_convert = CryptoToFiatConverter({"a": 22}) fiat_convert2 = CryptoToFiatConverter({}) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index f288cd0ba3d..cdcbd331a19 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -230,11 +230,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: def test_rpc_status_table(default_conf, ticker, fee, mocker, time_machine) -> None: time_machine.move_to("2024-05-10 11:15:00 +00:00", tick=False) - mocker.patch.multiple( - "freqtrade.rpc.fiat_convert.FtCoinGeckoApi", - get_price=MagicMock(return_value={"bitcoin": {"usd": 15000.0}}), - ) - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) + mocker.patch("freqtrade.rpc.telegram.Telegram", MagicMock()) mocker.patch.multiple( EXMS, @@ -279,6 +275,8 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker, time_machine) -> No # Test with fiat convert rpc._config["fiat_display_currency"] = "USD" rpc._fiat_converter = CryptoToFiatConverter({}) + mocker.patch.object(rpc._fiat_converter, "get_price", return_value=15000.0) + result, headers, fiat_profit_sum, total_sum = rpc._rpc_status_table( default_conf["stake_currency"], "USD" ) @@ -447,7 +445,6 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short): def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) mocker.patch("freqtrade.rpc.telegram.Telegram", MagicMock()) mocker.patch.multiple( EXMS, @@ -461,6 +458,7 @@ def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: rpc = RPC(freqtradebot) rpc._fiat_converter = CryptoToFiatConverter({}) + mocker.patch.object(rpc._fiat_converter, "get_price", return_value=1.1) res = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) assert res["trade_count"] == 0 @@ -520,11 +518,6 @@ def test_rpc_balance_handle_error(default_conf, mocker): } # ETH will be skipped due to mocked Error below - mocker.patch.multiple( - "freqtrade.rpc.fiat_convert.FtCoinGeckoApi", - get_price=MagicMock(return_value={"bitcoin": {"usd": 15000.0}}), - ) - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) mocker.patch("freqtrade.rpc.telegram.Telegram", MagicMock()) mocker.patch.multiple( EXMS, @@ -536,6 +529,7 @@ def test_rpc_balance_handle_error(default_conf, mocker): patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) rpc._fiat_converter = CryptoToFiatConverter({}) + mocker.patch.object(rpc._fiat_converter, "get_price", return_value=15000.0) res = rpc._rpc_balance(default_conf["stake_currency"], default_conf["fiat_display_currency"]) assert res["stake"] == "BTC" @@ -606,11 +600,6 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers, proxy_coin, marg } ] - mocker.patch.multiple( - "freqtrade.rpc.fiat_convert.FtCoinGeckoApi", - get_price=MagicMock(return_value={"bitcoin": {"usd": 1.2}}), - ) - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.2) mocker.patch("freqtrade.rpc.telegram.Telegram", MagicMock()) mocker.patch.multiple( EXMS, @@ -629,6 +618,7 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers, proxy_coin, marg patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) rpc._fiat_converter = CryptoToFiatConverter({}) + mocker.patch.object(rpc._fiat_converter, "get_price", return_value=1.2) result = rpc._rpc_balance( default_conf_usdt["stake_currency"], default_conf_usdt["fiat_display_currency"] diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index e0944e872bc..6042a9f0d31 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1199,6 +1199,11 @@ def test_api_logs(botclient): "winrate": 0.0, "expectancy": -0.0033695635, "expectancy_ratio": -1.0, + "cagr": -0.0024567404889381805, + "calmar": -1910.497317469542, + "sharpe": -58.138247358830355, + "sortino": -58.138247358830355, + "sqn": -1.5215, "trading_volume": 75.945, }, ), @@ -1231,6 +1236,11 @@ def test_api_logs(botclient): "winrate": 1.0, "expectancy": 0.0003695635, "expectancy_ratio": 100, + "cagr": 0.0002698167695580622, + "calmar": -100.0, + "sharpe": 65.81269184917424, + "sortino": -100.0, + "sqn": 1.7224, "trading_volume": 75.945, }, ), @@ -1263,6 +1273,11 @@ def test_api_logs(botclient): "winrate": 0.5, "expectancy": -0.0027145635000000003, "expectancy_ratio": -0.48612137582114445, + "cagr": -0.0019796559404918757, + "calmar": -1857.4671689202785, + "sharpe": -36.14602907243071, + "sortino": -100.0, + "sqn": -0.946, "trading_volume": 75.945, }, ), @@ -1326,6 +1341,11 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, is_short, expected) "winrate": expected["winrate"], "expectancy": expected["expectancy"], "expectancy_ratio": expected["expectancy_ratio"], + "sharpe": expected["sharpe"], + "sortino": expected["sortino"], + "sqn": expected["sqn"], + "calmar": expected["calmar"], + "cagr": expected["cagr"], "max_drawdown": ANY, "max_drawdown_abs": ANY, "max_drawdown_start": ANY, diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 49245230b63..fdcac65041f 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -119,7 +119,7 @@ async def dummy_exception(self, *args, **kwargs) -> None: raise Exception("test") -def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None): +def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None, mock_fiat=True): msg_mock = AsyncMock() if mock: mocker.patch.multiple( @@ -131,6 +131,9 @@ def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None): if not ftbot: ftbot = get_patched_freqtradebot(mocker, default_conf) rpc = RPC(ftbot) + if rpc._fiat_converter is not None and mock_fiat: + mocker.patch.object(rpc._fiat_converter, "get_price", return_value=1.1) + telegram = Telegram(rpc, default_conf) telegram._loop = MagicMock() patch_eventloop_threading(telegram) @@ -667,7 +670,6 @@ async def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> async def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -750,7 +752,6 @@ async def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: async def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: default_conf_usdt["max_open_trades"] = 1 - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -821,7 +822,6 @@ async def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, tim async def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: default_conf_usdt["max_open_trades"] = 1 - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -903,7 +903,6 @@ async def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, ti async def test_telegram_profit_handle( default_conf_usdt, update, ticker_usdt, ticker_sell_up, fee, limit_sell_order_usdt, mocker ) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) mocker.patch.multiple( EXMS, fetch_ticker=ticker_usdt, @@ -983,7 +982,6 @@ async def test_telegram_profit_long_short_handle( is consistent with /profit, covering both no trades and trades present cases. """ - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) mocker.patch.multiple(EXMS, fetch_ticker=ticker_usdt, get_fee=fee) telegram, _freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) @@ -1062,7 +1060,6 @@ async def test_telegram_profit_long_short_handle( @pytest.mark.parametrize("is_short", [True, False]) async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -1341,7 +1338,6 @@ async def test_reload_config_handle(default_conf, update, mocker) -> None: async def test_telegram_forceexit_handle( default_conf, update, ticker, fee, ticker_sell_up, mocker ) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) msg_mock = mocker.patch("freqtrade.rpc.telegram.Telegram.send_msg", MagicMock()) mocker.patch("freqtrade.rpc.telegram.Telegram._init", MagicMock()) patch_exchange(mocker) @@ -1411,9 +1407,6 @@ async def test_telegram_forceexit_handle( async def test_telegram_force_exit_down_handle( default_conf, update, ticker, fee, ticker_sell_down, mocker ) -> None: - mocker.patch( - "freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price", return_value=15000.0 - ) msg_mock = mocker.patch("freqtrade.rpc.telegram.Telegram.send_msg", MagicMock()) mocker.patch("freqtrade.rpc.telegram.Telegram._init", MagicMock()) patch_exchange(mocker) @@ -1484,9 +1477,6 @@ async def test_telegram_force_exit_down_handle( async def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None: patch_exchange(mocker) - mocker.patch( - "freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price", return_value=15000.0 - ) msg_mock = mocker.patch("freqtrade.rpc.telegram.Telegram.send_msg", MagicMock()) mocker.patch("freqtrade.rpc.telegram.Telegram._init", MagicMock()) patch_whitelist(mocker, default_conf) @@ -1549,10 +1539,6 @@ async def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) - async def test_forceexit_handle_invalid(default_conf, update, mocker) -> None: - mocker.patch( - "freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price", return_value=15000.0 - ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) @@ -1630,8 +1616,6 @@ async def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> async def test_force_enter_handle(default_conf, update, mocker) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) - fbuy_mock = MagicMock(return_value=None) mocker.patch("freqtrade.rpc.rpc.RPC._rpc_force_entry", fbuy_mock) @@ -1663,8 +1647,6 @@ async def test_force_enter_handle(default_conf, update, mocker) -> None: async def test_force_enter_handle_exception(default_conf, update, mocker) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) @@ -1675,10 +1657,7 @@ async def test_force_enter_handle_exception(default_conf, update, mocker) -> Non async def test_force_enter_no_pair(default_conf, update, mocker) -> None: - mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0) - - fbuy_mock = MagicMock(return_value=None) - mocker.patch("freqtrade.rpc.rpc.RPC._rpc_force_entry", fbuy_mock) + fbuy_mock = mocker.patch("freqtrade.rpc.rpc.RPC._rpc_force_entry", return_value=None) telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) @@ -2241,7 +2220,9 @@ def test_send_msg_enter_notification( "analyzed_candle": {"open": 1.1, "high": 2.2, "low": 1.0, "close": 1.5}, "open_date": dt_now() + timedelta(hours=-1), } - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject( + mocker, default_conf, mock_fiat=False + ) telegram.send_msg(msg) leverage_text = f" ({leverage:.3g}x)" if leverage and leverage != 1.0 else "" @@ -2347,7 +2328,7 @@ def test_send_msg_entry_fill_notification( default_conf, mocker, message_type, entered, enter_signal, leverage ) -> None: default_conf["telegram"]["notification_settings"]["entry_fill"] = "on" - telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf, mock_fiat=False) telegram.send_msg( {