Merge branch 'develop' into fix/orderflow_cache

This commit is contained in:
Matthias
2024-12-11 19:27:39 +01:00
54 changed files with 1404 additions and 401 deletions

View File

@@ -17,8 +17,8 @@ repos:
- types-cachetools==5.5.0.20240820
- types-filelock==3.2.7
- types-requests==2.32.0.20241016
- types-tabulate==0.9.0.20240106
- types-python-dateutil==2.9.0.20241003
- types-tabulate==0.9.0.20241207
- types-python-dateutil==2.9.0.20241206
- SQLAlchemy==2.0.36
# stages: [push]
@@ -31,7 +31,7 @@ repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.8.1'
rev: 'v0.8.2'
hooks:
- id: ruff
- id: ruff-format
@@ -56,6 +56,11 @@ repos:
.*\.md
)$
- repo: https://github.com/stefmolin/exif-stripper
rev: 0.6.1
hooks:
- id: strip-exif
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:

View File

@@ -102,8 +102,17 @@
},
"dry_run_wallet": {
"description": "Initial wallet balance for dry run mode.",
"type": "number",
"default": 1000
"type": [
"number",
"object"
],
"default": 1000,
"patternProperties": {
"^[a-zA-Z0-9]+$": {
"type": "number"
}
},
"additionalProperties": false
},
"cancel_open_orders_on_exit": {
"description": "Cancel open orders when exiting.",

View File

@@ -39,6 +39,7 @@ class SuperDuperHyperOptLoss(IHyperOptLoss):
config: Config,
processed: dict[str, DataFrame],
backtest_stats: dict[str, Any],
starting_balance: float,
**kwargs,
) -> float:
"""
@@ -70,6 +71,7 @@ Currently, the arguments are:
* `config`: Config object used (Note: Not all strategy-related parameters will be updated here if they are part of a hyperopt space).
* `processed`: Dict of Dataframes with the pair as keys containing the data used for backtesting.
* `backtest_stats`: Backtesting statistics using the same format as the backtesting file "strategy" substructure. Available fields can be seen in `generate_strategy_stats()` in `optimize_reports.py`.
* `starting_balance`: Starting balance used for backtesting.
This function needs to return a floating point number (`float`). Smaller numbers will be interpreted as better results. The parameters and balancing for this is up to you.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -39,13 +39,19 @@ Please note that Environment variables will overwrite corresponding settings in
Common example:
```
``` bash
FREQTRADE__TELEGRAM__CHAT_ID=<telegramchatid>
FREQTRADE__TELEGRAM__TOKEN=<telegramToken>
FREQTRADE__EXCHANGE__KEY=<yourExchangeKey>
FREQTRADE__EXCHANGE__SECRET=<yourExchangeSecret>
```
Json lists are parsed as json - so you can use the following to set a list of pairs:
``` bash
export FREQTRADE__EXCHANGE__PAIR_WHITELIST='["BTC/USDT", "ETH/USDT"]'
```
!!! Note
Environment variables detected are logged at startup - so if you can't find why a value is not what you think it should be based on the configuration, make sure it's not loaded from an environment variable.
@@ -54,7 +60,7 @@ FREQTRADE__EXCHANGE__SECRET=<yourExchangeSecret>
??? Warning "Loading sequence"
Environment variables are loaded after the initial configuration. As such, you cannot provide the path to the configuration through environment variables. Please use `--config path/to/config.json` for that.
This also applies to user_dir to some degree. while the user directory can be set through environment variables - the configuration will **not** be loaded from that location.
This also applies to `user_dir` to some degree. while the user directory can be set through environment variables - the configuration will **not** be loaded from that location.
### Multiple configuration files
@@ -168,7 +174,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `timeframe` | The timeframe to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). Usually missing in configuration, and specified in the strategy. [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** String
| `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency). <br> **Datatype:** String
| `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in Dry Run mode.<br>*Defaults to `1000`.* <br> **Datatype:** Float
| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in Dry Run mode. [More information below](#dry-run-wallet)<br>*Defaults to `1000`.* <br> **Datatype:** Float or Dict
| `cancel_open_orders_on_exit` | Cancel open orders when the `/stop` RPC command is issued, `Ctrl+C` is pressed or the bot dies unexpectedly. When set to `true`, this allows you to use `/stop` to cancel unfilled and partially filled orders in the event of a market crash. It does not impact open positions. <br>*Defaults to `false`.* <br> **Datatype:** Boolean
| `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `minimal_roi` | **Required.** Set the threshold as ratio the bot will use to exit a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict
@@ -324,6 +330,25 @@ To limit this calculation in case of large stoploss values, the calculated minim
!!! Warning
Since the limits on exchanges are usually stable and are not updated often, some pairs can show pretty high minimum limits, simply because the price increased a lot since the last limit adjustment by the exchange. Freqtrade adjusts the stake-amount to this value, unless it's > 30% more than the calculated/desired stake-amount - in which case the trade is rejected.
#### Dry-run wallet
When running in dry-run mode, the bot will use a simulated wallet to execute trades. The starting balance of this wallet is defined by `dry_run_wallet` (defaults to 1000).
For more complex scenarios, you can also assign a dictionary to `dry_run_wallet` to define the starting balance for each currency.
```json
"dry_run_wallet": {
"BTC": 0.01,
"ETH": 2,
"USDT": 1000
}
```
Command line options (`--dry-run-wallet`) can be used to override the configuration value, but only for the float value, not for the dictionary. If you'd like to use the dictionary, please adjust the configuration file.
!!! Note
Balances not in stake-currency will not be used for trading, but are shown as part of the wallet balance.
On Cross-margin exchanges, the wallet balance may be used to calculate the available collateral for trading.
#### Tradable balance
By default, the bot assumes that the `complete amount - 1%` is at it's disposal, and when using [dynamic stake amount](#dynamic-stake-amount), it will split the complete balance into `max_open_trades` buckets per trade.

View File

@@ -1,6 +1,6 @@
markdown==3.7
mkdocs==1.6.1
mkdocs-material==9.5.47
mkdocs-material==9.5.48
mdx_truly_sane_lists==1.3
pymdown-extensions==10.12
jinja2==3.1.4

View File

@@ -17,7 +17,7 @@ def setup_optimize_configuration(args: dict[str, Any], method: RunMode) -> dict[
:return: Configuration
"""
from freqtrade.configuration import setup_utils_configuration
from freqtrade.util import fmt_coin
from freqtrade.util import fmt_coin, get_dry_run_wallet
config = setup_utils_configuration(args, method)
@@ -26,7 +26,7 @@ def setup_optimize_configuration(args: dict[str, Any], method: RunMode) -> dict[
RunMode.HYPEROPT: "hyperoptimization",
}
if method in no_unlimited_runmodes.keys():
wallet_size = config["dry_run_wallet"] * config["tradable_balance_ratio"]
wallet_size = get_dry_run_wallet(config) * config["tradable_balance_ratio"]
# tradable_balance_ratio
if (
config["stake_amount"] != constants.UNLIMITED_STAKE_AMOUNT

View File

@@ -85,8 +85,10 @@ CONF_SCHEMA = {
},
"dry_run_wallet": {
"description": "Initial wallet balance for dry run mode.",
"type": "number",
"type": ["number", "object"],
"default": DRY_RUN_WALLET,
"patternProperties": {r"^[a-zA-Z0-9]+$": {"type": "number"}},
"additionalProperties": False,
},
"cancel_open_orders_on_exit": {
"description": "Cancel open orders when exiting.",

View File

@@ -2,6 +2,8 @@ import logging
import os
from typing import Any
import rapidjson
from freqtrade.constants import ENV_VAR_PREFIX
from freqtrade.misc import deep_merge_dicts
@@ -20,6 +22,14 @@ def _get_var_typed(val):
return True
elif val.lower() in ("f", "false"):
return False
# try to convert from json
try:
value = rapidjson.loads(val)
# Limited to lists for now
if isinstance(value, list):
return value
except rapidjson.JSONDecodeError:
pass
# keep as string
return val

View File

@@ -98,6 +98,7 @@ def populate_dataframe_with_trades(
trades = trades.loc[trades["candle_start"] >= start_date]
trades.reset_index(inplace=True, drop=True)
# group trades by candle start
trades_grouped_by_candle_start = trades.groupby("candle_start", group_keys=False)
candle_start: datetime

View File

@@ -57,8 +57,14 @@ class Binance(Exchange):
(TradingMode.FUTURES, MarginMode.ISOLATED)
]
def get_tickers(self, symbols: list[str] | None = None, *, cached: bool = False) -> Tickers:
tickers = super().get_tickers(symbols=symbols, cached=cached)
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
tickers = super().get_tickers(symbols=symbols, cached=cached, market_type=market_type)
if self.trading_mode == TradingMode.FUTURES:
# Binance's future result has no bid/ask values.
# Therefore we must fetch that from fetch_bids_asks and combine the two results.
@@ -85,7 +91,10 @@ class Binance(Exchange):
"\nHedge Mode is not supported by freqtrade. "
"Please change 'Position Mode' on your binance futures account."
)
if assets_margin.get("multiAssetsMargin") is True:
if (
assets_margin.get("multiAssetsMargin") is True
and self.margin_mode != MarginMode.CROSS
):
msg += (
"\nMulti-Asset Mode is not supported by freqtrade. "
"Please change 'Asset Mode' on your binance futures account."

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import asyncio
import inspect
import logging
import signal
from collections.abc import Coroutine
from collections.abc import Coroutine, Generator
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from math import floor, isnan
@@ -201,7 +201,7 @@ class Exchange:
self._cache_lock = Lock()
# Cache for 10 minutes ...
self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=2, ttl=60 * 10)
self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=4, ttl=60 * 10)
# Cache values for 300 to avoid frequent polling of the exchange for prices
# Caching only applies to RPC methods, so prices for open trades are still
# refreshed once every iteration.
@@ -705,14 +705,22 @@ class Exchange:
f"Available currencies are: {', '.join(quote_currencies)}"
)
def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> str:
def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> Generator[str, None, None]:
"""
Get valid pair combination of curr_1 and curr_2 by trying both combinations.
"""
for pair in [f"{curr_1}/{curr_2}", f"{curr_2}/{curr_1}"]:
yielded = False
for pair in (
f"{curr_1}/{curr_2}",
f"{curr_2}/{curr_1}",
f"{curr_1}/{curr_2}:{curr_2}",
f"{curr_2}/{curr_1}:{curr_1}",
):
if pair in self.markets and self.markets[pair].get("active"):
return pair
raise ValueError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
yielded = True
yield pair
if not yielded:
raise ValueError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
def validate_timeframes(self, timeframe: str | None) -> None:
"""
@@ -1801,24 +1809,37 @@ class Exchange:
raise OperationalException(e) from e
@retrier
def get_tickers(self, symbols: list[str] | None = None, *, cached: bool = False) -> Tickers:
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
"""
:param symbols: List of symbols to fetch
:param cached: Allow cached result
:param market_type: Market type to fetch - either spot or futures.
:return: fetch_tickers result
"""
tickers: Tickers
if not self.exchange_has("fetchTickers"):
return {}
cache_key = f"fetch_tickers_{market_type}" if market_type else "fetch_tickers"
if cached:
with self._cache_lock:
tickers = self._fetch_tickers_cache.get("fetch_tickers") # type: ignore
tickers = self._fetch_tickers_cache.get(cache_key) # type: ignore
if tickers:
return tickers
try:
tickers = self._api.fetch_tickers(symbols)
# Re-map futures to swap
market_types = {
TradingMode.FUTURES: "swap",
}
params = {"type": market_types.get(market_type, market_type)} if market_type else {}
tickers = self._api.fetch_tickers(symbols, params)
with self._cache_lock:
self._fetch_tickers_cache["fetch_tickers"] = tickers
self._fetch_tickers_cache[cache_key] = tickers
return tickers
except ccxt.NotSupported as e:
raise OperationalException(
@@ -1842,7 +1863,39 @@ class Exchange:
except ccxt.BaseError as e:
raise OperationalException(e) from e
# Pricing info
def get_conversion_rate(self, coin: str, currency: str) -> float | None:
"""
Quick and cached way to get conversion rate one currency to the other.
Can then be used as "rate * amount" to convert between currencies.
:param coin: Coin to convert
:param currency: Currency to convert to
:returns: Conversion rate from coin to currency
:raises: ExchangeErrors
"""
if coin == currency:
return 1.0
tickers = self.get_tickers(cached=True)
try:
for pair in self.get_valid_pair_combination(coin, currency):
ticker: Ticker | None = tickers.get(pair, None)
if not ticker:
tickers_other: Tickers = self.get_tickers(
cached=True,
market_type=(
TradingMode.SPOT
if self.trading_mode != TradingMode.SPOT
else TradingMode.FUTURES
),
)
ticker = tickers_other.get(pair, None)
if ticker:
rate: float | None = ticker.get("last", None)
if rate and pair.startswith(currency) and not pair.endswith(currency):
rate = 1.0 / rate
return rate
except ValueError:
return None
return None
@retrier
def fetch_ticker(self, pair: str) -> Ticker:
@@ -2198,10 +2251,13 @@ class Exchange:
# If cost is None or 0.0 -> falsy, return None
return None
try:
comb = self.get_valid_pair_combination(fee_curr, self._config["stake_currency"])
tick = self.fetch_ticker(comb)
fee_to_quote_rate = safe_value_fallback2(tick, tick, "last", "ask")
for comb in self.get_valid_pair_combination(
fee_curr, self._config["stake_currency"]
):
tick = self.fetch_ticker(comb)
fee_to_quote_rate = safe_value_fallback2(tick, tick, "last", "ask")
if tick:
break
except (ValueError, ExchangeError):
fee_to_quote_rate = self._config["exchange"].get("unknown_fee_rate", None)
if not fee_to_quote_rate:
@@ -3586,7 +3642,7 @@ class Exchange:
liquidation_price_buffer = (
liquidation_price - buffer_amount if is_short else liquidation_price + buffer_amount
)
return max(liquidation_price_buffer, 0.0)
return self.price_to_precision(pair, max(liquidation_price_buffer, 0.0))
else:
return None

View File

@@ -308,7 +308,9 @@ def price_to_precision(
decimal_to_precision(
price,
rounding_mode=rounding_mode,
precision=price_precision,
precision=int(price_precision)
if precisionMode != TICK_SIZE
else price_precision,
counting_mode=precisionMode,
)
)

View File

@@ -50,11 +50,17 @@ class Kraken(Exchange):
return parent_check and market.get("darkpool", False) is False
def get_tickers(self, symbols: list[str] | None = None, *, cached: bool = False) -> Tickers:
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
# Only fetch tickers for current stake currency
# Otherwise the request for kraken becomes too large.
symbols = list(self.get_markets(quote_currencies=[self._config["stake_currency"]]))
return super().get_tickers(symbols=symbols, cached=cached)
return super().get_tickers(symbols=symbols, cached=cached, market_type=market_type)
def consolidate_balances(self, balances: CcxtBalances) -> CcxtBalances:
"""

