diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 906666552..a33f75428 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,7 @@ version: 2 updates: - package-ecosystem: docker cooldown: - default-days: 4 + default-days: 7 directories: - "/" - "/docker" @@ -16,7 +16,7 @@ updates: - package-ecosystem: devcontainers directory: "/" cooldown: - default-days: 4 + default-days: 7 schedule: interval: daily open-pull-requests-limit: 10 @@ -24,7 +24,7 @@ updates: - package-ecosystem: pip directory: "/" cooldown: - default-days: 4 + default-days: 7 exclude: - ccxt schedule: @@ -51,7 +51,7 @@ updates: - package-ecosystem: "github-actions" directory: "/" cooldown: - default-days: 4 + default-days: 7 schedule: interval: "weekly" open-pull-requests-limit: 10 diff --git a/.github/workflows/binance-lev-tier-update.yml b/.github/workflows/binance-lev-tier-update.yml index 9ec4c3ec0..05ac6c01e 100644 --- a/.github/workflows/binance-lev-tier-update.yml +++ b/.github/workflows/binance-lev-tier-update.yml @@ -15,7 +15,7 @@ jobs: environment: name: develop steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -34,7 +34,7 @@ jobs: run: python build_helpers/binance_update_lev_tiers.py - - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: token: ${{ secrets.REPO_SCOPED_TOKEN }} add-paths: freqtrade/exchange/binance_leverage_tiers.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14a55ff7a..a341741f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -38,7 +38,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4 with: activate-environment: true enable-cache: true @@ -177,7 +177,7 @@ jobs: name: "Mypy Version Check" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -195,7 +195,7 @@ jobs: name: "Pre-commit checks" runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -208,7 +208,7 @@ jobs: name: "Documentation build" runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -240,7 +240,7 @@ jobs: name: "Tests and Linting - Online tests" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -250,7 +250,7 @@ jobs: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4 with: activate-environment: true enable-cache: true @@ -320,7 +320,7 @@ jobs: with: jobs: ${{ toJSON(needs) }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -367,7 +367,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -396,7 +396,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index da033cfb8..f109ddf7a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -19,7 +19,7 @@ jobs: name: Deploy Docs through mike runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: true diff --git a/.github/workflows/devcontainer-build.yml b/.github/workflows/devcontainer-build.yml index 9946af5e9..429a2bbae 100644 --- a/.github/workflows/devcontainer-build.yml +++ b/.github/workflows/devcontainer-build.yml @@ -24,7 +24,7 @@ jobs: packages: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false - name: Login to GitHub Container Registry diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b9faa7b75..6790b3497 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -33,7 +33,7 @@ jobs: if: github.repository == 'freqtrade/freqtrade' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -152,7 +152,7 @@ jobs: if: github.repository == 'freqtrade/freqtrade' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false diff --git a/.github/workflows/docker-update-readme.yml b/.github/workflows/docker-update-readme.yml index a510aadf6..de2e5faee 100644 --- a/.github/workflows/docker-update-readme.yml +++ b/.github/workflows/docker-update-readme.yml @@ -11,7 +11,7 @@ jobs: dockerHubDescription: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false diff --git a/.github/workflows/pre-commit-update.yml b/.github/workflows/pre-commit-update.yml index 666207901..601c61d76 100644 --- a/.github/workflows/pre-commit-update.yml +++ b/.github/workflows/pre-commit-update.yml @@ -13,7 +13,7 @@ jobs: auto-update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.0 with: persist-credentials: false @@ -28,7 +28,7 @@ jobs: - name: Run auto-update run: pre-commit autoupdate - - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: token: ${{ secrets.REPO_SCOPED_TOKEN }} add-paths: .pre-commit-config.yaml diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 0bceed8b0..594b6507d 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -22,9 +22,9 @@ jobs: # actions: read # only needed for private repos steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@v6.0.0 with: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0 + uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a578c707..7375708e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: # stages: [push] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.18.2" + rev: "v1.19.0" hooks: - id: mypy exclude: build_helpers @@ -31,7 +31,7 @@ repos: - types-requests==2.32.4.20250913 - types-tabulate==0.9.0.20241207 - types-python-dateutil==2.9.0.20251115 - - scipy-stubs==1.16.3.0 + - scipy-stubs==1.16.3.1 - SQLAlchemy==2.0.44 # stages: [push] @@ -44,7 +44,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: 'v0.14.6' + rev: 'v0.14.7' hooks: - id: ruff - id: ruff-format @@ -83,6 +83,6 @@ repos: # Ensure github actions remain safe - repo: https://github.com/woodruffw/zizmor-pre-commit - rev: v1.16.3 + rev: v1.18.0 hooks: - id: zizmor diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 40f8e4b45..b98977366 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -2,6 +2,6 @@ markdown==3.10 mkdocs==1.6.1 mkdocs-material==9.7.0 mdx_truly_sane_lists==1.3 -pymdown-extensions==10.17.1 +pymdown-extensions==10.17.2 jinja2==3.1.6 mike==2.1.3 diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 5853d23e1..727075215 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -634,7 +634,7 @@ class AwesomeStrategy(IStrategy): ## Custom order price rules -By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. +By default, freqtrade use the orderbook to automatically set an order price ([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits. diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 641600a4d..a7acc6638 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,6 +1,6 @@ """Freqtrade bot""" -__version__ = "2025.11-dev" +__version__ = "2025.12-dev" if "dev" in __version__: from pathlib import Path diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index 8f4a8d6c5..1467497d6 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -308,11 +308,15 @@ def _download_pair_history( candle_type=candle_type, until_ms=until_ms if until_ms else None, ) - logger.info(f"Downloaded data for {pair} with length {len(new_dataframe)}.") + logger.info( + f"Downloaded data for {pair}, {timeframe}, {candle_type} with length " + f"{len(new_dataframe)}." + ) else: new_dataframe = pair_candles logger.info( - f"Downloaded data for {pair} with length {len(new_dataframe)}. Parallel Method." + f"Downloaded data for {pair}, {timeframe}, {candle_type} with length " + f"{len(new_dataframe)}. Parallel Method." ) if data.empty: diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index a2d1eb859..717844db7 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -104,6 +104,7 @@ from freqtrade.misc import ( deep_merge_dicts, file_dump_json, file_load_json, + safe_value_fallback, safe_value_fallback2, ) from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now @@ -1119,6 +1120,7 @@ class Exchange: leverage: float, params: dict | None = None, stop_loss: bool = False, + stop_price: float | None = None, ) -> CcxtOrder: now = dt_now() order_id = f"dry_run_{side}_{pair}_{now.timestamp()}" @@ -1145,7 +1147,7 @@ class Exchange: } if stop_loss: dry_order["info"] = {"stopPrice": dry_order["price"]} - dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"] + dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"] # Workaround to avoid filling stoploss orders immediately dry_order["ft_order_type"] = "stoploss" orderbook: OrderBook | None = None @@ -1163,7 +1165,11 @@ class Exchange: if dry_order["type"] == "market" and not dry_order.get("ft_order_type"): # Update market order pricing - average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook) + slippage = 0.05 + worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage)) + average = self.get_dry_market_fill_price( + pair, side, amount, rate, worst_rate, orderbook + ) dry_order.update( { "average": average, @@ -1203,7 +1209,13 @@ class Exchange: return dry_order def get_dry_market_fill_price( - self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None + self, + pair: str, + side: str, + amount: float, + rate: float, + worst_rate: float, + orderbook: OrderBook | None, ) -> float: """ Get the market order fill price based on orderbook interpolation @@ -1212,8 +1224,6 @@ class Exchange: if not orderbook: orderbook = self.fetch_l2_order_book(pair, 20) ob_type: OBLiteral = "asks" if side == "buy" else "bids" - slippage = 0.05 - max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage)) remaining_amount = amount filled_value = 0.0 @@ -1237,11 +1247,10 @@ class Exchange: forecast_avg_filled_price = max(filled_value, 0) / amount # Limit max. slippage to specified value if side == "buy": - forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val) + forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate) else: - forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val) - + forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate) return self.price_to_precision(pair, forecast_avg_filled_price) return rate @@ -1253,13 +1262,15 @@ class Exchange: limit: float, orderbook: OrderBook | None = None, offset: float = 0.0, + is_stop: bool = False, ) -> bool: if not self.exchange_has("fetchL2OrderBook"): - return True + # True unless checking a stoploss order + return not is_stop if not orderbook: orderbook = self.fetch_l2_order_book(pair, 1) try: - if side == "buy": + if (side == "buy" and not is_stop) or (side == "sell" and is_stop): price = orderbook["asks"][0][0] if limit * (1 - offset) >= price: return True @@ -1278,6 +1289,38 @@ class Exchange: """ Check dry-run limit order fill and update fee (if it filled). """ + if order["status"] != "closed" and order.get("ft_order_type") == "stoploss": + pair = order["symbol"] + if not orderbook and self.exchange_has("fetchL2OrderBook"): + orderbook = self.fetch_l2_order_book(pair, 20) + price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price") + crossed = self._dry_is_price_crossed( + pair, order["side"], price, orderbook, is_stop=True + ) + if crossed: + average = self.get_dry_market_fill_price( + pair, + order["side"], + order["amount"], + price, + worst_rate=order["price"], + orderbook=orderbook, + ) + order.update( + { + "status": "closed", + "filled": order["amount"], + "remaining": 0, + "average": average, + "cost": order["amount"] * average, + } + ) + self.add_dry_order_fee( + pair, + order, + "taker" if immediate else "maker", + ) + return order if ( order["status"] != "closed" and order["type"] in ["limit"] @@ -1517,8 +1560,9 @@ class Exchange: ordertype, side, amount, - stop_price_norm, + limit_rate or stop_price_norm, stop_loss=True, + stop_price=stop_price_norm, leverage=leverage, ) return dry_order @@ -3740,10 +3784,11 @@ class Exchange: :param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price) :param futures_funding_rate: Fake funding rate to use if funding_rates are not available """ + relevant_cols = ["date", "open_mark", "open_fund"] if futures_funding_rate is None: return mark_rates.merge( funding_rates, on="date", how="inner", suffixes=["_mark", "_fund"] - ) + )[relevant_cols] else: if len(funding_rates) == 0: # No funding rate candles - full fillup with fallback variable @@ -3756,7 +3801,7 @@ class Exchange: "low": "low_mark", "volume": "volume_mark", } - ) + )[relevant_cols] else: # Fill up missing funding_rate candles with fallback value @@ -3764,7 +3809,7 @@ class Exchange: funding_rates, on="date", how="left", suffixes=["_mark", "_fund"] ) combined["open_fund"] = combined["open_fund"].fillna(futures_funding_rate) - return combined + return combined[relevant_cols] def calculate_funding_fees( self, diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 6485b5d11..09afd8528 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1063,7 +1063,16 @@ class FreqtradeBot(LoggingMixin): return True - def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade: + def cancel_stoploss_on_exchange(self, trade: Trade, allow_nonblocking: bool = False) -> Trade: + """ + Cancels on exchange stoploss orders for the given trade. + :param trade: Trade for which to cancel stoploss order + :param allow_nonblocking: If True, will skip cancelling stoploss on exchange + if the exchange supports blocking stoploss orders. + """ + if allow_nonblocking and not self.exchange.get_option("stoploss_blocks_assets", True): + logger.info(f"Skipping cancelling stoploss on exchange for {trade}.") + return trade # First cancelling stoploss on exchange ... for oslo in trade.open_sl_orders: try: @@ -2088,7 +2097,7 @@ class FreqtradeBot(LoggingMixin): limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) # First cancelling stoploss on exchange ... - trade = self.cancel_stoploss_on_exchange(trade) + trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True) order_type = ordertype or self.strategy.order_types[exit_type] if exit_check.exit_type == ExitType.EMERGENCY_EXIT: @@ -2378,6 +2387,8 @@ class FreqtradeBot(LoggingMixin): self.strategy.ft_stoploss_adjust( current_rate, trade, datetime.now(UTC), profit, 0, after_fill=True ) + if not trade.is_open: + self.cancel_stoploss_on_exchange(trade) # Updating wallets when order is closed self.wallets.update() return trade diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index bc4d1c47e..85f072747 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -48,7 +48,7 @@ from freqtrade.leverage import interest from freqtrade.misc import safe_value_fallback from freqtrade.persistence.base import ModelBase, SessionType from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData -from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none +from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none, round_value logger = logging.getLogger(__name__) @@ -654,9 +654,10 @@ class LocalTrade: ) return ( - f"Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, " - f"is_short={self.is_short or False}, leverage={self.leverage or 1.0}, " - f"open_rate={self.open_rate:.8f}, open_since={open_since})" + f"Trade(id={self.id}, pair={self.pair}, amount={round_value(self.amount, 8)}, " + f"is_short={self.is_short or False}, " + f"leverage={round_value(self.leverage or 1.0, 1)}, " + f"open_rate={round_value(self.open_rate, 8)}, open_since={open_since})" ) def to_json(self, minified: bool = False) -> dict[str, Any]: diff --git a/freqtrade/util/datetime_helpers.py b/freqtrade/util/datetime_helpers.py index e2390105d..b6535db5d 100644 --- a/freqtrade/util/datetime_helpers.py +++ b/freqtrade/util/datetime_helpers.py @@ -90,15 +90,16 @@ def dt_humanize_delta(dt: datetime): return humanize.naturaltime(dt) -def format_date(date: datetime | None) -> str: +def format_date(date: datetime | None, fallback: str = "") -> str: """ Return a formatted date string. Returns an empty string if date is None. :param date: datetime to format + :param fallback: value to return if date is None """ if date: return date.strftime(DATETIME_PRINT_FORMAT) - return "" + return fallback def format_ms_time(date: int | float) -> str: diff --git a/freqtrade/util/formatters.py b/freqtrade/util/formatters.py index 3d7493a2a..7ca028984 100644 --- a/freqtrade/util/formatters.py +++ b/freqtrade/util/formatters.py @@ -23,7 +23,7 @@ def strip_trailing_zeros(value: str) -> str: return value.rstrip("0").rstrip(".") -def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str: +def round_value(value: float | None, decimals: int, keep_trailing_zeros=False) -> str: """ Round value to given decimals :param value: Value to be rounded @@ -31,7 +31,7 @@ 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): + if value is None or isnan(value): return "N/A" val = f"{value:.{decimals}f}" if not keep_trailing_zeros: diff --git a/ft_client/freqtrade_client/__init__.py b/ft_client/freqtrade_client/__init__.py index 9182ce800..d9479dba0 100644 --- a/ft_client/freqtrade_client/__init__.py +++ b/ft_client/freqtrade_client/__init__.py @@ -1,7 +1,7 @@ from freqtrade_client.ft_rest_client import FtRestClient -__version__ = "2025.11-dev" +__version__ = "2025.12-dev" if "dev" in __version__: from pathlib import Path diff --git a/requirements-dev.txt b/requirements-dev.txt index f9386777e..a9add54c4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,9 +6,9 @@ -r requirements-freqai-rl.txt -r docs/requirements-docs.txt -ruff==0.14.5 +ruff==0.14.6 mypy==1.18.2 -pre-commit==4.4.0 +pre-commit==4.5.0 pytest==9.0.1 pytest-asyncio==1.3.0 pytest-cov==7.0.0 @@ -18,13 +18,13 @@ pytest-timeout==2.4.0 pytest-xdist==3.8.0 isort==7.0.0 # For datetime mocking -time-machine==3.0.0 +time-machine==3.1.0 # Convert jupyter notebooks to markdown documents nbconvert==7.16.6 # mypy types -scipy-stubs==1.16.3.0 # keep in sync with `scipy` in `requirements-hyperopt.txt` +scipy-stubs==1.16.3.1 # keep in sync with `scipy` in `requirements-hyperopt.txt` types-cachetools==6.2.0.20251022 types-filelock==3.2.7 types-requests==2.32.4.20250913 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index d50e2de78..24bf58986 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -7,6 +7,6 @@ scikit-learn==1.7.2 joblib==1.5.2 catboost==1.2.8; 'arm' not in platform_machine lightgbm==4.6.0 -xgboost==3.1.1 +xgboost==3.1.2 tensorboard==2.20.0 datasieve==0.1.9 diff --git a/requirements.txt b/requirements.txt index 43f79ab25..4efa1fa87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16 ta-lib==0.6.8 technical==1.5.3 -ccxt==4.5.20 +ccxt==4.5.22 cryptography==46.0.3 aiohttp==3.13.2 SQLAlchemy==2.0.44 @@ -37,8 +37,8 @@ orjson==3.11.4 sdnotify==0.3.2 # API Server -fastapi==0.121.3 -pydantic==2.12.4 +fastapi==0.122.0 +pydantic==2.12.5 uvicorn==0.38.0 pyjwt==2.10.1 aiofiles==25.1.0 diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 2dc3204db..115e73192 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -896,7 +896,7 @@ def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path, assert get_historic_ohlcv_mock.call_count == 0 # Verify the log message indicating parallel method was used (line 315-316) - assert log_has("Downloaded data for TEST/BTC with length 3. Parallel Method.", caplog) + assert log_has("Downloaded data for TEST/BTC, 5m, spot with length 3. Parallel Method.", caplog) # Verify data was stored assert data_handler_mock.ohlcv_store.call_count == 1 diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 2c11fc2f3..59a6c41ae 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker): assert "type" in order assert order["type"] == order_type - assert order["price"] == 220 + assert order["price"] == 217.8 + assert order["stopPrice"] == 220 assert order["amount"] == 1 diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 63dd0be94..8d7e0b0d8 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees( @pytest.mark.parametrize( - "side,limit,offset,expected", + "side,limit,offset,is_stop,expected", [ - ("buy", 46.0, 0.0, True), - ("buy", 26.0, 0.0, True), - ("buy", 25.55, 0.0, False), - ("buy", 1, 0.0, False), # Very far away - ("sell", 25.5, 0.0, True), - ("sell", 50, 0.0, False), # Very far away - ("sell", 25.58, 0.0, False), - ("sell", 25.563, 0.01, False), - ("sell", 5.563, 0.01, True), + ("buy", 46.0, 0.0, False, True), + ("buy", 46.0, 0.0, True, False), + ("buy", 26.0, 0.0, False, True), + ("buy", 26.0, 0.0, True, False), # Stop - didn't trigger + ("buy", 25.55, 0.0, False, False), + ("buy", 25.55, 0.0, True, True), # Stop - triggered + ("buy", 1, 0.0, False, False), # Very far away + ("buy", 1, 0.0, True, True), # Current price is above stop - triggered + ("sell", 25.5, 0.0, False, True), + ("sell", 50, 0.0, False, False), # Very far away + ("sell", 25.58, 0.0, False, False), + ("sell", 25.563, 0.01, False, False), + ("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid + ("sell", 25.566, 0.0, True, True), # stop order - triggered + ("sell", 26, 0.01, True, True), # stop order - triggered + ("sell", 5.563, 0.01, False, True), + ("sell", 5.563, 0.0, True, False), # stop order - not triggered ], ) def test__dry_is_price_crossed_with_orderbook( - default_conf, mocker, order_book_l2_usd, side, limit, offset, expected + default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected ): # Best bid 25.563 # Best ask 25.566 @@ -1134,14 +1142,14 @@ def test__dry_is_price_crossed_with_orderbook( exchange.fetch_l2_order_book = order_book_l2_usd orderbook = order_book_l2_usd.return_value result = exchange._dry_is_price_crossed( - "LTC/USDT", side, limit, orderbook=orderbook, offset=offset + "LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop ) assert result is expected assert order_book_l2_usd.call_count == 0 # Test without passing orderbook order_book_l2_usd.reset_mock() - result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset) + result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop) assert result is expected @@ -1165,7 +1173,10 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker): exchange.fetch_l2_order_book = MagicMock() mocker.patch(f"{EXMS}.exchange_has", return_value=False) assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0) + assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0) assert exchange.fetch_l2_order_book.call_count == 0 + assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True) + assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True) @pytest.mark.parametrize( @@ -1176,7 +1187,7 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker): (False, False, "sell", 1.0, "open", None, 0, None), ], ) -def test_check_dry_limit_order_filled_parametrized( +def test_check_dry_limit_order_filled( default_conf, mocker, crossed, @@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized( assert fee_mock.call_count == expected_calls +@pytest.mark.parametrize( + "immediate,crossed,expected_status,expected_fee_type", + [ + (True, True, "closed", "taker"), + (False, True, "closed", "maker"), + (True, False, "open", None), + ], +) +def test_check_dry_limit_order_filled_stoploss( + default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd +): + exchange = get_patched_exchange(mocker, default_conf) + mocker.patch.multiple( + EXMS, + exchange_has=MagicMock(return_value=True), + _dry_is_price_crossed=MagicMock(return_value=crossed), + fetch_l2_order_book=order_book_l2_usd, + ) + average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25) + fee_mock = mocker.patch( + f"{EXMS}.add_dry_order_fee", + autospec=True, + side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order, + ) + + amount = 1.75 + order = { + "symbol": "LTC/USDT", + "status": "open", + "type": "limit", + "side": "sell", + "amount": amount, + "filled": 0.0, + "remaining": amount, + "price": 25.0, + "average": 0.0, + "cost": 0.0, + "fee": None, + "ft_order_type": "stoploss", + "stopLossPrice": 24.5, + } + + result = exchange.check_dry_limit_order_filled(order, immediate=immediate) + + assert result["status"] == expected_status + assert order_book_l2_usd.call_count == 1 + if crossed: + assert result["filled"] == amount + assert result["remaining"] == 0 + assert result["average"] == 24.25 + assert result["cost"] == pytest.approx(amount * 24.25) + assert average_mock.call_count == 1 + assert fee_mock.call_count == 1 + assert fee_mock.call_args[0][1] == "LTC/USDT" + assert fee_mock.call_args[0][3] == expected_fee_type + else: + assert result["filled"] == 0.0 + assert result["remaining"] == amount + assert result["average"] == 0.0 + + assert average_mock.call_count == 0 + assert fee_mock.call_count == 0 + + @pytest.mark.parametrize( "side,price,filled,converted", [ @@ -5229,6 +5304,7 @@ def test_combine_funding_and_mark( {"date": trade_date, "open": mark_price}, ] ) + # Test fallback to futures funding rate for missing funding rates df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate) if futures_funding_rate is not None: @@ -5256,6 +5332,33 @@ def test_combine_funding_and_mark( assert len(df) == 0 + # Test fallback to futures funding rate for middle missing funding rate + funding_rates = DataFrame( + [ + {"date": prior2_date, "open": funding_rate}, + # missing 1 hour + {"date": trade_date, "open": funding_rate}, + ], + ) + mark_rates = DataFrame( + [ + {"date": prior2_date, "open": mark_price}, + {"date": prior_date, "open": mark_price}, + {"date": trade_date, "open": mark_price}, + ] + ) + df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate) + + if futures_funding_rate is not None: + assert len(df) == 3 + assert df.iloc[0]["open_fund"] == funding_rate + assert df.iloc[1]["open_fund"] == futures_funding_rate + assert df.iloc[2]["open_fund"] == funding_rate + assert df["date"].to_list() == [prior2_date, prior_date, trade_date] + else: + assert len(df) == 2 + assert df["date"].to_list() == [prior2_date, trade_date] + @pytest.mark.parametrize( "exchange,rate_start,rate_end,d1,d2,amount,expected_fees", diff --git a/tests/exchange/test_htx.py b/tests/exchange/test_htx.py index e32d7e85e..bffbde8c0 100644 --- a/tests/exchange/test_htx.py +++ b/tests/exchange/test_htx.py @@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker): assert "type" in order assert order["type"] == order_type - assert order["price"] == 220 + assert order["price"] == 217.8 + assert order["stopPrice"] == 220 assert order["amount"] == 1 diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index e36d70c12..f8b790c44 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -2548,9 +2548,9 @@ def test_manage_open_orders_exception( caplog.clear() freqtrade.manage_open_orders() assert log_has_re( - r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30.00000000, " - r"is_short=False, leverage=1.0, " - r"open_rate=2.00000000, open_since=" + r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30, " + r"is_short=False, leverage=1, " + r"open_rate=2, open_since=" f"{open_trade_usdt.open_date.strftime('%Y-%m-%d %H:%M:%S')}" r"\) due to Traceback \(most recent call last\):\n*", caplog, @@ -3751,8 +3751,8 @@ def test_get_real_amount_quote( # Amount is reduced by "fee" assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == (amount * 0.001) assert log_has( - "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False," - " leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.008.", + "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, is_short=False," + " leverage=1, open_rate=0.245441, open_since=closed), fee=0.008.", caplog, ) @@ -3805,8 +3805,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock # Amount is reduced by "fee" assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) is None assert log_has( - "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, " - "is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed) failed: " + "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, " + "is_short=False, leverage=1, open_rate=0.245441, open_since=closed) failed: " "myTrade-dict empty found", caplog, ) @@ -3825,8 +3825,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock 0, True, ( - "Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False, " - "leverage=1.0, open_rate=0.24544100, open_since=closed) [buy]: 0.00094518 BNB -" + "Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8, is_short=False, " + "leverage=1, open_rate=0.245441, open_since=closed) [buy]: 0.00094518 BNB -" " rate: None" ), ), @@ -3836,8 +3836,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock 0.004, False, ( - "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, " - "is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.004." + "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, " + "is_short=False, leverage=1, open_rate=0.245441, open_since=closed), fee=0.004." ), ), # invalid, no currency in from fee dict @@ -3941,8 +3941,8 @@ def test_get_real_amount_multi( assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == expected_amount assert log_has( ( - "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, " - "is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), " + "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, " + "is_short=False, leverage=1, open_rate=0.245441, open_since=closed), " f"fee={expected_amount}." ), caplog, diff --git a/tests/freqtradebot/test_integration.py b/tests/freqtradebot/test_integration.py index 2cb4b6aa8..5d3df4c3b 100644 --- a/tests/freqtradebot/test_integration.py +++ b/tests/freqtradebot/test_integration.py @@ -50,7 +50,14 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee, stoploss_order_mock = MagicMock(side_effect=stop_orders) # Sell 3rd trade (not called for the first trade) should_sell_mock = MagicMock(side_effect=[[], [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)]]) - cancel_order_mock = MagicMock() + + def patch_stoploss(order_id, *args, **kwargs): + slo = stoploss_order_open.copy() + slo["id"] = order_id + slo["status"] = "canceled" + return slo + + cancel_order_mock = MagicMock(side_effect=patch_stoploss) mocker.patch.multiple( EXMS, create_stoploss=stoploss, @@ -793,9 +800,13 @@ def test_dca_handle_similar_open_order( # Should Create a new exit order freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=2) freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-2) + msg = r"Skipping cancelling stoploss on exchange for.*" mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False) + assert not log_has_re(msg, caplog) freqtrade.process() + assert log_has_re(msg, caplog) + trade = Trade.get_trades().first() assert trade.orders[-2].status == "closed" diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 8fcc59691..6e779137d 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -372,8 +372,8 @@ def test_borrowed(fee, is_short, lev, borrowed, trading_mode): @pytest.mark.parametrize( "is_short,open_rate,close_rate,lev,profit,trading_mode", [ - (False, 2.0, 2.2, 1.0, 0.09451372, spot), - (True, 2.2, 2.0, 3.0, 0.25894253, margin), + (False, 2, 2.2, 1, 0.09451372, spot), + (True, 2.2, 2.0, 3, 0.25894253, margin), ], ) @pytest.mark.usefixtures("init_persistence") @@ -493,8 +493,8 @@ def test_update_limit_order( assert trade.close_date is None assert log_has_re( f"LIMIT_{entry_side.upper()} has been fulfilled for " - r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, " - f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, " + r"Trade\(id=2, pair=ADA/USDT, amount=30, " + f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, " r"open_since=.*\).", caplog, ) @@ -511,8 +511,8 @@ def test_update_limit_order( assert trade.close_date is not None assert log_has_re( f"LIMIT_{exit_side.upper()} has been fulfilled for " - r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, " - f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, " + r"Trade\(id=2, pair=ADA/USDT, amount=30, " + f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, " r"open_since=.*\).", caplog, ) @@ -545,8 +545,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee, assert trade.close_date is None assert log_has_re( r"MARKET_BUY has been fulfilled for Trade\(id=1, " - r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, " - r"open_rate=2.00000000, open_since=.*\).", + r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, " + r"open_rate=2, open_since=.*\).", caplog, ) @@ -561,8 +561,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee, assert trade.close_date is not None assert log_has_re( r"MARKET_SELL has been fulfilled for Trade\(id=1, " - r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, " - r"open_rate=2.00000000, open_since=.*\).", + r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, " + r"open_rate=2, open_since=.*\).", caplog, ) diff --git a/tests/util/test_datetime_helpers.py b/tests/util/test_datetime_helpers.py index 82dd6dbf6..8ccb8ba20 100644 --- a/tests/util/test_datetime_helpers.py +++ b/tests/util/test_datetime_helpers.py @@ -108,6 +108,7 @@ def test_format_date() -> None: date = datetime(2021, 9, 30, 22, 59, 3, 455555, tzinfo=UTC) assert format_date(date) == "2021-09-30 22:59:03" assert format_date(None) == "" + assert format_date(None, "closed") == "closed" def test_format_ms_time_detailed() -> None: diff --git a/tests/util/test_formatters.py b/tests/util/test_formatters.py index a884c0750..a7463fdfa 100644 --- a/tests/util/test_formatters.py +++ b/tests/util/test_formatters.py @@ -57,6 +57,8 @@ def test_round_value(): 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" + assert round_value(None, 10, True) == "N/A" + assert round_value(None, 1, True) == "N/A" def test_format_duration():