Merge branch 'freqtrade:develop' into feature/hyperliquid-hip3-support

This commit is contained in:
igi01
2025-12-21 07:30:20 +01:00
committed by GitHub
69 changed files with 5800 additions and 6249 deletions
@@ -15,7 +15,7 @@ jobs:
environment: environment:
name: develop name: develop
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
+8 -8
View File
@@ -28,7 +28,7 @@ jobs:
python-version: ["3.11", "3.12", "3.13"] python-version: ["3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
name: "Mypy Version Check" name: "Mypy Version Check"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -195,7 +195,7 @@ jobs:
name: "Pre-commit checks" name: "Pre-commit checks"
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -208,7 +208,7 @@ jobs:
name: "Documentation build" name: "Documentation build"
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -240,7 +240,7 @@ jobs:
name: "Tests and Linting - Online tests" name: "Tests and Linting - Online tests"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -320,7 +320,7 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -367,7 +367,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -396,7 +396,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
name: Deploy Docs through mike name: Deploy Docs through mike
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: true persist-credentials: true
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
packages: write packages: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
+8 -2
View File
@@ -33,10 +33,13 @@ jobs:
if: github.repository == 'freqtrade/freqtrade' if: github.repository == 'freqtrade/freqtrade'
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Visualize disk usage before build
run: df -h
- name: Set docker tag names - name: Set docker tag names
id: tags id: tags
uses: ./.github/actions/docker-tags uses: ./.github/actions/docker-tags
@@ -142,6 +145,9 @@ jobs:
run: | run: |
docker images docker images
- name: Visualize disk usage after build
run: df -h
deploy-arm: deploy-arm:
name: "Deploy Docker ARM64" name: "Deploy Docker ARM64"
permissions: permissions:
@@ -152,7 +158,7 @@ jobs:
if: github.repository == 'freqtrade/freqtrade' if: github.repository == 'freqtrade/freqtrade'
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
dockerHubDescription: dockerHubDescription:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
auto-update: auto-update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.0 - uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
# actions: read # only needed for private repos # actions: read # only needed for private repos
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.0 uses: actions/checkout@v6.0.1
with: with:
persist-credentials: false persist-credentials: false
+3 -3
View File
@@ -21,7 +21,7 @@ repos:
# stages: [push] # stages: [push]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.19.0" rev: "v1.19.1"
hooks: hooks:
- id: mypy - id: mypy
exclude: build_helpers exclude: build_helpers
@@ -31,7 +31,7 @@ repos:
- types-requests==2.32.4.20250913 - types-requests==2.32.4.20250913
- types-tabulate==0.9.0.20241207 - types-tabulate==0.9.0.20241207
- types-python-dateutil==2.9.0.20251115 - types-python-dateutil==2.9.0.20251115
- scipy-stubs==1.16.3.1 - scipy-stubs==1.16.3.2
- SQLAlchemy==2.0.44 - SQLAlchemy==2.0.44
# stages: [push] # stages: [push]
@@ -44,7 +44,7 @@ repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: 'v0.14.7' rev: 'v0.14.9'
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
+2 -2
View File
@@ -15,7 +15,7 @@ This software is for educational purposes only. Do not risk money which
you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS
AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS. AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS.
Always start by running a trading bot in Dry-run and do not engage money Always start by running a trading bot in Dry-Run and do not engage money
before you understand how it works and what profit/loss you should before you understand how it works and what profit/loss you should
expect. expect.
@@ -24,7 +24,7 @@ hesitate to read the source code and understand the mechanism of this bot.
## Supported Exchange marketplaces ## Supported Exchange marketplaces
Please read the [exchange specific notes](docs/exchanges.md) to learn about eventual, special configurations needed for each exchange. Please read the [exchange-specific notes](docs/exchanges.md) to learn about special configurations that maybe needed for each exchange.
- [X] [Binance](https://www.binance.com/) - [X] [Binance](https://www.binance.com/)
- [X] [BingX](https://bingx.com/invite/0EM9RX) - [X] [BingX](https://bingx.com/invite/0EM9RX)
+6
View File
@@ -11,6 +11,7 @@ usage: freqtrade download-data [-h] [-v] [--no-color] [--logfile FILE] [-V]
[--data-format-ohlcv {json,jsongz,feather,parquet}] [--data-format-ohlcv {json,jsongz,feather,parquet}]
[--data-format-trades {json,jsongz,feather,parquet}] [--data-format-trades {json,jsongz,feather,parquet}]
[--trading-mode {spot,margin,futures}] [--trading-mode {spot,margin,futures}]
[--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]]
[--prepend] [--prepend]
options: options:
@@ -50,6 +51,11 @@ options:
`feather`). `feather`).
--trading-mode, --tradingmode {spot,margin,futures} --trading-mode, --tradingmode {spot,margin,futures}
Select Trading mode Select Trading mode
--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]
Select candle type to download. Defaults to the
necessary candles for the selected trading mode (e.g.
'spot' or ('futures', 'funding_rate' and 'mark') for
futures).
--prepend Allow data prepending. (Data-appending is disabled) --prepend Allow data prepending. (Data-appending is disabled)
Common arguments: Common arguments:
+1
View File
@@ -60,6 +60,7 @@ freqtrade download-data --exchange binance --pairs ".*/USDT"
* Given starting points are ignored if data is already available, downloading only missing data up to today. * Given starting points are ignored if data is already available, downloading only missing data up to today.
* Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data. * Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data.
* To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options. * To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options.
* When downloading futures data (`--trading-mode futures` or a configuration specifying futures mode), freqtrade will automatically download the necessary candle types (e.g. `mark` and `funding_rate` candles) unless specified otherwise via `--candle-types`.
??? Note "Permission denied errors" ??? Note "Permission denied errors"
If your configuration directory `user_data` was made by docker, you may get the following error: If your configuration directory `user_data` was made by docker, you may get the following error:
+37
View File
@@ -98,3 +98,40 @@ Please use configuration based [log setup](advanced-setup.md#advanced-logging) i
The edge module has been deprecated in 2023.9 and removed in 2025.6. The edge module has been deprecated in 2023.9 and removed in 2025.6.
All functionalities of edge have been removed, and having edge configured will result in an error. All functionalities of edge have been removed, and having edge configured will result in an error.
## Adjustment to dynamic funding rate handling
With version 2025.12, the handling of dynamic funding rates has been adjusted to also support dynamic funding rates down to 1h funding intervals.
As a consequence, the mark and funding rate timeframes have been changed to 1h for every supported futures exchange.
As the timeframe for both mark and funding_fee candles has changed (usually from 8h to 1h) - already downloaded data will have to be adjusted or partially re-downloaded.
You can either re-download everything (`freqtrade download-data [...] --erase` - :warning: can take a long time) - or download the updated data selectively.
### Strategy
Most strategies should not need adjustments to continue to work as expected - however, strategies using `@informative("8h", candle_type="funding_rate")` or similar will have to switch the timeframe to 1h.
The same is true for `dp.get_pair_dataframe(metadata["pair"], "8h", candle_type="funding_rate")` - which will need to be switched to 1h.
freqtrade will auto-adjust the timeframe and return `funding_rates` despite the wrongly given timeframe. It'll issue a warning - and may still break your strategy.
### Selective data re-download
The script below should serve as an example - you may need to adjust the timeframe and exchange to your needs!
``` bash
# Cleanup no longer needed data
rm user_data/data/<exchange>/futures/*-mark-*
rm user_data/data/<exchange>/futures/*-funding_rate-*
# download new data (only required once to fix the mark and funding fee data)
freqtrade download-data -t 1h --trading-mode futures --candle-types funding_rate mark [...] --timerange <full timerange you've got other data for>
```
The result of the above will be that your funding_rates and mark data will have the 1h timeframe.
you can verify this with `freqtrade list-data --exchange <yourexchange> --show`.
!!! Note "Additional arguments"
Additional arguments to the above commands may be necessary, like configuration files or explicit user_data if they deviate from the default.
**Hyperliquid** is a special case now - which will no longer require 1h mark data - but will use regular candles instead (this data never existed and is identical to 1h futures candles). As we don't support download-data for hyperliquid (they don't provide historic data) - there won't be actions necessary for hyperliquid users.
+1 -1
View File
@@ -2,6 +2,6 @@ markdown==3.10
mkdocs==1.6.1 mkdocs==1.6.1
mkdocs-material==9.7.0 mkdocs-material==9.7.0
mdx_truly_sane_lists==1.3 mdx_truly_sane_lists==1.3
pymdown-extensions==10.17.2 pymdown-extensions==10.18
jinja2==3.1.6 jinja2==3.1.6
mike==2.1.3 mike==2.1.3
+6 -1
View File
@@ -31,9 +31,14 @@ The Order-type will be ignored if only one mode is available.
--8<-- "includes/exchange-features.md" --8<-- "includes/exchange-features.md"
!!! Note "Tight stoploss" !!! Note "Tight stoploss"
<ins>Do not set too low/tight stoploss value when using stop loss on exchange!</ins> Do not set too low/tight stoploss value when using stop loss on exchange!
If set to low/tight you will have greater risk of missing fill on the order and stoploss will not work. If set to low/tight you will have greater risk of missing fill on the order and stoploss will not work.
!!! Warning "Loose stoploss"
Using stoploss on exchange with a very wide stoploss (e.g. -1) may fail to place the stoploss order on exchange due to exchange limitations.
In that case, the bot will fallback to using the `emergency_exit` order type to place a market order as placing the stoploss order failed.
Freqtrade currently does not implement a limitation to avoid this situation, so please ensure your stoploss values are within reasonable limits for your exchange or disable stoploss on exchange.
### stoploss_on_exchange and stoploss_on_exchange_limit_ratio ### stoploss_on_exchange and stoploss_on_exchange_limit_ratio
Enable or Disable stop loss on exchange. Enable or Disable stop loss on exchange.
+1 -1
View File
@@ -644,7 +644,7 @@ Each of these methods are called right before placing an order on the exchange.
If your custom pricing function return None or an invalid value, price will fall back to `proposed_rate`, which is based on the regular pricing configuration. If your custom pricing function return None or an invalid value, price will fall back to `proposed_rate`, which is based on the regular pricing configuration.
!!! Note !!! Note
Using custom_entry_price, the Trade object will be available as soon as the first entry order associated with the trade is created, for the first entry, `trade` parameter value will be `None`. When using `custom_entry_price()`, the Trade object will be available as soon as the first entry order associated with the trade is created, for the first entry, `trade` parameter value will be `None`.
### Custom order entry and exit price example ### Custom order entry and exit price example
+7 -1
View File
@@ -3,6 +3,7 @@ This module contains the argument manager class
""" """
from argparse import ArgumentParser, Namespace, _ArgumentGroup from argparse import ArgumentParser, Namespace, _ArgumentGroup
from copy import deepcopy
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -174,6 +175,7 @@ ARGS_DOWNLOAD_DATA = [
"dataformat_ohlcv", "dataformat_ohlcv",
"dataformat_trades", "dataformat_trades",
"trading_mode", "trading_mode",
"candle_types",
"prepend_data", "prepend_data",
] ]
@@ -348,7 +350,11 @@ class Arguments:
def _build_args(self, optionlist: list[str], parser: ArgumentParser | _ArgumentGroup) -> None: def _build_args(self, optionlist: list[str], parser: ArgumentParser | _ArgumentGroup) -> None:
for val in optionlist: for val in optionlist:
opt = AVAILABLE_CLI_OPTIONS[val] opt = AVAILABLE_CLI_OPTIONS[val]
parser.add_argument(*opt.cli, dest=val, **opt.kwargs) options = deepcopy(opt.kwargs)
help_text = options.pop("help", None)
if opt.fthelp and isinstance(opt.fthelp, dict) and hasattr(parser, "prog"):
help_text = opt.fthelp.get(parser.prog, help_text)
parser.add_argument(*opt.cli, dest=val, help=help_text, **options)
def _build_subcommands(self) -> None: def _build_subcommands(self) -> None:
""" """
+15 -1
View File
@@ -38,8 +38,14 @@ def check_int_nonzero(value: str) -> int:
class Arg: class Arg:
# Optional CLI arguments # Optional CLI arguments
def __init__(self, *args, **kwargs): def __init__(self, *args, fthelp: dict[str, str] | None = None, **kwargs):
"""
CLI Arguments - used to build subcommand parsers consistently.
:param fthelp: dict - fthelp per command - should be "freqtrade <command>": help_text
If not provided or not found, 'help' from kwargs is used instead.
"""
self.cli = args self.cli = args
self.fthelp = fthelp
self.kwargs = kwargs self.kwargs = kwargs
@@ -422,6 +428,14 @@ AVAILABLE_CLI_OPTIONS = {
), ),
"candle_types": Arg( "candle_types": Arg(
"--candle-types", "--candle-types",
fthelp={
"freqtrade download-data": (
"Select candle type to download. "
"Defaults to the necessary candles for the selected trading mode "
"(e.g. 'spot' or ('futures', 'funding_rate' and 'mark') for futures)."
),
"_": "Select candle type to convert. Defaults to all available types.",
},
help="Select candle type to convert. Defaults to all available types.", help="Select candle type to convert. Defaults to all available types.",
choices=[c.value for c in CandleType], choices=[c.value for c in CandleType],
nargs="+", nargs="+",
+2 -1
View File
@@ -38,7 +38,8 @@ def ohlcv_to_dataframe(
cols = DEFAULT_DATAFRAME_COLUMNS cols = DEFAULT_DATAFRAME_COLUMNS
df = DataFrame(ohlcv, columns=cols) df = DataFrame(ohlcv, columns=cols)
df["date"] = to_datetime(df["date"], unit="ms", utc=True) # Floor date to seconds to account for exchange imprecisions
df["date"] = to_datetime(df["date"], unit="ms", utc=True).dt.floor("s")
# Some exchanges return int values for Volume and even for OHLC. # Some exchanges return int values for Volume and even for OHLC.
# Convert them since TA-LIB indicators used in the strategy assume floats # Convert them since TA-LIB indicators used in the strategy assume floats
+26
View File
@@ -348,6 +348,22 @@ class DataProvider:
) )
return total_candles return total_candles
def __fix_funding_rate_timeframe(
self, pair: str, timeframe: str | None, candle_type: str
) -> str | None:
if (
candle_type == CandleType.FUNDING_RATE
and (ff_tf := self.get_funding_rate_timeframe()) != timeframe
):
# TODO: does this message make sense? might be pointless as funding fees don't
# have a timeframe
logger.warning(
f"{pair}, {timeframe} requested - funding rate timeframe not matching {ff_tf}."
)
return ff_tf
return timeframe
def get_pair_dataframe( def get_pair_dataframe(
self, pair: str, timeframe: str | None = None, candle_type: str = "" self, pair: str, timeframe: str | None = None, candle_type: str = ""
) -> DataFrame: ) -> DataFrame:
@@ -361,6 +377,7 @@ class DataProvider:
:return: Dataframe for this pair :return: Dataframe for this pair
:param candle_type: '', mark, index, premiumIndex, or funding_rate :param candle_type: '', mark, index, premiumIndex, or funding_rate
""" """
timeframe = self.__fix_funding_rate_timeframe(pair, timeframe, candle_type)
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE): if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
# Get live OHLCV data. # Get live OHLCV data.
data = self.ohlcv(pair=pair, timeframe=timeframe, candle_type=candle_type) data = self.ohlcv(pair=pair, timeframe=timeframe, candle_type=candle_type)
@@ -620,3 +637,12 @@ class DataProvider:
except ExchangeError: except ExchangeError:
logger.warning(f"Could not fetch market data for {pair}. Assuming no delisting.") logger.warning(f"Could not fetch market data for {pair}. Assuming no delisting.")
return None return None
def get_funding_rate_timeframe(self) -> str:
"""
Get the funding rate timeframe from exchange options
:return: Timeframe string
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
return self._exchange.get_option("funding_fee_timeframe")
@@ -397,6 +397,9 @@ class IDataHandler(ABC):
pairdf = self._ohlcv_load( pairdf = self._ohlcv_load(
pair, timeframe, timerange=timerange_startup, candle_type=candle_type pair, timeframe, timerange=timerange_startup, candle_type=candle_type
) )
if not pairdf.empty and candle_type == CandleType.FUNDING_RATE:
# Funding rate data is sometimes off by a couple of ms - floor to seconds
pairdf["date"] = pairdf["date"].dt.floor("s")
if self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data): if self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data):
return pairdf return pairdf
else: else:
@@ -508,8 +511,15 @@ class IDataHandler(ABC):
Applies to bybit and okx, where funding-fee and mark candles have different timeframes. Applies to bybit and okx, where funding-fee and mark candles have different timeframes.
""" """
paircombs = self.ohlcv_get_available_data(self._datadir, TradingMode.FUTURES) paircombs = self.ohlcv_get_available_data(self._datadir, TradingMode.FUTURES)
ff_timeframe_s = timeframe_to_seconds(ff_timeframe)
funding_rate_combs = [ funding_rate_combs = [
f for f in paircombs if f[2] == CandleType.FUNDING_RATE and f[1] != ff_timeframe f
for f in paircombs
if f[2] == CandleType.FUNDING_RATE
and f[1] != ff_timeframe
# Only allow smaller timeframes to move from smaller to larger timeframes
and timeframe_to_seconds(f[1]) < ff_timeframe_s
] ]
if funding_rate_combs: if funding_rate_combs:
+42 -32
View File
@@ -353,6 +353,7 @@ def _download_pair_history(
def refresh_backtest_ohlcv_data( def refresh_backtest_ohlcv_data(
exchange: Exchange, exchange: Exchange,
*,
pairs: list[str], pairs: list[str],
timeframes: list[str], timeframes: list[str],
datadir: Path, datadir: Path,
@@ -363,6 +364,7 @@ def refresh_backtest_ohlcv_data(
data_format: str | None = None, data_format: str | None = None,
prepend: bool = False, prepend: bool = False,
progress_tracker: CustomProgress | None = None, progress_tracker: CustomProgress | None = None,
candle_types: list[CandleType] | None = None,
no_parallel_download: bool = False, no_parallel_download: bool = False,
) -> list[str]: ) -> list[str]:
""" """
@@ -375,10 +377,44 @@ def refresh_backtest_ohlcv_data(
pairs_not_available = [] pairs_not_available = []
fast_candles: dict[PairWithTimeframe, DataFrame] = {} fast_candles: dict[PairWithTimeframe, DataFrame] = {}
data_handler = get_datahandler(datadir, data_format) data_handler = get_datahandler(datadir, data_format)
candle_type = CandleType.get_default(trading_mode) def_candletype = CandleType.SPOT if trading_mode != "futures" else CandleType.FUTURES
if trading_mode != "futures":
# Ignore user passed candle types for non-futures trading
timeframes_with_candletype = [(tf, def_candletype) for tf in timeframes]
else:
# Filter out SPOT candle type for futures trading
candle_types = (
[ct for ct in candle_types if ct != CandleType.SPOT] if candle_types else None
)
fr_candle_type = CandleType.from_string(exchange.get_option("mark_ohlcv_price"))
tf_funding_rate = exchange.get_option("funding_fee_timeframe")
tf_mark = exchange.get_option("mark_ohlcv_timeframe")
if candle_types:
for ct in candle_types:
exchange.verify_candle_type_support(ct)
timeframes_with_candletype = [
(tf, ct)
for ct in candle_types
for tf in timeframes
if ct != CandleType.FUNDING_RATE
]
else:
# Default behavior
timeframes_with_candletype = [(tf, def_candletype) for tf in timeframes]
timeframes_with_candletype.append((tf_mark, fr_candle_type))
if not candle_types or CandleType.FUNDING_RATE in candle_types:
# All exchanges need FundingRate for futures trading.
# The timeframe is aligned to the mark-price timeframe.
timeframes_with_candletype.append((tf_funding_rate, CandleType.FUNDING_RATE))
# Deduplicate list ...
timeframes_with_candletype = list(dict.fromkeys(timeframes_with_candletype))
logger.debug(
"Downloading %s.", ", ".join(f'"{tf} {ct}"' for tf, ct in timeframes_with_candletype)
)
with progress_tracker as progress: with progress_tracker as progress:
tf_length = len(timeframes) if trading_mode != "futures" else len(timeframes) + 2 timeframe_task = progress.add_task("Timeframe", total=len(timeframes_with_candletype))
timeframe_task = progress.add_task("Timeframe", total=tf_length)
pair_task = progress.add_task("Downloading data...", total=len(pairs)) pair_task = progress.add_task("Downloading data...", total=len(pairs))
for pair in pairs: for pair in pairs:
@@ -389,7 +425,7 @@ def refresh_backtest_ohlcv_data(
pairs_not_available.append(f"{pair}: Pair not available on exchange.") pairs_not_available.append(f"{pair}: Pair not available on exchange.")
logger.info(f"Skipping pair {pair}...") logger.info(f"Skipping pair {pair}...")
continue continue
for timeframe in timeframes: for timeframe, candle_type in timeframes_with_candletype:
# Get fast candles via parallel method on first loop through per timeframe # Get fast candles via parallel method on first loop through per timeframe
# and candle type. Downloads all the pairs in the list and stores them. # and candle type. Downloads all the pairs in the list and stores them.
# Also skips if only 1 pair/timeframe combination is scheduled for download. # Also skips if only 1 pair/timeframe combination is scheduled for download.
@@ -416,7 +452,7 @@ def refresh_backtest_ohlcv_data(
# get the already downloaded pair candles if they exist # get the already downloaded pair candles if they exist
pair_candles = fast_candles.pop((pair, timeframe, candle_type), None) pair_candles = fast_candles.pop((pair, timeframe, candle_type), None)
progress.update(timeframe_task, description=f"Timeframe {timeframe}") progress.update(timeframe_task, description=f"Timeframe {timeframe} {candle_type}")
logger.debug(f"Downloading pair {pair}, {candle_type}, interval {timeframe}.") logger.debug(f"Downloading pair {pair}, {candle_type}, interval {timeframe}.")
_download_pair_history( _download_pair_history(
pair=pair, pair=pair,
@@ -432,33 +468,6 @@ def refresh_backtest_ohlcv_data(
pair_candles=pair_candles, # optional pass of dataframe of parallel candles pair_candles=pair_candles, # optional pass of dataframe of parallel candles
) )
progress.update(timeframe_task, advance=1) progress.update(timeframe_task, advance=1)
if trading_mode == "futures":
# Predefined candletype (and timeframe) depending on exchange
# Downloads what is necessary to backtest based on futures data.
tf_mark = exchange.get_option("mark_ohlcv_timeframe")
tf_funding_rate = exchange.get_option("funding_fee_timeframe")
fr_candle_type = CandleType.from_string(exchange.get_option("mark_ohlcv_price"))
# All exchanges need FundingRate for futures trading.
# The timeframe is aligned to the mark-price timeframe.
combs = ((CandleType.FUNDING_RATE, tf_funding_rate), (fr_candle_type, tf_mark))
for candle_type_f, tf in combs:
logger.debug(f"Downloading pair {pair}, {candle_type_f}, interval {tf}.")
_download_pair_history(
pair=pair,
datadir=datadir,
exchange=exchange,
timerange=timerange,
data_handler=data_handler,
timeframe=str(tf),
new_pairs_days=new_pairs_days,
candle_type=candle_type_f,
erase=erase,
prepend=prepend,
)
progress.update(
timeframe_task, advance=1, description=f"Timeframe {candle_type_f}, {tf}"
)
progress.update(pair_task, advance=1) progress.update(pair_task, advance=1)
progress.update(timeframe_task, description="Timeframe") progress.update(timeframe_task, description="Timeframe")
@@ -804,6 +813,7 @@ def download_data(
trading_mode=config.get("trading_mode", "spot"), trading_mode=config.get("trading_mode", "spot"),
prepend=config.get("prepend_data", False), prepend=config.get("prepend_data", False),
progress_tracker=progress_tracker, progress_tracker=progress_tracker,
candle_types=config.get("candle_types"),
no_parallel_download=config.get("no_parallel_download", False), no_parallel_download=config.get("no_parallel_download", False),
) )
finally: finally:
+2 -1
View File
@@ -74,9 +74,10 @@ def combined_dataframes_with_rel_mean(
df_comb = combine_dataframes_by_column(data, column) df_comb = combine_dataframes_by_column(data, column)
# Trim dataframes to the given timeframe # Trim dataframes to the given timeframe
df_comb = df_comb.iloc[(df_comb.index >= fromdt) & (df_comb.index < todt)] df_comb = df_comb.iloc[(df_comb.index >= fromdt) & (df_comb.index < todt)]
rel_mean = df_comb.pct_change().mean(axis=1).fillna(0).cumsum()
df_comb["count"] = df_comb.count(axis=1) df_comb["count"] = df_comb.count(axis=1)
df_comb["mean"] = df_comb.mean(axis=1) df_comb["mean"] = df_comb.mean(axis=1)
df_comb["rel_mean"] = df_comb["mean"].pct_change().fillna(0).cumsum() df_comb["rel_mean"] = rel_mean
return df_comb[["mean", "rel_mean", "count"]] return df_comb[["mean", "rel_mean", "count"]]
+1 -1
View File
@@ -4,7 +4,7 @@ from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS
from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange import Exchange
# isort: on # isort: on
from freqtrade.exchange.binance import Binance from freqtrade.exchange.binance import Binance, Binanceus, Binanceusdm
from freqtrade.exchange.bingx import Bingx from freqtrade.exchange.bingx import Bingx
from freqtrade.exchange.bitget import Bitget from freqtrade.exchange.bitget import Bitget
from freqtrade.exchange.bitmart import Bitmart from freqtrade.exchange.bitmart import Bitmart
+25
View File
@@ -51,6 +51,8 @@ class Binance(Exchange):
"funding_fee_candle_limit": 1000, "funding_fee_candle_limit": 1000,
"stoploss_order_types": {"limit": "stop", "market": "stop_market"}, "stoploss_order_types": {"limit": "stop", "market": "stop_market"},
"stoploss_blocks_assets": False, # Stoploss orders do not block assets "stoploss_blocks_assets": False, # Stoploss orders do not block assets
"stoploss_query_requires_stop_flag": True,
"stoploss_algo_order_info_id": "actualOrderId",
"tickers_have_price": False, "tickers_have_price": False,
"floor_leverage": True, "floor_leverage": True,
"fetch_orders_limit_minutes": 7 * 1440, # "fetch_orders" is limited to 7 days "fetch_orders_limit_minutes": 7 * 1440, # "fetch_orders" is limited to 7 days
@@ -544,3 +546,26 @@ class Binance(Exchange):
cache[ft_symbol] = delist_dt cache[ft_symbol] = delist_dt
return cache.get(pair, None) return cache.get(pair, None)
class Binanceusdm(Binance):
"""Binacne USDM Exchange
Same as Binance - only futures trading is supported (via ccxt).
Not actually necessary, binance should be preferred.
"""
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
(TradingMode.FUTURES, MarginMode.CROSS),
(TradingMode.FUTURES, MarginMode.ISOLATED),
]
class Binanceus(Binance):
"""Binance US exchange class.
Minimal adjustment to disable futures trading for the US subsidiary of Binance
"""
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
(TradingMode.SPOT, MarginMode.NONE),
]
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -31,11 +31,11 @@ class Bitget(Exchange):
"stop_price_prop": "stopPrice", "stop_price_prop": "stopPrice",
"stoploss_blocks_assets": False, # Stoploss orders do not block assets "stoploss_blocks_assets": False, # Stoploss orders do not block assets
"stoploss_order_types": {"limit": "limit", "market": "market"}, "stoploss_order_types": {"limit": "limit", "market": "market"},
"stoploss_query_requires_stop_flag": True,
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones. "ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"], "order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
} }
_ft_has_futures: FtHas = { _ft_has_futures: FtHas = {
"mark_ohlcv_timeframe": "4h",
"funding_fee_candle_limit": 100, "funding_fee_candle_limit": 100,
"has_delisting": True, "has_delisting": True,
} }
@@ -129,9 +129,6 @@ class Bitget(Exchange):
return self._fetch_stop_order_fallback(order_id, pair) return self._fetch_stop_order_fallback(order_id, pair)
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True})
@retrier @retrier
def additional_exchange_init(self) -> None: def additional_exchange_init(self) -> None:
""" """
-2
View File
@@ -38,8 +38,6 @@ class Bybit(Exchange):
} }
_ft_has_futures: FtHas = { _ft_has_futures: FtHas = {
"ohlcv_has_history": True, "ohlcv_has_history": True,
"mark_ohlcv_timeframe": "4h",
"funding_fee_timeframe": "8h",
"funding_fee_candle_limit": 200, "funding_fee_candle_limit": 200,
"stoploss_on_exchange": True, "stoploss_on_exchange": True,
"stoploss_order_types": {"limit": "limit", "market": "market"}, "stoploss_order_types": {"limit": "limit", "market": "market"},
+5 -2
View File
@@ -45,8 +45,6 @@ BAD_EXCHANGES = {
} }
MAP_EXCHANGE_CHILDCLASS = { MAP_EXCHANGE_CHILDCLASS = {
"binanceus": "binance",
"binanceusdm": "binance",
"okex": "okx", "okex": "okx",
"gateio": "gate", "gateio": "gate",
"huboi": "htx", "huboi": "htx",
@@ -54,6 +52,8 @@ MAP_EXCHANGE_CHILDCLASS = {
SUPPORTED_EXCHANGES = [ SUPPORTED_EXCHANGES = [
"binance", "binance",
"binanceus",
"binanceusdm",
"bingx", "bingx",
"bitmart", "bitmart",
"bitget", "bitget",
@@ -97,6 +97,9 @@ EXCHANGE_HAS_OPTIONAL = [
# 'fetchLeverageTiers', # Futures initialization # 'fetchLeverageTiers', # Futures initialization
# 'fetchMarketLeverageTiers', # Futures initialization # 'fetchMarketLeverageTiers', # Futures initialization
# 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance... # 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance...
# "fetchPremiumIndexOHLCV", # Futures additional data
# "fetchMarkOHLCV", # Futures additional data
# "fetchIndexOHLCV", # Futures additional data
# ccxt.pro # ccxt.pro
"watchOHLCV", "watchOHLCV",
] ]
+91 -23
View File
@@ -132,6 +132,7 @@ class Exchange:
"stop_price_prop": "stopLossPrice", # Used for stoploss_on_exchange response parsing "stop_price_prop": "stopLossPrice", # Used for stoploss_on_exchange response parsing
"stoploss_order_types": {}, "stoploss_order_types": {},
"stoploss_blocks_assets": True, # By default stoploss orders block assets "stoploss_blocks_assets": True, # By default stoploss orders block assets
"stoploss_query_requires_stop_flag": False, # Require "stop": True" to fetch stop orders
"order_time_in_force": ["GTC"], "order_time_in_force": ["GTC"],
"ohlcv_params": {}, "ohlcv_params": {},
"ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv "ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv
@@ -153,8 +154,8 @@ class Exchange:
"l2_limit_range_required": True, # Allow Empty L2 limit (kucoin) "l2_limit_range_required": True, # Allow Empty L2 limit (kucoin)
"l2_limit_upper": None, # Upper limit for L2 limit "l2_limit_upper": None, # Upper limit for L2 limit
"mark_ohlcv_price": "mark", "mark_ohlcv_price": "mark",
"mark_ohlcv_timeframe": "8h", "mark_ohlcv_timeframe": "1h",
"funding_fee_timeframe": "8h", "funding_fee_timeframe": "1h",
"ccxt_futures_name": "swap", "ccxt_futures_name": "swap",
"needs_trading_fees": False, # use fetch_trading_fees to cache fees "needs_trading_fees": False, # use fetch_trading_fees to cache fees
"order_props_in_contracts": ["amount", "filled", "remaining"], "order_props_in_contracts": ["amount", "filled", "remaining"],
@@ -1405,8 +1406,9 @@ class Exchange:
amount: float, amount: float,
rate: float, rate: float,
leverage: float, leverage: float,
reduceOnly: bool = False,
time_in_force: str = "GTC", time_in_force: str = "GTC",
reduceOnly: bool = False,
initial_order: bool = True,
) -> CcxtOrder: ) -> CcxtOrder:
if self._config["dry_run"]: if self._config["dry_run"]:
dry_order = self.create_dry_run_order( dry_order = self.create_dry_run_order(
@@ -1423,7 +1425,7 @@ class Exchange:
rate_for_order = self.price_to_precision(pair, rate) if needs_price else None rate_for_order = self.price_to_precision(pair, rate) if needs_price else None
if not reduceOnly: if not reduceOnly:
self._lev_prep(pair, leverage, side) self._lev_prep(pair, leverage, side, accept_fail=not initial_order)
order = self._api.create_order( order = self._api.create_order(
pair, pair,
@@ -1686,7 +1688,24 @@ class Exchange:
def fetch_stoploss_order( def fetch_stoploss_order(
self, order_id: str, pair: str, params: dict | None = None self, order_id: str, pair: str, params: dict | None = None
) -> CcxtOrder: ) -> CcxtOrder:
return self.fetch_order(order_id, pair, params) if self.get_option("stoploss_query_requires_stop_flag"):
params = params or {}
params["stop"] = True
order = self.fetch_order(order_id, pair, params)
val = self.get_option("stoploss_algo_order_info_id")
if val and order.get("status", "open") == "closed":
if new_orderid := order.get("info", {}).get(val):
# Fetch real order, which was placed by the algo order.
actual_order = self.fetch_order(order_id=new_orderid, pair=pair, params=None)
actual_order["id_stop"] = actual_order["id"]
actual_order["id"] = order_id
actual_order["type"] = "stoploss"
actual_order["stopPrice"] = order.get("stopPrice")
actual_order["status_stop"] = "triggered"
return actual_order
return order
def fetch_order_or_stoploss_order( def fetch_order_or_stoploss_order(
self, order_id: str, pair: str, stoploss_order: bool = False self, order_id: str, pair: str, stoploss_order: bool = False
@@ -1740,6 +1759,9 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict: def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
if self.get_option("stoploss_query_requires_stop_flag"):
params = params or {}
params["stop"] = True
return self.cancel_order(order_id, pair, params) return self.cancel_order(order_id, pair, params)
def is_cancel_order_result_suitable(self, corder) -> TypeGuard[CcxtOrder]: def is_cancel_order_result_suitable(self, corder) -> TypeGuard[CcxtOrder]:
@@ -1834,9 +1856,9 @@ class Exchange:
if self._config["dry_run"] or self.trading_mode != TradingMode.FUTURES: if self._config["dry_run"] or self.trading_mode != TradingMode.FUTURES:
return [] return []
try: try:
symbols = [] symbols = None
if pair: if pair:
symbols.append(pair) symbols = [pair]
positions: list[CcxtPosition] = self._api.fetch_positions(symbols) positions: list[CcxtPosition] = self._api.fetch_positions(symbols)
self._log_exchange_response("fetch_positions", positions) self._log_exchange_response("fetch_positions", positions)
return positions return positions
@@ -2690,24 +2712,25 @@ class Exchange:
input_coroutines: list[Coroutine[Any, Any, OHLCVResponse]] = [] input_coroutines: list[Coroutine[Any, Any, OHLCVResponse]] = []
cached_pairs = [] cached_pairs = []
for pair, timeframe, candle_type in set(pair_list): for pair, timeframe, candle_type in set(pair_list):
invalid_funding = ( if candle_type == CandleType.FUNDING_RATE and timeframe != (
candle_type == CandleType.FUNDING_RATE ff_tf := self.get_option("funding_fee_timeframe")
and timeframe != self.get_option("funding_fee_timeframe") ):
# TODO: does this message make sense? would docs be better?
# if any, this should be cached to avoid log spam!
logger.warning(
f"Wrong funding rate timeframe {timeframe} for pair {pair}, "
f"downloading {ff_tf} instead."
) )
timeframe = ff_tf
invalid_timeframe = timeframe not in self.timeframes and candle_type in ( invalid_timeframe = timeframe not in self.timeframes and candle_type in (
CandleType.SPOT, CandleType.SPOT,
CandleType.FUTURES, CandleType.FUTURES,
) )
if invalid_timeframe or invalid_funding: if invalid_timeframe:
timeframes_ = (
", ".join(self.timeframes)
if candle_type != CandleType.FUNDING_RATE
else self.get_option("funding_fee_timeframe")
)
logger.warning( logger.warning(
f"Cannot download ({pair}, {timeframe}, {candle_type}) combination as this " f"Cannot download ({pair}, {timeframe}, {candle_type}) combination as this "
f"timeframe is not available on {self.name}. Available timeframes are " f"timeframe is not available on {self.name}. Available timeframes are "
f"{timeframes_}." f"{', '.join(self.timeframes)}."
) )
continue continue
@@ -2744,7 +2767,11 @@ class Exchange:
has_cache = cache and (pair, timeframe, c_type) in self._klines has_cache = cache and (pair, timeframe, c_type) in self._klines
# in case of existing cache, fill_missing happens after concatenation # in case of existing cache, fill_missing happens after concatenation
ohlcv_df = ohlcv_to_dataframe( ohlcv_df = ohlcv_to_dataframe(
ticks, timeframe, pair=pair, fill_missing=not has_cache, drop_incomplete=drop_incomplete ticks,
timeframe,
pair=pair,
fill_missing=not has_cache and c_type != CandleType.FUNDING_RATE,
drop_incomplete=drop_incomplete,
) )
# keeping parsed dataframe in cache # keeping parsed dataframe in cache
if cache: if cache:
@@ -2755,7 +2782,7 @@ class Exchange:
concat([old, ohlcv_df], axis=0), concat([old, ohlcv_df], axis=0),
timeframe, timeframe,
pair, pair,
fill_missing=True, fill_missing=c_type != CandleType.FUNDING_RATE,
drop_incomplete=False, drop_incomplete=False,
) )
candle_limit = self.ohlcv_candle_limit(timeframe, self._config["candle_type_def"]) candle_limit = self.ohlcv_candle_limit(timeframe, self._config["candle_type_def"])
@@ -2890,9 +2917,10 @@ class Exchange:
timeframe, candle_type=candle_type, since_ms=since_ms timeframe, candle_type=candle_type, since_ms=since_ms
) )
if candle_type and candle_type not in (CandleType.SPOT, CandleType.FUTURES):
params.update({"price": candle_type.value})
if candle_type != CandleType.FUNDING_RATE: if candle_type != CandleType.FUNDING_RATE:
if candle_type and candle_type not in (CandleType.SPOT, CandleType.FUTURES):
self.verify_candle_type_support(candle_type)
params.update({"price": str(candle_type)})
data = await self._api_async.fetch_ohlcv( data = await self._api_async.fetch_ohlcv(
pair, timeframe=timeframe, since=since_ms, limit=candle_limit, params=params pair, timeframe=timeframe, since=since_ms, limit=candle_limit, params=params
) )
@@ -2957,6 +2985,38 @@ class Exchange:
data = [[x["timestamp"], x["fundingRate"], 0, 0, 0, 0] for x in data] data = [[x["timestamp"], x["fundingRate"], 0, 0, 0, 0] for x in data]
return data return data
def check_candle_type_support(self, candle_type: CandleType) -> bool:
"""
Check that the exchange supports the given candle type.
:param candle_type: CandleType to verify
:return: True if supported, False otherwise
"""
if candle_type == CandleType.FUNDING_RATE:
if not self.exchange_has("fetchFundingRateHistory"):
return False
elif candle_type not in (CandleType.SPOT, CandleType.FUTURES):
mapping = {
CandleType.MARK: "fetchMarkOHLCV",
CandleType.INDEX: "fetchIndexOHLCV",
CandleType.PREMIUMINDEX: "fetchPremiumIndexOHLCV",
CandleType.FUNDING_RATE: "fetchFundingRateHistory",
}
_method = mapping.get(candle_type, "fetchOHLCV")
if not self.exchange_has(_method):
return False
return True
def verify_candle_type_support(self, candle_type: CandleType) -> None:
"""
Verify that the exchange supports the given candle type.
:param candle_type: CandleType to verify
:raises OperationalException: if the candle type is not supported
"""
if not self.check_candle_type_support(candle_type):
raise OperationalException(
f"Exchange {self._api.name} does not support fetching {candle_type} candles."
)
# fetch Trade data stuff # fetch Trade data stuff
def needed_candle_for_trades_ms(self, timeframe: str, candle_type: CandleType) -> int: def needed_candle_for_trades_ms(self, timeframe: str, candle_type: CandleType) -> int:
@@ -3808,8 +3868,16 @@ class Exchange:
combined = mark_rates.merge( combined = mark_rates.merge(
funding_rates, on="date", how="left", suffixes=["_mark", "_fund"] funding_rates, on="date", how="left", suffixes=["_mark", "_fund"]
) )
combined["open_fund"] = combined["open_fund"].fillna(futures_funding_rate) # Fill only leading missing funding rates so gaps stay untouched
return combined[relevant_cols] first_valid_idx = combined["open_fund"].first_valid_index()
if first_valid_idx is None:
combined["open_fund"] = futures_funding_rate
else:
is_leading_na = (combined.index <= first_valid_idx) & combined[
"open_fund"
].isna()
combined.loc[is_leading_na, "open_fund"] = futures_funding_rate
return combined[relevant_cols].dropna()
def calculate_funding_fees( def calculate_funding_fees(
self, self,
+2
View File
@@ -19,6 +19,8 @@ class FtHas(TypedDict, total=False):
stop_price_type_value_mapping: dict stop_price_type_value_mapping: dict
stoploss_order_types: dict[str, str] stoploss_order_types: dict[str, str]
stoploss_blocks_assets: bool stoploss_blocks_assets: bool
stoploss_query_requires_stop_flag: bool
stoploss_algo_order_info_id: str
# ohlcv # ohlcv
ohlcv_params: dict ohlcv_params: dict
ohlcv_candle_limit: int ohlcv_candle_limit: int
+3 -22
View File
@@ -30,6 +30,8 @@ class Gate(Exchange):
"stoploss_order_types": {"limit": "limit"}, "stoploss_order_types": {"limit": "limit"},
"stop_price_param": "stopPrice", "stop_price_param": "stopPrice",
"stop_price_prop": "stopPrice", "stop_price_prop": "stopPrice",
"stoploss_query_requires_stop_flag": True,
"stoploss_algo_order_info_id": "fired_order_id",
"l2_limit_upper": 1000, "l2_limit_upper": 1000,
"marketOrderRequiresPrice": True, "marketOrderRequiresPrice": True,
"trades_has_history": False, # Endpoint would support this - but ccxt doesn't. "trades_has_history": False, # Endpoint would support this - but ccxt doesn't.
@@ -42,6 +44,7 @@ class Gate(Exchange):
"stop_price_type_field": "price_type", "stop_price_type_field": "price_type",
"l2_limit_upper": 300, "l2_limit_upper": 300,
"stoploss_blocks_assets": False, "stoploss_blocks_assets": False,
"stoploss_algo_order_info_id": "trade_id",
"stop_price_type_value_mapping": { "stop_price_type_value_mapping": {
PriceType.LAST: 0, PriceType.LAST: 0,
PriceType.MARK: 1, PriceType.MARK: 1,
@@ -132,25 +135,3 @@ class Gate(Exchange):
def get_order_id_conditional(self, order: CcxtOrder) -> str: def get_order_id_conditional(self, order: CcxtOrder) -> str:
return safe_value_fallback2(order, order, "id_stop", "id") return safe_value_fallback2(order, order, "id_stop", "id")
def fetch_stoploss_order(
self, order_id: str, pair: str, params: dict | None = None
) -> CcxtOrder:
order = self.fetch_order(order_id=order_id, pair=pair, params={"stop": True})
if order.get("status", "open") == "closed":
# Places a real order - which we need to fetch explicitly.
val = "trade_id" if self.trading_mode == TradingMode.FUTURES else "fired_order_id"
if new_orderid := order.get("info", {}).get(val):
order1 = self.fetch_order(order_id=new_orderid, pair=pair, params=params)
order1["id_stop"] = order1["id"]
order1["id"] = order_id
order1["type"] = "stoploss"
order1["stopPrice"] = order.get("stopPrice")
order1["status_stop"] = "triggered"
return order1
return order
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True})
-1
View File
@@ -35,7 +35,6 @@ class Kraken(Exchange):
"trades_pagination_arg": "since", "trades_pagination_arg": "since",
"trades_pagination_overlap": False, "trades_pagination_overlap": False,
"trades_has_history": True, "trades_has_history": True,
"mark_ohlcv_timeframe": "4h",
} }
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
+3 -1
View File
@@ -44,8 +44,9 @@ class Kucoin(Exchange):
amount: float, amount: float,
rate: float, rate: float,
leverage: float, leverage: float,
reduceOnly: bool = False,
time_in_force: str = "GTC", time_in_force: str = "GTC",
reduceOnly: bool = False,
initial_order: bool = True,
) -> CcxtOrder: ) -> CcxtOrder:
res = super().create_order( res = super().create_order(
pair=pair, pair=pair,
@@ -56,6 +57,7 @@ class Kucoin(Exchange):
leverage=leverage, leverage=leverage,
reduceOnly=reduceOnly, reduceOnly=reduceOnly,
time_in_force=time_in_force, time_in_force=time_in_force,
initial_order=initial_order,
) )
# Kucoin returns only the order-id. # Kucoin returns only the order-id.
# ccxt returns status = 'closed' at the moment - which is information ccxt invented. # ccxt returns status = 'closed' at the moment - which is information ccxt invented.
+3 -14
View File
@@ -29,10 +29,9 @@ class Okx(Exchange):
_ft_has: FtHas = { _ft_has: FtHas = {
"ohlcv_candle_limit": 100, # Warning, special case with data prior to X months "ohlcv_candle_limit": 100, # Warning, special case with data prior to X months
"mark_ohlcv_timeframe": "4h",
"funding_fee_timeframe": "8h",
"stoploss_order_types": {"limit": "limit"}, "stoploss_order_types": {"limit": "limit"},
"stoploss_on_exchange": True, "stoploss_on_exchange": True,
"stoploss_query_requires_stop_flag": True,
"trades_has_history": False, # Endpoint doesn't have a "since" parameter "trades_has_history": False, # Endpoint doesn't have a "since" parameter
"ws_enabled": True, "ws_enabled": True,
} }
@@ -41,8 +40,8 @@ class Okx(Exchange):
"stop_price_type_field": "slTriggerPxType", "stop_price_type_field": "slTriggerPxType",
"stop_price_type_value_mapping": { "stop_price_type_value_mapping": {
PriceType.LAST: "last", PriceType.LAST: "last",
PriceType.MARK: "index", PriceType.MARK: "mark",
PriceType.INDEX: "mark", PriceType.INDEX: "index",
}, },
"stoploss_blocks_assets": False, "stoploss_blocks_assets": False,
"ws_enabled": True, "ws_enabled": True,
@@ -265,16 +264,6 @@ class Okx(Exchange):
return safe_value_fallback2(order, order, "id_stop", "id") return safe_value_fallback2(order, order, "id_stop", "id")
return order["id"] return order["id"]
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
params1 = {"stop": True}
# 'ordType': 'conditional'
#
return self.cancel_order(
order_id=order_id,
pair=pair,
params=params1,
)
def _fetch_orders_emulate(self, pair: str, since_ms: int) -> list[CcxtOrder]: def _fetch_orders_emulate(self, pair: str, since_ms: int) -> list[CcxtOrder]:
orders = [] orders = []
+18 -11
View File
@@ -937,6 +937,7 @@ class FreqtradeBot(LoggingMixin):
reduceOnly=False, reduceOnly=False,
time_in_force=time_in_force, time_in_force=time_in_force,
leverage=leverage, leverage=leverage,
initial_order=trade is None,
) )
order_obj = Order.parse_from_ccxt_object(order, pair, side, amount, enter_limit_requested) order_obj = Order.parse_from_ccxt_object(order, pair, side, amount, enter_limit_requested)
order_obj.ft_order_tag = enter_tag order_obj.ft_order_tag = enter_tag
@@ -2011,14 +2012,14 @@ class FreqtradeBot(LoggingMixin):
def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float: def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float:
""" """
Get sellable amount. Get exitable amount.
Should be trade.amount - but will fall back to the available amount if necessary. Should be trade.amount - but will fall back to the available amount if necessary.
This should cover cases where get_real_amount() was not able to update the amount This should cover cases where get_real_amount() was not able to update the amount
for whatever reason. for whatever reason.
:param trade: Trade we're working with :param trade: Trade we're working with
:param pair: Pair we're trying to sell :param pair: Pair we're trying to exit
:param amount: amount we expect to be available :param amount: amount we expect to be available
:return: amount to sell :return: amount to exit
:raise: DependencyException: if available balance is not within 2% of the available amount. :raise: DependencyException: if available balance is not within 2% of the available amount.
""" """
# Update wallets to ensure amounts tied up in a stoploss is now free! # Update wallets to ensure amounts tied up in a stoploss is now free!
@@ -2054,11 +2055,12 @@ class FreqtradeBot(LoggingMixin):
exit_tag: str | None = None, exit_tag: str | None = None,
ordertype: str | None = None, ordertype: str | None = None,
sub_trade_amt: float | None = None, sub_trade_amt: float | None = None,
skip_custom_exit_price: bool = False,
) -> bool: ) -> bool:
""" """
Executes a trade exit for the given trade and limit Executes a trade exit for the given trade and limit
:param trade: Trade instance :param trade: Trade instance
:param limit: limit rate for the sell order :param limit: limit rate for the exit order
:param exit_check: CheckTuple with signal and reason :param exit_check: CheckTuple with signal and reason
:return: True if it succeeds False :return: True if it succeeds False
""" """
@@ -2080,9 +2082,18 @@ class FreqtradeBot(LoggingMixin):
): ):
exit_type = "stoploss" exit_type = "stoploss"
order_type = (
(ordertype or self.strategy.order_types[exit_type])
if exit_check.exit_type != ExitType.EMERGENCY_EXIT
else self.strategy.order_types.get("emergency_exit", "market")
)
# set custom_exit_price if available # set custom_exit_price if available
proposed_limit_rate = limit proposed_limit_rate = limit
custom_exit_price = limit
current_profit = trade.calc_profit_ratio(limit) current_profit = trade.calc_profit_ratio(limit)
if order_type == "limit" and not skip_custom_exit_price:
custom_exit_price = strategy_safe_wrapper( custom_exit_price = strategy_safe_wrapper(
self.strategy.custom_exit_price, default_retval=proposed_limit_rate self.strategy.custom_exit_price, default_retval=proposed_limit_rate
)( )(
@@ -2099,11 +2110,6 @@ class FreqtradeBot(LoggingMixin):
# First cancelling stoploss on exchange ... # First cancelling stoploss on exchange ...
trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True) 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:
# Emergency sells (default to market!)
order_type = self.strategy.order_types.get("emergency_exit", "market")
amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount) amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount)
time_in_force = self.strategy.order_time_in_force["exit"] time_in_force = self.strategy.order_time_in_force["exit"]
@@ -2130,7 +2136,7 @@ class FreqtradeBot(LoggingMixin):
return False return False
try: try:
# Execute sell and update trade record # Execute exit and update trade record
order = self.exchange.create_order( order = self.exchange.create_order(
pair=trade.pair, pair=trade.pair,
ordertype=order_type, ordertype=order_type,
@@ -2140,6 +2146,7 @@ class FreqtradeBot(LoggingMixin):
leverage=trade.leverage, leverage=trade.leverage,
reduceOnly=self.trading_mode == TradingMode.FUTURES, reduceOnly=self.trading_mode == TradingMode.FUTURES,
time_in_force=time_in_force, time_in_force=time_in_force,
initial_order=False,
) )
except InsufficientFundsError as e: except InsufficientFundsError as e:
logger.warning(f"Unable to place order {e}.") logger.warning(f"Unable to place order {e}.")
@@ -2157,7 +2164,7 @@ class FreqtradeBot(LoggingMixin):
trade.exit_reason = exit_reason trade.exit_reason = exit_reason
self._notify_exit(trade, order_type, sub_trade=bool(sub_trade_amt), order=order_obj) self._notify_exit(trade, order_type, sub_trade=bool(sub_trade_amt), order=order_obj)
# In case of market sell orders the order can be closed immediately # In case of market exit orders the order can be closed immediately
if order.get("status", "unknown") in ("closed", "expired"): if order.get("status", "unknown") in ("closed", "expired"):
self.update_trade_state(trade, order_obj.order_id, order) self.update_trade_state(trade, order_obj.order_id, order)
Trade.commit() Trade.commit()
+1
View File
@@ -374,6 +374,7 @@ class Backtesting:
timerange=self.timerange, timerange=self.timerange,
startup_candles=0, startup_candles=0,
fail_without_data=True, fail_without_data=True,
fill_up_missing=False,
data_format=self.config["dataformat_ohlcv"], data_format=self.config["dataformat_ohlcv"],
candle_type=CandleType.FUNDING_RATE, candle_type=CandleType.FUNDING_RATE,
) )
@@ -63,6 +63,8 @@ def pairlists_evaluate(
config_loc["timeframes"] = payload.timeframes config_loc["timeframes"] = payload.timeframes
config_loc["erase"] = payload.erase config_loc["erase"] = payload.erase
config_loc["download_trades"] = payload.download_trades config_loc["download_trades"] = payload.download_trades
if payload.candle_types is not None:
config_loc["candle_types"] = payload.candle_types
handleExchangePayload(payload, config_loc) handleExchangePayload(payload, config_loc)
+2
View File
@@ -426,6 +426,7 @@ class ForceExitPayload(BaseModel):
tradeid: str | int tradeid: str | int
ordertype: OrderTypeValues | None = None ordertype: OrderTypeValues | None = None
amount: float | None = None amount: float | None = None
price: float | None = None
class BlacklistPayload(BaseModel): class BlacklistPayload(BaseModel):
@@ -506,6 +507,7 @@ class DownloadDataPayload(ExchangeModePayloadMixin, BaseModel):
timerange: str | None = None timerange: str | None = None
erase: bool = False erase: bool = False
download_trades: bool = False download_trades: bool = False
candle_types: list[str] | None = None
@model_validator(mode="before") @model_validator(mode="before")
def check_mutually_exclusive(cls, values): def check_mutually_exclusive(cls, values):
+6 -2
View File
@@ -91,7 +91,9 @@ logger = logging.getLogger(__name__)
# 2.41: Add download-data endpoint # 2.41: Add download-data endpoint
# 2.42: Add /pair_history endpoint with live data # 2.42: Add /pair_history endpoint with live data
# 2.43: Add /profit_all endpoint # 2.43: Add /profit_all endpoint
API_VERSION = 2.43 # 2.44: Add candle_types parameter to download-data endpoint
# 2.45: Add price to forceexit endpoint
API_VERSION = 2.45
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()
@@ -324,7 +326,9 @@ def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)):
@router.post("/forcesell", response_model=ResultMsg, tags=["trading"]) @router.post("/forcesell", response_model=ResultMsg, tags=["trading"])
def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)): def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)):
ordertype = payload.ordertype.value if payload.ordertype else None ordertype = payload.ordertype.value if payload.ordertype else None
return rpc._rpc_force_exit(str(payload.tradeid), ordertype, amount=payload.amount) return rpc._rpc_force_exit(
str(payload.tradeid), ordertype, amount=payload.amount, price=payload.price
)
@router.get("/blacklist", response_model=BlacklistResponse, tags=["info", "pairlist"]) @router.get("/blacklist", response_model=BlacklistResponse, tags=["info", "pairlist"])
+25 -6
View File
@@ -940,7 +940,11 @@ class RPC:
return {"status": "Reloaded from orders from exchange"} return {"status": "Reloaded from orders from exchange"}
def __exec_force_exit( def __exec_force_exit(
self, trade: Trade, ordertype: str | None, amount: float | None = None self,
trade: Trade,
ordertype: str | None,
amount: float | None = None,
price: float | None = None,
) -> bool: ) -> bool:
# Check if there is there are open orders # Check if there is there are open orders
trade_entry_cancelation_registry = [] trade_entry_cancelation_registry = []
@@ -964,9 +968,14 @@ class RPC:
# Order cancellation failed, so we can't exit. # Order cancellation failed, so we can't exit.
return False return False
# Get current rate and execute sell # Get current rate and execute sell
current_rate = self._freqtrade.exchange.get_rate(
current_rate = (
self._freqtrade.exchange.get_rate(
trade.pair, side="exit", is_short=trade.is_short, refresh=True trade.pair, side="exit", is_short=trade.is_short, refresh=True
) )
if ordertype == "market" or price is None
else price
)
exit_check = ExitCheckTuple(exit_type=ExitType.FORCE_EXIT) exit_check = ExitCheckTuple(exit_type=ExitType.FORCE_EXIT)
order_type = ordertype or self._freqtrade.strategy.order_types.get( order_type = ordertype or self._freqtrade.strategy.order_types.get(
"force_exit", self._freqtrade.strategy.order_types["exit"] "force_exit", self._freqtrade.strategy.order_types["exit"]
@@ -983,18 +992,28 @@ class RPC:
sub_amount = amount sub_amount = amount
self._freqtrade.execute_trade_exit( self._freqtrade.execute_trade_exit(
trade, current_rate, exit_check, ordertype=order_type, sub_trade_amt=sub_amount trade,
current_rate,
exit_check,
ordertype=order_type,
sub_trade_amt=sub_amount,
skip_custom_exit_price=price is not None and ordertype == "limit",
) )
return True return True
return False return False
def _rpc_force_exit( def _rpc_force_exit(
self, trade_id: str, ordertype: str | None = None, *, amount: float | None = None self,
trade_id: str,
ordertype: str | None = None,
*,
amount: float | None = None,
price: float | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
""" """
Handler for forceexit <id>. Handler for forceexit <id>.
Sells the given trade at current price exits the given trade. Uses current price if price is None.
""" """
if self._freqtrade.state == State.STOPPED: if self._freqtrade.state == State.STOPPED:
@@ -1024,7 +1043,7 @@ class RPC:
logger.warning("force_exit: Invalid argument received") logger.warning("force_exit: Invalid argument received")
raise RPCException("invalid argument") raise RPCException("invalid argument")
result = self.__exec_force_exit(trade, ordertype, amount) result = self.__exec_force_exit(trade, ordertype, amount, price)
Trade.commit() Trade.commit()
self._freqtrade.wallets.update() self._freqtrade.wallets.update()
if not result: if not result:
+6 -3
View File
@@ -104,8 +104,11 @@ def _create_and_merge_informative_pair(
): ):
asset = inf_data.asset or "" asset = inf_data.asset or ""
timeframe = inf_data.timeframe timeframe = inf_data.timeframe
timeframe1 = inf_data.timeframe
fmt = inf_data.fmt fmt = inf_data.fmt
candle_type = inf_data.candle_type candle_type = inf_data.candle_type
if candle_type == CandleType.FUNDING_RATE:
timeframe1 = strategy.dp.get_funding_rate_timeframe()
config = strategy.config config = strategy.config
@@ -132,10 +135,10 @@ def _create_and_merge_informative_pair(
fmt = "{base}_{quote}_" + fmt # Informatives of other pairs fmt = "{base}_{quote}_" + fmt # Informatives of other pairs
inf_metadata = {"pair": asset, "timeframe": timeframe} inf_metadata = {"pair": asset, "timeframe": timeframe}
inf_dataframe = strategy.dp.get_pair_dataframe(asset, timeframe, candle_type) inf_dataframe = strategy.dp.get_pair_dataframe(asset, timeframe1, candle_type)
if inf_dataframe.empty: if inf_dataframe.empty:
raise ValueError( raise ValueError(
f"Informative dataframe for ({asset}, {timeframe}, {candle_type}) is empty. " f"Informative dataframe for ({asset}, {timeframe1}, {candle_type}) is empty. "
"Can't populate informative indicators." "Can't populate informative indicators."
) )
inf_dataframe = populate_indicators_fn(strategy, inf_dataframe, inf_metadata) inf_dataframe = populate_indicators_fn(strategy, inf_dataframe, inf_metadata)
@@ -163,7 +166,7 @@ def _create_and_merge_informative_pair(
dataframe, dataframe,
inf_dataframe, inf_dataframe,
strategy.timeframe, strategy.timeframe,
timeframe, timeframe1,
ffill=inf_data.ffill, ffill=inf_data.ffill,
append_timeframe=False, append_timeframe=False,
date_column=date_column, date_column=date_column,
+1 -1
View File
@@ -1718,7 +1718,7 @@ class IStrategy(ABC, HyperStrategyMixin):
timeout_unit = self.config.get("unfilledtimeout", {}).get("unit", "minutes") timeout_unit = self.config.get("unfilledtimeout", {}).get("unit", "minutes")
timeout_kwargs = {timeout_unit: -timeout} timeout_kwargs = {timeout_unit: -timeout}
timeout_threshold = current_time + timedelta(**timeout_kwargs) timeout_threshold = current_time + timedelta(**timeout_kwargs)
timedout = order.status == "open" and order.order_date_utc < timeout_threshold timedout = order.status == "open" and order.order_date_utc <= timeout_threshold
if timedout: if timedout:
return True return True
time_method = ( time_method = (
+1 -1
View File
@@ -1,3 +1,3 @@
# Requirements for freqtrade client library # Requirements for freqtrade client library
requests==2.32.5 requests==2.32.5
python-rapidjson==1.22 python-rapidjson==1.23
+4 -4
View File
@@ -6,10 +6,10 @@
-r requirements-freqai-rl.txt -r requirements-freqai-rl.txt
-r docs/requirements-docs.txt -r docs/requirements-docs.txt
ruff==0.14.6 ruff==0.14.8
mypy==1.18.2 mypy==1.19.0
pre-commit==4.5.0 pre-commit==4.5.0
pytest==9.0.1 pytest==9.0.2
pytest-asyncio==1.3.0 pytest-asyncio==1.3.0
pytest-cov==7.0.0 pytest-cov==7.0.0
pytest-mock==3.15.1 pytest-mock==3.15.1
@@ -24,7 +24,7 @@ time-machine==3.1.0
nbconvert==7.16.6 nbconvert==7.16.6
# mypy types # mypy types
scipy-stubs==1.16.3.1 # keep in sync with `scipy` in `requirements-hyperopt.txt` scipy-stubs==1.16.3.2 # keep in sync with `scipy` in `requirements-hyperopt.txt`
types-cachetools==6.2.0.20251022 types-cachetools==6.2.0.20251022
types-filelock==3.2.7 types-filelock==3.2.7
types-requests==2.32.4.20250913 types-requests==2.32.4.20250913
+1 -1
View File
@@ -5,7 +5,7 @@
torch==2.9.1; sys_platform != 'darwin' or platform_machine != 'x86_64' torch==2.9.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
gymnasium==1.2.2 gymnasium==1.2.2
# SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos # SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos
stable_baselines3==2.7.0; sys_platform != 'darwin' or platform_machine != 'x86_64' stable_baselines3==2.7.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
sb3_contrib>=2.2.1; sys_platform != 'darwin' or platform_machine != 'x86_64' sb3_contrib>=2.2.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
# Progress bar for stable-baselines3 and sb3-contrib # Progress bar for stable-baselines3 and sb3-contrib
tqdm==4.67.1 tqdm==4.67.1
+1 -1
View File
@@ -4,6 +4,6 @@
# Required for hyperopt # Required for hyperopt
scipy==1.16.3 scipy==1.16.3
scikit-learn==1.7.2 scikit-learn==1.7.2
filelock==3.20.0 filelock==3.20.1
optuna==4.6.0 optuna==4.6.0
cmaes==0.12.0 cmaes==0.12.0
+3 -3
View File
@@ -1767,7 +1767,7 @@ def test_start_list_data(testdatadir, capsys):
pargs["config"] = None pargs["config"] = None
start_list_data(pargs) start_list_data(pargs)
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Found 16 pair / timeframe combinations." in captured.out assert "Found 18 pair / timeframe combinations." in captured.out
assert re.search(r".*Pair.*Timeframe.*Type.*\n", captured.out) assert re.search(r".*Pair.*Timeframe.*Type.*\n", captured.out)
assert re.search(r"\n.* UNITTEST/BTC .* 1m, 5m, 8m, 30m .* spot |\n", captured.out) assert re.search(r"\n.* UNITTEST/BTC .* 1m, 5m, 8m, 30m .* spot |\n", captured.out)
@@ -1801,10 +1801,10 @@ def test_start_list_data(testdatadir, capsys):
start_list_data(pargs) start_list_data(pargs)
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Found 6 pair / timeframe combinations." in captured.out assert "Found 5 pair / timeframe combinations." in captured.out
assert re.search(r".*Pair.*Timeframe.*Type.*\n", captured.out) assert re.search(r".*Pair.*Timeframe.*Type.*\n", captured.out)
assert re.search(r"\n.* XRP/USDT:USDT .* 5m, 1h .* futures |\n", captured.out) assert re.search(r"\n.* XRP/USDT:USDT .* 5m, 1h .* futures |\n", captured.out)
assert re.search(r"\n.* XRP/USDT:USDT .* 1h, 8h .* mark |\n", captured.out) assert re.search(r"\n.* XRP/USDT:USDT .* 1h.* mark |\n", captured.out)
args = [ args = [
"list-data", "list-data",
+8 -5
View File
@@ -290,20 +290,23 @@ def test_combine_dataframes_with_mean(testdatadir):
def test_combined_dataframes_with_rel_mean(testdatadir): def test_combined_dataframes_with_rel_mean(testdatadir):
pairs = ["ETH/BTC", "ADA/BTC"] pairs = ["BTC/USDT", "XRP/USDT"]
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m") data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
df = combined_dataframes_with_rel_mean( df = combined_dataframes_with_rel_mean(
data, datetime(2018, 1, 12, tzinfo=UTC), datetime(2018, 1, 28, tzinfo=UTC) data,
fromdt=data["BTC/USDT"].at[0, "date"],
todt=data["BTC/USDT"].at[data["BTC/USDT"].index[-1], "date"],
) )
assert isinstance(df, DataFrame) assert isinstance(df, DataFrame)
assert "ETH/BTC" not in df.columns assert "BTC/USDT" not in df.columns
assert "ADA/BTC" not in df.columns assert "XRP/USDT" not in df.columns
assert "mean" in df.columns assert "mean" in df.columns
assert "rel_mean" in df.columns assert "rel_mean" in df.columns
assert "count" in df.columns assert "count" in df.columns
assert df.iloc[0]["count"] == 2 assert df.iloc[0]["count"] == 2
assert df.iloc[-1]["count"] == 2 assert df.iloc[-1]["count"] == 2
assert len(df) < len(data["ETH/BTC"]) assert len(df) < len(data["BTC/USDT"])
assert df["rel_mean"].between(-0.5, 0.5).all()
def test_combine_dataframes_with_mean_no_data(testdatadir): def test_combine_dataframes_with_mean_no_data(testdatadir):
+5 -2
View File
@@ -40,6 +40,8 @@ def test_datahandler_ohlcv_get_pairs(testdatadir):
"NXT/BTC", "NXT/BTC",
"DASH/BTC", "DASH/BTC",
"XRP/ETH", "XRP/ETH",
"BTC/USDT",
"XRP/USDT",
} }
pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, "8m", candle_type=CandleType.SPOT) pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, "8m", candle_type=CandleType.SPOT)
@@ -111,6 +113,8 @@ def test_datahandler_ohlcv_get_available_data(testdatadir):
("DASH/BTC", "5m", CandleType.SPOT), ("DASH/BTC", "5m", CandleType.SPOT),
("XRP/ETH", "1m", CandleType.SPOT), ("XRP/ETH", "1m", CandleType.SPOT),
("XRP/ETH", "5m", CandleType.SPOT), ("XRP/ETH", "5m", CandleType.SPOT),
("BTC/USDT", "5m", CandleType.SPOT),
("XRP/USDT", "5m", CandleType.SPOT),
("UNITTEST/BTC", "30m", CandleType.SPOT), ("UNITTEST/BTC", "30m", CandleType.SPOT),
("UNITTEST/BTC", "8m", CandleType.SPOT), ("UNITTEST/BTC", "8m", CandleType.SPOT),
} }
@@ -122,8 +126,7 @@ def test_datahandler_ohlcv_get_available_data(testdatadir):
("XRP/USDT:USDT", "5m", "futures"), ("XRP/USDT:USDT", "5m", "futures"),
("XRP/USDT:USDT", "1h", "futures"), ("XRP/USDT:USDT", "1h", "futures"),
("XRP/USDT:USDT", "1h", "mark"), ("XRP/USDT:USDT", "1h", "mark"),
("XRP/USDT:USDT", "8h", "mark"), ("XRP/USDT:USDT", "1h", "funding_rate"),
("XRP/USDT:USDT", "8h", "funding_rate"),
} }
paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT)
+41 -1
View File
@@ -9,7 +9,7 @@ from freqtrade.enums import CandleType, RunMode
from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exceptions import ExchangeError, OperationalException
from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.pairlistmanager import PairListManager
from freqtrade.util import dt_utc from freqtrade.util import dt_utc
from tests.conftest import EXMS, generate_test_data, get_patched_exchange from tests.conftest import EXMS, generate_test_data, get_patched_exchange, log_has_re
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -185,6 +185,28 @@ def test_get_pair_dataframe(mocker, default_conf, ohlcv_history, candle_type):
assert len(df) == 2 # ohlcv_history is limited to 2 rows now assert len(df) == 2 # ohlcv_history is limited to 2 rows now
def test_get_pair_dataframe_funding_rate(mocker, default_conf, ohlcv_history, caplog):
default_conf["runmode"] = RunMode.DRY_RUN
timeframe = "1h"
exchange = get_patched_exchange(mocker, default_conf)
candletype = CandleType.FUNDING_RATE
exchange._klines[("XRP/BTC", timeframe, candletype)] = ohlcv_history
exchange._klines[("UNITTEST/BTC", timeframe, candletype)] = ohlcv_history
dp = DataProvider(default_conf, exchange)
assert dp.runmode == RunMode.DRY_RUN
assert ohlcv_history.equals(
dp.get_pair_dataframe("UNITTEST/BTC", timeframe, candle_type="funding_rate")
)
msg = r".*funding rate timeframe not matching"
assert not log_has_re(msg, caplog)
assert ohlcv_history.equals(
dp.get_pair_dataframe("UNITTEST/BTC", "5h", candle_type="funding_rate")
)
assert log_has_re(msg, caplog)
def test_available_pairs(mocker, default_conf, ohlcv_history): def test_available_pairs(mocker, default_conf, ohlcv_history):
exchange = get_patched_exchange(mocker, default_conf) exchange = get_patched_exchange(mocker, default_conf)
timeframe = default_conf["timeframe"] timeframe = default_conf["timeframe"]
@@ -636,3 +658,21 @@ def test_check_delisting(mocker, default_conf_usdt):
assert res == dt_utc(2025, 10, 2) assert res == dt_utc(2025, 10, 2)
assert delist_mock2.call_count == 1 assert delist_mock2.call_count == 1
def test_get_funding_rate_timeframe(mocker, default_conf_usdt):
default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = "isolated"
exchange = get_patched_exchange(mocker, default_conf_usdt)
mock_get_option = mocker.spy(exchange, "get_option")
dp = DataProvider(default_conf_usdt, exchange)
assert dp.get_funding_rate_timeframe() == "1h"
mock_get_option.assert_called_once_with("funding_fee_timeframe")
def test_get_funding_rate_timeframe_no_exchange(default_conf_usdt):
dp = DataProvider(default_conf_usdt, None)
with pytest.raises(OperationalException, match=r"Exchange is not available to DataProvider."):
dp.get_funding_rate_timeframe()
+28 -8
View File
@@ -534,18 +534,19 @@ def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> No
@pytest.mark.parametrize( @pytest.mark.parametrize(
"trademode,callcount", "trademode,callcount, callcount_parallel",
[ [
("spot", 4), ("spot", 4, 2),
("margin", 4), ("margin", 4, 2),
("futures", 8), # Called 8 times - 4 normal, 2 funding and 2 mark/index calls ("futures", 8, 4), # Called 8 times - 4 normal, 2 funding and 2 mark/index calls
], ],
) )
def test_refresh_backtest_ohlcv_data( def test_refresh_backtest_ohlcv_data(
mocker, default_conf, markets, caplog, testdatadir, trademode, callcount mocker, default_conf, markets, caplog, testdatadir, trademode, callcount, callcount_parallel
): ):
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
dl_mock = mocker.patch("freqtrade.data.history.history_utils._download_pair_history") dl_mock = mocker.patch("freqtrade.data.history.history_utils._download_pair_history")
mocker.patch(f"{EXMS}.verify_candle_type_support", MagicMock())
def parallel_mock(pairs, timeframe, candle_type, **kwargs): def parallel_mock(pairs, timeframe, candle_type, **kwargs):
return {(pair, timeframe, candle_type): DataFrame() for pair in pairs} return {(pair, timeframe, candle_type): DataFrame() for pair in pairs}
@@ -573,14 +574,15 @@ def test_refresh_backtest_ohlcv_data(
) )
# Called once per timeframe (as we return an empty dataframe) # Called once per timeframe (as we return an empty dataframe)
assert parallel_mock.call_count == 2 # called twice for spot/margin and 4 times for futures
assert parallel_mock.call_count == callcount_parallel
assert dl_mock.call_count == callcount assert dl_mock.call_count == callcount
assert dl_mock.call_args[1]["timerange"].starttype == "date" assert dl_mock.call_args[1]["timerange"].starttype == "date"
assert log_has_re(r"Downloading pair ETH/BTC, .* interval 1m\.", caplog) assert log_has_re(r"Downloading pair ETH/BTC, .* interval 1m\.", caplog)
if trademode == "futures": if trademode == "futures":
assert log_has_re(r"Downloading pair ETH/BTC, funding_rate, interval 8h\.", caplog) assert log_has_re(r"Downloading pair ETH/BTC, funding_rate, interval 1h\.", caplog)
assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 4h\.", caplog) assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 1h\.", caplog)
# Test with only one pair - no parallel download should happen 1 pair/timeframe combination # Test with only one pair - no parallel download should happen 1 pair/timeframe combination
# doesn't justify parallelization # doesn't justify parallelization
@@ -599,6 +601,24 @@ def test_refresh_backtest_ohlcv_data(
) )
assert parallel_mock.call_count == 0 assert parallel_mock.call_count == 0
if trademode == "futures":
dl_mock.reset_mock()
refresh_backtest_ohlcv_data(
exchange=ex,
pairs=[
"ETH/BTC",
],
timeframes=["5m", "1h"],
datadir=testdatadir,
timerange=timerange,
erase=False,
trading_mode=trademode,
no_parallel_download=True,
candle_types=["premiumIndex", "funding_rate"],
)
assert parallel_mock.call_count == 0
assert dl_mock.call_count == 3 # 2 timeframes premiumIndex + 1x funding_rate
def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir): def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir):
dl_mock = mocker.patch( dl_mock = mocker.patch(
+99 -22
View File
@@ -2389,6 +2389,7 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_
] ]
] ]
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name) exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
mocker.patch.object(exchange, "verify_candle_type_support")
# Monkey-patch async function # Monkey-patch async function
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
@@ -2439,6 +2440,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf_usdt, caplog, candle_type) ->
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
exchange = get_patched_exchange(mocker, default_conf_usdt) exchange = get_patched_exchange(mocker, default_conf_usdt)
mocker.patch.object(exchange, "verify_candle_type_support")
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
pairs = [("IOTA/USDT", "5m", candle_type), ("XRP/USDT", "5m", candle_type)] pairs = [("IOTA/USDT", "5m", candle_type), ("XRP/USDT", "5m", candle_type)]
@@ -2689,6 +2691,7 @@ def test_refresh_latest_ohlcv_cache(mocker, default_conf, candle_type, time_mach
time_machine.move_to(start + timedelta(hours=99, minutes=30)) time_machine.move_to(start + timedelta(hours=99, minutes=30))
exchange = get_patched_exchange(mocker, default_conf) exchange = get_patched_exchange(mocker, default_conf)
mocker.patch.object(exchange, "verify_candle_type_support")
exchange._set_startup_candle_count(default_conf) exchange._set_startup_candle_count(default_conf)
mocker.patch(f"{EXMS}.ohlcv_candle_limit", return_value=100) mocker.patch(f"{EXMS}.ohlcv_candle_limit", return_value=100)
@@ -2837,6 +2840,29 @@ def test_refresh_ohlcv_with_cache(mocker, default_conf, time_machine) -> None:
assert ohlcv_mock.call_args_list[0][0][0] == pairs assert ohlcv_mock.call_args_list[0][0][0] == pairs
def test_refresh_latest_ohlcv_funding_rate(mocker, default_conf_usdt, caplog) -> None:
ohlcv = generate_test_data_raw("1h", 24, "2025-01-02 12:00:00+00:00")
funding_data = [{"timestamp": x[0], "fundingRate": x[1]} for x in ohlcv]
caplog.set_level(logging.DEBUG)
exchange = get_patched_exchange(mocker, default_conf_usdt)
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
exchange._api_async.fetch_funding_rate_history = get_mock_coro(funding_data)
pairs = [
("IOTA/USDT:USDT", "8h", CandleType.FUNDING_RATE),
("XRP/USDT:USDT", "1h", CandleType.FUNDING_RATE),
]
# empty dicts
assert not exchange._klines
res = exchange.refresh_latest_ohlcv(pairs, cache=False)
assert len(res) == len(pairs)
assert log_has_re(r"Wrong funding rate timeframe 8h for pair IOTA/USDT:USDT", caplog)
assert not log_has_re(r"Wrong funding rate timeframe 8h for pair XRP/USDT:USDT", caplog)
assert exchange._api_async.fetch_ohlcv.call_count == 0
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name): async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name):
ohlcv = [ ohlcv = [
@@ -3901,37 +3927,29 @@ def test_cancel_stoploss_order(default_conf, mocker, exchange_name):
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_cancel_stoploss_order_with_result(default_conf, mocker, exchange_name): def test_cancel_stoploss_order_with_result(default_conf, mocker, exchange_name):
default_conf["dry_run"] = False default_conf["dry_run"] = False
mock_prefix = "freqtrade.exchange.gate.Gate"
if exchange_name == "okx":
mock_prefix = "freqtrade.exchange.okx.Okx"
mocker.patch(f"{EXMS}.fetch_stoploss_order", return_value={"for": 123})
mocker.patch(f"{mock_prefix}.fetch_stoploss_order", return_value={"for": 123})
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name) exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
mocker.patch.object(exchange, "fetch_stoploss_order", return_value={"for": 123})
res = {"fee": {}, "status": "canceled", "amount": 1234} res = {"fee": {}, "status": "canceled", "amount": 1234}
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value=res) mocker.patch.object(exchange, "cancel_stoploss_order", return_value=res)
mocker.patch(f"{mock_prefix}.cancel_stoploss_order", return_value=res)
co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555) co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555)
assert co == res assert co == res
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value="canceled") mocker.patch.object(exchange, "cancel_stoploss_order", return_value="canceled")
mocker.patch(f"{mock_prefix}.cancel_stoploss_order", return_value="canceled")
# Fall back to fetch_stoploss_order # Fall back to fetch_stoploss_order
co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555) co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555)
assert co == {"for": 123} assert co == {"for": 123}
exc = InvalidOrderException("") exc = InvalidOrderException("")
mocker.patch(f"{EXMS}.fetch_stoploss_order", side_effect=exc) mocker.patch.object(exchange, "fetch_stoploss_order", side_effect=exc)
mocker.patch(f"{mock_prefix}.fetch_stoploss_order", side_effect=exc)
co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555) co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555)
assert co["amount"] == 555 assert co["amount"] == 555
assert co == {"id": "_", "fee": {}, "status": "canceled", "amount": 555, "info": {}} assert co == {"id": "_", "fee": {}, "status": "canceled", "amount": 555, "info": {}}
with pytest.raises(InvalidOrderException): with pytest.raises(InvalidOrderException):
exc = InvalidOrderException("Did not find order") exc = InvalidOrderException("Did not find order")
mocker.patch(f"{EXMS}.cancel_stoploss_order", side_effect=exc)
mocker.patch(f"{mock_prefix}.cancel_stoploss_order", side_effect=exc)
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name) exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
mocker.patch.object(exchange, "cancel_stoploss_order", side_effect=exc)
exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=123) exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=123)
@@ -4116,7 +4134,7 @@ def test_fetch_order_or_stoploss_order(default_conf, mocker):
fetch_order_mock = MagicMock() fetch_order_mock = MagicMock()
fetch_stoploss_order_mock = MagicMock() fetch_stoploss_order_mock = MagicMock()
mocker.patch.multiple( mocker.patch.multiple(
EXMS, exchange,
fetch_order=fetch_order_mock, fetch_order=fetch_order_mock,
fetch_stoploss_order=fetch_stoploss_order_mock, fetch_stoploss_order=fetch_stoploss_order_mock,
) )
@@ -5350,11 +5368,12 @@ def test_combine_funding_and_mark(
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate) df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
if futures_funding_rate is not None: if futures_funding_rate is not None:
assert len(df) == 3 assert len(df) == 2
assert df.iloc[0]["open_fund"] == funding_rate assert df.iloc[0]["open_fund"] == funding_rate
assert df.iloc[1]["open_fund"] == futures_funding_rate # assert df.iloc[1]["open_fund"] == futures_funding_rate
assert df.iloc[2]["open_fund"] == funding_rate assert df.iloc[-1]["open_fund"] == funding_rate
assert df["date"].to_list() == [prior2_date, prior_date, trade_date] # Mid-candle is dropped ...
assert df["date"].to_list() == [prior2_date, trade_date]
else: else:
assert len(df) == 2 assert len(df) == 2
assert df["date"].to_list() == [prior2_date, trade_date] assert df["date"].to_list() == [prior2_date, trade_date]
@@ -5448,8 +5467,13 @@ def test__fetch_and_calculate_funding_fees(
api_mock = MagicMock() api_mock = MagicMock()
api_mock.fetch_funding_rate_history = get_mock_coro(return_value=funding_rate_history) api_mock.fetch_funding_rate_history = get_mock_coro(return_value=funding_rate_history)
api_mock.fetch_ohlcv = get_mock_coro(return_value=mark_ohlcv) api_mock.fetch_ohlcv = get_mock_coro(return_value=mark_ohlcv)
type(api_mock).has = PropertyMock(return_value={"fetchOHLCV": True}) type(api_mock).has = PropertyMock(
type(api_mock).has = PropertyMock(return_value={"fetchFundingRateHistory": True}) return_value={
"fetchFundingRateHistory": True,
"fetchMarkOHLCV": True,
"fetchOHLCV": True,
}
)
ex = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange) ex = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange)
mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["1h", "4h", "8h"])) mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["1h", "4h", "8h"]))
@@ -5493,8 +5517,13 @@ def test__fetch_and_calculate_funding_fees_datetime_called(
api_mock.fetch_funding_rate_history = get_mock_coro( api_mock.fetch_funding_rate_history = get_mock_coro(
return_value=funding_rate_history_octohourly return_value=funding_rate_history_octohourly
) )
type(api_mock).has = PropertyMock(return_value={"fetchOHLCV": True}) type(api_mock).has = PropertyMock(
type(api_mock).has = PropertyMock(return_value={"fetchFundingRateHistory": True}) return_value={
"fetchFundingRateHistory": True,
"fetchMarkOHLCV": True,
"fetchOHLCV": True,
}
)
mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["4h", "8h"])) mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["4h", "8h"]))
exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange) exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange)
d1 = datetime.strptime("2021-08-31 23:00:01 +0000", "%Y-%m-%d %H:%M:%S %z") d1 = datetime.strptime("2021-08-31 23:00:01 +0000", "%Y-%m-%d %H:%M:%S %z")
@@ -6581,3 +6610,51 @@ def test_fetch_funding_rate(default_conf, mocker, exchange_name):
with pytest.raises(DependencyException, match=r"Pair XRP/ETH not available"): with pytest.raises(DependencyException, match=r"Pair XRP/ETH not available"):
exchange.fetch_funding_rate(pair="XRP/ETH") exchange.fetch_funding_rate(pair="XRP/ETH")
def test_verify_candle_type_support(default_conf, mocker):
api_mock = MagicMock()
type(api_mock).has = PropertyMock(
return_value={
"fetchFundingRateHistory": True,
"fetchIndexOHLCV": True,
"fetchMarkOHLCV": True,
"fetchPremiumIndexOHLCV": False,
}
)
exchange = get_patched_exchange(mocker, default_conf, api_mock)
# Should pass
exchange.verify_candle_type_support("futures")
exchange.verify_candle_type_support(CandleType.FUTURES)
exchange.verify_candle_type_support(CandleType.FUNDING_RATE)
exchange.verify_candle_type_support(CandleType.SPOT)
exchange.verify_candle_type_support(CandleType.MARK)
# Should fail:
with pytest.raises(
OperationalException,
match=r"Exchange .* does not support fetching premiumindex candles\.",
):
exchange.verify_candle_type_support(CandleType.PREMIUMINDEX)
type(api_mock).has = PropertyMock(
return_value={
"fetchFundingRateHistory": False,
"fetchIndexOHLCV": False,
"fetchMarkOHLCV": False,
"fetchPremiumIndexOHLCV": True,
}
)
for candle_type in [
CandleType.FUNDING_RATE,
CandleType.INDEX,
CandleType.MARK,
]:
with pytest.raises(
OperationalException,
match=rf"Exchange .* does not support fetching {candle_type.value} candles\.",
):
exchange.verify_candle_type_support(candle_type)
exchange.verify_candle_type_support(CandleType.PREMIUMINDEX)
+8 -10
View File
@@ -16,9 +16,9 @@ def test_fetch_stoploss_order_gate(default_conf, mocker):
exchange.fetch_stoploss_order("1234", "ETH/BTC") exchange.fetch_stoploss_order("1234", "ETH/BTC")
assert fetch_order_mock.call_count == 1 assert fetch_order_mock.call_count == 1
assert fetch_order_mock.call_args_list[0][1]["order_id"] == "1234" assert fetch_order_mock.call_args_list[0][0][0] == "1234"
assert fetch_order_mock.call_args_list[0][1]["pair"] == "ETH/BTC" assert fetch_order_mock.call_args_list[0][0][1] == "ETH/BTC"
assert fetch_order_mock.call_args_list[0][1]["params"] == {"stop": True} assert fetch_order_mock.call_args_list[0][0][2] == {"stop": True}
default_conf["trading_mode"] = "futures" default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated" default_conf["margin_mode"] = "isolated"
@@ -36,21 +36,19 @@ def test_fetch_stoploss_order_gate(default_conf, mocker):
exchange.fetch_stoploss_order("1234", "ETH/BTC") exchange.fetch_stoploss_order("1234", "ETH/BTC")
assert exchange.fetch_order.call_count == 2 assert exchange.fetch_order.call_count == 2
assert exchange.fetch_order.call_args_list[0][1]["order_id"] == "1234" assert exchange.fetch_order.call_args_list[0][0][0] == "1234"
assert exchange.fetch_order.call_args_list[1][1]["order_id"] == "222555" assert exchange.fetch_order.call_args_list[1][1]["order_id"] == "222555"
def test_cancel_stoploss_order_gate(default_conf, mocker): def test_cancel_stoploss_order_gate(default_conf, mocker):
exchange = get_patched_exchange(mocker, default_conf, exchange="gate") exchange = get_patched_exchange(mocker, default_conf, exchange="gate")
cancel_order_mock = mocker.patch.object(exchange, "cancel_order", autospec=True)
cancel_order_mock = MagicMock()
exchange.cancel_order = cancel_order_mock
exchange.cancel_stoploss_order("1234", "ETH/BTC") exchange.cancel_stoploss_order("1234", "ETH/BTC")
assert cancel_order_mock.call_count == 1 assert cancel_order_mock.call_count == 1
assert cancel_order_mock.call_args_list[0][1]["order_id"] == "1234" assert cancel_order_mock.call_args_list[0][0][0] == "1234"
assert cancel_order_mock.call_args_list[0][1]["pair"] == "ETH/BTC" assert cancel_order_mock.call_args_list[0][0][1] == "ETH/BTC"
assert cancel_order_mock.call_args_list[0][1]["params"] == {"stop": True} assert cancel_order_mock.call_args_list[0][0][2] == {"stop": True}
@pytest.mark.parametrize( @pytest.mark.parametrize(
+6 -6
View File
@@ -661,14 +661,14 @@ def test_stoploss_adjust_okx(mocker, default_conf, sl1, sl2, sl3, side):
def test_stoploss_cancel_okx(mocker, default_conf): def test_stoploss_cancel_okx(mocker, default_conf):
exchange = get_patched_exchange(mocker, default_conf, exchange="okx") exchange = get_patched_exchange(mocker, default_conf, exchange="okx")
co_mock = mocker.patch.object(exchange, "cancel_order", autospec=True)
exchange.cancel_order = MagicMock()
exchange.cancel_stoploss_order("1234", "ETH/USDT") exchange.cancel_stoploss_order("1234", "ETH/USDT")
assert exchange.cancel_order.call_count == 1 assert co_mock.call_count == 1
assert exchange.cancel_order.call_args_list[0][1]["order_id"] == "1234" args, _ = co_mock.call_args
assert exchange.cancel_order.call_args_list[0][1]["pair"] == "ETH/USDT" assert args[0] == "1234"
assert exchange.cancel_order.call_args_list[0][1]["params"] == {"stop": True} assert args[1] == "ETH/USDT"
assert args[2] == {"stop": True}
def test__get_stop_params_okx(mocker, default_conf): def test__get_stop_params_okx(mocker, default_conf):
+2 -1
View File
@@ -515,7 +515,8 @@ EXCHANGES = {
], ],
}, },
"hyperliquid": { "hyperliquid": {
"pair": "UBTC/USDC", # TODO: Should be UBTC/USDC - probably needs a fix in ccxt
"pair": "BTC/USDC",
"stake_currency": "USDC", "stake_currency": "USDC",
"hasQuoteVolume": False, "hasQuoteVolume": False,
"timeframe": "30m", "timeframe": "30m",
+43 -11
View File
@@ -270,11 +270,14 @@ class TestCCXTExchange:
assert exch.klines(pair_tf).iloc[-1]["date"] >= timeframe_to_prev_date(timeframe, now) assert exch.klines(pair_tf).iloc[-1]["date"] >= timeframe_to_prev_date(timeframe, now)
assert exch.klines(pair_tf)["date"].astype(int).iloc[0] // 1e6 == since_ms assert exch.klines(pair_tf)["date"].astype(int).iloc[0] // 1e6 == since_ms
def _ccxt__async_get_candle_history(self, exchange, pair, timeframe, candle_type, factor=0.9): def _ccxt__async_get_candle_history(
self, exchange, pair: str, timeframe: str, candle_type: CandleType, factor: float = 0.9
):
timeframe_ms = timeframe_to_msecs(timeframe) timeframe_ms = timeframe_to_msecs(timeframe)
timeframe_ms_8h = timeframe_to_msecs("8h")
now = timeframe_to_prev_date(timeframe, datetime.now(UTC)) now = timeframe_to_prev_date(timeframe, datetime.now(UTC))
for offset in (360, 120, 30, 10, 5, 2): for offset_days in (360, 120, 30, 10, 5, 2):
since = now - timedelta(days=offset) since = now - timedelta(days=offset_days)
since_ms = int(since.timestamp() * 1000) since_ms = int(since.timestamp() * 1000)
res = exchange.loop.run_until_complete( res = exchange.loop.run_until_complete(
@@ -289,8 +292,15 @@ class TestCCXTExchange:
candles = res[3] candles = res[3]
candle_count = exchange.ohlcv_candle_limit(timeframe, candle_type, since_ms) * factor candle_count = exchange.ohlcv_candle_limit(timeframe, candle_type, since_ms) * factor
candle_count1 = (now.timestamp() * 1000 - since_ms) // timeframe_ms * factor candle_count1 = (now.timestamp() * 1000 - since_ms) // timeframe_ms * factor
assert len(candles) >= min(candle_count, candle_count1), ( # funding fees can be 1h or 8h - depending on pair and time.
f"{len(candles)} < {candle_count} in {timeframe}, Offset: {offset} {factor}" candle_count2 = (now.timestamp() * 1000 - since_ms) // timeframe_ms_8h * factor
min_value = min(
candle_count,
candle_count1,
candle_count2 if candle_type == CandleType.FUNDING_RATE else candle_count1,
)
assert len(candles) >= min_value, (
f"{len(candles)} < {candle_count} in {timeframe} {offset_days=} {factor=}"
) )
# Check if first-timeframe is either the start, or start + 1 # Check if first-timeframe is either the start, or start + 1
assert candles[0][0] == since_ms or (since_ms + timeframe_ms) assert candles[0][0] == since_ms or (since_ms + timeframe_ms)
@@ -309,6 +319,8 @@ class TestCCXTExchange:
[ [
CandleType.FUTURES, CandleType.FUTURES,
CandleType.FUNDING_RATE, CandleType.FUNDING_RATE,
CandleType.INDEX,
CandleType.PREMIUMINDEX,
CandleType.MARK, CandleType.MARK,
], ],
) )
@@ -322,6 +334,10 @@ class TestCCXTExchange:
timeframe = exchange._ft_has.get( timeframe = exchange._ft_has.get(
"funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"] "funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"]
) )
else:
# never skip funding rate!
if not exchange.check_candle_type_support(candle_type):
pytest.skip(f"Exchange does not support candle type {candle_type}")
self._ccxt__async_get_candle_history( self._ccxt__async_get_candle_history(
exchange, exchange,
pair=pair, pair=pair,
@@ -337,6 +353,7 @@ class TestCCXTExchange:
timeframe_ff = exchange._ft_has.get( timeframe_ff = exchange._ft_has.get(
"funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"] "funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"]
) )
timeframe_ff_8h = "8h"
pair_tf = (pair, timeframe_ff, CandleType.FUNDING_RATE) pair_tf = (pair, timeframe_ff, CandleType.FUNDING_RATE)
funding_ohlcv = exchange.refresh_latest_ohlcv( funding_ohlcv = exchange.refresh_latest_ohlcv(
@@ -350,14 +367,26 @@ class TestCCXTExchange:
hour1 = timeframe_to_prev_date(timeframe_ff, this_hour - timedelta(minutes=1)) hour1 = timeframe_to_prev_date(timeframe_ff, this_hour - timedelta(minutes=1))
hour2 = timeframe_to_prev_date(timeframe_ff, hour1 - timedelta(minutes=1)) hour2 = timeframe_to_prev_date(timeframe_ff, hour1 - timedelta(minutes=1))
hour3 = timeframe_to_prev_date(timeframe_ff, hour2 - timedelta(minutes=1)) hour3 = timeframe_to_prev_date(timeframe_ff, hour2 - timedelta(minutes=1))
val0 = rate[rate["date"] == this_hour].iloc[0]["open"] # Alternative 8h timeframe - funding fee timeframe is not stable.
val1 = rate[rate["date"] == hour1].iloc[0]["open"] h8_this_hour = timeframe_to_prev_date(timeframe_ff_8h)
val2 = rate[rate["date"] == hour2].iloc[0]["open"] h8_hour1 = timeframe_to_prev_date(timeframe_ff_8h, h8_this_hour - timedelta(minutes=1))
val3 = rate[rate["date"] == hour3].iloc[0]["open"] h8_hour2 = timeframe_to_prev_date(timeframe_ff_8h, h8_hour1 - timedelta(minutes=1))
h8_hour3 = timeframe_to_prev_date(timeframe_ff_8h, h8_hour2 - timedelta(minutes=1))
row0 = rate.iloc[-1]
row1 = rate.iloc[-2]
row2 = rate.iloc[-3]
row3 = rate.iloc[-4]
assert row0["date"] == this_hour or row0["date"] == h8_this_hour
assert row1["date"] == hour1 or row1["date"] == h8_hour1
assert row2["date"] == hour2 or row2["date"] == h8_hour2
assert row3["date"] == hour3 or row3["date"] == h8_hour3
# Test For last 4 hours # Test For last 4 hours
# Avoids random test-failure when funding-fees are 0 for a few hours. # Avoids random test-failure when funding-fees are 0 for a few hours.
assert val0 != 0.0 or val1 != 0.0 or val2 != 0.0 or val3 != 0.0 assert (
row0["open"] != 0.0 or row1["open"] != 0.0 or row2["open"] != 0.0 or row3["open"] != 0.0
)
# We expect funding rates to be different from 0.0 - or moving around. # We expect funding rates to be different from 0.0 - or moving around.
assert ( assert (
rate["open"].max() != 0.0 rate["open"].max() != 0.0
@@ -369,7 +398,10 @@ class TestCCXTExchange:
exchange, exchangename = exchange_futures exchange, exchangename = exchange_futures
pair = EXCHANGES[exchangename].get("futures_pair", EXCHANGES[exchangename]["pair"]) pair = EXCHANGES[exchangename].get("futures_pair", EXCHANGES[exchangename]["pair"])
since = int((datetime.now(UTC) - timedelta(days=5)).timestamp() * 1000) since = int((datetime.now(UTC) - timedelta(days=5)).timestamp() * 1000)
pair_tf = (pair, "1h", CandleType.MARK) candle_type = CandleType.from_string(
exchange.get_option("mark_ohlcv_price", default=CandleType.MARK)
)
pair_tf = (pair, "1h", candle_type)
mark_ohlcv = exchange.refresh_latest_ohlcv([pair_tf], since_ms=since, drop_incomplete=False) mark_ohlcv = exchange.refresh_latest_ohlcv([pair_tf], since_ms=since, drop_incomplete=False)
+1 -1
View File
@@ -3092,7 +3092,7 @@ def test_execute_trade_exit_custom_exit_price(
"exit_reason": "foo", "exit_reason": "foo",
"open_date": ANY, "open_date": ANY,
"close_date": ANY, "close_date": ANY,
"close_rate": ANY, "close_rate": 2.25, # the custom exit price
"sub_trade": False, "sub_trade": False,
"cumulative_profit": 0.0, "cumulative_profit": 0.0,
"stake_amount": pytest.approx(60), "stake_amount": pytest.approx(60),
+6 -3
View File
@@ -60,13 +60,10 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
cancel_order_mock = MagicMock(side_effect=patch_stoploss) cancel_order_mock = MagicMock(side_effect=patch_stoploss)
mocker.patch.multiple( mocker.patch.multiple(
EXMS, EXMS,
create_stoploss=stoploss,
fetch_ticker=ticker, fetch_ticker=ticker,
get_fee=fee, get_fee=fee,
amount_to_precision=lambda s, x, y: y, amount_to_precision=lambda s, x, y: y,
price_to_precision=lambda s, x, y: y, price_to_precision=lambda s, x, y: y,
fetch_stoploss_order=stoploss_order_mock,
cancel_stoploss_order_with_result=cancel_order_mock,
) )
mocker.patch.multiple( mocker.patch.multiple(
@@ -80,6 +77,12 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
mocker.patch("freqtrade.wallets.Wallets.check_exit_amount", return_value=True) mocker.patch("freqtrade.wallets.Wallets.check_exit_amount", return_value=True)
freqtrade = get_patched_freqtradebot(mocker, default_conf) freqtrade = get_patched_freqtradebot(mocker, default_conf)
mocker.patch.multiple(
freqtrade.exchange,
create_stoploss=stoploss,
fetch_stoploss_order=stoploss_order_mock,
cancel_stoploss_order_with_result=cancel_order_mock,
)
freqtrade.strategy.order_types["stoploss_on_exchange"] = True freqtrade.strategy.order_types["stoploss_on_exchange"] = True
# Switch ordertype to market to close trade immediately # Switch ordertype to market to close trade immediately
freqtrade.strategy.order_types["exit"] = "market" freqtrade.strategy.order_types["exit"] = "market"
+80 -48
View File
@@ -103,7 +103,7 @@ def test_handle_stoploss_on_exchange(
trade.is_open = True trade.is_open = True
hanging_stoploss_order = MagicMock(return_value={"id": "13434334", "status": "open"}) hanging_stoploss_order = MagicMock(return_value={"id": "13434334", "status": "open"})
mocker.patch(f"{EXMS}.fetch_stoploss_order", hanging_stoploss_order) mocker.patch.object(freqtrade.exchange, "fetch_stoploss_order", hanging_stoploss_order)
assert freqtrade.handle_stoploss_on_exchange(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False
hanging_stoploss_order.assert_called_once_with("13434334", trade.pair) hanging_stoploss_order.assert_called_once_with("13434334", trade.pair)
@@ -116,7 +116,7 @@ def test_handle_stoploss_on_exchange(
trade.is_open = True trade.is_open = True
canceled_stoploss_order = MagicMock(return_value={"id": "13434334", "status": "canceled"}) canceled_stoploss_order = MagicMock(return_value={"id": "13434334", "status": "canceled"})
mocker.patch(f"{EXMS}.fetch_stoploss_order", canceled_stoploss_order) mocker.patch.object(freqtrade.exchange, "fetch_stoploss_order", canceled_stoploss_order)
stoploss.reset_mock() stoploss.reset_mock()
amount_before = trade.amount amount_before = trade.amount
@@ -149,7 +149,7 @@ def test_handle_stoploss_on_exchange(
"amount": enter_order["amount"], "amount": enter_order["amount"],
} }
) )
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_order_hit) mocker.patch.object(freqtrade.exchange, "fetch_stoploss_order", stoploss_order_hit)
freqtrade.strategy.order_filled = MagicMock(return_value=None) freqtrade.strategy.order_filled = MagicMock(return_value=None)
assert freqtrade.handle_stoploss_on_exchange(trade) is True assert freqtrade.handle_stoploss_on_exchange(trade) is True
assert log_has_re(r"STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.", caplog) assert log_has_re(r"STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.", caplog)
@@ -158,7 +158,7 @@ def test_handle_stoploss_on_exchange(
assert freqtrade.strategy.order_filled.call_count == 1 assert freqtrade.strategy.order_filled.call_count == 1
caplog.clear() caplog.clear()
mocker.patch(f"{EXMS}.create_stoploss", side_effect=ExchangeError()) mocker.patch.object(freqtrade.exchange, "create_stoploss", side_effect=ExchangeError())
trade.is_open = True trade.is_open = True
freqtrade.handle_stoploss_on_exchange(trade) freqtrade.handle_stoploss_on_exchange(trade)
assert log_has("Unable to place a stoploss order on exchange.", caplog) assert log_has("Unable to place a stoploss order on exchange.", caplog)
@@ -168,8 +168,13 @@ def test_handle_stoploss_on_exchange(
# It should try to add stoploss order # It should try to add stoploss order
stop_order_dict.update({"id": "105"}) stop_order_dict.update({"id": "105"})
stoploss.reset_mock() stoploss.reset_mock()
mocker.patch(f"{EXMS}.fetch_stoploss_order", side_effect=InvalidOrderException()) mocker.patch.multiple(
mocker.patch(f"{EXMS}.create_stoploss", stoploss) freqtrade.exchange,
fetch_stoploss_order=MagicMock(
side_effect=InvalidOrderException(),
),
create_stoploss=stoploss,
)
freqtrade.handle_stoploss_on_exchange(trade) freqtrade.handle_stoploss_on_exchange(trade)
assert len(trade.open_sl_orders) == 1 assert len(trade.open_sl_orders) == 1
assert stoploss.call_count == 1 assert stoploss.call_count == 1
@@ -179,8 +184,7 @@ def test_handle_stoploss_on_exchange(
trade.is_open = False trade.is_open = False
trade.open_sl_orders[-1].ft_is_open = False trade.open_sl_orders[-1].ft_is_open = False
stoploss.reset_mock() stoploss.reset_mock()
mocker.patch(f"{EXMS}.fetch_order") mocker.patch.multiple(freqtrade.exchange, fetch_order=MagicMock(), create_stoploss=stoploss)
mocker.patch(f"{EXMS}.create_stoploss", stoploss)
assert freqtrade.handle_stoploss_on_exchange(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False
assert trade.has_open_sl_orders is False assert trade.has_open_sl_orders is False
assert stoploss.call_count == 0 assert stoploss.call_count == 0
@@ -252,9 +256,12 @@ def test_handle_stoploss_on_exchange_emergency(
stoploss = MagicMock(side_effect=InvalidOrderException()) stoploss = MagicMock(side_effect=InvalidOrderException())
assert trade.has_open_sl_orders is True assert trade.has_open_sl_orders is True
Trade.commit() Trade.commit()
mocker.patch(f"{EXMS}.cancel_stoploss_order_with_result", side_effect=InvalidOrderException()) mocker.patch.multiple(
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_order_cancelled) freqtrade.exchange,
mocker.patch(f"{EXMS}.create_stoploss", stoploss) cancel_stoploss_order_with_result=MagicMock(side_effect=InvalidOrderException()),
fetch_stoploss_order=stoploss_order_cancelled,
create_stoploss=stoploss,
)
assert freqtrade.handle_stoploss_on_exchange(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False
assert trade.has_open_sl_orders is False assert trade.has_open_sl_orders is False
assert trade.is_open is False assert trade.is_open is False
@@ -311,7 +318,7 @@ def test_handle_stoploss_on_exchange_partial(
"amount": enter_order["amount"], "amount": enter_order["amount"],
} }
) )
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_order_hit) mocker.patch.multiple(freqtrade.exchange, fetch_stoploss_order=stoploss_order_hit)
assert freqtrade.handle_stoploss_on_exchange(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False
# Stoploss filled partially ... # Stoploss filled partially ...
assert trade.amount == 15 assert trade.amount == 15
@@ -383,8 +390,11 @@ def test_handle_stoploss_on_exchange_partial_cancel_here(
"amount": enter_order["amount"], "amount": enter_order["amount"],
} }
) )
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_order_hit) mocker.patch.multiple(
mocker.patch(f"{EXMS}.cancel_stoploss_order_with_result", stoploss_order_cancel) freqtrade.exchange,
fetch_stoploss_order=stoploss_order_hit,
cancel_stoploss_order_with_result=stoploss_order_cancel,
)
time_machine.shift(timedelta(minutes=15)) time_machine.shift(timedelta(minutes=15))
assert freqtrade.handle_stoploss_on_exchange(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False
@@ -408,20 +418,20 @@ def test_handle_sle_cancel_cant_recreate(
mocker.patch.multiple( mocker.patch.multiple(
EXMS, EXMS,
fetch_ticker=MagicMock(return_value={"bid": 1.9, "ask": 2.2, "last": 1.9}), fetch_ticker=MagicMock(return_value={"bid": 1.9, "ask": 2.2, "last": 1.9}),
get_fee=fee,
)
freqtrade = FreqtradeBot(default_conf_usdt)
mocker.patch.multiple(
freqtrade.exchange,
create_order=MagicMock( create_order=MagicMock(
side_effect=[ side_effect=[
enter_order, enter_order,
exit_order, exit_order,
] ]
), ),
get_fee=fee,
)
mocker.patch.multiple(
EXMS,
fetch_stoploss_order=MagicMock(return_value={"status": "canceled", "id": "100"}), fetch_stoploss_order=MagicMock(return_value={"status": "canceled", "id": "100"}),
create_stoploss=MagicMock(side_effect=ExchangeError()), create_stoploss=MagicMock(side_effect=ExchangeError()),
) )
freqtrade = FreqtradeBot(default_conf_usdt)
patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short)
freqtrade.enter_positions() freqtrade.enter_positions()
@@ -644,8 +654,11 @@ def test_handle_stoploss_on_exchange_trailing(
stoploss_order_cancel = deepcopy(stoploss_order_hanging) stoploss_order_cancel = deepcopy(stoploss_order_hanging)
stoploss_order_cancel["status"] = "canceled" stoploss_order_cancel["status"] = "canceled"
mocker.patch(f"{EXMS}.fetch_stoploss_order", return_value=stoploss_order_hanging) mocker.patch.multiple(
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value=stoploss_order_cancel) freqtrade.exchange,
fetch_stoploss_order=MagicMock(return_value=stoploss_order_hanging),
cancel_stoploss_order=MagicMock(return_value=stoploss_order_cancel),
)
# stoploss initially at 5% # stoploss initially at 5%
assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_trade(trade) is False
@@ -671,9 +684,12 @@ def test_handle_stoploss_on_exchange_trailing(
return_value={"id": "13434334", "status": "canceled", "fee": {}, "amount": trade.amount} return_value={"id": "13434334", "status": "canceled", "fee": {}, "amount": trade.amount}
) )
stoploss_order_mock = MagicMock(return_value={"id": "so1", "status": "open"}) stoploss_order_mock = MagicMock(return_value={"id": "so1", "status": "open"})
mocker.patch(f"{EXMS}.fetch_stoploss_order") mocker.patch.multiple(
mocker.patch(f"{EXMS}.cancel_stoploss_order", cancel_order_mock) freqtrade.exchange,
mocker.patch(f"{EXMS}.create_stoploss", stoploss_order_mock) fetch_stoploss_order=MagicMock(),
cancel_stoploss_order=cancel_order_mock,
create_stoploss=stoploss_order_mock,
)
# stoploss should not be updated as the interval is 60 seconds # stoploss should not be updated as the interval is 60 seconds
assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_trade(trade) is False
@@ -711,8 +727,9 @@ def test_handle_stoploss_on_exchange_trailing(
} }
), ),
) )
mocker.patch( mocker.patch.object(
f"{EXMS}.cancel_stoploss_order_with_result", freqtrade.exchange,
"cancel_stoploss_order_with_result",
return_value={"id": "so1", "status": "canceled"}, return_value={"id": "so1", "status": "canceled"},
) )
assert len(trade.open_sl_orders) == 1 assert len(trade.open_sl_orders) == 1
@@ -786,8 +803,12 @@ def test_handle_stoploss_on_exchange_trailing_error(
order_date=dt_now(), order_date=dt_now(),
) )
) )
mocker.patch(f"{EXMS}.cancel_stoploss_order", side_effect=InvalidOrderException()) mocker.patch.object(
mocker.patch(f"{EXMS}.fetch_stoploss_order", return_value=stoploss_order_hanging) freqtrade.exchange, "cancel_stoploss_order", side_effect=InvalidOrderException()
)
mocker.patch.object(
freqtrade.exchange, "fetch_stoploss_order", return_value=stoploss_order_hanging
)
time_machine.shift(timedelta(minutes=50)) time_machine.shift(timedelta(minutes=50))
freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging)
assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog) assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog)
@@ -799,8 +820,8 @@ def test_handle_stoploss_on_exchange_trailing_error(
# Fail creating stoploss order # Fail creating stoploss order
caplog.clear() caplog.clear()
cancel_mock = mocker.patch(f"{EXMS}.cancel_stoploss_order") cancel_mock = mocker.patch.object(freqtrade.exchange, "cancel_stoploss_order")
mocker.patch(f"{EXMS}.create_stoploss", side_effect=ExchangeError()) mocker.patch.object(freqtrade.exchange, "create_stoploss", side_effect=ExchangeError())
time_machine.shift(timedelta(minutes=50)) time_machine.shift(timedelta(minutes=50))
freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging)
assert cancel_mock.call_count == 2 assert cancel_mock.call_count == 2
@@ -846,20 +867,9 @@ def test_handle_stoploss_on_exchange_custom_stop(
mocker.patch.multiple( mocker.patch.multiple(
EXMS, EXMS,
fetch_ticker=MagicMock(return_value={"bid": 1.9, "ask": 2.2, "last": 1.9}), fetch_ticker=MagicMock(return_value={"bid": 1.9, "ask": 2.2, "last": 1.9}),
create_order=MagicMock(
side_effect=[
enter_order,
exit_order,
]
),
get_fee=fee, get_fee=fee,
is_cancel_order_result_suitable=MagicMock(return_value=True), is_cancel_order_result_suitable=MagicMock(return_value=True),
) )
mocker.patch.multiple(
EXMS,
create_stoploss=stoploss,
stoploss_adjust=MagicMock(return_value=True),
)
# enabling TSL # enabling TSL
default_conf_usdt["use_custom_stoploss"] = True default_conf_usdt["use_custom_stoploss"] = True
@@ -868,6 +878,17 @@ def test_handle_stoploss_on_exchange_custom_stop(
default_conf_usdt["minimal_roi"]["0"] = 999999999 default_conf_usdt["minimal_roi"]["0"] = 999999999
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
mocker.patch.multiple(
freqtrade.exchange,
create_order=MagicMock(
side_effect=[
enter_order,
exit_order,
]
),
create_stoploss=stoploss,
stoploss_adjust=MagicMock(return_value=True),
)
# enabling stoploss on exchange # enabling stoploss on exchange
freqtrade.strategy.order_types["stoploss_on_exchange"] = True freqtrade.strategy.order_types["stoploss_on_exchange"] = True
@@ -912,8 +933,11 @@ def test_handle_stoploss_on_exchange_custom_stop(
x["id"] = order_id x["id"] = order_id
return x return x
mocker.patch(f"{EXMS}.fetch_stoploss_order", MagicMock(fetch_stoploss_order_mock)) mocker.patch.multiple(
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value=slo_canceled) freqtrade.exchange,
fetch_stoploss_order=MagicMock(fetch_stoploss_order_mock),
cancel_stoploss_order=MagicMock(return_value=slo_canceled),
)
assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_trade(trade) is False
assert freqtrade.handle_stoploss_on_exchange(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False
@@ -932,8 +956,11 @@ def test_handle_stoploss_on_exchange_custom_stop(
cancel_order_mock = MagicMock() cancel_order_mock = MagicMock()
stoploss_order_mock = MagicMock(return_value={"id": "so1", "status": "open"}) stoploss_order_mock = MagicMock(return_value={"id": "so1", "status": "open"})
mocker.patch(f"{EXMS}.cancel_stoploss_order", cancel_order_mock) mocker.patch.multiple(
mocker.patch(f"{EXMS}.create_stoploss", stoploss_order_mock) freqtrade.exchange,
cancel_stoploss_order=cancel_order_mock,
create_stoploss=stoploss_order_mock,
)
# stoploss should not be updated as the interval is 60 seconds # stoploss should not be updated as the interval is 60 seconds
assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_trade(trade) is False
@@ -1054,7 +1081,9 @@ def test_execute_trade_exit_sloe_cancel_exception(
mocker, default_conf_usdt, ticker_usdt, fee, caplog mocker, default_conf_usdt, ticker_usdt, fee, caplog
) -> None: ) -> None:
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
mocker.patch(f"{EXMS}.cancel_stoploss_order", side_effect=InvalidOrderException()) mocker.patch.object(
freqtrade.exchange, "cancel_stoploss_order", side_effect=InvalidOrderException()
)
mocker.patch("freqtrade.wallets.Wallets.get_free", MagicMock(return_value=300)) mocker.patch("freqtrade.wallets.Wallets.get_free", MagicMock(return_value=300))
create_order_mock = MagicMock( create_order_mock = MagicMock(
side_effect=[ side_effect=[
@@ -1114,12 +1143,15 @@ def test_execute_trade_exit_with_stoploss_on_exchange(
get_fee=fee, get_fee=fee,
amount_to_precision=lambda s, x, y: y, amount_to_precision=lambda s, x, y: y,
price_to_precision=lambda s, x, y: y, price_to_precision=lambda s, x, y: y,
)
freqtrade = FreqtradeBot(default_conf_usdt)
mocker.patch.multiple(
freqtrade.exchange,
create_stoploss=stoploss, create_stoploss=stoploss,
cancel_stoploss_order=cancel_order, cancel_stoploss_order=cancel_order,
_dry_is_price_crossed=MagicMock(side_effect=[True, False]), _dry_is_price_crossed=MagicMock(side_effect=[True, False]),
) )
freqtrade = FreqtradeBot(default_conf_usdt)
freqtrade.strategy.order_types["stoploss_on_exchange"] = True freqtrade.strategy.order_types["stoploss_on_exchange"] = True
patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short)
@@ -1208,7 +1240,7 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit(
"trades": None, "trades": None,
} }
) )
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_executed) mocker.patch.object(freqtrade.exchange, "fetch_stoploss_order", stoploss_executed)
freqtrade.exit_positions(trades) freqtrade.exit_positions(trades)
assert trade.has_open_sl_orders is False assert trade.has_open_sl_orders is False
+12 -9
View File
@@ -879,6 +879,10 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
patch_exchange(mocker) patch_exchange(mocker)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001) 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_max_pair_stake_amount", return_value=float("inf"))
default_conf_usdt["unfilledtimeout"] = {
"entry": 11,
"exit": 30,
}
if use_detail: if use_detail:
default_conf_usdt["timeframe_detail"] = "1m" default_conf_usdt["timeframe_detail"] = "1m"
@@ -916,7 +920,7 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
) )
results = result["results"] results = result["results"]
assert not results.empty assert not results.empty
# Timeout settings from default_conf = entry: 10, exit: 30 # Timeout settings from = entry: 11, exit: 30
assert len(results) == (2 if use_detail else 3) assert len(results) == (2 if use_detail else 3)
assert "orders" in results.columns assert "orders" in results.columns
@@ -966,8 +970,8 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"use_detail,exp_funding_fee, exp_ff_updates", "use_detail,exp_funding_fee, exp_ff_updates",
[ [
(True, -0.018054162, 10), (True, -0.0180457882, 15),
(False, -0.01780296, 6), (False, -0.0178000543, 12),
], ],
) )
def test_backtest_one_detail_futures( def test_backtest_one_detail_futures(
@@ -1077,8 +1081,8 @@ def test_backtest_one_detail_futures(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"use_detail,entries,max_stake,ff_updates,expected_ff", "use_detail,entries,max_stake,ff_updates,expected_ff",
[ [
(True, 50, 3000, 55, -1.18038144), (True, 50, 3000, 78, -1.17988972),
(False, 6, 360, 11, -0.14679994), (False, 6, 360, 34, -0.14673681),
], ],
) )
def test_backtest_one_detail_futures_funding_fees( def test_backtest_one_detail_futures_funding_fees(
@@ -1800,7 +1804,7 @@ def test_backtest_multi_pair_detail_simplified(
if use_detail: if use_detail:
# Backtest loop is called once per candle per pair # Backtest loop is called once per candle per pair
# Exact numbers depend on trade state - but should be around 2_600 # Exact numbers depend on trade state - but should be around 2_600
assert bl_spy.call_count > 2_170 assert bl_spy.call_count > 2_159
assert bl_spy.call_count < 2_800 assert bl_spy.call_count < 2_800
assert len(evaluate_result_multi(results["results"], "1h", 3)) > 0 assert len(evaluate_result_multi(results["results"], "1h", 3)) > 0
else: else:
@@ -2378,13 +2382,12 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, caplog, testda
f"Using data directory: {testdatadir} ...", f"Using data directory: {testdatadir} ...",
"Loading data from 2021-11-17 01:00:00 up to 2021-11-21 04:00:00 (4 days).", "Loading data from 2021-11-17 01:00:00 up to 2021-11-21 04:00:00 (4 days).",
"Backtesting with data from 2021-11-17 21:00:00 up to 2021-11-21 04:00:00 (3 days).", "Backtesting with data from 2021-11-17 21:00:00 up to 2021-11-21 04:00:00 (3 days).",
"XRP/USDT:USDT, funding_rate, 8h, data starts at 2021-11-18 00:00:00", "XRP/USDT:USDT, funding_rate, 1h, data starts at 2021-11-18 00:00:00",
"XRP/USDT:USDT, mark, 8h, data starts at 2021-11-18 00:00:00",
f"Running backtesting for Strategy {CURRENT_TEST_STRATEGY}", f"Running backtesting for Strategy {CURRENT_TEST_STRATEGY}",
] ]
for line in exists: for line in exists:
assert log_has(line, caplog) assert log_has(line, caplog), line
captured = capsys.readouterr() captured = capsys.readouterr()
assert "BACKTESTING REPORT" in captured.out assert "BACKTESTING REPORT" in captured.out
+11 -4
View File
@@ -386,11 +386,14 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short):
mocker.patch.multiple( mocker.patch.multiple(
EXMS, EXMS,
markets=PropertyMock(return_value=markets), markets=PropertyMock(return_value=markets),
cancel_order=cancel_mock,
cancel_stoploss_order=stoploss_mock,
) )
freqtradebot = get_patched_freqtradebot(mocker, default_conf) freqtradebot = get_patched_freqtradebot(mocker, default_conf)
mocker.patch.multiple(
freqtradebot.exchange,
cancel_order=cancel_mock,
cancel_stoploss_order=stoploss_mock,
)
freqtradebot.strategy.order_types["stoploss_on_exchange"] = True freqtradebot.strategy.order_types["stoploss_on_exchange"] = True
create_mock_trades(fee, is_short) create_mock_trades(fee, is_short)
rpc = RPC(freqtradebot) rpc = RPC(freqtradebot)
@@ -426,13 +429,17 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short):
assert stoploss_mock.call_count == 1 assert stoploss_mock.call_count == 1
assert res["cancel_order_count"] == 1 assert res["cancel_order_count"] == 1
stoploss_mock = mocker.patch(f"{EXMS}.cancel_stoploss_order", side_effect=InvalidOrderException) stoploss_mock = mocker.patch.object(
freqtradebot.exchange, "cancel_stoploss_order", side_effect=InvalidOrderException
)
res = rpc._rpc_delete("3") res = rpc._rpc_delete("3")
assert stoploss_mock.call_count == 1 assert stoploss_mock.call_count == 1
stoploss_mock.reset_mock() stoploss_mock.reset_mock()
cancel_mock = mocker.patch(f"{EXMS}.cancel_order", side_effect=InvalidOrderException) cancel_mock = mocker.patch.object(
freqtradebot.exchange, "cancel_order", side_effect=InvalidOrderException
)
res = rpc._rpc_delete("4") res = rpc._rpc_delete("4")
assert cancel_mock.call_count == 1 assert cancel_mock.call_count == 1
+30 -4
View File
@@ -1034,8 +1034,7 @@ def test_api_delete_trade(botclient, mocker, fee, markets, is_short):
stoploss_mock = MagicMock() stoploss_mock = MagicMock()
cancel_mock = MagicMock() cancel_mock = MagicMock()
mocker.patch.multiple( mocker.patch.multiple(
EXMS, ftbot.exchange,
markets=PropertyMock(return_value=markets),
cancel_order=cancel_mock, cancel_order=cancel_mock,
cancel_stoploss_order=stoploss_mock, cancel_stoploss_order=stoploss_mock,
) )
@@ -1853,9 +1852,35 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets):
Trade.rollback() Trade.rollback()
trade = Trade.get_trades([Trade.id == 5]).first() trade = Trade.get_trades([Trade.id == 5]).first()
last_order = trade.orders[-1]
assert last_order.side == "sell"
assert last_order.status == "closed"
assert last_order.order_type == "market"
assert last_order.amount == 23
assert pytest.approx(trade.amount) == 100 assert pytest.approx(trade.amount) == 100
assert trade.is_open is True assert trade.is_open is True
# Test with explicit price
rc = client_post(
client,
f"{BASE_URI}/forceexit",
data={"tradeid": "5", "ordertype": "limit", "amount": 25, "price": 0.12345},
)
assert_response(rc)
assert rc.json() == {"result": "Created exit order for trade 5."}
Trade.rollback()
trade = Trade.get_trades([Trade.id == 5]).first()
last_order = trade.orders[-1]
assert last_order.status == "closed"
assert last_order.order_type == "limit"
assert pytest.approx(last_order.safe_price) == 0.12345
assert pytest.approx(last_order.amount) == 25
assert pytest.approx(trade.amount) == 75
assert trade.is_open is True
rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5"}) rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5"})
assert_response(rc) assert_response(rc)
assert rc.json() == {"result": "Created exit order for trade 5."} assert rc.json() == {"result": "Created exit order for trade 5."}
@@ -2758,12 +2783,12 @@ def test_list_available_pairs(botclient):
rc = client_get(client, f"{BASE_URI}/available_pairs") rc = client_get(client, f"{BASE_URI}/available_pairs")
assert_response(rc) assert_response(rc)
assert rc.json()["length"] == 12 assert rc.json()["length"] == 14
assert isinstance(rc.json()["pairs"], list) assert isinstance(rc.json()["pairs"], list)
rc = client_get(client, f"{BASE_URI}/available_pairs?timeframe=5m") rc = client_get(client, f"{BASE_URI}/available_pairs?timeframe=5m")
assert_response(rc) assert_response(rc)
assert rc.json()["length"] == 12 assert rc.json()["length"] == 14
rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH") rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH")
assert_response(rc) assert_response(rc)
@@ -3251,6 +3276,7 @@ def test_api_download_data(botclient, mocker, tmp_path):
body = { body = {
"pairs": ["ETH/BTC", "XRP/BTC"], "pairs": ["ETH/BTC", "XRP/BTC"],
"timeframes": ["5m"], "timeframes": ["5m"],
"candle_types": ["spot"],
} }
# Fail, already running # Fail, already running
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -20,8 +20,8 @@ def test_binance_mig_data_conversion(default_conf_usdt, tmp_path, testdatadir):
files = [ files = [
"-1h-mark.feather", "-1h-mark.feather",
"-1h-futures.feather", "-1h-futures.feather",
"-8h-funding_rate.feather", "-1h-funding_rate.feather",
"-8h-mark.feather", "-1h-mark.feather",
] ]
# Copy files to tmpdir and rename to old naming # Copy files to tmpdir and rename to old naming
+7 -7
View File
@@ -5,13 +5,13 @@ from freqtrade.util.migrations import migrate_funding_fee_timeframe
def test_migrate_funding_rate_timeframe(default_conf_usdt, tmp_path, testdatadir): def test_migrate_funding_rate_timeframe(default_conf_usdt, tmp_path, testdatadir):
copytree(testdatadir / "futures", tmp_path / "futures") copytree(testdatadir / "futures", tmp_path / "futures")
file_4h = tmp_path / "futures" / "XRP_USDT_USDT-4h-funding_rate.feather" file_30m = tmp_path / "futures" / "XRP_USDT_USDT-30m-funding_rate.feather"
file_8h = tmp_path / "futures" / "XRP_USDT_USDT-8h-funding_rate.feather" file_1h_fr = tmp_path / "futures" / "XRP_USDT_USDT-1h-funding_rate.feather"
file_1h = tmp_path / "futures" / "XRP_USDT_USDT-1h-futures.feather" file_1h = tmp_path / "futures" / "XRP_USDT_USDT-1h-futures.feather"
file_8h.rename(file_4h) file_1h_fr.rename(file_30m)
assert file_1h.exists() assert file_1h.exists()
assert file_4h.exists() assert file_30m.exists()
assert not file_8h.exists() assert not file_1h_fr.exists()
default_conf_usdt["datadir"] = tmp_path default_conf_usdt["datadir"] = tmp_path
@@ -22,7 +22,7 @@ def test_migrate_funding_rate_timeframe(default_conf_usdt, tmp_path, testdatadir
migrate_funding_fee_timeframe(default_conf_usdt, None) migrate_funding_fee_timeframe(default_conf_usdt, None)
assert not file_4h.exists() assert not file_30m.exists()
assert file_8h.exists() assert file_1h_fr.exists()
# futures files is untouched. # futures files is untouched.
assert file_1h.exists() assert file_1h.exists()