View File

@@ -5,7 +5,6 @@ from xgboost import XGBRFRegressor
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.tensorboard import TBCallback
logger = logging.getLogger(__name__)
@@ -45,7 +44,12 @@ class XGBoostRFRegressor(BaseRegressionModel):
model = XGBRFRegressor(**self.model_training_parameters)
model.set_params(callbacks=[TBCallback(dk.data_path)])
# Callbacks are not supported for XGBRFRegressor, and version 2.1.x started to throw
# the following error:
# NotImplementedError: `early_stopping_rounds` and `callbacks` are not implemented
# for random forest.
# model.set_params(callbacks=[TBCallback(dk.data_path)])
model.fit(
X=X,
y=y,
@@ -55,6 +59,6 @@ class XGBoostRFRegressor(BaseRegressionModel):
xgb_model=xgb_model,
)
# set the callbacks to empty so that we can serialize to disk later
model.set_params(callbacks=[])
# model.set_params(callbacks=[])
return model

View File

@@ -27,9 +27,12 @@ def update_liquidation_prices(
total_wallet_stake = 0.0
if dry_run:
# Parameters only needed for cross margin
total_wallet_stake = wallets.get_total(stake_currency)
total_wallet_stake = wallets.get_collateral()
logger.info("Updating liquidation price for all open trades.")
logger.info(
"Updating liquidation price for all open trades. "
f"Collateral {total_wallet_stake} {stake_currency}."
)
open_trades = Trade.get_open_trades()
for t in open_trades:
# TODO: This should be done in a batch update

View File

@@ -10,7 +10,7 @@ from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException
from freqtrade.optimize.analysis.lookahead import LookaheadAnalysis
from freqtrade.resolvers import StrategyResolver
from freqtrade.util import print_rich_table
from freqtrade.util import get_dry_run_wallet, print_rich_table
logger = logging.getLogger(__name__)
@@ -163,7 +163,7 @@ class LookaheadAnalysisSubFunctions:
config["max_open_trades"] = len(config["pairs"])
min_dry_run_wallet = 1000000000
if config["dry_run_wallet"] < min_dry_run_wallet:
if get_dry_run_wallet(config) < min_dry_run_wallet:
logger.info(
"Dry run wallet was not set to 1 billion, pushing it up there "
"just to avoid false positives"

View File

@@ -28,6 +28,7 @@ from freqtrade.optimize.hyperopt_loss.hyperopt_loss_interface import IHyperOptLo
from freqtrade.optimize.hyperopt_tools import HyperoptStateContainer, HyperoptTools
from freqtrade.optimize.optimize_reports import generate_strategy_stats
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver
from freqtrade.util.dry_run_wallet import get_dry_run_wallet
# Suppress scikit-learn FutureWarnings from skopt
@@ -363,6 +364,7 @@ class HyperOptimizer:
config=self.config,
processed=processed,
backtest_stats=strat_stats,
starting_balance=get_dry_run_wallet(self.config),
)
return {
"loss": loss,

View File

@@ -9,7 +9,6 @@ from datetime import datetime
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_calmar
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -24,10 +23,9 @@ class CalmarHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
min_date: datetime,
max_date: datetime,
config: Config,
starting_balance: float,
*args,
**kwargs,
) -> float:
@@ -36,7 +34,6 @@ class CalmarHyperOptLoss(IHyperOptLoss):
Uses Calmar Ratio calculation.
"""
starting_balance = config["dry_run_wallet"]
calmar_ratio = calculate_calmar(results, min_date, max_date, starting_balance)
# print(expected_returns_mean, max_drawdown, calmar_ratio)
return -calmar_ratio

View File

@@ -31,6 +31,7 @@ class IHyperOptLoss(ABC):
config: Config,
processed: dict[str, DataFrame],
backtest_stats: dict[str, Any],
starting_balance: float,
**kwargs,
) -> float:
"""

View File

@@ -7,7 +7,6 @@ Hyperoptimization.
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_underwater
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -21,7 +20,9 @@ class MaxDrawDownRelativeHyperOptLoss(IHyperOptLoss):
"""
@staticmethod
def hyperopt_loss_function(results: DataFrame, config: Config, *args, **kwargs) -> float:
def hyperopt_loss_function(
results: DataFrame, starting_balance: float, *args, **kwargs
) -> float:
"""
Objective function.
@@ -31,7 +32,7 @@ class MaxDrawDownRelativeHyperOptLoss(IHyperOptLoss):
total_profit = results["profit_abs"].sum()
try:
drawdown_df = calculate_underwater(
results, value_col="profit_abs", starting_balance=config["dry_run_wallet"]
results, value_col="profit_abs", starting_balance=starting_balance
)
max_drawdown = abs(min(drawdown_df["drawdown"]))
relative_drawdown = max(drawdown_df["drawdown_relative"])

View File

@@ -33,7 +33,6 @@ TARGET_TRADE_AMOUNT variable sets the minimum number of trades required to avoid
import numpy as np
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_expectancy, calculate_max_drawdown
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -57,7 +56,7 @@ class MultiMetricHyperOptLoss(IHyperOptLoss):
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
config: Config,
starting_balance: float,
**kwargs,
) -> float:
total_profit = results["profit_abs"].sum()
@@ -83,7 +82,7 @@ class MultiMetricHyperOptLoss(IHyperOptLoss):
# Calculate drawdown
try:
drawdown = calculate_max_drawdown(
results, starting_balance=config["dry_run_wallet"], value_col="profit_abs"
results, starting_balance=starting_balance, value_col="profit_abs"
)
relative_account_drawdown = drawdown.relative_account_drawdown
except ValueError:

View File

@@ -10,7 +10,6 @@ individual needs.
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_max_drawdown
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -21,12 +20,14 @@ DRAWDOWN_MULT = 0.075
class ProfitDrawDownHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(results: DataFrame, config: Config, *args, **kwargs) -> float:
def hyperopt_loss_function(
results: DataFrame, starting_balance: float, *args, **kwargs
) -> float:
total_profit = results["profit_abs"].sum()
try:
drawdown = calculate_max_drawdown(
results, starting_balance=config["dry_run_wallet"], value_col="profit_abs"
results, starting_balance=starting_balance, value_col="profit_abs"
)
relative_account_drawdown = drawdown.relative_account_drawdown
except ValueError:

View File

@@ -9,7 +9,6 @@ from datetime import datetime
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_sharpe
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -24,10 +23,9 @@ class SharpeHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
min_date: datetime,
max_date: datetime,
config: Config,
starting_balance: float,
*args,
**kwargs,
) -> float:
@@ -36,7 +34,6 @@ class SharpeHyperOptLoss(IHyperOptLoss):
Uses Sharpe Ratio calculation.
"""
starting_balance = config["dry_run_wallet"]
sharp_ratio = calculate_sharpe(results, min_date, max_date, starting_balance)
# print(expected_returns_mean, up_stdev, sharp_ratio)
return -sharp_ratio

View File

@@ -9,7 +9,6 @@ from datetime import datetime
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_sortino
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -24,10 +23,9 @@ class SortinoHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
min_date: datetime,
max_date: datetime,
config: Config,
starting_balance: float,
*args,
**kwargs,
) -> float:
@@ -36,7 +34,6 @@ class SortinoHyperOptLoss(IHyperOptLoss):
Uses Sortino Ratio calculation.
"""
starting_balance = config["dry_run_wallet"]
sortino_ratio = calculate_sortino(results, min_date, max_date, starting_balance)
# print(expected_returns_mean, down_stdev, sortino_ratio)
return -sortino_ratio

View File

@@ -18,7 +18,7 @@ from freqtrade.data.metrics import (
calculate_sortino,
)
from freqtrade.ft_types import BacktestResultType
from freqtrade.util import decimals_per_coin, fmt_coin
from freqtrade.util import decimals_per_coin, fmt_coin, get_dry_run_wallet
logger = logging.getLogger(__name__)
@@ -69,7 +69,7 @@ def generate_rejected_signals(
def _generate_result_line(
result: DataFrame, starting_balance: int, first_column: str | list[str]
result: DataFrame, starting_balance: float, first_column: str | list[str]
) -> dict:
"""
Generate one result dict, with "first_column" as key.
@@ -111,7 +111,7 @@ def _generate_result_line(
def generate_pair_metrics(
pairlist: list[str],
stake_currency: str,
starting_balance: int,
starting_balance: float,
results: DataFrame,
skip_nan: bool = False,
) -> list[dict]:
@@ -144,7 +144,7 @@ def generate_pair_metrics(
def generate_tag_metrics(
tag_type: Literal["enter_tag", "exit_reason"] | list[Literal["enter_tag", "exit_reason"]],
starting_balance: int,
starting_balance: float,
results: DataFrame,
skip_nan: bool = False,
) -> list[dict]:
@@ -373,7 +373,7 @@ def generate_strategy_stats(
return {}
config = content["config"]
max_open_trades = min(config["max_open_trades"], len(pairlist))
start_balance = config["dry_run_wallet"]
start_balance = get_dry_run_wallet(config)
stake_currency = config["stake_currency"]
pair_results = generate_pair_metrics(

View File

@@ -28,6 +28,7 @@ from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.strategy import IStrategy
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.util import get_dry_run_wallet
logger = logging.getLogger(__name__)
@@ -706,7 +707,7 @@ def plot_profit(config: Config) -> None:
trades,
config["timeframe"],
config.get("stake_currency", ""),
config.get("available_capital", config["dry_run_wallet"]),
config.get("available_capital", get_dry_run_wallet(config)),
)
store_plot_file(
fig,

View File

@@ -8,7 +8,7 @@ defined period or as coming from ticker
import logging
from datetime import timedelta
from typing import Any
from typing import TypedDict
from cachetools import TTLCache
from pandas import DataFrame
@@ -24,6 +24,11 @@ from freqtrade.util import dt_now, format_ms_time
logger = logging.getLogger(__name__)
class SymbolWithPercentage(TypedDict):
symbol: str
percentage: float | None
class PercentChangePairList(IPairList):
is_pairlist_generator = True
supports_backtesting = SupportsBacktesting.NO
@@ -191,7 +196,6 @@ class PercentChangePairList(IPairList):
for k, v in tickers.items()
if (
self._exchange.get_pair_quote_currency(k) == self._stake_currency
and (self._use_range or v.get("percentage") is not None)
and v["symbol"] in _pairlist
)
]
@@ -212,13 +216,15 @@ class PercentChangePairList(IPairList):
:param tickers: Tickers (from exchange.get_tickers). May be cached.
:return: new whitelist
"""
filtered_tickers: list[dict[str, Any]] = [{"symbol": k} for k in pairlist]
filtered_tickers: list[SymbolWithPercentage] = [
{"symbol": k, "percentage": None} for k in pairlist
]
if self._use_range:
# calculating using lookback_period
self.fetch_percent_change_from_lookback_period(filtered_tickers)
filtered_tickers = self.fetch_percent_change_from_lookback_period(filtered_tickers)
else:
# Fetching 24h change by default from supported exchange tickers
self.fetch_percent_change_from_tickers(filtered_tickers, tickers)
filtered_tickers = self.fetch_percent_change_from_tickers(filtered_tickers, tickers)
if self._min_value is not None:
filtered_tickers = [v for v in filtered_tickers if v["percentage"] > self._min_value]
@@ -228,7 +234,7 @@ class PercentChangePairList(IPairList):
sorted_tickers = sorted(
filtered_tickers,
reverse=self._sort_direction == "desc",
key=lambda t: t["percentage"],
key=lambda t: t["percentage"], # type: ignore
)
# Validate whitelist to only have active market pairs
@@ -240,7 +246,7 @@ class PercentChangePairList(IPairList):
return pairs
def fetch_candles_for_lookback_period(
self, filtered_tickers: list[dict[str, str]]
self, filtered_tickers: list[SymbolWithPercentage]
) -> dict[PairWithTimeframe, DataFrame]:
since_ms = (
int(
@@ -262,7 +268,6 @@ class PercentChangePairList(IPairList):
)
* 1000
)
# todo: utc date output for starting date
self.log_once(
f"Using change range of {self._lookback_period} candles, timeframe: "
f"{self._lookback_timeframe}, starting from {format_ms_time(since_ms)} "
@@ -277,7 +282,9 @@ class PercentChangePairList(IPairList):
candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms)
return candles
def fetch_percent_change_from_lookback_period(self, filtered_tickers: list[dict[str, Any]]):
def fetch_percent_change_from_lookback_period(
self, filtered_tickers: list[SymbolWithPercentage]
) -> list[SymbolWithPercentage]:
# get lookback period in ms, for exchange ohlcv fetch
candles = self.fetch_candles_for_lookback_period(filtered_tickers)
@@ -302,16 +309,23 @@ class PercentChangePairList(IPairList):
filtered_tickers[i]["percentage"] = pct_change
else:
filtered_tickers[i]["percentage"] = 0
return filtered_tickers
def fetch_percent_change_from_tickers(self, filtered_tickers: list[dict[str, Any]], tickers):
for i, p in enumerate(filtered_tickers):
def fetch_percent_change_from_tickers(
self, filtered_tickers: list[SymbolWithPercentage], tickers
) -> list[SymbolWithPercentage]:
valid_tickers: list[SymbolWithPercentage] = []
for p in filtered_tickers:
# Filter out assets
if not self._validate_pair(
p["symbol"], tickers[p["symbol"]] if p["symbol"] in tickers else None
if (
self._validate_pair(
p["symbol"], tickers[p["symbol"]] if p["symbol"] in tickers else None
)
and p["symbol"] != "UNI/USDT"
):
filtered_tickers.remove(p)
else:
filtered_tickers[i]["percentage"] = tickers[p["symbol"]]["percentage"]
p["percentage"] = tickers[p["symbol"]]["percentage"]
valid_tickers.append(p)
return valid_tickers
def _validate_pair(self, pair: str, ticker: Ticker | None) -> bool:
"""

View File

@@ -7,7 +7,7 @@ from abc import abstractmethod
from collections.abc import Generator, Sequence
from datetime import date, datetime, timedelta, timezone
from math import isnan
from typing import Any, cast
from typing import Any
import psutil
from dateutil.relativedelta import relativedelta
@@ -32,7 +32,7 @@ from freqtrade.enums import (
)
from freqtrade.exceptions import ExchangeError, PricingError
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs
from freqtrade.exchange.exchange_types import Ticker, Tickers
from freqtrade.exchange.exchange_utils import price_to_precision
from freqtrade.loggers import bufferHandler
from freqtrade.persistence import KeyStoreKeys, KeyValueStore, PairLocks, Trade
from freqtrade.persistence.models import PairLock
@@ -243,7 +243,11 @@ class RPC:
stoploss_entry_dist_ratio = stop_entry.profit_ratio
# calculate distance to stoploss
stoploss_current_dist = trade.stop_loss - current_rate
stoploss_current_dist = price_to_precision(
trade.stop_loss - current_rate,
trade.price_precision,
trade.precision_mode_price,
)
stoploss_current_dist_ratio = stoploss_current_dist / current_rate
trade_dict = trade.to_json()
@@ -670,7 +674,7 @@ class RPC:
}
def __balance_get_est_stake(
self, coin: str, stake_currency: str, amount: float, balance: Wallet, tickers: Tickers
self, coin: str, stake_currency: str, amount: float, balance: Wallet
) -> tuple[float, float]:
est_stake = 0.0
est_bot_stake = 0.0
@@ -681,14 +685,18 @@ class RPC:
est_stake = balance.free
est_bot_stake = amount
else:
pair = self._freqtrade.exchange.get_valid_pair_combination(coin, stake_currency)
rate: float | None = cast(Ticker, tickers.get(pair, {})).get("last", None)
if rate:
if pair.startswith(stake_currency) and not pair.endswith(stake_currency):
rate = 1.0 / rate
est_stake = rate * balance.total
est_bot_stake = rate * amount
try:
rate: float | None = self._freqtrade.exchange.get_conversion_rate(
coin, stake_currency
)
if rate:
est_stake = rate * balance.total
est_bot_stake = rate * amount
return est_stake, est_bot_stake
except (ExchangeError, PricingError) as e:
logger.warning(f"Error {e} getting rate for {coin}")
pass
return est_stake, est_bot_stake
def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> dict:
@@ -696,10 +704,6 @@ class RPC:
currencies: list[dict] = []
total = 0.0
total_bot = 0.0
try:
tickers: Tickers = self._freqtrade.exchange.get_tickers(cached=True)
except ExchangeError:
raise RPCException("Error getting current tickers.")
open_trades: list[Trade] = Trade.get_open_trades()
open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in open_trades}
@@ -715,7 +719,7 @@ class RPC:
coin: str
balance: Wallet
for coin, balance in self._freqtrade.wallets.get_all_balances().items():
if not balance.total:
if not balance.total and not balance.free:
continue
trade = open_assets.get(coin, None)
@@ -726,7 +730,7 @@ class RPC:
try:
est_stake, est_stake_bot = self.__balance_get_est_stake(
coin, stake_currency, trade_amount, balance, tickers
coin, stake_currency, trade_amount, balance
)
except ValueError:
continue

View File

@@ -1160,10 +1160,10 @@ class IStrategy(ABC, HyperStrategyMixin):
logger.warning(f"Empty candle (OHLCV) data for pair {pair}")
return None, None
latest_date = dataframe["date"].max()
latest = dataframe.loc[dataframe["date"] == latest_date].iloc[-1]
latest_date_pd = dataframe["date"].max()
latest = dataframe.loc[dataframe["date"] == latest_date_pd].iloc[-1]
# Explicitly convert to datetime object to ensure the below comparison does not fail
latest_date = latest_date.to_pydatetime()
latest_date: datetime = latest_date_pd.to_pydatetime()
# Check if dataframe is out of date
timeframe_minutes = timeframe_to_minutes(timeframe)

View File

@@ -11,6 +11,7 @@ from freqtrade.util.datetime_helpers import (
format_ms_time,
shorten_date,
)
from freqtrade.util.dry_run_wallet import get_dry_run_wallet
from freqtrade.util.formatters import decimals_per_coin, fmt_coin, fmt_coin2, round_value
from freqtrade.util.ft_precise import FtPrecise
from freqtrade.util.measure_time import MeasureTime
@@ -35,6 +36,7 @@ __all__ = [
"dt_utc",
"format_date",
"format_ms_time",
"get_dry_run_wallet",
"FtPrecise",
"PeriodicCache",
"shorten_date",

View File

@@ -0,0 +1,12 @@
from freqtrade.constants import Config
def get_dry_run_wallet(config: Config) -> int | float:
"""
Return dry-run wallet balance in stake currency from configuration.
This setup also supports dictionary mode for dry-run-wallet.
"""
if isinstance(_start_cap := config["dry_run_wallet"], float | int):
return _start_cap
else:
return _start_cap.get("stake_currency")

View File

@@ -41,7 +41,14 @@ class Wallets:
self._exchange = exchange
self._wallets: dict[str, Wallet] = {}
self._positions: dict[str, PositionWallet] = {}
self._start_cap = config["dry_run_wallet"]
self._start_cap: dict[str, float] = {}
self._stake_currency = config["stake_currency"]
if isinstance(_start_cap := config["dry_run_wallet"], float | int):
self._start_cap[self._stake_currency] = _start_cap
else:
self._start_cap = _start_cap
self._last_wallet_refresh: datetime | None = None
self.update()
@@ -66,6 +73,18 @@ class Wallets:
else:
return 0
def get_collateral(self) -> float:
"""
Get total collateral for liquidation price calculation.
"""
if self._config.get("margin_mode") == "cross":
# free includes all balances and, combined with position collateral,
# is used as "wallet balance".
return self.get_free(self._stake_currency) + sum(
pos.collateral for pos in self._positions.values()
)
return self.get_total(self._stake_currency)
def get_owned(self, pair: str, base_currency: str) -> float:
"""
Get currently owned value.
@@ -109,36 +128,51 @@ class Wallets:
for o in trade.open_orders
if o.amount and o.ft_order_side == trade.exit_side
)
curr_wallet_bal = self._start_cap.get(curr, 0)
_wallets[curr] = Wallet(curr, trade.amount - pending, pending, trade.amount)
current_stake = self._start_cap + tot_profit - tot_in_trades
total_stake = current_stake + used_stake
_wallets[curr] = Wallet(
curr,
curr_wallet_bal + trade.amount - pending,
pending,
trade.amount + curr_wallet_bal,
)
else:
tot_in_trades = 0
for position in open_trades:
# size = self._exchange._contracts_to_amount(position.pair, position['contracts'])
size = position.amount
collateral = position.stake_amount
leverage = position.leverage
tot_in_trades += collateral
_positions[position.pair] = PositionWallet(
position.pair,
position=size,
leverage=leverage,
collateral=collateral,
position=position.amount,
leverage=position.leverage,
collateral=position.stake_amount,
side=position.trade_direction,
)
current_stake = self._start_cap + tot_profit - tot_in_trades
used_stake = tot_in_trades
total_stake = current_stake + tot_in_trades
_wallets[self._config["stake_currency"]] = Wallet(
currency=self._config["stake_currency"],
free=current_stake,
used_stake = tot_in_trades
cross_margin = 0.0
if self._config.get("margin_mode") == "cross":
# In cross-margin mode, the total balance is used as collateral.
# This is moved as "free" into the stake currency balance.
# strongly tied to the get_collateral() implementation.
for curr, bal in self._start_cap.items():
if curr == self._stake_currency:
continue
rate = self._exchange.get_conversion_rate(curr, self._stake_currency)
if rate:
cross_margin += bal * rate
current_stake = self._start_cap.get(self._stake_currency, 0) + tot_profit - tot_in_trades
total_stake = current_stake + used_stake
_wallets[self._stake_currency] = Wallet(
currency=self._stake_currency,
free=current_stake + cross_margin,
used=used_stake,
total=total_stake,
)
for currency, bal in self._start_cap.items():
if currency not in _wallets:
_wallets[currency] = Wallet(currency, bal, 0, bal)
self._wallets = _wallets
self._positions = _positions
@@ -244,7 +278,7 @@ class Wallets:
else:
tot_profit = Trade.get_total_closed_profit()
open_stakes = Trade.total_open_trades_stakes()
available_balance = self.get_free(self._config["stake_currency"])
available_balance = self.get_free(self._stake_currency)
return available_balance - tot_profit + open_stakes
def get_total_stake_amount(self):
@@ -264,9 +298,9 @@ class Wallets:
# Ensure <tradable_balance_ratio>% is used from the overall balance
# Otherwise we'd risk lowering stakes with each open trade.
# (tied up + current free) * ratio) - tied up
available_amount = (
val_tied_up + self.get_free(self._config["stake_currency"])
) * self._config["tradable_balance_ratio"]
available_amount = (val_tied_up + self.get_free(self._stake_currency)) * self._config[
"tradable_balance_ratio"
]
return available_amount
def get_available_stake_amount(self) -> float:
@@ -277,7 +311,7 @@ class Wallets:
(<open_trade stakes> + free amount) * tradable_balance_ratio - <open_trade stakes>
"""
free = self.get_free(self._config["stake_currency"])
free = self.get_free(self._stake_currency)
return min(self.get_total_stake_amount() - Trade.total_open_trades_stakes(), free)
def _calculate_unlimited_stake_amount(
@@ -316,7 +350,7 @@ class Wallets:
f"lower than stake amount ({stake_amount} {self._config['stake_currency']})"
)
return stake_amount
return max(stake_amount, 0)
def get_trade_stake_amount(
self, pair: str, max_open_trades: IntOrInf, edge=None, update: bool = True
@@ -336,8 +370,8 @@ class Wallets:
if edge:
stake_amount = edge.stake_amount(
pair,
self.get_free(self._config["stake_currency"]),
self.get_total(self._config["stake_currency"]),
self.get_free(self._stake_currency),
self.get_total(self._stake_currency),
val_tied_up,
)
else:

View File

@@ -7,7 +7,7 @@
-r docs/requirements-docs.txt
coveralls==4.0.1
ruff==0.8.1
ruff==0.8.2
mypy==1.13.0
pre-commit==4.0.1
pytest==8.3.4
@@ -28,5 +28,5 @@ nbconvert==7.16.4
types-cachetools==5.5.0.20240820
types-filelock==3.2.7
types-requests==2.32.0.20241016
types-tabulate==0.9.0.20240106
types-python-dateutil==2.9.0.20241003
types-tabulate==0.9.0.20241207
types-python-dateutil==2.9.0.20241206

View File

@@ -10,6 +10,6 @@ catboost==1.2.7; 'arm' not in platform_machine
# Temporary downgrade of matplotlib due to https://github.com/matplotlib/matplotlib/issues/28551
matplotlib==3.9.3
lightgbm==4.5.0
xgboost==2.0.3
xgboost==2.1.3
tensorboard==2.18.0
datasieve==0.1.7

View File

@@ -4,12 +4,12 @@ bottleneck==1.4.2
numexpr==2.10.2
pandas-ta==0.3.14b
ccxt==4.4.35
ccxt==4.4.37
cryptography==42.0.8; platform_machine == 'armv7l'
cryptography==44.0.0; platform_machine != 'armv7l'
aiohttp==3.10.11
SQLAlchemy==2.0.36
python-telegram-bot==21.8
python-telegram-bot==21.9
# can't be hard-pinned due to telegram-bot pinning httpx with ~
httpx>=0.24.1
humanize==4.11.0
@@ -39,8 +39,8 @@ orjson==3.10.12
sdnotify==0.3.2
# API Server
fastapi==0.115.5
pydantic==2.10.2
fastapi==0.115.6
pydantic==2.10.3
uvicorn==0.32.1
pyjwt==2.10.1
aiofiles==24.1.0

View File

@@ -1,6 +1,7 @@
# pragma pylint: disable=missing-docstring
import json
import logging
import platform
import re
from copy import deepcopy
from datetime import datetime, timedelta, timezone
@@ -517,6 +518,30 @@ def patch_gc(mocker) -> None:
mocker.patch("freqtrade.main.gc_set_threshold")
def is_arm() -> bool:
machine = platform.machine()
return "arm" in machine or "aarch64" in machine
def is_mac() -> bool:
machine = platform.system()
return "Darwin" in machine
@pytest.fixture(autouse=True)
def patch_torch_initlogs(mocker) -> None:
if is_mac():
# Mock torch import completely
import sys
import types
module_name = "torch"
mocked_module = types.ModuleType(module_name)
sys.modules[module_name] = mocked_module
else:
mocker.patch("torch._logging._init_logs")
@pytest.fixture(autouse=True)
def user_dir(mocker, tmp_path) -> Path:
user_dir = tmp_path / "user_data"
@@ -2212,7 +2237,7 @@ def tickers():
"first": None,
"last": 8603.67,
"change": -0.879,
"percentage": None,
"percentage": -8.95,
"average": None,
"baseVolume": 30414.604298,
"quoteVolume": 259629896.48584127,
@@ -2256,7 +2281,7 @@ def tickers():
"first": None,
"last": 129.28,
"change": 1.795,
"percentage": None,
"percentage": -2.5,
"average": None,
"baseVolume": 59698.79897,
"quoteVolume": 29132399.743954,

View File

@@ -293,6 +293,7 @@ def test_liquidation_price_binance(
default_conf["trading_mode"] = trading_mode
default_conf["margin_mode"] = margin_mode
default_conf["liquidation_buffer"] = 0.0
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
exchange = get_patched_exchange(mocker, default_conf, exchange="binance")
def get_maint_ratio(pair_, stake_amount):

View File

@@ -2006,6 +2006,46 @@ def test_get_tickers(default_conf, mocker, exchange_name, caplog):
assert exchange.get_tickers() == {}
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_get_conversion_rate(default_conf_usdt, mocker, exchange_name):
api_mock = MagicMock()
tick = {
"ETH/USDT": {
"last": 42,
},
"BCH/USDT": {
"last": 41,
},
"ETH/BTC": {
"last": 250,
},
}
tick2 = {
"ADA/USDT:USDT": {
"last": 2.5,
}
}
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
api_mock.fetch_tickers = MagicMock(side_effect=[tick, tick2])
api_mock.fetch_bids_asks = MagicMock(return_value={})
exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange=exchange_name)
# retrieve original ticker
assert exchange.get_conversion_rate("USDT", "USDT") == 1
assert api_mock.fetch_tickers.call_count == 0
assert exchange.get_conversion_rate("ETH", "USDT") == 42
assert exchange.get_conversion_rate("ETH", "USDC") is None
assert exchange.get_conversion_rate("ETH", "BTC") == 250
assert exchange.get_conversion_rate("BTC", "ETH") == 0.004
assert api_mock.fetch_tickers.call_count == 1
api_mock.fetch_tickers.reset_mock()
assert exchange.get_conversion_rate("ADA", "USDT") == 2.5
# Only the call to the "others" market
assert api_mock.fetch_tickers.call_count == 1
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_ticker(default_conf, mocker, exchange_name):
api_mock = MagicMock()
@@ -4079,10 +4119,16 @@ def test_get_valid_pair_combination(default_conf, mocker, markets):
)
ex = Exchange(default_conf)
assert ex.get_valid_pair_combination("ETH", "BTC") == "ETH/BTC"
assert ex.get_valid_pair_combination("BTC", "ETH") == "ETH/BTC"
assert next(ex.get_valid_pair_combination("ETH", "BTC")) == "ETH/BTC"
assert next(ex.get_valid_pair_combination("BTC", "ETH")) == "ETH/BTC"
multicombs = list(ex.get_valid_pair_combination("ETH", "USDT"))
assert len(multicombs) == 2
assert "ETH/USDT" in multicombs
assert "ETH/USDT:USDT" in multicombs
with pytest.raises(ValueError, match=r"Could not combine.* to get a valid pair."):
ex.get_valid_pair_combination("NOPAIR", "ETH")
for x in ex.get_valid_pair_combination("NOPAIR", "ETH"):
pass
@pytest.mark.parametrize(
@@ -6131,6 +6177,7 @@ def test_get_liquidation_price(
default_conf_usdt["exchange"]["name"] = exchange_name
default_conf_usdt["margin_mode"] = margin_mode
mocker.patch("freqtrade.exchange.gate.Gate.validate_ordertypes")
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange=exchange_name)
exchange.get_maintenance_ratio_and_amt = MagicMock(return_value=(0.01, 0.01))

View File

@@ -1,4 +1,3 @@
import platform
import sys
from copy import deepcopy
from pathlib import Path
@@ -20,30 +19,6 @@ def is_py12() -> bool:
return sys.version_info >= (3, 12)
def is_mac() -> bool:
machine = platform.system()
return "Darwin" in machine
def is_arm() -> bool:
machine = platform.machine()
return "arm" in machine or "aarch64" in machine
@pytest.fixture(autouse=True)
def patch_torch_initlogs(mocker) -> None:
if is_mac():
# Mock torch import completely
import sys
import types
module_name = "torch"
mocked_module = types.ModuleType(module_name)
sys.modules[module_name] = mocked_module
else:
mocker.patch("torch._logging._init_logs")
@pytest.fixture(scope="function")
def freqai_conf(default_conf, tmp_path):
freqaiconf = deepcopy(default_conf)

View File

@@ -10,11 +10,10 @@ from freqtrade.configuration import TimeRange
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import OperationalException
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from tests.conftest import get_patched_exchange
from tests.conftest import get_patched_exchange, is_mac
from tests.freqai.conftest import (
get_patched_data_kitchen,
get_patched_freqai_strategy,
is_mac,
make_unfiltered_dataframe,
)

View File

@@ -13,11 +13,16 @@ from freqtrade.freqai.utils import download_all_data_for_training, get_required_
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import Trade
from freqtrade.plugins.pairlistmanager import PairListManager
from tests.conftest import EXMS, create_mock_trades, get_patched_exchange, log_has_re
from tests.freqai.conftest import (
get_patched_freqai_strategy,
from tests.conftest import (
EXMS,
create_mock_trades,
get_patched_exchange,
is_arm,
is_mac,
log_has_re,
)
from tests.freqai.conftest import (
get_patched_freqai_strategy,
make_rl_config,
mock_pytorch_mlp_model_training_parameters,
)

View File

@@ -4021,7 +4021,7 @@ def test_get_real_amount_fees_order(
default_conf_usdt, market_buy_order_usdt_doublefee, fee, mocker
):
tfo_mock = mocker.patch(f"{EXMS}.get_trades_for_order", return_value=[])
mocker.patch(f"{EXMS}.get_valid_pair_combination", return_value="BNB/USDT")
mocker.patch(f"{EXMS}.get_valid_pair_combination", return_value=["BNB/USDT"])
mocker.patch(f"{EXMS}.fetch_ticker", return_value={"last": 200})
trade = Trade(
pair="LTC/USDT",

View File

@@ -29,7 +29,7 @@ def test_update_liquidation_prices(mocker, margin_mode, dry_run):
assert trade_mock.set_liquidation_price.call_count == 1
assert wallets.get_total.call_count == (
assert wallets.get_collateral.call_count == (
0 if margin_mode == MarginMode.ISOLATED or not dry_run else 1
)

View File

@@ -568,6 +568,7 @@ def test_backtest__enter_trade_futures(default_conf_usdt, fee, mocker) -> None:
mocker.patch(f"{EXMS}.get_fee", fee)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
mocker.patch(f"{EXMS}.get_max_leverage", return_value=100)
mocker.patch("freqtrade.optimize.backtesting.price_to_precision", lambda p, *args: p)
patch_exchange(mocker)
@@ -1842,6 +1843,7 @@ def test_backtest_multi_pair_long_short_switch(
if use_detail:
default_conf_usdt["timeframe_detail"] = "1m"
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
mocker.patch(f"{EXMS}.get_fee", fee)

View File

@@ -39,13 +39,34 @@ def test_loss_calculation_prefer_correct_trade_count(hyperopt_conf, hyperopt_res
hyperopt_conf.update({"hyperopt_loss": "ShortTradeDurHyperOptLoss"})
hl = HyperOptLossResolver.load_hyperoptloss(hyperopt_conf)
correct = hl.hyperopt_loss_function(
hyperopt_results, 600, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(
hyperopt_results, 600 + 100, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600 + 100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
under = hl.hyperopt_loss_function(
hyperopt_results, 600 - 100, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600 - 100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
assert over > correct
assert under > correct
@@ -58,9 +79,25 @@ def test_loss_calculation_prefer_shorter_trades(hyperopt_conf, hyperopt_results)
hyperopt_conf.update({"hyperopt_loss": "ShortTradeDurHyperOptLoss"})
hl = HyperOptLossResolver.load_hyperoptloss(hyperopt_conf)
longer = hl.hyperopt_loss_function(
hyperopt_results, 100, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
shorter = hl.hyperopt_loss_function(
results=resultsb,
trade_count=100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": resultsb["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
shorter = hl.hyperopt_loss_function(resultsb, 100, datetime(2019, 1, 1), datetime(2019, 5, 1))
assert shorter < longer
@@ -73,11 +110,34 @@ def test_loss_calculation_has_limited_profit(hyperopt_conf, hyperopt_results) ->
hyperopt_conf.update({"hyperopt_loss": "ShortTradeDurHyperOptLoss"})
hl = HyperOptLossResolver.load_hyperoptloss(hyperopt_conf)
correct = hl.hyperopt_loss_function(
hyperopt_results, 600, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(
results=results_over,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": results_over["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(results_over, 600, datetime(2019, 1, 1), datetime(2019, 5, 1))
under = hl.hyperopt_loss_function(
results_under, 600, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=results_under,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": results_under["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
assert over < correct
assert under > correct
@@ -109,31 +169,34 @@ def test_loss_functions_better_profits(default_conf, hyperopt_results, lossfunct
default_conf.update({"hyperopt_loss": lossfunction})
hl = HyperOptLossResolver.load_hyperoptloss(default_conf)
correct = hl.hyperopt_loss_function(
hyperopt_results,
results=hyperopt_results,
trade_count=len(hyperopt_results),
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=default_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=default_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(
results_over,
results=results_over,
trade_count=len(results_over),
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=default_conf,
processed=None,
backtest_stats={"profit_total": results_over["profit_abs"].sum()},
starting_balance=default_conf["dry_run_wallet"],
)
under = hl.hyperopt_loss_function(
results_under,
results=results_under,
trade_count=len(results_under),
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=default_conf,
processed=None,
backtest_stats={"profit_total": results_under["profit_abs"].sum()},
starting_balance=default_conf["dry_run_wallet"],
)
assert over < correct
assert under > correct

View File

@@ -360,9 +360,16 @@ def test_gen_pairlist_from_tickers(mocker, rpl_config, tickers):
exchange = get_patched_exchange(mocker, rpl_config, exchange="binance")
pairlistmanager = PairListManager(exchange, rpl_config)
remote_pairlist = PercentChangePairList(
exchange, pairlistmanager, rpl_config, rpl_config["pairlists"][0], 0
)
remote_pairlist = pairlistmanager._pairlist_handlers[0]
# The generator returns BTC ETH and TKN - filtering the first ensures removing pairs
# in this step ain't problematic.
def _validate_pair(pair, ticker):
if pair == "BTC/USDT":
return False
return True
remote_pairlist._validate_pair = _validate_pair
result = remote_pairlist.gen_pairlist(tickers.return_value)

View File

@@ -514,8 +514,13 @@ def test_rpc_balance_handle_error(default_conf, mocker):
patch_get_signal(freqtradebot)
rpc = RPC(freqtradebot)
rpc._fiat_converter = CryptoToFiatConverter({})
with pytest.raises(RPCException, match="Error getting current tickers."):
rpc._rpc_balance(default_conf["stake_currency"], default_conf["fiat_display_currency"])
res = rpc._rpc_balance(default_conf["stake_currency"], default_conf["fiat_display_currency"])
assert res["stake"] == "BTC"
assert len(res["currencies"]) == 1
assert res["currencies"][0]["currency"] == "BTC"
# ETH has not been converted.
assert all(currency["currency"] != "ETH" for currency in res["currencies"])
def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
@@ -530,6 +535,13 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
"total": 5.0,
"used": 4.0,
},
# Invalid coin not in tickers list.
# This triggers a 2nd call to get_tickers
"NotACoin": {
"free": 0.0,
"total": 2.0,
"used": 0.0,
},
"USDT": {
"free": 50.0,
"total": 100.0,
@@ -574,7 +586,7 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
fetch_positions=MagicMock(return_value=mock_pos),
get_tickers=tickers,
get_valid_pair_combination=MagicMock(
side_effect=lambda a, b: f"{b}/{a}" if a == "USDT" else f"{a}/{b}"
side_effect=lambda a, b: [f"{b}/{a}" if a == "USDT" else f"{a}/{b}"]
),
)
default_conf_usdt["dry_run"] = False
@@ -590,8 +602,10 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
assert pytest.approx(result["total"]) == 2824.83464
assert pytest.approx(result["value"]) == 2824.83464 * 1.2
assert tickers.call_count == 1
assert tickers.call_count == 4
assert tickers.call_args_list[0][1]["cached"] is True
# Testing futures - so we should get spot tickers
assert tickers.call_args_list[-1][1]["market_type"] == "spot"
assert "USD" == result["symbol"]
assert result["currencies"] == [
{
@@ -622,6 +636,20 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
"is_bot_managed": False,
"is_position": False,
},
{
"currency": "NotACoin",
"balance": 2.0,
"bot_owned": 0,
"est_stake": 0,
"est_stake_bot": 0,
"free": 0.0,
"is_bot_managed": False,
"is_position": False,
"position": 0,
"side": "long",
"stake": "USDT",
"used": 0.0,
},
{
"currency": "USDT",
"free": 50.0,

View File

@@ -556,7 +556,7 @@ def test_api_balance(botclient, mocker, rpc_balance, tickers):
ftbot.config["dry_run"] = False
mocker.patch(f"{EXMS}.get_balances", return_value=rpc_balance)
mocker.patch(f"{EXMS}.get_tickers", tickers)
mocker.patch(f"{EXMS}.get_valid_pair_combination", side_effect=lambda a, b: f"{a}/{b}")
mocker.patch(f"{EXMS}.get_valid_pair_combination", side_effect=lambda a, b: [f"{a}/{b}"])
ftbot.wallets.update()
rc = client_get(client, f"{BASE_URI}/balance")

View File

@@ -960,7 +960,7 @@ async def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance
default_conf["dry_run"] = False
mocker.patch(f"{EXMS}.get_balances", return_value=rpc_balance)
mocker.patch(f"{EXMS}.get_tickers", tickers)
mocker.patch(f"{EXMS}.get_valid_pair_combination", side_effect=lambda a, b: f"{a}/{b}")
mocker.patch(f"{EXMS}.get_valid_pair_combination", side_effect=lambda a, b: [f"{a}/{b}"])
telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
patch_get_signal(freqtradebot)
@@ -1049,7 +1049,7 @@ async def test_telegram_balance_handle_futures(
mocker.patch(f"{EXMS}.get_balances", return_value=rpc_balance)
mocker.patch(f"{EXMS}.fetch_positions", return_value=mock_pos)
mocker.patch(f"{EXMS}.get_tickers", tickers)
mocker.patch(f"{EXMS}.get_valid_pair_combination", side_effect=lambda a, b: f"{a}/{b}")
mocker.patch(f"{EXMS}.get_valid_pair_combination", side_effect=lambda a, b: [f"{a}/{b}"])
telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
patch_get_signal(freqtradebot)

View File

@@ -1481,6 +1481,12 @@ def test_flat_vars_to_nested_dict(caplog):
"FREQTRADE__STAKE_AMOUNT": "200.05",
"FREQTRADE__TELEGRAM__CHAT_ID": "2151",
"NOT_RELEVANT": "200.0", # Will be ignored
"FREQTRADE__ARRAY": '[{"name":"default","host":"xxx"}]',
"FREQTRADE__EXCHANGE__PAIR_WHITELIST": '["BTC/USDT", "ETH/USDT"]',
# Fails due to trailing comma
"FREQTRADE__ARRAY_TRAIL_COMMA": '[{"name":"default","host":"xxx",}]',
# Object fails
"FREQTRADE__OBJECT": '{"name":"default","host":"xxx"}',
}
expected = {
"stake_amount": 200.05,
@@ -1494,8 +1500,12 @@ def test_flat_vars_to_nested_dict(caplog):
},
"some_setting": True,
"some_false_setting": False,
"pair_whitelist": ["BTC/USDT", "ETH/USDT"],
},
"telegram": {"chat_id": "2151"},
"array": [{"name": "default", "host": "xxx"}],
"object": '{"name":"default","host":"xxx"}',
"array_trail_comma": '[{"name":"default","host":"xxx",}]',
}
res = _flat_vars_to_nested_dict(test_args, ENV_VAR_PREFIX)
assert res == expected

View File

@@ -168,7 +168,7 @@ def test_get_trade_stake_amount_unlimited_amount(
assert result == 0
freqtrade.config["dry_run_wallet"] = 200
freqtrade.wallets._start_cap = 200
freqtrade.wallets._start_cap["BTC"] = 200
result = freqtrade.wallets.get_trade_stake_amount("XRP/USDT", 3)
assert round(result, 4) == round(result2, 4)
@@ -451,3 +451,151 @@ def test_check_exit_amount_futures(mocker, default_conf, fee):
assert freqtrade.wallets.check_exit_amount(trade) is False
assert total_mock.call_count == 0
assert update_mock.call_count == 1
@pytest.mark.parametrize(
"config,wallets",
[
(
{"stake_currency": "USDT", "dry_run_wallet": 1000.0},
{"USDT": {"currency": "USDT", "free": 1000.0, "used": 0.0, "total": 1000.0}},
),
(
{"stake_currency": "USDT", "dry_run_wallet": {"USDT": 1000.0, "BTC": 0.1, "ETH": 2.0}},
{
"USDT": {"currency": "USDT", "free": 1000.0, "used": 0.0, "total": 1000.0},
"BTC": {"currency": "BTC", "free": 0.1, "used": 0.0, "total": 0.1},
"ETH": {"currency": "ETH", "free": 2.0, "used": 0.0, "total": 2.0},
},
),
(
{
"stake_currency": "USDT",
"margin_mode": "cross",
"dry_run_wallet": {"USDC": 1000.0, "BTC": 0.1, "ETH": 2.0},
},
{
# USDT wallet should be created with 0 balance, but Free balance, since
# it's converted from the other currencies
"USDT": {"currency": "USDT", "free": 4200.0, "used": 0.0, "total": 0.0},
"USDC": {"currency": "USDC", "free": 1000.0, "used": 0.0, "total": 1000.0},
"BTC": {"currency": "BTC", "free": 0.1, "used": 0.0, "total": 0.1},
"ETH": {"currency": "ETH", "free": 2.0, "used": 0.0, "total": 2.0},
},
),
(
{
"stake_currency": "USDT",
"margin_mode": "cross",
"dry_run_wallet": {"USDT": 500, "USDC": 1000.0, "BTC": 0.1, "ETH": 2.0},
},
{
# USDT wallet should be created with 500 balance, but Free balance, since
# it's converted from the other currencies
"USDT": {"currency": "USDT", "free": 4700.0, "used": 0.0, "total": 500.0},
"USDC": {"currency": "USDC", "free": 1000.0, "used": 0.0, "total": 1000.0},
"BTC": {"currency": "BTC", "free": 0.1, "used": 0.0, "total": 0.1},
"ETH": {"currency": "ETH", "free": 2.0, "used": 0.0, "total": 2.0},
},
),
(
# Same as above, but without cross
{
"stake_currency": "USDT",
"dry_run_wallet": {"USDT": 500, "USDC": 1000.0, "BTC": 0.1, "ETH": 2.0},
},
{
# No "free" transfer for USDT wallet
"USDT": {"currency": "USDT", "free": 500.0, "used": 0.0, "total": 500.0},
"USDC": {"currency": "USDC", "free": 1000.0, "used": 0.0, "total": 1000.0},
"BTC": {"currency": "BTC", "free": 0.1, "used": 0.0, "total": 0.1},
"ETH": {"currency": "ETH", "free": 2.0, "used": 0.0, "total": 2.0},
},
),
(
# Same as above, but with futures and cross
{
"stake_currency": "USDT",
"margin_mode": "cross",
"trading_mode": "futures",
"dry_run_wallet": {"USDT": 500, "USDC": 1000.0, "BTC": 0.1, "ETH": 2.0},
},
{
# USDT wallet should be created with 500 balance, but Free balance, since
# it's converted from the other currencies
"USDT": {"currency": "USDT", "free": 4700.0, "used": 0.0, "total": 500.0},
"USDC": {"currency": "USDC", "free": 1000.0, "used": 0.0, "total": 1000.0},
"BTC": {"currency": "BTC", "free": 0.1, "used": 0.0, "total": 0.1},
"ETH": {"currency": "ETH", "free": 2.0, "used": 0.0, "total": 2.0},
},
),
],
)
def test_dry_run_wallet_initialization(mocker, default_conf_usdt, config, wallets):
default_conf_usdt.update(config)
mocker.patch(
f"{EXMS}.get_tickers",
return_value={
"USDC/USDT": {"last": 1.0},
"BTC/USDT": {"last": 20_000.0},
"ETH/USDT": {"last": 1100.0},
},
)
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
stake_currency = config["stake_currency"]
# Verify each wallet matches the expected values
for currency, expected_wallet in wallets.items():
wallet = freqtrade.wallets._wallets[currency]
assert wallet.currency == expected_wallet["currency"]
assert wallet.free == expected_wallet["free"]
assert wallet.used == expected_wallet["used"]
assert wallet.total == expected_wallet["total"]
# Verify no extra wallets were created
assert len(freqtrade.wallets._wallets) == len(wallets)
# Create a trade and verify the new currency is added to the wallets
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.0)
mocker.patch(f"{EXMS}.get_rate", return_value=2.22)
mocker.patch(
f"{EXMS}.fetch_ticker",
return_value={
"bid": 0.20,
"ask": 0.22,
"last": 0.22,
},
)
# Without position, collateral will be the same as free
assert freqtrade.wallets.get_collateral() == freqtrade.wallets.get_free(stake_currency)
freqtrade.execute_entry("NEO/USDT", 100.0)
# Update wallets and verify NEO is now included
freqtrade.wallets.update()
if default_conf_usdt["trading_mode"] != "futures":
assert "NEO" in freqtrade.wallets._wallets
assert freqtrade.wallets._wallets["NEO"].total == 45.04504504 # 100 USDT / 0.22
assert freqtrade.wallets._wallets["NEO"].used == 0.0
assert freqtrade.wallets._wallets["NEO"].free == 45.04504504
assert freqtrade.wallets.get_collateral() == freqtrade.wallets.get_free(stake_currency)
# Verify USDT wallet was reduced by trade amount
assert (
pytest.approx(freqtrade.wallets._wallets[stake_currency].total)
== wallets[stake_currency]["total"] - 100.0
)
assert len(freqtrade.wallets._wallets) == len(wallets) + 1 # Original wallets + NEO
else:
# Futures mode
assert "NEO" not in freqtrade.wallets._wallets
assert freqtrade.wallets._positions["NEO/USDT"].position == 45.04504504
assert pytest.approx(freqtrade.wallets._positions["NEO/USDT"].collateral) == 100
# Verify USDT wallet's free was reduced by trade amount
assert (
pytest.approx(freqtrade.wallets.get_collateral())
== freqtrade.wallets.get_free(stake_currency) + 100
)
assert (
pytest.approx(freqtrade.wallets._wallets[stake_currency].free)
== wallets[stake_currency]["free"] - 100.0
)