Merge branch 'develop' into maint/python_3.14
This commit is contained in:
@@ -2,7 +2,7 @@ version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
directories:
|
||||
- "/"
|
||||
- "/docker"
|
||||
@@ -16,7 +16,7 @@ updates:
|
||||
- package-ecosystem: devcontainers
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
@@ -24,7 +24,7 @@ updates:
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
exclude:
|
||||
- ccxt
|
||||
schedule:
|
||||
@@ -51,7 +51,7 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
environment:
|
||||
name: develop
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: python build_helpers/binance_update_lev_tiers.py
|
||||
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ secrets.REPO_SCOPED_TOKEN }}
|
||||
add-paths: freqtrade/exchange/binance_leverage_tiers.json
|
||||
|
||||
+34
-26
@@ -28,7 +28,7 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -74,15 +74,17 @@ jobs:
|
||||
run: |
|
||||
pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc
|
||||
|
||||
- name: Coveralls
|
||||
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Cleanup codecov dirty state files
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
|
||||
env:
|
||||
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories
|
||||
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
|
||||
run: |
|
||||
# Allow failure for coveralls
|
||||
uv pip install coveralls
|
||||
coveralls || true
|
||||
# See https://github.com/codecov/codecov-action/issues/1851
|
||||
rm -rf codecov codecov.SHA256SUM codecov.SHA256SUM.sig
|
||||
|
||||
- name: Run json schema extract
|
||||
# This should be kept before the repository check to ensure that the schema is up-to-date
|
||||
@@ -175,7 +177,7 @@ jobs:
|
||||
name: "Mypy Version Check"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -193,7 +195,7 @@ jobs:
|
||||
name: "Pre-commit checks"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -206,7 +208,7 @@ jobs:
|
||||
name: "Documentation build"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -238,7 +240,7 @@ jobs:
|
||||
name: "Tests and Linting - Online tests"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -248,7 +250,7 @@ jobs:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -273,10 +275,7 @@ jobs:
|
||||
# Notify only once - when CI completes (and after deploy) in case it's successful
|
||||
notify-complete:
|
||||
needs: [
|
||||
tests,
|
||||
docs-check,
|
||||
mypy-version-check,
|
||||
pre-commit,
|
||||
build,
|
||||
build-linux-online
|
||||
]
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -304,12 +303,24 @@ jobs:
|
||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
build:
|
||||
if: always()
|
||||
name: "Build"
|
||||
needs: [ tests, docs-check, mypy-version-check, pre-commit ]
|
||||
needs: [
|
||||
tests,
|
||||
docs-check,
|
||||
mypy-version-check,
|
||||
pre-commit,
|
||||
]
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Decide whether the needed jobs succeeded or failed
|
||||
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -356,7 +367,7 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -385,7 +396,7 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -403,10 +414,7 @@ jobs:
|
||||
docker-build:
|
||||
name: "Docker Build and Deploy"
|
||||
needs: [
|
||||
tests,
|
||||
docs-check,
|
||||
mypy-version-check,
|
||||
pre-commit
|
||||
build,
|
||||
]
|
||||
if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade'
|
||||
uses: ./.github/workflows/docker-build.yml
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Deploy Docs through mike
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Login to GitHub Container Registry
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
if: github.repository == 'freqtrade/freqtrade'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
if: github.repository == 'freqtrade/freqtrade'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
dockerHubDescription:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
auto-update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Run auto-update
|
||||
run: pre-commit autoupdate
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ secrets.REPO_SCOPED_TOKEN }}
|
||||
add-paths: .pre-commit-config.yaml
|
||||
|
||||
@@ -14,6 +14,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
@@ -21,9 +22,9 @@ jobs:
|
||||
# actions: read # only needed for private repos
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0
|
||||
uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
|
||||
|
||||
@@ -21,7 +21,7 @@ repos:
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: "v1.18.2"
|
||||
rev: "v1.19.1"
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: build_helpers
|
||||
@@ -30,8 +30,8 @@ repos:
|
||||
- types-filelock==3.2.7
|
||||
- types-requests==2.32.4.20250913
|
||||
- types-tabulate==0.9.0.20241207
|
||||
- types-python-dateutil==2.9.0.20251008
|
||||
- scipy-stubs==1.16.3.0
|
||||
- types-python-dateutil==2.9.0.20251115
|
||||
- scipy-stubs==1.16.3.2
|
||||
- SQLAlchemy==2.0.44
|
||||
# stages: [push]
|
||||
|
||||
@@ -44,7 +44,7 @@ repos:
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: 'v0.14.4'
|
||||
rev: 'v0.14.9'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
@@ -83,6 +83,6 @@ repos:
|
||||
|
||||
# Ensure github actions remain safe
|
||||
- repo: https://github.com/woodruffw/zizmor-pre-commit
|
||||
rev: v1.16.3
|
||||
rev: v1.18.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
@@ -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
|
||||
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
|
||||
expect.
|
||||
|
||||
@@ -24,7 +24,7 @@ hesitate to read the source code and understand the mechanism of this bot.
|
||||
|
||||
## 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] [BingX](https://bingx.com/invite/0EM9RX)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import subprocess # noqa: S404, RUF100
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -62,4 +63,9 @@ def extract_command_partials():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.version_info < (3, 13): # pragma: no cover
|
||||
sys.exit(
|
||||
"argparse output changed in Python 3.13+. "
|
||||
"To keep command partials up to date, please run this script with Python 3.13+."
|
||||
)
|
||||
extract_command_partials()
|
||||
|
||||
@@ -11,6 +11,7 @@ usage: freqtrade download-data [-h] [-v] [--no-color] [--logfile FILE] [-V]
|
||||
[--data-format-ohlcv {json,jsongz,feather,parquet}]
|
||||
[--data-format-trades {json,jsongz,feather,parquet}]
|
||||
[--trading-mode {spot,margin,futures}]
|
||||
[--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]]
|
||||
[--prepend]
|
||||
|
||||
options:
|
||||
@@ -50,6 +51,11 @@ options:
|
||||
`feather`).
|
||||
--trading-mode, --tradingmode {spot,margin,futures}
|
||||
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)
|
||||
|
||||
Common arguments:
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
usage: freqtrade list-timeframes [-h] [-v] [--no-color] [--logfile FILE] [-V]
|
||||
[-c PATH] [-d PATH] [--userdir PATH]
|
||||
[--exchange EXCHANGE] [-1]
|
||||
[--trading-mode {spot,margin,futures}]
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--exchange EXCHANGE Exchange name. Only valid if no config is provided.
|
||||
-1, --one-column Print output in one column.
|
||||
--trading-mode, --tradingmode {spot,margin,futures}
|
||||
Select Trading mode
|
||||
|
||||
Common arguments:
|
||||
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
|
||||
|
||||
@@ -675,7 +675,7 @@ Should you experience problems you suspect are caused by websockets, you can dis
|
||||
Should you be required to use a proxy, please refer to the [proxy section](#using-a-proxy-with-freqtrade) for more information.
|
||||
|
||||
!!! Info "Rollout"
|
||||
We're implementing this out slowly, ensuring stability of your bots.
|
||||
We're rolling this out slowly, ensuring stability of your bots.
|
||||
Currently, usage is limited to ohlcv data streams.
|
||||
It's also limited to a few exchanges, with new exchanges being added on an ongoing basis.
|
||||
|
||||
|
||||
@@ -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.
|
||||
* 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.
|
||||
* 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"
|
||||
If your configuration directory `user_data` was made by docker, you may get the following error:
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
+12
-3
@@ -26,10 +26,19 @@ Alternatively (e.g. if your system is not supported by the setup.sh script), fol
|
||||
|
||||
This will install all required tools for development, including `pytest`, `ruff`, `mypy`, and `coveralls`.
|
||||
|
||||
Then install the git hook scripts by running `pre-commit install`, so your changes will be verified locally before committing.
|
||||
This avoids a lot of waiting for CI already, as some basic formatting checks are done locally on your machine.
|
||||
Run the following command to install the git hook scripts:
|
||||
|
||||
Before opening a pull request, please familiarize yourself with our [Contributing Guidelines](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md).
|
||||
``` bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
These pre-commit scripts check your changes automatically before each commit.
|
||||
If any formatting issues are found, the commit will fail and will prompt for fixes.
|
||||
This reduces unnecessary CI failures, reduces maintenance burden, and improves code quality.
|
||||
|
||||
You can run the checks manually when necessary with `pre-commit run -a`.
|
||||
|
||||
Before opening a pull request, please also familiarize yourself with our [Contributing Guidelines](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md).
|
||||
|
||||
### Devcontainer setup
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ This filter allows freqtrade to ignore pairs until they have been listed for at
|
||||
Removes pairs that will be delisted on the exchange maximum `max_days_from_now` days from now (defaults to `0` which remove all future delisted pairs no matter how far from now). Currently this filter only supports following exchanges:
|
||||
|
||||
!!! Note "Available exchanges"
|
||||
Delist filter is only available on Binance, where Binance Futures will work for both dry and live modes, while Binance Spot is limited to live mode (for technical reasons).
|
||||
Delist filter is available on Bybit Futures, Bitget Futures and Binance, where Binance Futures will work for both dry and live modes, while Binance Spot is limited to live mode (for technical reasons).
|
||||
|
||||
!!! Warning "Backtesting"
|
||||
`DelistFilter` does not support backtesting mode.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
markdown==3.10
|
||||
mkdocs==1.6.1
|
||||
mkdocs-material==9.6.23
|
||||
mkdocs-material==9.7.0
|
||||
mdx_truly_sane_lists==1.3
|
||||
pymdown-extensions==10.16.1
|
||||
pymdown-extensions==10.18
|
||||
jinja2==3.1.6
|
||||
mike==2.1.3
|
||||
|
||||
+6
-1
@@ -31,9 +31,14 @@ The Order-type will be ignored if only one mode is available.
|
||||
--8<-- "includes/exchange-features.md"
|
||||
|
||||
!!! 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.
|
||||
|
||||
!!! 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
|
||||
|
||||
Enable or Disable stop loss on exchange.
|
||||
|
||||
@@ -634,7 +634,7 @@ class AwesomeStrategy(IStrategy):
|
||||
|
||||
## Custom order price rules
|
||||
|
||||
By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy.
|
||||
By default, freqtrade use the orderbook to automatically set an order price ([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy.
|
||||
|
||||
You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits.
|
||||
|
||||
@@ -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.
|
||||
|
||||
!!! 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Freqtrade bot"""
|
||||
|
||||
__version__ = "2025.11-dev"
|
||||
__version__ = "2025.12-dev"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -3,6 +3,7 @@ This module contains the argument manager class
|
||||
"""
|
||||
|
||||
from argparse import ArgumentParser, Namespace, _ArgumentGroup
|
||||
from copy import deepcopy
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -104,7 +105,7 @@ ARGS_BACKTEST_SHOW = [
|
||||
|
||||
ARGS_LIST_EXCHANGES = ["print_one_column", "list_exchanges_all", "trading_mode", "dex_exchanges"]
|
||||
|
||||
ARGS_LIST_TIMEFRAMES = ["exchange", "print_one_column"]
|
||||
ARGS_LIST_TIMEFRAMES = ["exchange", "print_one_column", "trading_mode"]
|
||||
|
||||
ARGS_LIST_PAIRS = [
|
||||
"exchange",
|
||||
@@ -174,6 +175,7 @@ ARGS_DOWNLOAD_DATA = [
|
||||
"dataformat_ohlcv",
|
||||
"dataformat_trades",
|
||||
"trading_mode",
|
||||
"candle_types",
|
||||
"prepend_data",
|
||||
]
|
||||
|
||||
@@ -348,7 +350,11 @@ class Arguments:
|
||||
def _build_args(self, optionlist: list[str], parser: ArgumentParser | _ArgumentGroup) -> None:
|
||||
for val in optionlist:
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -38,8 +38,14 @@ def check_int_nonzero(value: str) -> int:
|
||||
|
||||
class Arg:
|
||||
# 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.fthelp = fthelp
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
@@ -422,6 +428,14 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
),
|
||||
"candle_types": Arg(
|
||||
"--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.",
|
||||
choices=[c.value for c in CandleType],
|
||||
nargs="+",
|
||||
|
||||
@@ -38,7 +38,8 @@ def ohlcv_to_dataframe(
|
||||
cols = DEFAULT_DATAFRAME_COLUMNS
|
||||
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.
|
||||
# Convert them since TA-LIB indicators used in the strategy assume floats
|
||||
|
||||
@@ -348,6 +348,22 @@ class DataProvider:
|
||||
)
|
||||
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(
|
||||
self, pair: str, timeframe: str | None = None, candle_type: str = ""
|
||||
) -> DataFrame:
|
||||
@@ -361,6 +377,7 @@ class DataProvider:
|
||||
:return: Dataframe for this pair
|
||||
: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):
|
||||
# Get live OHLCV data.
|
||||
data = self.ohlcv(pair=pair, timeframe=timeframe, candle_type=candle_type)
|
||||
@@ -620,3 +637,12 @@ class DataProvider:
|
||||
except ExchangeError:
|
||||
logger.warning(f"Could not fetch market data for {pair}. Assuming no delisting.")
|
||||
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(
|
||||
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):
|
||||
return pairdf
|
||||
else:
|
||||
@@ -508,8 +511,15 @@ class IDataHandler(ABC):
|
||||
Applies to bybit and okx, where funding-fee and mark candles have different timeframes.
|
||||
"""
|
||||
paircombs = self.ohlcv_get_available_data(self._datadir, TradingMode.FUTURES)
|
||||
ff_timeframe_s = timeframe_to_seconds(ff_timeframe)
|
||||
|
||||
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:
|
||||
|
||||
@@ -308,11 +308,15 @@ def _download_pair_history(
|
||||
candle_type=candle_type,
|
||||
until_ms=until_ms if until_ms else None,
|
||||
)
|
||||
logger.info(f"Downloaded data for {pair} with length {len(new_dataframe)}.")
|
||||
logger.info(
|
||||
f"Downloaded data for {pair}, {timeframe}, {candle_type} with length "
|
||||
f"{len(new_dataframe)}."
|
||||
)
|
||||
else:
|
||||
new_dataframe = pair_candles
|
||||
logger.info(
|
||||
f"Downloaded data for {pair} with length {len(new_dataframe)}. Parallel Method."
|
||||
f"Downloaded data for {pair}, {timeframe}, {candle_type} with length "
|
||||
f"{len(new_dataframe)}. Parallel Method."
|
||||
)
|
||||
|
||||
if data.empty:
|
||||
@@ -349,6 +353,7 @@ def _download_pair_history(
|
||||
|
||||
def refresh_backtest_ohlcv_data(
|
||||
exchange: Exchange,
|
||||
*,
|
||||
pairs: list[str],
|
||||
timeframes: list[str],
|
||||
datadir: Path,
|
||||
@@ -359,6 +364,7 @@ def refresh_backtest_ohlcv_data(
|
||||
data_format: str | None = None,
|
||||
prepend: bool = False,
|
||||
progress_tracker: CustomProgress | None = None,
|
||||
candle_types: list[CandleType] | None = None,
|
||||
no_parallel_download: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
@@ -371,10 +377,44 @@ def refresh_backtest_ohlcv_data(
|
||||
pairs_not_available = []
|
||||
fast_candles: dict[PairWithTimeframe, DataFrame] = {}
|
||||
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:
|
||||
tf_length = len(timeframes) if trading_mode != "futures" else len(timeframes) + 2
|
||||
timeframe_task = progress.add_task("Timeframe", total=tf_length)
|
||||
timeframe_task = progress.add_task("Timeframe", total=len(timeframes_with_candletype))
|
||||
pair_task = progress.add_task("Downloading data...", total=len(pairs))
|
||||
|
||||
for pair in pairs:
|
||||
@@ -385,11 +425,13 @@ def refresh_backtest_ohlcv_data(
|
||||
pairs_not_available.append(f"{pair}: Pair not available on exchange.")
|
||||
logger.info(f"Skipping pair {pair}...")
|
||||
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
|
||||
# and candle type. Downloads all the pairs in the list and stores them.
|
||||
# Also skips if only 1 pair/timeframe combination is scheduled for download.
|
||||
if (
|
||||
not no_parallel_download
|
||||
and (len(pairs) + len(timeframes)) > 2
|
||||
and exchange.get_option("download_data_parallel_quick", True)
|
||||
and (
|
||||
((pair, timeframe, candle_type) not in fast_candles)
|
||||
@@ -410,7 +452,7 @@ def refresh_backtest_ohlcv_data(
|
||||
# get the already downloaded pair candles if they exist
|
||||
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}.")
|
||||
_download_pair_history(
|
||||
pair=pair,
|
||||
@@ -426,33 +468,6 @@ def refresh_backtest_ohlcv_data(
|
||||
pair_candles=pair_candles, # optional pass of dataframe of parallel candles
|
||||
)
|
||||
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(timeframe_task, description="Timeframe")
|
||||
@@ -474,7 +489,7 @@ def _download_all_pairs_history_parallel(
|
||||
:return: Candle pairs with timeframes
|
||||
"""
|
||||
candles: dict[PairWithTimeframe, DataFrame] = {}
|
||||
since = 0
|
||||
since: int | None = None
|
||||
if timerange:
|
||||
if timerange.starttype == "date":
|
||||
since = timerange.startts * 1000
|
||||
@@ -482,10 +497,12 @@ def _download_all_pairs_history_parallel(
|
||||
candle_limit = exchange.ohlcv_candle_limit(timeframe, candle_type)
|
||||
one_call_min_time_dt = dt_ts(date_minus_candles(timeframe, candle_limit))
|
||||
# check if we can get all candles in one go, if so then we can download them in parallel
|
||||
if since > one_call_min_time_dt:
|
||||
if since is None or since > one_call_min_time_dt:
|
||||
logger.info(
|
||||
f"Downloading parallel candles for {timeframe} for all pairs "
|
||||
f"since {format_ms_time(since)}"
|
||||
f"Downloading parallel candles for {timeframe} for all pairs"
|
||||
f" since {format_ms_time(since)}"
|
||||
if since
|
||||
else "."
|
||||
)
|
||||
needed_pairs: ListPairsWithTimeframes = [
|
||||
(p, timeframe, candle_type) for p in [p for p in pairs]
|
||||
@@ -796,6 +813,7 @@ def download_data(
|
||||
trading_mode=config.get("trading_mode", "spot"),
|
||||
prepend=config.get("prepend_data", False),
|
||||
progress_tracker=progress_tracker,
|
||||
candle_types=config.get("candle_types"),
|
||||
no_parallel_download=config.get("no_parallel_download", False),
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -74,9 +74,10 @@ def combined_dataframes_with_rel_mean(
|
||||
df_comb = combine_dataframes_by_column(data, column)
|
||||
# Trim dataframes to the given timeframe
|
||||
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["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"]]
|
||||
|
||||
|
||||
@@ -143,6 +144,20 @@ def _calc_drawdown_series(
|
||||
max_drawdown_df["drawdown_relative"] = (
|
||||
max_drawdown_df["high_value"] - max_drawdown_df["cumulative"]
|
||||
) / max_drawdown_df["high_value"]
|
||||
|
||||
# Add zero row at start to account for edge-cases with no winning / losing trades - so high/low
|
||||
# will be 0.0 in such cases.
|
||||
zero_row = pd.DataFrame(
|
||||
{
|
||||
"cumulative": [0.0],
|
||||
"high_value": [0.0],
|
||||
"drawdown": [0.0],
|
||||
"drawdown_relative": [0.0],
|
||||
"date": [profit_results.loc[0, date_col]],
|
||||
}
|
||||
)
|
||||
|
||||
max_drawdown_df = pd.concat([zero_row, max_drawdown_df], ignore_index=True)
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
@@ -215,6 +230,7 @@ def calculate_max_drawdown(
|
||||
max_drawdown_df = _calc_drawdown_series(
|
||||
profit_results, date_col=date_col, value_col=value_col, starting_balance=starting_balance
|
||||
)
|
||||
# max_drawdown_df has an extra zero row at the start
|
||||
|
||||
# Calculate maximum drawdown
|
||||
idxmin = (
|
||||
@@ -223,15 +239,15 @@ def calculate_max_drawdown(
|
||||
else max_drawdown_df["drawdown"].idxmin()
|
||||
)
|
||||
high_idx = max_drawdown_df.iloc[: idxmin + 1]["high_value"].idxmax()
|
||||
high_date = profit_results.loc[high_idx, date_col]
|
||||
low_date = profit_results.loc[idxmin, date_col]
|
||||
high_val = max_drawdown_df.loc[high_idx, "cumulative"]
|
||||
low_val = max_drawdown_df.loc[idxmin, "cumulative"]
|
||||
max_drawdown_rel = max_drawdown_df.loc[idxmin, "drawdown_relative"]
|
||||
high_date = profit_results.at[max(high_idx - 1, 0), date_col]
|
||||
low_date = profit_results.at[max(idxmin - 1, 0), date_col]
|
||||
high_val = max_drawdown_df.at[high_idx, "cumulative"]
|
||||
low_val = max_drawdown_df.at[idxmin, "cumulative"]
|
||||
max_drawdown_rel = max_drawdown_df.at[idxmin, "drawdown_relative"]
|
||||
|
||||
# Calculate current drawdown
|
||||
current_high_idx = max_drawdown_df["high_value"].iloc[:-1].idxmax()
|
||||
current_high_date = profit_results.loc[current_high_idx, date_col]
|
||||
current_high_date = profit_results.at[max(current_high_idx - 1, 0), date_col]
|
||||
current_high_value = max_drawdown_df.iloc[-1]["high_value"]
|
||||
current_cumulative = max_drawdown_df.iloc[-1]["cumulative"]
|
||||
current_drawdown_abs = current_high_value - current_cumulative
|
||||
|
||||
@@ -4,7 +4,7 @@ from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS
|
||||
from freqtrade.exchange.exchange import Exchange
|
||||
|
||||
# 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.bitget import Bitget
|
||||
from freqtrade.exchange.bitmart import Bitmart
|
||||
|
||||
@@ -5,7 +5,6 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import ccxt
|
||||
from cachetools import TTLCache
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
|
||||
@@ -18,9 +17,10 @@ from freqtrade.exchange.binance_public_data import (
|
||||
download_archive_trades,
|
||||
)
|
||||
from freqtrade.exchange.common import retrier
|
||||
from freqtrade.exchange.exchange_types import FtHas, Tickers
|
||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas, Tickers
|
||||
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
|
||||
from freqtrade.misc import deep_merge_dicts, json_load
|
||||
from freqtrade.util import FtTTLCache
|
||||
from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class Binance(Exchange):
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._spot_delist_schedule_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
|
||||
self._spot_delist_schedule_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
|
||||
|
||||
def get_proxy_coin(self) -> str:
|
||||
"""
|
||||
@@ -145,6 +145,20 @@ class Binance(Exchange):
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def fetch_stoploss_order(
|
||||
self, order_id: str, pair: str, params: dict | None = None
|
||||
) -> CcxtOrder:
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
params = params or {}
|
||||
params.update({"stop": True})
|
||||
return self.fetch_order(order_id, pair, params)
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
params = params or {}
|
||||
params.update({"stop": True})
|
||||
return self.cancel_order(order_id=order_id, pair=pair, params=params)
|
||||
|
||||
def get_historic_ohlcv(
|
||||
self,
|
||||
pair: str,
|
||||
@@ -544,3 +558,26 @@ class Binance(Exchange):
|
||||
cache[ft_symbol] = delist_dt
|
||||
|
||||
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,10 +1,10 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import CandleType, MarginMode, TradingMode
|
||||
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
OperationalException,
|
||||
@@ -14,7 +14,7 @@ from freqtrade.exceptions import (
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import API_RETRY_COUNT, retrier
|
||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
|
||||
from freqtrade.util.datetime_helpers import dt_now, dt_ts
|
||||
from freqtrade.util import dt_from_ts, dt_now, dt_ts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,8 +35,8 @@ class Bitget(Exchange):
|
||||
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
|
||||
}
|
||||
_ft_has_futures: FtHas = {
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_candle_limit": 100,
|
||||
"has_delisting": True,
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
@@ -236,3 +236,35 @@ class Bitget(Exchange):
|
||||
raise OperationalException(
|
||||
"Freqtrade currently only supports isolated futures for bitget"
|
||||
)
|
||||
|
||||
def check_delisting_time(self, pair: str) -> datetime | None:
|
||||
"""
|
||||
Check if the pair gonna be delisted.
|
||||
By default, it returns None.
|
||||
:param pair: Market symbol
|
||||
:return: Datetime if the pair gonna be delisted, None otherwise
|
||||
"""
|
||||
if self._config["runmode"] in OPTIMIZE_MODES:
|
||||
return None
|
||||
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
return self._check_delisting_futures(pair)
|
||||
return None
|
||||
|
||||
def _check_delisting_futures(self, pair: str) -> datetime | None:
|
||||
delivery_time = self.markets.get(pair, {}).get("info", {}).get("limitOpenTime", None)
|
||||
if delivery_time:
|
||||
if isinstance(delivery_time, str) and (delivery_time != ""):
|
||||
delivery_time = int(delivery_time)
|
||||
|
||||
if not isinstance(delivery_time, int) or delivery_time <= 0:
|
||||
return None
|
||||
|
||||
max_delivery = dt_ts() + (
|
||||
14 * 24 * 60 * 60 * 1000
|
||||
) # Assume exchange don't announce delisting more than 14 days in advance
|
||||
|
||||
if delivery_time < max_delivery:
|
||||
return dt_from_ts(delivery_time)
|
||||
|
||||
return None
|
||||
|
||||
@@ -4,12 +4,13 @@ from datetime import datetime, timedelta
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, PriceType, TradingMode
|
||||
from freqtrade.enums import OPTIMIZE_MODES, MarginMode, PriceType, TradingMode
|
||||
from freqtrade.exceptions import DDosProtection, ExchangeError, OperationalException, TemporaryError
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import retrier
|
||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
|
||||
from freqtrade.misc import deep_merge_dicts
|
||||
from freqtrade.util import dt_from_ts, dt_ts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,8 +38,6 @@ class Bybit(Exchange):
|
||||
}
|
||||
_ft_has_futures: FtHas = {
|
||||
"ohlcv_has_history": True,
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_timeframe": "8h",
|
||||
"funding_fee_candle_limit": 200,
|
||||
"stoploss_on_exchange": True,
|
||||
"stoploss_order_types": {"limit": "limit", "market": "market"},
|
||||
@@ -54,6 +53,7 @@ class Bybit(Exchange):
|
||||
"exchange_has_overrides": {
|
||||
"fetchOrder": True,
|
||||
},
|
||||
"has_delisting": True,
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
@@ -294,3 +294,35 @@ class Bybit(Exchange):
|
||||
|
||||
self.cache_leverage_tiers(tiers, self._config["stake_currency"])
|
||||
return tiers
|
||||
|
||||
def check_delisting_time(self, pair: str) -> datetime | None:
|
||||
"""
|
||||
Check if the pair gonna be delisted.
|
||||
By default, it returns None.
|
||||
:param pair: Market symbol
|
||||
:return: Datetime if the pair gonna be delisted, None otherwise
|
||||
"""
|
||||
if self._config["runmode"] in OPTIMIZE_MODES:
|
||||
return None
|
||||
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
return self._check_delisting_futures(pair)
|
||||
return None
|
||||
|
||||
def _check_delisting_futures(self, pair: str) -> datetime | None:
|
||||
delivery_time = self.markets.get(pair, {}).get("info", {}).get("deliveryTime", 0)
|
||||
if delivery_time:
|
||||
if isinstance(delivery_time, str) and (delivery_time != ""):
|
||||
delivery_time = int(delivery_time)
|
||||
|
||||
if not isinstance(delivery_time, int) or delivery_time <= 0:
|
||||
return None
|
||||
|
||||
max_delivery = dt_ts() + (
|
||||
14 * 24 * 60 * 60 * 1000
|
||||
) # Assume exchange don't announce delisting more than 14 days in advance
|
||||
|
||||
if delivery_time < max_delivery:
|
||||
return dt_from_ts(delivery_time)
|
||||
|
||||
return None
|
||||
|
||||
@@ -45,8 +45,6 @@ BAD_EXCHANGES = {
|
||||
}
|
||||
|
||||
MAP_EXCHANGE_CHILDCLASS = {
|
||||
"binanceus": "binance",
|
||||
"binanceusdm": "binance",
|
||||
"okex": "okx",
|
||||
"gateio": "gate",
|
||||
"huboi": "htx",
|
||||
@@ -54,6 +52,8 @@ MAP_EXCHANGE_CHILDCLASS = {
|
||||
|
||||
SUPPORTED_EXCHANGES = [
|
||||
"binance",
|
||||
"binanceus",
|
||||
"binanceusdm",
|
||||
"bingx",
|
||||
"bitmart",
|
||||
"bitget",
|
||||
@@ -97,6 +97,9 @@ EXCHANGE_HAS_OPTIONAL = [
|
||||
# 'fetchLeverageTiers', # Futures initialization
|
||||
# 'fetchMarketLeverageTiers', # Futures initialization
|
||||
# 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance...
|
||||
# "fetchPremiumIndexOHLCV", # Futures additional data
|
||||
# "fetchMarkOHLCV", # Futures additional data
|
||||
# "fetchIndexOHLCV", # Futures additional data
|
||||
# ccxt.pro
|
||||
"watchOHLCV",
|
||||
]
|
||||
|
||||
+152
-51
@@ -16,7 +16,6 @@ from typing import Any, Literal, TypeGuard, TypeVar
|
||||
|
||||
import ccxt
|
||||
import ccxt.pro as ccxt_pro
|
||||
from cachetools import TTLCache
|
||||
from ccxt import TICK_SIZE
|
||||
from dateutil import parser
|
||||
from pandas import DataFrame, concat
|
||||
@@ -105,11 +104,11 @@ from freqtrade.misc import (
|
||||
deep_merge_dicts,
|
||||
file_dump_json,
|
||||
file_load_json,
|
||||
safe_value_fallback,
|
||||
safe_value_fallback2,
|
||||
)
|
||||
from freqtrade.util import dt_from_ts, dt_now
|
||||
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
|
||||
from freqtrade.util.datetime_helpers import dt_humanize_delta, dt_ts, format_ms_time
|
||||
from freqtrade.util.periodic_cache import PeriodicCache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -154,8 +153,8 @@ class Exchange:
|
||||
"l2_limit_range_required": True, # Allow Empty L2 limit (kucoin)
|
||||
"l2_limit_upper": None, # Upper limit for L2 limit
|
||||
"mark_ohlcv_price": "mark",
|
||||
"mark_ohlcv_timeframe": "8h",
|
||||
"funding_fee_timeframe": "8h",
|
||||
"mark_ohlcv_timeframe": "1h",
|
||||
"funding_fee_timeframe": "1h",
|
||||
"ccxt_futures_name": "swap",
|
||||
"needs_trading_fees": False, # use fetch_trading_fees to cache fees
|
||||
"order_props_in_contracts": ["amount", "filled", "remaining"],
|
||||
@@ -230,13 +229,13 @@ class Exchange:
|
||||
|
||||
self._cache_lock = Lock()
|
||||
# Cache for 10 minutes ...
|
||||
self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=4, ttl=60 * 10)
|
||||
self._fetch_tickers_cache: FtTTLCache = FtTTLCache(maxsize=4, ttl=60 * 10)
|
||||
# Cache values for 300 to avoid frequent polling of the exchange for prices
|
||||
# Caching only applies to RPC methods, so prices for open trades are still
|
||||
# refreshed once every iteration.
|
||||
# Shouldn't be too high either, as it'll freeze UI updates in case of open orders.
|
||||
self._exit_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
|
||||
self._entry_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
|
||||
self._exit_rate_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
|
||||
self._entry_rate_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
|
||||
|
||||
# Holds candles
|
||||
self._klines: dict[PairWithTimeframe, DataFrame] = {}
|
||||
@@ -430,7 +429,15 @@ class Exchange:
|
||||
|
||||
@property
|
||||
def timeframes(self) -> list[str]:
|
||||
return list((self._api.timeframes or {}).keys())
|
||||
market_type = (
|
||||
"spot"
|
||||
if self.trading_mode != TradingMode.FUTURES
|
||||
else self._ft_has["ccxt_futures_name"]
|
||||
)
|
||||
timeframes = self._api.options.get("timeframes", {}).get(market_type)
|
||||
if timeframes is None:
|
||||
timeframes = self._api.timeframes
|
||||
return list((timeframes or {}).keys())
|
||||
|
||||
@property
|
||||
def markets(self) -> dict[str, Any]:
|
||||
@@ -1113,6 +1120,7 @@ class Exchange:
|
||||
leverage: float,
|
||||
params: dict | None = None,
|
||||
stop_loss: bool = False,
|
||||
stop_price: float | None = None,
|
||||
) -> CcxtOrder:
|
||||
now = dt_now()
|
||||
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
|
||||
@@ -1139,7 +1147,7 @@ class Exchange:
|
||||
}
|
||||
if stop_loss:
|
||||
dry_order["info"] = {"stopPrice": dry_order["price"]}
|
||||
dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"]
|
||||
dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"]
|
||||
# Workaround to avoid filling stoploss orders immediately
|
||||
dry_order["ft_order_type"] = "stoploss"
|
||||
orderbook: OrderBook | None = None
|
||||
@@ -1157,7 +1165,11 @@ class Exchange:
|
||||
|
||||
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
|
||||
# Update market order pricing
|
||||
average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook)
|
||||
slippage = 0.05
|
||||
worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
|
||||
average = self.get_dry_market_fill_price(
|
||||
pair, side, amount, rate, worst_rate, orderbook
|
||||
)
|
||||
dry_order.update(
|
||||
{
|
||||
"average": average,
|
||||
@@ -1197,7 +1209,13 @@ class Exchange:
|
||||
return dry_order
|
||||
|
||||
def get_dry_market_fill_price(
|
||||
self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None
|
||||
self,
|
||||
pair: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
worst_rate: float,
|
||||
orderbook: OrderBook | None,
|
||||
) -> float:
|
||||
"""
|
||||
Get the market order fill price based on orderbook interpolation
|
||||
@@ -1206,8 +1224,6 @@ class Exchange:
|
||||
if not orderbook:
|
||||
orderbook = self.fetch_l2_order_book(pair, 20)
|
||||
ob_type: OBLiteral = "asks" if side == "buy" else "bids"
|
||||
slippage = 0.05
|
||||
max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
|
||||
|
||||
remaining_amount = amount
|
||||
filled_value = 0.0
|
||||
@@ -1231,11 +1247,10 @@ class Exchange:
|
||||
forecast_avg_filled_price = max(filled_value, 0) / amount
|
||||
# Limit max. slippage to specified value
|
||||
if side == "buy":
|
||||
forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val)
|
||||
forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate)
|
||||
|
||||
else:
|
||||
forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val)
|
||||
|
||||
forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate)
|
||||
return self.price_to_precision(pair, forecast_avg_filled_price)
|
||||
|
||||
return rate
|
||||
@@ -1247,13 +1262,15 @@ class Exchange:
|
||||
limit: float,
|
||||
orderbook: OrderBook | None = None,
|
||||
offset: float = 0.0,
|
||||
is_stop: bool = False,
|
||||
) -> bool:
|
||||
if not self.exchange_has("fetchL2OrderBook"):
|
||||
return True
|
||||
# True unless checking a stoploss order
|
||||
return not is_stop
|
||||
if not orderbook:
|
||||
orderbook = self.fetch_l2_order_book(pair, 1)
|
||||
try:
|
||||
if side == "buy":
|
||||
if (side == "buy" and not is_stop) or (side == "sell" and is_stop):
|
||||
price = orderbook["asks"][0][0]
|
||||
if limit * (1 - offset) >= price:
|
||||
return True
|
||||
@@ -1272,6 +1289,38 @@ class Exchange:
|
||||
"""
|
||||
Check dry-run limit order fill and update fee (if it filled).
|
||||
"""
|
||||
if order["status"] != "closed" and order.get("ft_order_type") == "stoploss":
|
||||
pair = order["symbol"]
|
||||
if not orderbook and self.exchange_has("fetchL2OrderBook"):
|
||||
orderbook = self.fetch_l2_order_book(pair, 20)
|
||||
price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price")
|
||||
crossed = self._dry_is_price_crossed(
|
||||
pair, order["side"], price, orderbook, is_stop=True
|
||||
)
|
||||
if crossed:
|
||||
average = self.get_dry_market_fill_price(
|
||||
pair,
|
||||
order["side"],
|
||||
order["amount"],
|
||||
price,
|
||||
worst_rate=order["price"],
|
||||
orderbook=orderbook,
|
||||
)
|
||||
order.update(
|
||||
{
|
||||
"status": "closed",
|
||||
"filled": order["amount"],
|
||||
"remaining": 0,
|
||||
"average": average,
|
||||
"cost": order["amount"] * average,
|
||||
}
|
||||
)
|
||||
self.add_dry_order_fee(
|
||||
pair,
|
||||
order,
|
||||
"taker" if immediate else "maker",
|
||||
)
|
||||
return order
|
||||
if (
|
||||
order["status"] != "closed"
|
||||
and order["type"] in ["limit"]
|
||||
@@ -1295,7 +1344,7 @@ class Exchange:
|
||||
|
||||
return order
|
||||
|
||||
def fetch_dry_run_order(self, order_id) -> CcxtOrder:
|
||||
def fetch_dry_run_order(self, order_id: str) -> CcxtOrder:
|
||||
"""
|
||||
Return dry-run order
|
||||
Only call if running in dry-run mode.
|
||||
@@ -1307,11 +1356,12 @@ class Exchange:
|
||||
except KeyError as e:
|
||||
from freqtrade.persistence import Order
|
||||
|
||||
order = Order.order_by_id(order_id)
|
||||
if order:
|
||||
ccxt_order = order.to_ccxt_object(self._ft_has["stop_price_prop"])
|
||||
self._dry_run_open_orders[order_id] = ccxt_order
|
||||
return ccxt_order
|
||||
order_obj = Order.order_by_id(order_id)
|
||||
if order_obj:
|
||||
order = order_obj.to_ccxt_object(self._ft_has["stop_price_prop"])
|
||||
order = self.check_dry_limit_order_filled(order)
|
||||
self._dry_run_open_orders[order_id] = order
|
||||
return order
|
||||
# Gracefully handle errors with dry-run orders.
|
||||
raise InvalidOrderException(
|
||||
f"Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}"
|
||||
@@ -1355,8 +1405,9 @@ class Exchange:
|
||||
amount: float,
|
||||
rate: float,
|
||||
leverage: float,
|
||||
reduceOnly: bool = False,
|
||||
time_in_force: str = "GTC",
|
||||
reduceOnly: bool = False,
|
||||
initial_order: bool = True,
|
||||
) -> CcxtOrder:
|
||||
if self._config["dry_run"]:
|
||||
dry_order = self.create_dry_run_order(
|
||||
@@ -1373,7 +1424,7 @@ class Exchange:
|
||||
rate_for_order = self.price_to_precision(pair, rate) if needs_price else None
|
||||
|
||||
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(
|
||||
pair,
|
||||
@@ -1510,8 +1561,9 @@ class Exchange:
|
||||
ordertype,
|
||||
side,
|
||||
amount,
|
||||
stop_price_norm,
|
||||
limit_rate or stop_price_norm,
|
||||
stop_loss=True,
|
||||
stop_price=stop_price_norm,
|
||||
leverage=leverage,
|
||||
)
|
||||
return dry_order
|
||||
@@ -1762,7 +1814,7 @@ class Exchange:
|
||||
balances.pop("total", None)
|
||||
balances.pop("used", None)
|
||||
|
||||
self._log_exchange_response("fetch_balances", balances)
|
||||
self._log_exchange_response("fetch_balance", balances)
|
||||
return balances
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
@@ -1783,9 +1835,9 @@ class Exchange:
|
||||
if self._config["dry_run"] or self.trading_mode != TradingMode.FUTURES:
|
||||
return []
|
||||
try:
|
||||
symbols = []
|
||||
symbols = None
|
||||
if pair:
|
||||
symbols.append(pair)
|
||||
symbols = [pair]
|
||||
positions: list[CcxtPosition] = self._api.fetch_positions(symbols)
|
||||
self._log_exchange_response("fetch_positions", positions)
|
||||
return positions
|
||||
@@ -2155,7 +2207,9 @@ class Exchange:
|
||||
name = side.capitalize()
|
||||
strat_name = "entry_pricing" if side == "entry" else "exit_pricing"
|
||||
|
||||
cache_rate: TTLCache = self._entry_rate_cache if side == "entry" else self._exit_rate_cache
|
||||
cache_rate: FtTTLCache = (
|
||||
self._entry_rate_cache if side == "entry" else self._exit_rate_cache
|
||||
)
|
||||
if not refresh:
|
||||
with self._cache_lock:
|
||||
rate = cache_rate.get(pair)
|
||||
@@ -2637,24 +2691,25 @@ class Exchange:
|
||||
input_coroutines: list[Coroutine[Any, Any, OHLCVResponse]] = []
|
||||
cached_pairs = []
|
||||
for pair, timeframe, candle_type in set(pair_list):
|
||||
invalid_funding = (
|
||||
candle_type == CandleType.FUNDING_RATE
|
||||
and timeframe != self.get_option("funding_fee_timeframe")
|
||||
)
|
||||
if candle_type == CandleType.FUNDING_RATE and timeframe != (
|
||||
ff_tf := 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 (
|
||||
CandleType.SPOT,
|
||||
CandleType.FUTURES,
|
||||
)
|
||||
if invalid_timeframe or invalid_funding:
|
||||
timeframes_ = (
|
||||
", ".join(self.timeframes)
|
||||
if candle_type != CandleType.FUNDING_RATE
|
||||
else self.get_option("funding_fee_timeframe")
|
||||
)
|
||||
if invalid_timeframe:
|
||||
logger.warning(
|
||||
f"Cannot download ({pair}, {timeframe}, {candle_type}) combination as this "
|
||||
f"timeframe is not available on {self.name}. Available timeframes are "
|
||||
f"{timeframes_}."
|
||||
f"{', '.join(self.timeframes)}."
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -2691,7 +2746,11 @@ class Exchange:
|
||||
has_cache = cache and (pair, timeframe, c_type) in self._klines
|
||||
# in case of existing cache, fill_missing happens after concatenation
|
||||
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
|
||||
if cache:
|
||||
@@ -2702,7 +2761,7 @@ class Exchange:
|
||||
concat([old, ohlcv_df], axis=0),
|
||||
timeframe,
|
||||
pair,
|
||||
fill_missing=True,
|
||||
fill_missing=c_type != CandleType.FUNDING_RATE,
|
||||
drop_incomplete=False,
|
||||
)
|
||||
candle_limit = self.ohlcv_candle_limit(timeframe, self._config["candle_type_def"])
|
||||
@@ -2837,9 +2896,10 @@ class Exchange:
|
||||
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 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(
|
||||
pair, timeframe=timeframe, since=since_ms, limit=candle_limit, params=params
|
||||
)
|
||||
@@ -2904,6 +2964,38 @@ class Exchange:
|
||||
data = [[x["timestamp"], x["fundingRate"], 0, 0, 0, 0] for x in 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
|
||||
|
||||
def needed_candle_for_trades_ms(self, timeframe: str, candle_type: CandleType) -> int:
|
||||
@@ -3731,10 +3823,11 @@ class Exchange:
|
||||
:param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price)
|
||||
:param futures_funding_rate: Fake funding rate to use if funding_rates are not available
|
||||
"""
|
||||
relevant_cols = ["date", "open_mark", "open_fund"]
|
||||
if futures_funding_rate is None:
|
||||
return mark_rates.merge(
|
||||
funding_rates, on="date", how="inner", suffixes=["_mark", "_fund"]
|
||||
)
|
||||
)[relevant_cols]
|
||||
else:
|
||||
if len(funding_rates) == 0:
|
||||
# No funding rate candles - full fillup with fallback variable
|
||||
@@ -3747,15 +3840,23 @@ class Exchange:
|
||||
"low": "low_mark",
|
||||
"volume": "volume_mark",
|
||||
}
|
||||
)
|
||||
)[relevant_cols]
|
||||
|
||||
else:
|
||||
# Fill up missing funding_rate candles with fallback value
|
||||
combined = mark_rates.merge(
|
||||
funding_rates, on="date", how="left", suffixes=["_mark", "_fund"]
|
||||
)
|
||||
combined["open_fund"] = combined["open_fund"].fillna(futures_funding_rate)
|
||||
return combined
|
||||
# Fill only leading missing funding rates so gaps stay untouched
|
||||
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(
|
||||
self,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
@@ -36,9 +37,9 @@ class Hyperliquid(Exchange):
|
||||
"stoploss_order_types": {"limit": "limit"},
|
||||
"stoploss_blocks_assets": False,
|
||||
"stop_price_prop": "stopPrice",
|
||||
"funding_fee_timeframe": "1h",
|
||||
"funding_fee_candle_limit": 500,
|
||||
"uses_leverage_tiers": False,
|
||||
"mark_ohlcv_price": "futures",
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
@@ -56,6 +57,13 @@ class Hyperliquid(Exchange):
|
||||
config.update(super()._ccxt_config)
|
||||
return config
|
||||
|
||||
def market_is_tradable(self, market: dict[str, Any]) -> bool:
|
||||
parent_check = super().market_is_tradable(market)
|
||||
|
||||
# Exclude hip3 markets for now - which have the format XYZ:GOOGL/USDT:USDT -
|
||||
# and XYZ:GOOGL as base
|
||||
return parent_check and ":" not in market["base"]
|
||||
|
||||
def get_max_leverage(self, pair: str, stake_amount: float | None) -> float:
|
||||
# There are no leverage tiers
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
|
||||
@@ -35,7 +35,6 @@ class Kraken(Exchange):
|
||||
"trades_pagination_arg": "since",
|
||||
"trades_pagination_overlap": False,
|
||||
"trades_has_history": True,
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
@@ -82,7 +81,7 @@ class Kraken(Exchange):
|
||||
balances.pop("free", None)
|
||||
balances.pop("total", None)
|
||||
balances.pop("used", None)
|
||||
self._log_exchange_response("fetch_balances", balances)
|
||||
self._log_exchange_response("fetch_balance", balances)
|
||||
|
||||
# Consolidate balances
|
||||
balances = self.consolidate_balances(balances)
|
||||
@@ -104,7 +103,7 @@ class Kraken(Exchange):
|
||||
balances[bal]["used"] = sum(order[1] for order in order_list if order[0] == bal)
|
||||
balances[bal]["free"] = balances[bal]["total"] - balances[bal]["used"]
|
||||
|
||||
self._log_exchange_response("fetch_balances2", balances)
|
||||
self._log_exchange_response("fetch_balance2", balances)
|
||||
return balances
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
|
||||
@@ -44,8 +44,9 @@ class Kucoin(Exchange):
|
||||
amount: float,
|
||||
rate: float,
|
||||
leverage: float,
|
||||
reduceOnly: bool = False,
|
||||
time_in_force: str = "GTC",
|
||||
reduceOnly: bool = False,
|
||||
initial_order: bool = True,
|
||||
) -> CcxtOrder:
|
||||
res = super().create_order(
|
||||
pair=pair,
|
||||
@@ -56,6 +57,7 @@ class Kucoin(Exchange):
|
||||
leverage=leverage,
|
||||
reduceOnly=reduceOnly,
|
||||
time_in_force=time_in_force,
|
||||
initial_order=initial_order,
|
||||
)
|
||||
# Kucoin returns only the order-id.
|
||||
# ccxt returns status = 'closed' at the moment - which is information ccxt invented.
|
||||
|
||||
@@ -29,8 +29,6 @@ class Okx(Exchange):
|
||||
|
||||
_ft_has: FtHas = {
|
||||
"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_on_exchange": True,
|
||||
"trades_has_history": False, # Endpoint doesn't have a "since" parameter
|
||||
@@ -41,8 +39,8 @@ class Okx(Exchange):
|
||||
"stop_price_type_field": "slTriggerPxType",
|
||||
"stop_price_type_value_mapping": {
|
||||
PriceType.LAST: "last",
|
||||
PriceType.MARK: "index",
|
||||
PriceType.INDEX: "mark",
|
||||
PriceType.MARK: "mark",
|
||||
PriceType.INDEX: "index",
|
||||
},
|
||||
"stoploss_blocks_assets": False,
|
||||
"ws_enabled": True,
|
||||
@@ -266,14 +264,7 @@ class Okx(Exchange):
|
||||
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,
|
||||
)
|
||||
return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True})
|
||||
|
||||
def _fetch_orders_emulate(self, pair: str, since_ms: int) -> list[CcxtOrder]:
|
||||
orders = []
|
||||
|
||||
+43
-25
@@ -63,7 +63,7 @@ from freqtrade.rpc.rpc_types import (
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||
from freqtrade.util import FtPrecise, MeasureTime, PeriodicCache, dt_from_ts, dt_now
|
||||
from freqtrade.util.migrations.binance_mig import migrate_binance_futures_names
|
||||
from freqtrade.util.migrations import migrate_live_content
|
||||
from freqtrade.wallets import Wallets
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
Called on startup and after reloading the bot - triggers notifications and
|
||||
performs startup tasks
|
||||
"""
|
||||
migrate_binance_futures_names(self.config)
|
||||
migrate_live_content(self.config, self.exchange)
|
||||
set_startup_time()
|
||||
|
||||
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
|
||||
@@ -937,6 +937,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
reduceOnly=False,
|
||||
time_in_force=time_in_force,
|
||||
leverage=leverage,
|
||||
initial_order=trade is None,
|
||||
)
|
||||
order_obj = Order.parse_from_ccxt_object(order, pair, side, amount, enter_limit_requested)
|
||||
order_obj.ft_order_tag = enter_tag
|
||||
@@ -1063,7 +1064,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
return True
|
||||
|
||||
def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade:
|
||||
def cancel_stoploss_on_exchange(self, trade: Trade, allow_nonblocking: bool = False) -> Trade:
|
||||
"""
|
||||
Cancels on exchange stoploss orders for the given trade.
|
||||
:param trade: Trade for which to cancel stoploss order
|
||||
:param allow_nonblocking: If True, will skip cancelling stoploss on exchange
|
||||
if the exchange supports blocking stoploss orders.
|
||||
"""
|
||||
if allow_nonblocking and not self.exchange.get_option("stoploss_blocks_assets", True):
|
||||
logger.info(f"Skipping cancelling stoploss on exchange for {trade}.")
|
||||
return trade
|
||||
# First cancelling stoploss on exchange ...
|
||||
for oslo in trade.open_sl_orders:
|
||||
try:
|
||||
@@ -2002,14 +2012,14 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
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.
|
||||
This should cover cases where get_real_amount() was not able to update the amount
|
||||
for whatever reason.
|
||||
: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
|
||||
:return: amount to sell
|
||||
:return: amount to exit
|
||||
: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!
|
||||
@@ -2045,11 +2055,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
exit_tag: str | None = None,
|
||||
ordertype: str | None = None,
|
||||
sub_trade_amt: float | None = None,
|
||||
skip_custom_exit_price: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Executes a trade exit for the given trade and limit
|
||||
: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
|
||||
:return: True if it succeeds False
|
||||
"""
|
||||
@@ -2071,29 +2082,33 @@ class FreqtradeBot(LoggingMixin):
|
||||
):
|
||||
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
|
||||
proposed_limit_rate = limit
|
||||
custom_exit_price = limit
|
||||
|
||||
current_profit = trade.calc_profit_ratio(limit)
|
||||
custom_exit_price = strategy_safe_wrapper(
|
||||
self.strategy.custom_exit_price, default_retval=proposed_limit_rate
|
||||
)(
|
||||
pair=trade.pair,
|
||||
trade=trade,
|
||||
current_time=datetime.now(UTC),
|
||||
proposed_rate=proposed_limit_rate,
|
||||
current_profit=current_profit,
|
||||
exit_tag=exit_reason,
|
||||
)
|
||||
if order_type == "limit" and not skip_custom_exit_price:
|
||||
custom_exit_price = strategy_safe_wrapper(
|
||||
self.strategy.custom_exit_price, default_retval=proposed_limit_rate
|
||||
)(
|
||||
pair=trade.pair,
|
||||
trade=trade,
|
||||
current_time=datetime.now(UTC),
|
||||
proposed_rate=proposed_limit_rate,
|
||||
current_profit=current_profit,
|
||||
exit_tag=exit_reason,
|
||||
)
|
||||
|
||||
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
|
||||
|
||||
# First cancelling stoploss on exchange ...
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
|
||||
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")
|
||||
trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True)
|
||||
|
||||
amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount)
|
||||
time_in_force = self.strategy.order_time_in_force["exit"]
|
||||
@@ -2121,7 +2136,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Execute sell and update trade record
|
||||
# Execute exit and update trade record
|
||||
order = self.exchange.create_order(
|
||||
pair=trade.pair,
|
||||
ordertype=order_type,
|
||||
@@ -2131,6 +2146,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
leverage=trade.leverage,
|
||||
reduceOnly=self.trading_mode == TradingMode.FUTURES,
|
||||
time_in_force=time_in_force,
|
||||
initial_order=False,
|
||||
)
|
||||
except InsufficientFundsError as e:
|
||||
logger.warning(f"Unable to place order {e}.")
|
||||
@@ -2148,7 +2164,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.exit_reason = exit_reason
|
||||
|
||||
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"):
|
||||
self.update_trade_state(trade, order_obj.order_id, order)
|
||||
Trade.commit()
|
||||
@@ -2378,6 +2394,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
self.strategy.ft_stoploss_adjust(
|
||||
current_rate, trade, datetime.now(UTC), profit, 0, after_fill=True
|
||||
)
|
||||
if not trade.is_open:
|
||||
self.cancel_stoploss_on_exchange(trade)
|
||||
# Updating wallets when order is closed
|
||||
self.wallets.update()
|
||||
return trade
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from cachetools import TTLCache, cached
|
||||
from cachetools import cached
|
||||
|
||||
from freqtrade.util import FtTTLCache
|
||||
|
||||
|
||||
class LoggingMixin:
|
||||
@@ -18,7 +20,7 @@ class LoggingMixin:
|
||||
"""
|
||||
self.logger = logger
|
||||
self.refresh_period = refresh_period
|
||||
self._log_cache: TTLCache = TTLCache(maxsize=1024, ttl=self.refresh_period)
|
||||
self._log_cache: FtTTLCache = FtTTLCache(maxsize=1024, ttl=self.refresh_period)
|
||||
|
||||
def log_once(self, message: str, logmethod: Callable, force_show: bool = False) -> None:
|
||||
"""
|
||||
|
||||
@@ -39,6 +39,7 @@ class RecursiveAnalysis(BaseAnalysis):
|
||||
self.dict_recursive: dict[str, Any] = dict()
|
||||
|
||||
self.pair_to_used: str | None = None
|
||||
self._strat_scc: int | None = None
|
||||
|
||||
# For recursive bias check
|
||||
# analyzes two data frames with processed indicators and shows differences between them.
|
||||
@@ -151,7 +152,8 @@ class RecursiveAnalysis(BaseAnalysis):
|
||||
backtesting._set_strategy(backtesting.strategylist[0])
|
||||
|
||||
strat = backtesting.strategy
|
||||
self._strat_scc = strat.startup_candle_count
|
||||
if self._strat_scc is None:
|
||||
self._strat_scc = strat.startup_candle_count
|
||||
|
||||
if self._strat_scc < 1:
|
||||
raise ConfigurationError(
|
||||
|
||||
@@ -126,6 +126,7 @@ class Backtesting:
|
||||
|
||||
self.config["dry_run"] = True
|
||||
self.price_pair_prec: dict[str, Series] = {}
|
||||
self.available_pairs: list[str] = []
|
||||
self.run_ids: dict[str, str] = {}
|
||||
self.strategylist: list[IStrategy] = []
|
||||
self.all_bt_content: dict[str, BacktestContentType] = {}
|
||||
@@ -176,7 +177,8 @@ class Backtesting:
|
||||
self._validate_pairlists_for_backtesting()
|
||||
|
||||
self.dataprovider.add_pairlisthandler(self.pairlists)
|
||||
self.pairlists.refresh_pairlist()
|
||||
self.dynamic_pairlist: bool = self.config.get("enable_dynamic_pairlist", False)
|
||||
self.pairlists.refresh_pairlist(only_first=self.dynamic_pairlist)
|
||||
|
||||
if len(self.pairlists.whitelist) == 0:
|
||||
raise OperationalException("No pair in whitelist.")
|
||||
@@ -211,7 +213,6 @@ class Backtesting:
|
||||
self._can_short = self.trading_mode != TradingMode.SPOT
|
||||
self._position_stacking: bool = self.config.get("position_stacking", False)
|
||||
self.enable_protections: bool = self.config.get("enable_protections", False)
|
||||
self.dynamic_pairlist: bool = self.config.get("enable_dynamic_pairlist", False)
|
||||
migrate_data(config, self.exchange)
|
||||
|
||||
self.init_backtest()
|
||||
@@ -335,10 +336,12 @@ class Backtesting:
|
||||
self.progress.set_new_value(1)
|
||||
self._load_bt_data_detail()
|
||||
self.price_pair_prec = {}
|
||||
|
||||
for pair in self.pairlists.whitelist:
|
||||
if pair in data:
|
||||
# Load price precision logic
|
||||
self.price_pair_prec[pair] = get_tick_size_over_time(data[pair])
|
||||
self.available_pairs.append(pair)
|
||||
return data, self.timerange
|
||||
|
||||
def _load_bt_data_detail(self) -> None:
|
||||
@@ -371,6 +374,7 @@ class Backtesting:
|
||||
timerange=self.timerange,
|
||||
startup_candles=0,
|
||||
fail_without_data=True,
|
||||
fill_up_missing=False,
|
||||
data_format=self.config["dataformat_ohlcv"],
|
||||
candle_type=CandleType.FUNDING_RATE,
|
||||
)
|
||||
@@ -1587,7 +1591,7 @@ class Backtesting:
|
||||
self.check_abort()
|
||||
|
||||
if self.dynamic_pairlist and self.pairlists:
|
||||
self.pairlists.refresh_pairlist()
|
||||
self.pairlists.refresh_pairlist(pairs=self.available_pairs)
|
||||
pairs = self.pairlists.whitelist
|
||||
|
||||
# Reset open trade count for this candle
|
||||
|
||||
@@ -48,7 +48,7 @@ from freqtrade.leverage import interest
|
||||
from freqtrade.misc import safe_value_fallback
|
||||
from freqtrade.persistence.base import ModelBase, SessionType
|
||||
from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData
|
||||
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none
|
||||
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none, round_value
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -654,9 +654,10 @@ class LocalTrade:
|
||||
)
|
||||
|
||||
return (
|
||||
f"Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, "
|
||||
f"is_short={self.is_short or False}, leverage={self.leverage or 1.0}, "
|
||||
f"open_rate={self.open_rate:.8f}, open_since={open_since})"
|
||||
f"Trade(id={self.id}, pair={self.pair}, amount={round_value(self.amount, 8)}, "
|
||||
f"is_short={self.is_short or False}, "
|
||||
f"leverage={round_value(self.leverage or 1.0, 1)}, "
|
||||
f"open_rate={round_value(self.open_rate, 8)}, open_since={open_since})"
|
||||
)
|
||||
|
||||
def to_json(self, minified: bool = False) -> dict[str, Any]:
|
||||
@@ -755,6 +756,8 @@ class LocalTrade:
|
||||
"precision_mode": self.precision_mode,
|
||||
"precision_mode_price": self.precision_mode_price,
|
||||
"contract_size": self.contract_size,
|
||||
"nr_of_successful_entries": self.nr_of_successful_entries,
|
||||
"nr_of_successful_exits": self.nr_of_successful_exits,
|
||||
"has_open_orders": self.has_open_orders,
|
||||
"orders": orders_json,
|
||||
}
|
||||
|
||||
@@ -75,11 +75,11 @@ def init_plotscript(config, markets: list, startup_candles: int = 0):
|
||||
)
|
||||
|
||||
no_trades = False
|
||||
filename = config.get("exportfilename")
|
||||
filename = config.get("exportfilename") or config.get("exportdirectory")
|
||||
if config.get("no_trades", False):
|
||||
no_trades = True
|
||||
elif config["trade_source"] == "file":
|
||||
if not filename.is_dir() and not filename.is_file():
|
||||
if not filename or (not filename.is_dir() and not filename.is_file()):
|
||||
logger.warning("Backtest file is missing skipping trades.")
|
||||
no_trades = True
|
||||
try:
|
||||
|
||||
@@ -7,11 +7,10 @@ Provides dynamic pair list based on Market Cap
|
||||
import logging
|
||||
import math
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange.exchange_types import Tickers
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import FtTTLCache
|
||||
from freqtrade.util.coin_gecko import FtCoinGeckoApi
|
||||
|
||||
|
||||
@@ -38,7 +37,7 @@ class MarketCapPairList(IPairList):
|
||||
self._max_rank = self._pairlistconfig.get("max_rank", 30)
|
||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 86400)
|
||||
self._categories = self._pairlistconfig.get("categories", [])
|
||||
self._marketcap_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._marketcap_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
|
||||
_coingecko_config = self._config.get("coingecko", {})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import logging
|
||||
from datetime import timedelta
|
||||
from typing import TypedDict
|
||||
|
||||
from cachetools import TTLCache
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe
|
||||
@@ -18,7 +17,7 @@ from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
|
||||
from freqtrade.exchange.exchange_types import Ticker, Tickers
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import dt_now, format_ms_time
|
||||
from freqtrade.util import FtTTLCache, dt_now, format_ms_time
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,7 +46,7 @@ class PercentChangePairList(IPairList):
|
||||
self._min_value = self._pairlistconfig.get("min_value", None)
|
||||
self._max_value = self._pairlistconfig.get("max_value", None)
|
||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._lookback_days = self._pairlistconfig.get("lookback_days", 0)
|
||||
self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d")
|
||||
self._lookback_period = self._pairlistconfig.get("lookback_period", 0)
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any
|
||||
|
||||
import rapidjson
|
||||
import requests
|
||||
from cachetools import TTLCache
|
||||
|
||||
from freqtrade import __version__
|
||||
from freqtrade.configuration.load_config import CONFIG_PARSE_MODE
|
||||
@@ -18,6 +17,7 @@ from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange.exchange_types import Tickers
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||
from freqtrade.util import FtTTLCache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,7 +48,7 @@ class RemotePairList(IPairList):
|
||||
self._number_pairs = self._pairlistconfig["number_assets"]
|
||||
self._refresh_period: int = self._pairlistconfig.get("refresh_period", 1800)
|
||||
self._keep_pairlist_on_failure = self._pairlistconfig.get("keep_pairlist_on_failure", True)
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._pairlist_url = self._pairlistconfig.get("pairlist_url", "")
|
||||
self._read_timeout = self._pairlistconfig.get("read_timeout", 60)
|
||||
self._bearer_token = self._pairlistconfig.get("bearer_token", "")
|
||||
@@ -159,7 +159,7 @@ class RemotePairList(IPairList):
|
||||
)
|
||||
|
||||
self._refresh_period = remote_refresh_period
|
||||
self._pair_cache = TTLCache(maxsize=1, ttl=remote_refresh_period)
|
||||
self._pair_cache = FtTTLCache(maxsize=1, ttl=remote_refresh_period)
|
||||
|
||||
self._init_done = True
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import sys
|
||||
from datetime import timedelta
|
||||
|
||||
import numpy as np
|
||||
from cachetools import TTLCache
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import ListPairsWithTimeframes
|
||||
@@ -15,7 +14,7 @@ from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange.exchange_types import Tickers
|
||||
from freqtrade.misc import plural
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import dt_floor_day, dt_now, dt_ts
|
||||
from freqtrade.util import FtTTLCache, dt_floor_day, dt_now, dt_ts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +37,7 @@ class VolatilityFilter(IPairList):
|
||||
self._def_candletype = self._config["candle_type_def"]
|
||||
self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", None)
|
||||
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
|
||||
candle_limit = self._exchange.ohlcv_candle_limit("1d", self._def_candletype)
|
||||
if self._days < 1:
|
||||
|
||||
@@ -8,14 +8,12 @@ import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
from freqtrade.constants import ListPairsWithTimeframes
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
|
||||
from freqtrade.exchange.exchange_types import Tickers
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import dt_now, format_ms_time
|
||||
from freqtrade.util import FtTTLCache, dt_now, format_ms_time
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -43,7 +41,7 @@ class VolumePairList(IPairList):
|
||||
self._min_value = self._pairlistconfig.get("min_value", 0)
|
||||
self._max_value = self._pairlistconfig.get("max_value", None)
|
||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
self._lookback_days = self._pairlistconfig.get("lookback_days", 0)
|
||||
self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d")
|
||||
self._lookback_period = self._pairlistconfig.get("lookback_period", 0)
|
||||
|
||||
@@ -5,7 +5,6 @@ Rate of change pairlist filter
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from cachetools import TTLCache
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import ListPairsWithTimeframes
|
||||
@@ -13,7 +12,7 @@ from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange.exchange_types import Tickers
|
||||
from freqtrade.misc import plural
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import dt_floor_day, dt_now, dt_ts
|
||||
from freqtrade.util import FtTTLCache, dt_floor_day, dt_now, dt_ts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,7 +31,7 @@ class RangeStabilityFilter(IPairList):
|
||||
self._def_candletype = self._config["candle_type_def"]
|
||||
self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", None)
|
||||
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
|
||||
candle_limit = self._exchange.ohlcv_candle_limit("1d", self._def_candletype)
|
||||
if self._days < 1:
|
||||
|
||||
@@ -5,7 +5,7 @@ PairList manager class
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from cachetools import LRUCache, TTLCache, cached
|
||||
from cachetools import LRUCache, cached
|
||||
|
||||
from freqtrade.constants import Config, ListPairsWithTimeframes
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
@@ -17,6 +17,7 @@ from freqtrade.mixins import LoggingMixin
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, SupportsBacktesting
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||
from freqtrade.resolvers import PairListResolver
|
||||
from freqtrade.util import FtTTLCache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -129,12 +130,24 @@ class PairListManager(LoggingMixin):
|
||||
"""List of short_desc for each Pairlist Handler"""
|
||||
return [{p.name: p.short_desc()} for p in self._pairlist_handlers]
|
||||
|
||||
@cached(TTLCache(maxsize=1, ttl=1800))
|
||||
@cached(FtTTLCache(maxsize=1, ttl=1800))
|
||||
def _get_cached_tickers(self) -> Tickers:
|
||||
return self._exchange.get_tickers()
|
||||
|
||||
def refresh_pairlist(self) -> None:
|
||||
"""Run pairlist through all configured Pairlist Handlers."""
|
||||
def refresh_pairlist(self, only_first: bool = False, pairs: list[str] | None = None) -> None:
|
||||
"""
|
||||
Run pairlist through all configured Pairlist Handlers.
|
||||
|
||||
:param only_first: If True, only run the first PairList handler (the generator)
|
||||
and skip all subsequent filters. Used during backtesting startup to ensure
|
||||
historic data is loaded for the complete universe of pairs that the
|
||||
generator can produce (even if later filters would reduce the list size).
|
||||
Prevents missing data when a filter returns a variable number of pairs
|
||||
across refresh cycles.
|
||||
:param pairs: Optional list of pairs to intersect with the generated pairlist.
|
||||
Only pairs present both in the generated list and this parameter are kept.
|
||||
Used in backtesting to filter out pairs with no available data.
|
||||
"""
|
||||
# Tickers should be cached to avoid calling the exchange on each call.
|
||||
tickers: dict = {}
|
||||
if self._tickers_needed:
|
||||
@@ -143,10 +156,15 @@ class PairListManager(LoggingMixin):
|
||||
# Generate the pairlist with first Pairlist Handler in the chain
|
||||
pairlist = self._pairlist_handlers[0].gen_pairlist(tickers)
|
||||
|
||||
# Process all Pairlist Handlers in the chain
|
||||
# except for the first one, which is the generator.
|
||||
for pairlist_handler in self._pairlist_handlers[1:]:
|
||||
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
|
||||
# Optional intersection with an explicit list of pairs (used in backtesting)
|
||||
if pairs is not None:
|
||||
pairlist = [p for p in pairlist if p in pairs]
|
||||
|
||||
if not only_first:
|
||||
# Process all Pairlist Handlers in the chain
|
||||
# except for the first one, which is the generator.
|
||||
for pairlist_handler in self._pairlist_handlers[1:]:
|
||||
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
|
||||
|
||||
# Validation against blacklist happens after the chain of Pairlist Handlers
|
||||
# to ensure blacklist is respected.
|
||||
|
||||
@@ -63,6 +63,8 @@ def pairlists_evaluate(
|
||||
config_loc["timeframes"] = payload.timeframes
|
||||
config_loc["erase"] = payload.erase
|
||||
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)
|
||||
|
||||
|
||||
@@ -340,6 +340,8 @@ class TradeSchema(BaseModel):
|
||||
|
||||
min_rate: float | None = None
|
||||
max_rate: float | None = None
|
||||
nr_of_successful_entries: int
|
||||
nr_of_successful_exits: int
|
||||
has_open_orders: bool
|
||||
orders: list[OrderSchema]
|
||||
|
||||
@@ -424,6 +426,7 @@ class ForceExitPayload(BaseModel):
|
||||
tradeid: str | int
|
||||
ordertype: OrderTypeValues | None = None
|
||||
amount: float | None = None
|
||||
price: float | None = None
|
||||
|
||||
|
||||
class BlacklistPayload(BaseModel):
|
||||
@@ -504,6 +507,7 @@ class DownloadDataPayload(ExchangeModePayloadMixin, BaseModel):
|
||||
timerange: str | None = None
|
||||
erase: bool = False
|
||||
download_trades: bool = False
|
||||
candle_types: list[str] | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
def check_mutually_exclusive(cls, values):
|
||||
|
||||
@@ -91,7 +91,9 @@ logger = logging.getLogger(__name__)
|
||||
# 2.41: Add download-data endpoint
|
||||
# 2.42: Add /pair_history endpoint with live data
|
||||
# 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.
|
||||
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"])
|
||||
def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)):
|
||||
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"])
|
||||
|
||||
@@ -37,7 +37,7 @@ class ApiBG:
|
||||
|
||||
# Generic background jobs
|
||||
|
||||
# TODO: Change this to TTLCache
|
||||
# TODO: Change this to FtTTLCache
|
||||
jobs: dict[str, JobsContainer] = {}
|
||||
# Pairlist evaluate things
|
||||
pairlist_running: bool = False
|
||||
|
||||
@@ -7,11 +7,11 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from cachetools import TTLCache
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from freqtrade.constants import SUPPORTED_FIAT, Config
|
||||
from freqtrade.mixins.logging_mixin import LoggingMixin
|
||||
from freqtrade.util import FtTTLCache
|
||||
from freqtrade.util.coin_gecko import FtCoinGeckoApi
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class CryptoToFiatConverter(LoggingMixin):
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
# Timeout: 6h
|
||||
self._pair_price: TTLCache = TTLCache(maxsize=500, ttl=6 * 60 * 60)
|
||||
self._pair_price: FtTTLCache = FtTTLCache(maxsize=500, ttl=6 * 60 * 60)
|
||||
|
||||
_coingecko_config = config.get("coingecko", {})
|
||||
self._coingecko = FtCoinGeckoApi(
|
||||
|
||||
+28
-8
@@ -47,6 +47,7 @@ from freqtrade.util import (
|
||||
dt_ts,
|
||||
dt_ts_def,
|
||||
format_date,
|
||||
format_pct,
|
||||
shorten_date,
|
||||
)
|
||||
from freqtrade.wallets import PositionWallet, Wallet
|
||||
@@ -302,7 +303,7 @@ class RPC:
|
||||
fiat_total_profit_sum = nan
|
||||
for trade in self._rpc_trade_status():
|
||||
# Format profit as a string with the right sign
|
||||
profit = f"{trade['profit_ratio']:.2%}"
|
||||
profit = f"{format_pct(trade['profit_ratio'])}"
|
||||
fiat_profit = trade.get("profit_fiat", None)
|
||||
if fiat_profit is None or isnan(fiat_profit):
|
||||
fiat_profit = trade.get("profit_abs", 0.0)
|
||||
@@ -939,7 +940,11 @@ class RPC:
|
||||
return {"status": "Reloaded from orders from exchange"}
|
||||
|
||||
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:
|
||||
# Check if there is there are open orders
|
||||
trade_entry_cancelation_registry = []
|
||||
@@ -963,8 +968,13 @@ class RPC:
|
||||
# Order cancellation failed, so we can't exit.
|
||||
return False
|
||||
# Get current rate and execute sell
|
||||
current_rate = self._freqtrade.exchange.get_rate(
|
||||
trade.pair, side="exit", is_short=trade.is_short, refresh=True
|
||||
|
||||
current_rate = (
|
||||
self._freqtrade.exchange.get_rate(
|
||||
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)
|
||||
order_type = ordertype or self._freqtrade.strategy.order_types.get(
|
||||
@@ -982,18 +992,28 @@ class RPC:
|
||||
sub_amount = amount
|
||||
|
||||
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 False
|
||||
|
||||
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]:
|
||||
"""
|
||||
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:
|
||||
@@ -1023,7 +1043,7 @@ class RPC:
|
||||
logger.warning("force_exit: Invalid argument received")
|
||||
raise RPCException("invalid argument")
|
||||
|
||||
result = self.__exec_force_exit(trade, ordertype, amount)
|
||||
result = self.__exec_force_exit(trade, ordertype, amount, price)
|
||||
Trade.commit()
|
||||
self._freqtrade.wallets.update()
|
||||
if not result:
|
||||
|
||||
+61
-60
@@ -48,6 +48,7 @@ from freqtrade.util import (
|
||||
fmt_coin,
|
||||
fmt_coin2,
|
||||
format_date,
|
||||
format_pct,
|
||||
round_value,
|
||||
)
|
||||
|
||||
@@ -481,7 +482,7 @@ class Telegram(RPCHandler):
|
||||
if is_final_exit:
|
||||
profit_prefix = "Sub "
|
||||
cp_extra = (
|
||||
f"*Final Profit:* `{msg['final_profit_ratio']:.2%} "
|
||||
f"*Final Profit:* `{format_pct(msg['final_profit_ratio'])} "
|
||||
f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n"
|
||||
)
|
||||
else:
|
||||
@@ -497,7 +498,7 @@ class Telegram(RPCHandler):
|
||||
f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n"
|
||||
f"{self._add_analyzed_candle(msg['pair'])}"
|
||||
f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* "
|
||||
f"`{msg['profit_ratio']:.2%}{profit_extra}`\n"
|
||||
f"`{format_pct(msg['profit_ratio'])}{profit_extra}`\n"
|
||||
f"{cp_extra}"
|
||||
f"{enter_tag}"
|
||||
f"*Exit Reason:* `{msg['exit_reason']}`\n"
|
||||
@@ -670,14 +671,14 @@ class Telegram(RPCHandler):
|
||||
# TODO: This calculation ignores fees.
|
||||
price_to_1st_entry = (cur_entry_average - first_avg) / first_avg
|
||||
if is_open:
|
||||
lines.append("({})".format(dt_humanize_delta(order["order_filled_date"])))
|
||||
lines.append(f"({dt_humanize_delta(order['order_filled_date'])})")
|
||||
lines.append(
|
||||
f"*Amount:* {round_value(cur_entry_amount, 8)} "
|
||||
f"({fmt_coin(order['cost'], quote_currency)})"
|
||||
)
|
||||
lines.append(
|
||||
f"*Average {wording} Price:* {round_value(cur_entry_average, 8)} "
|
||||
f"({price_to_1st_entry:.2%} from 1st entry rate)"
|
||||
f"({format_pct(price_to_1st_entry)} from 1st entry rate)"
|
||||
)
|
||||
lines.append(f"*Order Filled:* {order['order_filled_date']}")
|
||||
|
||||
@@ -701,7 +702,7 @@ class Telegram(RPCHandler):
|
||||
|
||||
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
|
||||
for r in results:
|
||||
lines = ["*Order List for Trade #*`{trade_id}`"]
|
||||
lines = [f"*Order List for Trade #*`{r['trade_id']}`"]
|
||||
|
||||
lines_detail = self._prepare_order_details(
|
||||
r["orders"], r["quote_currency"], r["is_open"]
|
||||
@@ -720,10 +721,10 @@ class Telegram(RPCHandler):
|
||||
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
|
||||
msg += line + "\n"
|
||||
else:
|
||||
await self._send_msg(msg.format(**r))
|
||||
msg = "*Order List for Trade #*`{trade_id}` - continued\n" + line + "\n"
|
||||
await self._send_msg(msg)
|
||||
msg = f"*Order List for Trade #*`{r['trade_id']}` - continued\n" + line + "\n"
|
||||
|
||||
await self._send_msg(msg.format(**r))
|
||||
await self._send_msg(msg)
|
||||
|
||||
@authorized_only
|
||||
async def _status(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -757,15 +758,7 @@ class Telegram(RPCHandler):
|
||||
max_entries = self._config.get("max_entry_position_adjustment", -1)
|
||||
for r in results:
|
||||
r["open_date_hum"] = dt_humanize_delta(r["open_date"])
|
||||
r["num_entries"] = len([o for o in r["orders"] if o["ft_is_entry"]])
|
||||
r["num_exits"] = len(
|
||||
[
|
||||
o
|
||||
for o in r["orders"]
|
||||
if not o["ft_is_entry"] and not o["ft_order_side"] == "stoploss"
|
||||
]
|
||||
)
|
||||
r["exit_reason"] = r.get("exit_reason", "")
|
||||
|
||||
r["stake_amount_r"] = fmt_coin(r["stake_amount"], r["quote_currency"])
|
||||
r["max_stake_amount_r"] = fmt_coin(
|
||||
r["max_stake_amount"] or r["stake_amount"], r["quote_currency"]
|
||||
@@ -774,26 +767,25 @@ class Telegram(RPCHandler):
|
||||
r["realized_profit_r"] = fmt_coin(r["realized_profit"], r["quote_currency"])
|
||||
r["total_profit_abs_r"] = fmt_coin(r["total_profit_abs"], r["quote_currency"])
|
||||
lines = [
|
||||
"*Trade ID:* `{trade_id}`" + (" `(since {open_date_hum})`" if r["is_open"] else ""),
|
||||
"*Current Pair:* {pair}",
|
||||
f"*Trade ID:* `{r['trade_id']}`"
|
||||
+ (f" `(since {r['open_date_hum']})`" if r["is_open"] else ""),
|
||||
f"*Current Pair:* {r['pair']}",
|
||||
(
|
||||
f"*Direction:* {'`Short`' if r.get('is_short') else '`Long`'}"
|
||||
+ " ` ({leverage}x)`"
|
||||
if r.get("leverage")
|
||||
else ""
|
||||
+ (f" ` ({r['leverage']}x)`" if r.get("leverage") else "")
|
||||
),
|
||||
"*Amount:* `{amount} ({stake_amount_r})`",
|
||||
"*Total invested:* `{max_stake_amount_r}`" if position_adjust else "",
|
||||
"*Enter Tag:* `{enter_tag}`" if r["enter_tag"] else "",
|
||||
"*Exit Reason:* `{exit_reason}`" if r["exit_reason"] else "",
|
||||
f"*Amount:* `{r['amount']} ({r['stake_amount_r']})`",
|
||||
f"*Total invested:* `{r['max_stake_amount_r']}`" if position_adjust else "",
|
||||
f"*Enter Tag:* `{r['enter_tag']}`" if r["enter_tag"] else "",
|
||||
f"*Exit Reason:* `{r['exit_reason']}`" if r.get("exit_reason") else "",
|
||||
]
|
||||
|
||||
if position_adjust:
|
||||
max_buy_str = f"/{max_entries + 1}" if (max_entries > 0) else ""
|
||||
lines.extend(
|
||||
[
|
||||
"*Number of Entries:* `{num_entries}" + max_buy_str + "`",
|
||||
"*Number of Exits:* `{num_exits}`",
|
||||
f"*Number of Entries:* `{r['nr_of_successful_entries']}{max_buy_str}`",
|
||||
f"*Number of Exits:* `{r['nr_of_successful_exits']}`",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -801,53 +793,62 @@ class Telegram(RPCHandler):
|
||||
[
|
||||
f"*Open Rate:* `{round_value(r['open_rate'], 8)}`",
|
||||
f"*Close Rate:* `{round_value(r['close_rate'], 8)}`" if r["close_rate"] else "",
|
||||
"*Open Date:* `{open_date}`",
|
||||
"*Close Date:* `{close_date}`" if r["close_date"] else "",
|
||||
f"*Open Date:* `{r['open_date']}`",
|
||||
f"*Close Date:* `{r['close_date']}`" if r["close_date"] else "",
|
||||
(
|
||||
f" \n*Current Rate:* `{round_value(r['current_rate'], 8)}`"
|
||||
if r["is_open"]
|
||||
else ""
|
||||
),
|
||||
("*Unrealized Profit:* " if r["is_open"] else "*Close Profit: *")
|
||||
+ "`{profit_ratio:.2%}` `({profit_abs_r})`",
|
||||
+ f"`{format_pct(r['profit_ratio'])}` `({r['profit_abs_r']})`",
|
||||
]
|
||||
)
|
||||
|
||||
if r["is_open"]:
|
||||
if r.get("realized_profit"):
|
||||
lines.extend(
|
||||
[
|
||||
"*Realized Profit:* `{realized_profit_ratio:.2%} "
|
||||
"({realized_profit_r})`",
|
||||
"*Total Profit:* `{total_profit_ratio:.2%} ({total_profit_abs_r})`",
|
||||
]
|
||||
if (
|
||||
r.get("realized_profit") is not None
|
||||
and r.get("realized_profit_ratio") is not None
|
||||
):
|
||||
lines.append(
|
||||
f"*Realized Profit:* `{format_pct(r['realized_profit_ratio'])} "
|
||||
f"({r['realized_profit_r']})`"
|
||||
)
|
||||
if r.get("total_profit_ratio") is not None:
|
||||
lines.append(
|
||||
f"*Total Profit:* `{format_pct(r['total_profit_ratio'])} "
|
||||
f"({r['total_profit_abs_r']})`"
|
||||
)
|
||||
|
||||
# Append empty line to improve readability
|
||||
lines.append(" ")
|
||||
# Adding liquidation only if it is not None
|
||||
if liquidation := r.get("liquidation_price"):
|
||||
lines.append(f"*Liquidation:* `{round_value(liquidation, 8)}`")
|
||||
|
||||
if (
|
||||
r["stop_loss_abs"] != r["initial_stop_loss_abs"]
|
||||
and r["initial_stop_loss_ratio"] is not None
|
||||
):
|
||||
# Adding initial stoploss only if it is different from stoploss
|
||||
lines.append(
|
||||
"*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
|
||||
"`({initial_stop_loss_ratio:.2%})`"
|
||||
f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
|
||||
f"`({format_pct(r['initial_stop_loss_ratio'])})`"
|
||||
)
|
||||
|
||||
# Adding stoploss and stoploss percentage only if it is not None
|
||||
lines.append(
|
||||
f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` "
|
||||
+ ("`({stop_loss_ratio:.2%})`" if r["stop_loss_ratio"] else "")
|
||||
+ (f"`({format_pct(r['stop_loss_ratio'])})`" if r["stop_loss_ratio"] else "")
|
||||
)
|
||||
lines.append(
|
||||
f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` "
|
||||
"`({stoploss_current_dist_ratio:.2%})`"
|
||||
f"`({format_pct(r['stoploss_current_dist_ratio'])})`"
|
||||
)
|
||||
if r.get("open_orders"):
|
||||
if open_orders := r.get("open_orders"):
|
||||
lines.append(
|
||||
"*Open Order:* `{open_orders}`"
|
||||
+ ("- `{exit_order_status}`" if r["exit_order_status"] else "")
|
||||
f"*Open Order:* `{open_orders}`"
|
||||
+ (f"- `{r['exit_order_status']}`" if r["exit_order_status"] else "")
|
||||
)
|
||||
|
||||
await self.__send_status_msg(lines, r)
|
||||
@@ -863,10 +864,10 @@ class Telegram(RPCHandler):
|
||||
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
|
||||
msg += line + "\n"
|
||||
else:
|
||||
await self._send_msg(msg.format(**r))
|
||||
msg = "*Trade ID:* `{trade_id}` - continued\n" + line + "\n"
|
||||
await self._send_msg(msg)
|
||||
msg = f"*Trade ID:* `{r['trade_id']}` - continued\n" + line + "\n"
|
||||
|
||||
await self._send_msg(msg.format(**r))
|
||||
await self._send_msg(msg)
|
||||
|
||||
@authorized_only
|
||||
async def _status_table(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -953,7 +954,7 @@ class Telegram(RPCHandler):
|
||||
f"{period['date']:{val.dateformat}} ({period['trade_count']})",
|
||||
f"{fmt_coin(period['abs_profit'], stats['stake_currency'])}",
|
||||
f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
|
||||
f"{period['rel_profit']:.2%}",
|
||||
f"{format_pct(period['rel_profit'])}",
|
||||
]
|
||||
for period in stats["data"]
|
||||
],
|
||||
@@ -1069,7 +1070,7 @@ class Telegram(RPCHandler):
|
||||
markdown_msg = (
|
||||
f"{closed_roi_label}\n"
|
||||
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
|
||||
f"({profit_closed_ratio_mean:.2%}) "
|
||||
f"({format_pct(profit_closed_ratio_mean)}) "
|
||||
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||
f"{fiat_closed_trades}"
|
||||
)
|
||||
@@ -1082,7 +1083,7 @@ class Telegram(RPCHandler):
|
||||
markdown_msg += (
|
||||
f"{all_roi_label}\n"
|
||||
f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
|
||||
f"({profit_all_ratio_mean:.2%}) "
|
||||
f"({format_pct(profit_all_ratio_mean)}) "
|
||||
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||
f"{fiat_all_trades}"
|
||||
f"*Total Trade Count:* `{trade_count}`\n"
|
||||
@@ -1091,7 +1092,7 @@ class Telegram(RPCHandler):
|
||||
f"`{first_trade_date}`\n"
|
||||
f"*Latest Trade opened:* `{latest_trade_date}`\n"
|
||||
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n"
|
||||
f"*Winrate:* `{winrate:.2%}`\n"
|
||||
f"*Winrate:* `{format_pct(winrate)}`\n"
|
||||
f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
|
||||
)
|
||||
|
||||
@@ -1099,16 +1100,16 @@ class Telegram(RPCHandler):
|
||||
markdown_msg += (
|
||||
f"\n*Avg. Duration:* `{avg_duration}`\n"
|
||||
f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} "
|
||||
f"({best_pair_profit_ratio:.2%})`\n"
|
||||
f"({format_pct(best_pair_profit_ratio)})`\n"
|
||||
f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
|
||||
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
|
||||
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
|
||||
f"*Max Drawdown:* `{format_pct(stats['max_drawdown'])} "
|
||||
f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
|
||||
f" from `{stats['max_drawdown_start']} "
|
||||
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
|
||||
f" to `{stats['max_drawdown_end']} "
|
||||
f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
|
||||
f"*Current Drawdown:* `{stats['current_drawdown']:.2%} "
|
||||
f"*Current Drawdown:* `{format_pct(stats['current_drawdown'])} "
|
||||
f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n"
|
||||
f" from `{stats['current_drawdown_start']} "
|
||||
f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n"
|
||||
@@ -1561,7 +1562,7 @@ class Telegram(RPCHandler):
|
||||
dt_humanize_delta(dt_from_ts(trade["close_timestamp"])),
|
||||
f"{trade['pair']} (#{trade['trade_id']}"
|
||||
f"{(' ' + ('S' if trade['is_short'] else 'L')) if nonspot else ''})",
|
||||
f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})",
|
||||
f"{format_pct(trade['close_profit'])} ({trade['close_profit_abs']})",
|
||||
]
|
||||
for trade in trades["trades"]
|
||||
],
|
||||
@@ -1625,7 +1626,7 @@ class Telegram(RPCHandler):
|
||||
stat_line = (
|
||||
f"{i + 1}.\t <code>{trade['pair']}\t"
|
||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({format_pct(trade['profit_ratio'])}) "
|
||||
f"({trade['count']})</code>\n"
|
||||
)
|
||||
|
||||
@@ -1662,7 +1663,7 @@ class Telegram(RPCHandler):
|
||||
stat_line = (
|
||||
f"{i + 1}.\t `{trade['enter_tag']}\t"
|
||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({format_pct(trade['profit_ratio'])}) "
|
||||
f"({trade['count']})`\n"
|
||||
)
|
||||
|
||||
@@ -1699,7 +1700,7 @@ class Telegram(RPCHandler):
|
||||
stat_line = (
|
||||
f"{i + 1}.\t `{trade['exit_reason']}\t"
|
||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({format_pct(trade['profit_ratio'])}) "
|
||||
f"({trade['count']})`\n"
|
||||
)
|
||||
|
||||
@@ -1736,7 +1737,7 @@ class Telegram(RPCHandler):
|
||||
stat_line = (
|
||||
f"{i + 1}.\t `{trade['mix_tag']}\t"
|
||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({format_pct(trade['profit_ratio'])}) "
|
||||
f"({trade['count']})`\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -104,8 +104,11 @@ def _create_and_merge_informative_pair(
|
||||
):
|
||||
asset = inf_data.asset or ""
|
||||
timeframe = inf_data.timeframe
|
||||
timeframe1 = inf_data.timeframe
|
||||
fmt = inf_data.fmt
|
||||
candle_type = inf_data.candle_type
|
||||
if candle_type == CandleType.FUNDING_RATE:
|
||||
timeframe1 = strategy.dp.get_funding_rate_timeframe()
|
||||
|
||||
config = strategy.config
|
||||
|
||||
@@ -132,10 +135,10 @@ def _create_and_merge_informative_pair(
|
||||
fmt = "{base}_{quote}_" + fmt # Informatives of other pairs
|
||||
|
||||
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:
|
||||
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."
|
||||
)
|
||||
inf_dataframe = populate_indicators_fn(strategy, inf_dataframe, inf_metadata)
|
||||
@@ -163,7 +166,7 @@ def _create_and_merge_informative_pair(
|
||||
dataframe,
|
||||
inf_dataframe,
|
||||
strategy.timeframe,
|
||||
timeframe,
|
||||
timeframe1,
|
||||
ffill=inf_data.ffill,
|
||||
append_timeframe=False,
|
||||
date_column=date_column,
|
||||
|
||||
@@ -1718,7 +1718,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
timeout_unit = self.config.get("unfilledtimeout", {}).get("unit", "minutes")
|
||||
timeout_kwargs = {timeout_unit: -timeout}
|
||||
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:
|
||||
return True
|
||||
time_method = (
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing":{
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
|
||||
@@ -18,9 +18,11 @@ from freqtrade.util.formatters import (
|
||||
fmt_coin,
|
||||
fmt_coin2,
|
||||
format_duration,
|
||||
format_pct,
|
||||
round_value,
|
||||
)
|
||||
from freqtrade.util.ft_precise import FtPrecise
|
||||
from freqtrade.util.ft_ttlcache import FtTTLCache
|
||||
from freqtrade.util.measure_time import MeasureTime
|
||||
from freqtrade.util.periodic_cache import PeriodicCache
|
||||
from freqtrade.util.progress_tracker import ( # noqa F401
|
||||
@@ -44,6 +46,7 @@ __all__ = [
|
||||
"format_date",
|
||||
"format_ms_time",
|
||||
"format_ms_time_det",
|
||||
"format_pct",
|
||||
"get_dry_run_wallet",
|
||||
"FtPrecise",
|
||||
"PeriodicCache",
|
||||
@@ -57,4 +60,5 @@ __all__ = [
|
||||
"print_rich_table",
|
||||
"print_df_rich_table",
|
||||
"CustomProgress",
|
||||
"FtTTLCache",
|
||||
]
|
||||
|
||||
@@ -90,15 +90,16 @@ def dt_humanize_delta(dt: datetime):
|
||||
return humanize.naturaltime(dt)
|
||||
|
||||
|
||||
def format_date(date: datetime | None) -> str:
|
||||
def format_date(date: datetime | None, fallback: str = "") -> str:
|
||||
"""
|
||||
Return a formatted date string.
|
||||
Returns an empty string if date is None.
|
||||
:param date: datetime to format
|
||||
:param fallback: value to return if date is None
|
||||
"""
|
||||
if date:
|
||||
return date.strftime(DATETIME_PRINT_FORMAT)
|
||||
return ""
|
||||
return fallback
|
||||
|
||||
|
||||
def format_ms_time(date: int | float) -> str:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from numpy import isnan
|
||||
|
||||
from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN
|
||||
|
||||
|
||||
@@ -21,7 +23,7 @@ def strip_trailing_zeros(value: str) -> str:
|
||||
return value.rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str:
|
||||
def round_value(value: float | None, decimals: int, keep_trailing_zeros=False) -> str:
|
||||
"""
|
||||
Round value to given decimals
|
||||
:param value: Value to be rounded
|
||||
@@ -29,6 +31,8 @@ def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str:
|
||||
:param keep_trailing_zeros: Keep trailing zeros "222.200" vs. "222.2"
|
||||
:return: Rounded value as string
|
||||
"""
|
||||
if value is None or isnan(value):
|
||||
return "N/A"
|
||||
val = f"{value:.{decimals}f}"
|
||||
if not keep_trailing_zeros:
|
||||
val = strip_trailing_zeros(val)
|
||||
@@ -80,3 +84,15 @@ def format_duration(td: timedelta) -> str:
|
||||
h, r = divmod(td.seconds, 3600)
|
||||
m, _ = divmod(r, 60)
|
||||
return f"{d}d {h:02d}:{m:02d}"
|
||||
|
||||
|
||||
def format_pct(value: float | None) -> str:
|
||||
"""
|
||||
Format a float value as percentage string with 2 decimals
|
||||
None and NaN values are formatted as "N/A"
|
||||
:param value: Float value to format
|
||||
:return: Formatted percentage string
|
||||
"""
|
||||
if value is None or isnan(value):
|
||||
return "N/A"
|
||||
return f"{value:.2%}"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import time
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
class FtTTLCache(TTLCache):
|
||||
"""
|
||||
A TTLCache with a different default timer to allow for easier mocking in tests.
|
||||
"""
|
||||
|
||||
def __init__(self, maxsize, ttl, timer=time.time, getsizeof=None):
|
||||
super().__init__(maxsize=maxsize, ttl=ttl, timer=timer, getsizeof=getsizeof)
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from cachetools import TTLCache
|
||||
from freqtrade.util import FtTTLCache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,7 +27,7 @@ class MeasureTime:
|
||||
"""
|
||||
self._callback = callback
|
||||
self._time_limit = time_limit
|
||||
self.__cache: TTLCache = TTLCache(maxsize=1, ttl=ttl)
|
||||
self.__cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=ttl)
|
||||
|
||||
def __enter__(self):
|
||||
self._start = time.time()
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.util.migrations.binance_mig import migrate_binance_futures_data
|
||||
from freqtrade.util.migrations.binance_mig import (
|
||||
migrate_binance_futures_data,
|
||||
migrate_binance_futures_names,
|
||||
)
|
||||
from freqtrade.util.migrations.funding_rate_mig import migrate_funding_fee_timeframe
|
||||
|
||||
|
||||
def migrate_data(config, exchange: Exchange | None = None):
|
||||
def migrate_data(config, exchange: Exchange | None = None) -> None:
|
||||
"""
|
||||
Migrate persisted data from old formats to new formats
|
||||
"""
|
||||
migrate_binance_futures_data(config)
|
||||
|
||||
migrate_funding_fee_timeframe(config, exchange)
|
||||
|
||||
|
||||
def migrate_live_content(config, exchange: Exchange | None = None) -> None:
|
||||
"""
|
||||
Migrate database content from old formats to new formats
|
||||
Used for dry/live mode.
|
||||
"""
|
||||
migrate_binance_futures_names(config)
|
||||
|
||||
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate_binance_futures_names(config: Config):
|
||||
"""
|
||||
Migrate binance futures names in both database and data files.
|
||||
This is needed because ccxt naming changed from "BTC/USDT" to "BTC/USDT:USDT"
|
||||
"""
|
||||
if not (
|
||||
config.get("trading_mode", TradingMode.SPOT) == TradingMode.FUTURES
|
||||
and config["exchange"]["name"] == "binance"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from freqtrade_client.ft_rest_client import FtRestClient
|
||||
|
||||
|
||||
__version__ = "2025.11-dev"
|
||||
__version__ = "2025.12-dev"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Requirements for freqtrade client library
|
||||
requests==2.32.5
|
||||
python-rapidjson==1.22
|
||||
python-rapidjson==1.23
|
||||
|
||||
@@ -183,6 +183,7 @@ skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*", "**/user_data/*"
|
||||
known_first_party = ["freqtrade_client"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# TODO: should be migrated to [tool.pytest] as support for this was added in 9.0
|
||||
log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
-r requirements-freqai-rl.txt
|
||||
-r docs/requirements-docs.txt
|
||||
|
||||
ruff==0.14.3
|
||||
mypy==1.18.2
|
||||
pre-commit==4.3.0
|
||||
pytest==8.4.2
|
||||
pytest-asyncio==1.2.0
|
||||
ruff==0.14.8
|
||||
mypy==1.19.0
|
||||
pre-commit==4.5.0
|
||||
pytest==9.0.2
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-mock==3.15.1
|
||||
pytest-random-order==1.2.0
|
||||
@@ -18,15 +18,15 @@ pytest-timeout==2.4.0
|
||||
pytest-xdist==3.8.0
|
||||
isort==7.0.0
|
||||
# For datetime mocking
|
||||
time-machine==2.19.0
|
||||
time-machine==3.1.0
|
||||
|
||||
# Convert jupyter notebooks to markdown documents
|
||||
nbconvert==7.16.6
|
||||
|
||||
# mypy types
|
||||
scipy-stubs==1.16.3.0 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
scipy-stubs==1.16.3.2 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
types-cachetools==6.2.0.20251022
|
||||
types-filelock==3.2.7
|
||||
types-requests==2.32.4.20250913
|
||||
types-tabulate==0.9.0.20241207
|
||||
types-python-dateutil==2.9.0.20251008
|
||||
types-python-dateutil==2.9.0.20251115
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
-r requirements-freqai.txt
|
||||
|
||||
# Required for freqai-rl
|
||||
torch==2.9.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
torch==2.9.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
gymnasium==1.2.2
|
||||
# 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'
|
||||
# Progress bar for stable-baselines3 and sb3-contrib
|
||||
tqdm==4.67.1
|
||||
|
||||
@@ -7,6 +7,6 @@ scikit-learn==1.7.2
|
||||
joblib==1.5.2
|
||||
catboost==1.2.8; 'arm' not in platform_machine and python_version < '3.14'
|
||||
lightgbm==4.6.0
|
||||
xgboost==3.1.1
|
||||
xgboost==3.1.2
|
||||
tensorboard==2.20.0
|
||||
datasieve==0.1.9
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
scipy==1.16.3
|
||||
scikit-learn==1.7.2
|
||||
filelock==3.20.0
|
||||
optuna==4.5.0
|
||||
optuna==4.6.0
|
||||
cmaes==0.12.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Include all requirements to run the bot.
|
||||
-r requirements.txt
|
||||
|
||||
plotly==6.4.0
|
||||
plotly==6.5.0
|
||||
|
||||
+9
-9
@@ -1,4 +1,4 @@
|
||||
numpy==2.3.4
|
||||
numpy==2.3.5
|
||||
pandas==2.3.3
|
||||
bottleneck==1.6.0
|
||||
numexpr==2.14.1
|
||||
@@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16
|
||||
ta-lib==0.6.8
|
||||
technical==1.5.3
|
||||
|
||||
ccxt==4.5.17
|
||||
ccxt==4.5.27
|
||||
cryptography==46.0.3
|
||||
aiohttp==3.13.2
|
||||
SQLAlchemy==2.0.44
|
||||
@@ -15,10 +15,10 @@ python-telegram-bot==22.5
|
||||
# can't be hard-pinned due to telegram-bot pinning httpx with ~
|
||||
httpx>=0.24.1
|
||||
humanize==4.14.0
|
||||
cachetools==6.2.1
|
||||
cachetools==6.2.2
|
||||
requests==2.32.5
|
||||
urllib3==2.5.0
|
||||
certifi==2025.10.5
|
||||
urllib3==2.6.0
|
||||
certifi==2025.11.12
|
||||
jsonschema==4.25.1
|
||||
tabulate==0.9.0
|
||||
pycoingecko==3.2.0
|
||||
@@ -29,16 +29,16 @@ pyarrow==22.0.0; platform_machine != 'armv7l'
|
||||
|
||||
|
||||
# Load ticker files 30% faster
|
||||
python-rapidjson==1.22
|
||||
python-rapidjson==1.23
|
||||
# Properly format api responses
|
||||
orjson==3.11.4
|
||||
orjson==3.11.5
|
||||
|
||||
# Notify systemd
|
||||
sdnotify==0.3.2
|
||||
|
||||
# API Server
|
||||
fastapi==0.121.0
|
||||
pydantic==2.12.4
|
||||
fastapi==0.124.0
|
||||
pydantic==2.12.5
|
||||
uvicorn==0.38.0
|
||||
pyjwt==2.10.1
|
||||
aiofiles==25.1.0
|
||||
|
||||
@@ -198,6 +198,8 @@ def test_list_timeframes(mocker, capsys):
|
||||
"1h": "hour",
|
||||
"1d": "day",
|
||||
}
|
||||
api_mock.options = {}
|
||||
|
||||
patch_exchange(mocker, api_mock=api_mock, exchange="bybit")
|
||||
args = [
|
||||
"list-timeframes",
|
||||
@@ -286,6 +288,52 @@ def test_list_timeframes(mocker, capsys):
|
||||
assert re.search(r"^1h$", captured.out, re.MULTILINE)
|
||||
assert re.search(r"^1d$", captured.out, re.MULTILINE)
|
||||
|
||||
api_mock.options = {
|
||||
"timeframes": {
|
||||
"spot": {"1m": "1m", "5m": "5m", "15m": "15m"},
|
||||
"swap": {"1m": "1m", "15m": "15m", "1h": "1h"},
|
||||
}
|
||||
}
|
||||
|
||||
args = [
|
||||
"list-timeframes",
|
||||
"--exchange",
|
||||
"binance",
|
||||
]
|
||||
start_list_timeframes(get_args(args))
|
||||
captured = capsys.readouterr()
|
||||
assert re.match(
|
||||
"Timeframes available for the exchange `Binance`: 1m, 5m, 15m",
|
||||
captured.out,
|
||||
)
|
||||
|
||||
args = [
|
||||
"list-timeframes",
|
||||
"--exchange",
|
||||
"binance",
|
||||
"--trading-mode",
|
||||
"spot",
|
||||
]
|
||||
start_list_timeframes(get_args(args))
|
||||
captured = capsys.readouterr()
|
||||
assert re.match(
|
||||
"Timeframes available for the exchange `Binance`: 1m, 5m, 15m",
|
||||
captured.out,
|
||||
)
|
||||
args = [
|
||||
"list-timeframes",
|
||||
"--exchange",
|
||||
"binance",
|
||||
"--trading-mode",
|
||||
"futures",
|
||||
]
|
||||
start_list_timeframes(get_args(args))
|
||||
captured = capsys.readouterr()
|
||||
assert re.match(
|
||||
"Timeframes available for the exchange `Binance`: 1m, 15m, 1h",
|
||||
captured.out,
|
||||
)
|
||||
|
||||
|
||||
def test_list_markets(mocker, markets_static, capsys):
|
||||
api_mock = MagicMock()
|
||||
@@ -1719,7 +1767,7 @@ def test_start_list_data(testdatadir, capsys):
|
||||
pargs["config"] = None
|
||||
start_list_data(pargs)
|
||||
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"\n.* UNITTEST/BTC .* 1m, 5m, 8m, 30m .* spot |\n", captured.out)
|
||||
|
||||
@@ -1753,10 +1801,10 @@ def test_start_list_data(testdatadir, capsys):
|
||||
start_list_data(pargs)
|
||||
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"\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 = [
|
||||
"list-data",
|
||||
|
||||
@@ -303,6 +303,7 @@ def mock_order_usdt_6(is_short: bool):
|
||||
"side": entry_side(is_short),
|
||||
"type": "limit",
|
||||
"price": 10.0,
|
||||
"cost": 20.0,
|
||||
"amount": 2.0,
|
||||
"filled": 2.0,
|
||||
"remaining": 0.0,
|
||||
@@ -317,6 +318,7 @@ def mock_order_usdt_6_exit(is_short: bool):
|
||||
"side": exit_side(is_short),
|
||||
"type": "limit",
|
||||
"price": 12.0,
|
||||
"cost": 24.0,
|
||||
"amount": 2.0,
|
||||
"filled": 0.0,
|
||||
"remaining": 2.0,
|
||||
|
||||
@@ -290,20 +290,23 @@ def test_combine_dataframes_with_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")
|
||||
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 "ETH/BTC" not in df.columns
|
||||
assert "ADA/BTC" not in df.columns
|
||||
assert "BTC/USDT" not in df.columns
|
||||
assert "XRP/USDT" not in df.columns
|
||||
assert "mean" in df.columns
|
||||
assert "rel_mean" in df.columns
|
||||
assert "count" in df.columns
|
||||
assert df.iloc[0]["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):
|
||||
@@ -575,12 +578,18 @@ def test_calculate_max_drawdown2():
|
||||
# No losing trade ...
|
||||
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
|
||||
assert drawdown.drawdown_abs == 0.0
|
||||
assert drawdown.low_value == 0.0
|
||||
assert drawdown.current_high_value >= 0.0
|
||||
assert drawdown.current_drawdown_abs == 0.0
|
||||
|
||||
df1 = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
|
||||
df1.loc[:, "profit"] = df1["profit"] * -1
|
||||
# No winning trade ...
|
||||
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
|
||||
assert drawdown.drawdown_abs == 0.055545
|
||||
assert drawdown.high_value == 0.0
|
||||
assert drawdown.current_high_value == 0.0
|
||||
assert drawdown.current_drawdown_abs == 0.055545
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -40,6 +40,8 @@ def test_datahandler_ohlcv_get_pairs(testdatadir):
|
||||
"NXT/BTC",
|
||||
"DASH/BTC",
|
||||
"XRP/ETH",
|
||||
"BTC/USDT",
|
||||
"XRP/USDT",
|
||||
}
|
||||
|
||||
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),
|
||||
("XRP/ETH", "1m", CandleType.SPOT),
|
||||
("XRP/ETH", "5m", CandleType.SPOT),
|
||||
("BTC/USDT", "5m", CandleType.SPOT),
|
||||
("XRP/USDT", "5m", CandleType.SPOT),
|
||||
("UNITTEST/BTC", "30m", 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", "1h", "futures"),
|
||||
("XRP/USDT:USDT", "1h", "mark"),
|
||||
("XRP/USDT:USDT", "8h", "mark"),
|
||||
("XRP/USDT:USDT", "8h", "funding_rate"),
|
||||
("XRP/USDT:USDT", "1h", "funding_rate"),
|
||||
}
|
||||
|
||||
paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT)
|
||||
|
||||
@@ -9,7 +9,7 @@ from freqtrade.enums import CandleType, RunMode
|
||||
from freqtrade.exceptions import ExchangeError, OperationalException
|
||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||
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(
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
timeframe = default_conf["timeframe"]
|
||||
@@ -636,3 +658,21 @@ def test_check_delisting(mocker, default_conf_usdt):
|
||||
assert res == dt_utc(2025, 10, 2)
|
||||
|
||||
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()
|
||||
|
||||
+49
-11
@@ -534,18 +534,19 @@ def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> No
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trademode,callcount",
|
||||
"trademode,callcount, callcount_parallel",
|
||||
[
|
||||
("spot", 4),
|
||||
("margin", 4),
|
||||
("futures", 8), # Called 8 times - 4 normal, 2 funding and 2 mark/index calls
|
||||
("spot", 4, 2),
|
||||
("margin", 4, 2),
|
||||
("futures", 8, 4), # Called 8 times - 4 normal, 2 funding and 2 mark/index calls
|
||||
],
|
||||
)
|
||||
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)
|
||||
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):
|
||||
return {(pair, timeframe, candle_type): DataFrame() for pair in pairs}
|
||||
@@ -573,14 +574,50 @@ def test_refresh_backtest_ohlcv_data(
|
||||
)
|
||||
|
||||
# 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_args[1]["timerange"].starttype == "date"
|
||||
|
||||
assert log_has_re(r"Downloading pair ETH/BTC, .* interval 1m\.", caplog)
|
||||
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, mark, interval 4h\.", 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 1h\.", caplog)
|
||||
|
||||
# Test with only one pair - no parallel download should happen 1 pair/timeframe combination
|
||||
# doesn't justify parallelization
|
||||
parallel_mock.reset_mock()
|
||||
dl_mock.reset_mock()
|
||||
refresh_backtest_ohlcv_data(
|
||||
exchange=ex,
|
||||
pairs=[
|
||||
"ETH/BTC",
|
||||
],
|
||||
timeframes=["5m"],
|
||||
datadir=testdatadir,
|
||||
timerange=timerange,
|
||||
erase=False,
|
||||
trading_mode=trademode,
|
||||
)
|
||||
assert parallel_mock.call_count == 0
|
||||
|
||||
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):
|
||||
@@ -780,6 +817,7 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt):
|
||||
exchange.refresh_latest_ohlcv.reset_mock()
|
||||
|
||||
# Test without timerange
|
||||
# expected to call refresh_latest_ohlcv - as we can't know how much will be required.
|
||||
result3 = _download_all_pairs_history_parallel(
|
||||
exchange=exchange,
|
||||
pairs=pairs,
|
||||
@@ -787,8 +825,8 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt):
|
||||
candle_type=candle_type,
|
||||
timerange=None,
|
||||
)
|
||||
assert result3 == {}
|
||||
assert exchange.refresh_latest_ohlcv.call_count == 0
|
||||
assert result3 == expected
|
||||
assert exchange.refresh_latest_ohlcv.call_count == 1
|
||||
|
||||
|
||||
def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path, caplog) -> None:
|
||||
@@ -878,7 +916,7 @@ def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path,
|
||||
assert get_historic_ohlcv_mock.call_count == 0
|
||||
|
||||
# Verify the log message indicating parallel method was used (line 315-316)
|
||||
assert log_has("Downloaded data for TEST/BTC with length 3. Parallel Method.", caplog)
|
||||
assert log_has("Downloaded data for TEST/BTC, 5m, spot with length 3. Parallel Method.", caplog)
|
||||
|
||||
# Verify data was stored
|
||||
assert data_handler_mock.ohlcv_store.call_count == 1
|
||||
|
||||
@@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker):
|
||||
assert "type" in order
|
||||
|
||||
assert order["type"] == order_type
|
||||
assert order["price"] == 220
|
||||
assert order["price"] == 217.8
|
||||
assert order["stopPrice"] == 220
|
||||
assert order["amount"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freqtrade.enums import CandleType, MarginMode, TradingMode
|
||||
from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode
|
||||
from freqtrade.exceptions import OperationalException, RetryableOrderError
|
||||
from freqtrade.exchange.common import API_RETRY_COUNT
|
||||
from freqtrade.util import dt_now, dt_ts
|
||||
from freqtrade.util import dt_now, dt_ts, dt_utc
|
||||
from tests.conftest import EXMS, get_patched_exchange
|
||||
from tests.exchange.test_exchange import ccxt_exceptionhandlers
|
||||
|
||||
@@ -193,3 +194,43 @@ def test__lev_prep_bitget(default_conf, mocker):
|
||||
assert api_mock.set_margin_mode.call_count == 0
|
||||
assert api_mock.set_leverage.call_count == 1
|
||||
api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=19.99)
|
||||
|
||||
|
||||
def test_check_delisting_time_bitget(default_conf_usdt, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget")
|
||||
exchange._config["runmode"] = RunMode.BACKTEST
|
||||
delist_fut_mock = MagicMock(return_value=None)
|
||||
mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock)
|
||||
|
||||
# Invalid run mode
|
||||
resp = exchange.check_delisting_time("BTC/USDT")
|
||||
assert resp is None
|
||||
assert delist_fut_mock.call_count == 0
|
||||
|
||||
# Delist spot called
|
||||
exchange._config["runmode"] = RunMode.DRY_RUN
|
||||
resp1 = exchange.check_delisting_time("BTC/USDT")
|
||||
assert resp1 is None
|
||||
assert delist_fut_mock.call_count == 0
|
||||
|
||||
# Delist futures called
|
||||
exchange.trading_mode = TradingMode.FUTURES
|
||||
resp1 = exchange.check_delisting_time("BTC/USDT:USDT")
|
||||
assert resp1 is None
|
||||
assert delist_fut_mock.call_count == 1
|
||||
|
||||
|
||||
def test__check_delisting_futures_bitget(default_conf_usdt, mocker, markets):
|
||||
markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"])
|
||||
markets["BTC/USDT:USDT"]["info"]["limitOpenTime"] = "-1"
|
||||
markets["SOL/BUSD:BUSD"]["info"]["limitOpenTime"] = "-1"
|
||||
markets["ADA/USDT:USDT"]["info"]["limitOpenTime"] = "1760745600000" # 2025-10-18
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget")
|
||||
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets))
|
||||
|
||||
resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD")
|
||||
# No delisting date
|
||||
assert resp_sol is None
|
||||
# Has a delisting date
|
||||
resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT")
|
||||
assert resp_ada == dt_utc(2025, 10, 18)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freqtrade.enums.marginmode import MarginMode
|
||||
from freqtrade.enums.tradingmode import TradingMode
|
||||
from freqtrade.enums import MarginMode, RunMode, TradingMode
|
||||
from freqtrade.util import dt_utc
|
||||
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has
|
||||
from tests.exchange.test_exchange import ccxt_exceptionhandlers
|
||||
|
||||
@@ -214,3 +215,43 @@ def test_bybit__order_needs_price(
|
||||
exchange.unified_account = uta
|
||||
|
||||
assert exchange._order_needs_price(side, order_type) == expected
|
||||
|
||||
|
||||
def test_check_delisting_time_bybit(default_conf_usdt, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bybit")
|
||||
exchange._config["runmode"] = RunMode.BACKTEST
|
||||
delist_fut_mock = MagicMock(return_value=None)
|
||||
mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock)
|
||||
|
||||
# Invalid run mode
|
||||
resp = exchange.check_delisting_time("BTC/USDT:USDT")
|
||||
assert resp is None
|
||||
assert delist_fut_mock.call_count == 0
|
||||
|
||||
# Delist spot called
|
||||
exchange._config["runmode"] = RunMode.DRY_RUN
|
||||
resp1 = exchange.check_delisting_time("BTC/USDT")
|
||||
assert resp1 is None
|
||||
assert delist_fut_mock.call_count == 0
|
||||
|
||||
# Delist futures called
|
||||
exchange.trading_mode = TradingMode.FUTURES
|
||||
resp1 = exchange.check_delisting_time("BTC/USDT:USDT")
|
||||
assert resp1 is None
|
||||
assert delist_fut_mock.call_count == 1
|
||||
|
||||
|
||||
def test__check_delisting_futures_bybit(default_conf_usdt, mocker, markets):
|
||||
markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"])
|
||||
markets["BTC/USDT:USDT"]["info"]["deliveryTime"] = "0"
|
||||
markets["SOL/BUSD:BUSD"]["info"]["deliveryTime"] = "0"
|
||||
markets["ADA/USDT:USDT"]["info"]["deliveryTime"] = "1760745600000" # 2025-10-18
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bybit")
|
||||
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets))
|
||||
|
||||
resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD")
|
||||
# SOL has no delisting date
|
||||
assert resp_sol is None
|
||||
# Actually has a delisting date
|
||||
resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT")
|
||||
assert resp_ada == dt_utc(2025, 10, 18)
|
||||
|
||||
+318
-28
@@ -742,10 +742,11 @@ def test_get_pair_base_currency(default_conf, mocker, pair, expected):
|
||||
def test_validate_timeframes(default_conf, mocker, timeframe):
|
||||
default_conf["timeframe"] = timeframe
|
||||
api_mock = MagicMock()
|
||||
id_mock = PropertyMock(return_value="test_exchange")
|
||||
type(api_mock).id = id_mock
|
||||
timeframes = PropertyMock(return_value={"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"})
|
||||
type(api_mock).timeframes = timeframes
|
||||
id_mock = MagicMock(return_value="test_exchange")
|
||||
api_mock.id = id_mock
|
||||
api_mock.options = {}
|
||||
timeframes = {"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
|
||||
api_mock.timeframes = timeframes
|
||||
|
||||
mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock))
|
||||
mocker.patch(f"{EXMS}.reload_markets")
|
||||
@@ -757,12 +758,11 @@ def test_validate_timeframes(default_conf, mocker, timeframe):
|
||||
def test_validate_timeframes_failed(default_conf, mocker):
|
||||
default_conf["timeframe"] = "3m"
|
||||
api_mock = MagicMock()
|
||||
id_mock = PropertyMock(return_value="test_exchange")
|
||||
type(api_mock).id = id_mock
|
||||
timeframes = PropertyMock(
|
||||
return_value={"15s": "15s", "1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
|
||||
)
|
||||
type(api_mock).timeframes = timeframes
|
||||
id_mock = MagicMock(return_value="test_exchange")
|
||||
api_mock.id = id_mock
|
||||
timeframes = {"15s": "15s", "1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
|
||||
api_mock.timeframes = timeframes
|
||||
api_mock.options = {}
|
||||
|
||||
mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock))
|
||||
mocker.patch(f"{EXMS}.reload_markets")
|
||||
@@ -1110,6 +1110,191 @@ def test_create_dry_run_order_fees(
|
||||
assert order1["fee"]["rate"] == fee
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side,limit,offset,is_stop,expected",
|
||||
[
|
||||
("buy", 46.0, 0.0, False, True),
|
||||
("buy", 46.0, 0.0, True, False),
|
||||
("buy", 26.0, 0.0, False, True),
|
||||
("buy", 26.0, 0.0, True, False), # Stop - didn't trigger
|
||||
("buy", 25.55, 0.0, False, False),
|
||||
("buy", 25.55, 0.0, True, True), # Stop - triggered
|
||||
("buy", 1, 0.0, False, False), # Very far away
|
||||
("buy", 1, 0.0, True, True), # Current price is above stop - triggered
|
||||
("sell", 25.5, 0.0, False, True),
|
||||
("sell", 50, 0.0, False, False), # Very far away
|
||||
("sell", 25.58, 0.0, False, False),
|
||||
("sell", 25.563, 0.01, False, False),
|
||||
("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid
|
||||
("sell", 25.566, 0.0, True, True), # stop order - triggered
|
||||
("sell", 26, 0.01, True, True), # stop order - triggered
|
||||
("sell", 5.563, 0.01, False, True),
|
||||
("sell", 5.563, 0.0, True, False), # stop order - not triggered
|
||||
],
|
||||
)
|
||||
def test__dry_is_price_crossed_with_orderbook(
|
||||
default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected
|
||||
):
|
||||
# Best bid 25.563
|
||||
# Best ask 25.566
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
exchange.fetch_l2_order_book = order_book_l2_usd
|
||||
orderbook = order_book_l2_usd.return_value
|
||||
result = exchange._dry_is_price_crossed(
|
||||
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop
|
||||
)
|
||||
assert result is expected
|
||||
assert order_book_l2_usd.call_count == 0
|
||||
|
||||
# Test without passing orderbook
|
||||
order_book_l2_usd.reset_mock()
|
||||
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop)
|
||||
assert result is expected
|
||||
|
||||
|
||||
def test__dry_is_price_crossed_empty_orderbook(default_conf, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
empty_book = {"asks": [], "bids": []}
|
||||
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 100.0, orderbook=empty_book)
|
||||
|
||||
|
||||
def test__dry_is_price_crossed_fetches_orderbook(default_conf, mocker, order_book_l2_usd):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
exchange.fetch_l2_order_book = order_book_l2_usd
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 26.0)
|
||||
assert order_book_l2_usd.call_count == 1
|
||||
|
||||
|
||||
def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
exchange.fetch_l2_order_book = MagicMock()
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0)
|
||||
assert exchange.fetch_l2_order_book.call_count == 0
|
||||
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True)
|
||||
assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"crossed,immediate,side,amount,expected_status,expected_fee_rate,expected_calls,taker_or_maker",
|
||||
[
|
||||
(True, True, "buy", 2.0, "closed", 0.005, 1, "taker"),
|
||||
(True, False, "sell", 1.5, "closed", 0.005, 1, "maker"),
|
||||
(False, False, "sell", 1.0, "open", None, 0, None),
|
||||
],
|
||||
)
|
||||
def test_check_dry_limit_order_filled(
|
||||
default_conf,
|
||||
mocker,
|
||||
crossed,
|
||||
immediate,
|
||||
side,
|
||||
amount,
|
||||
expected_status,
|
||||
expected_fee_rate,
|
||||
expected_calls,
|
||||
taker_or_maker,
|
||||
):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=crossed)
|
||||
fee_mock = mocker.patch(f"{EXMS}.get_fee", return_value=0.005)
|
||||
|
||||
order = {
|
||||
"symbol": "LTC/USDT",
|
||||
"status": "open",
|
||||
"type": "limit",
|
||||
"side": side,
|
||||
"price": 25.0,
|
||||
"amount": amount,
|
||||
"filled": 0.0,
|
||||
"remaining": amount,
|
||||
"cost": 25.0 * amount,
|
||||
"fee": None,
|
||||
}
|
||||
|
||||
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
|
||||
|
||||
assert result["status"] == expected_status
|
||||
if crossed:
|
||||
assert result["filled"] == amount
|
||||
assert result["remaining"] == 0.0
|
||||
assert result["fee"]["rate"] == expected_fee_rate
|
||||
fee_mock.assert_called_once_with("LTC/USDT", taker_or_maker=taker_or_maker)
|
||||
else:
|
||||
assert result["filled"] == 0.0
|
||||
assert result["remaining"] == amount
|
||||
assert result["fee"] is None
|
||||
assert fee_mock.call_count == expected_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"immediate,crossed,expected_status,expected_fee_type",
|
||||
[
|
||||
(True, True, "closed", "taker"),
|
||||
(False, True, "closed", "maker"),
|
||||
(True, False, "open", None),
|
||||
],
|
||||
)
|
||||
def test_check_dry_limit_order_filled_stoploss(
|
||||
default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd
|
||||
):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
_dry_is_price_crossed=MagicMock(return_value=crossed),
|
||||
fetch_l2_order_book=order_book_l2_usd,
|
||||
)
|
||||
average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25)
|
||||
fee_mock = mocker.patch(
|
||||
f"{EXMS}.add_dry_order_fee",
|
||||
autospec=True,
|
||||
side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order,
|
||||
)
|
||||
|
||||
amount = 1.75
|
||||
order = {
|
||||
"symbol": "LTC/USDT",
|
||||
"status": "open",
|
||||
"type": "limit",
|
||||
"side": "sell",
|
||||
"amount": amount,
|
||||
"filled": 0.0,
|
||||
"remaining": amount,
|
||||
"price": 25.0,
|
||||
"average": 0.0,
|
||||
"cost": 0.0,
|
||||
"fee": None,
|
||||
"ft_order_type": "stoploss",
|
||||
"stopLossPrice": 24.5,
|
||||
}
|
||||
|
||||
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
|
||||
|
||||
assert result["status"] == expected_status
|
||||
assert order_book_l2_usd.call_count == 1
|
||||
if crossed:
|
||||
assert result["filled"] == amount
|
||||
assert result["remaining"] == 0
|
||||
assert result["average"] == 24.25
|
||||
assert result["cost"] == pytest.approx(amount * 24.25)
|
||||
assert average_mock.call_count == 1
|
||||
assert fee_mock.call_count == 1
|
||||
assert fee_mock.call_args[0][1] == "LTC/USDT"
|
||||
assert fee_mock.call_args[0][3] == expected_fee_type
|
||||
else:
|
||||
assert result["filled"] == 0.0
|
||||
assert result["remaining"] == amount
|
||||
assert result["average"] == 0.0
|
||||
|
||||
assert average_mock.call_count == 0
|
||||
assert fee_mock.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side,price,filled,converted",
|
||||
[
|
||||
@@ -2204,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)
|
||||
mocker.patch.object(exchange, "verify_candle_type_support")
|
||||
# Monkey-patch async function
|
||||
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
|
||||
|
||||
@@ -2254,6 +2440,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf_usdt, caplog, candle_type) ->
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
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)
|
||||
|
||||
pairs = [("IOTA/USDT", "5m", candle_type), ("XRP/USDT", "5m", candle_type)]
|
||||
@@ -2504,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))
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch.object(exchange, "verify_candle_type_support")
|
||||
exchange._set_startup_candle_count(default_conf)
|
||||
|
||||
mocker.patch(f"{EXMS}.ohlcv_candle_limit", return_value=100)
|
||||
@@ -2652,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
|
||||
|
||||
|
||||
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)
|
||||
async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name):
|
||||
ohlcv = [
|
||||
@@ -3716,37 +3927,29 @@ def test_cancel_stoploss_order(default_conf, mocker, exchange_name):
|
||||
@pytest.mark.parametrize("exchange_name", EXCHANGES)
|
||||
def test_cancel_stoploss_order_with_result(default_conf, mocker, exchange_name):
|
||||
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)
|
||||
mocker.patch.object(exchange, "fetch_stoploss_order", return_value={"for": 123})
|
||||
|
||||
res = {"fee": {}, "status": "canceled", "amount": 1234}
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value=res)
|
||||
mocker.patch(f"{mock_prefix}.cancel_stoploss_order", return_value=res)
|
||||
mocker.patch.object(exchange, "cancel_stoploss_order", return_value=res)
|
||||
co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555)
|
||||
assert co == res
|
||||
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value="canceled")
|
||||
mocker.patch(f"{mock_prefix}.cancel_stoploss_order", return_value="canceled")
|
||||
mocker.patch.object(exchange, "cancel_stoploss_order", return_value="canceled")
|
||||
# Fall back to fetch_stoploss_order
|
||||
co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555)
|
||||
assert co == {"for": 123}
|
||||
|
||||
exc = InvalidOrderException("")
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", side_effect=exc)
|
||||
mocker.patch(f"{mock_prefix}.fetch_stoploss_order", side_effect=exc)
|
||||
mocker.patch.object(exchange, "fetch_stoploss_order", side_effect=exc)
|
||||
co = exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=555)
|
||||
assert co["amount"] == 555
|
||||
assert co == {"id": "_", "fee": {}, "status": "canceled", "amount": 555, "info": {}}
|
||||
|
||||
with pytest.raises(InvalidOrderException):
|
||||
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)
|
||||
mocker.patch.object(exchange, "cancel_stoploss_order", side_effect=exc)
|
||||
exchange.cancel_stoploss_order_with_result(order_id="_", pair="TKN/BTC", amount=123)
|
||||
|
||||
|
||||
@@ -3931,7 +4134,7 @@ def test_fetch_order_or_stoploss_order(default_conf, mocker):
|
||||
fetch_order_mock = MagicMock()
|
||||
fetch_stoploss_order_mock = MagicMock()
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
exchange,
|
||||
fetch_order=fetch_order_mock,
|
||||
fetch_stoploss_order=fetch_stoploss_order_mock,
|
||||
)
|
||||
@@ -5119,6 +5322,7 @@ def test_combine_funding_and_mark(
|
||||
{"date": trade_date, "open": mark_price},
|
||||
]
|
||||
)
|
||||
# Test fallback to futures funding rate for missing funding rates
|
||||
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
|
||||
|
||||
if futures_funding_rate is not None:
|
||||
@@ -5146,6 +5350,34 @@ def test_combine_funding_and_mark(
|
||||
|
||||
assert len(df) == 0
|
||||
|
||||
# Test fallback to futures funding rate for middle missing funding rate
|
||||
funding_rates = DataFrame(
|
||||
[
|
||||
{"date": prior2_date, "open": funding_rate},
|
||||
# missing 1 hour
|
||||
{"date": trade_date, "open": funding_rate},
|
||||
],
|
||||
)
|
||||
mark_rates = DataFrame(
|
||||
[
|
||||
{"date": prior2_date, "open": mark_price},
|
||||
{"date": prior_date, "open": mark_price},
|
||||
{"date": trade_date, "open": mark_price},
|
||||
]
|
||||
)
|
||||
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
|
||||
|
||||
if futures_funding_rate is not None:
|
||||
assert len(df) == 2
|
||||
assert df.iloc[0]["open_fund"] == funding_rate
|
||||
# assert df.iloc[1]["open_fund"] == futures_funding_rate
|
||||
assert df.iloc[-1]["open_fund"] == funding_rate
|
||||
# Mid-candle is dropped ...
|
||||
assert df["date"].to_list() == [prior2_date, trade_date]
|
||||
else:
|
||||
assert len(df) == 2
|
||||
assert df["date"].to_list() == [prior2_date, trade_date]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exchange,rate_start,rate_end,d1,d2,amount,expected_fees",
|
||||
@@ -5235,8 +5467,13 @@ def test__fetch_and_calculate_funding_fees(
|
||||
api_mock = MagicMock()
|
||||
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)
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchOHLCV": True})
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchFundingRateHistory": True})
|
||||
type(api_mock).has = PropertyMock(
|
||||
return_value={
|
||||
"fetchFundingRateHistory": True,
|
||||
"fetchMarkOHLCV": True,
|
||||
"fetchOHLCV": True,
|
||||
}
|
||||
)
|
||||
|
||||
ex = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange)
|
||||
mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["1h", "4h", "8h"]))
|
||||
@@ -5280,8 +5517,13 @@ def test__fetch_and_calculate_funding_fees_datetime_called(
|
||||
api_mock.fetch_funding_rate_history = get_mock_coro(
|
||||
return_value=funding_rate_history_octohourly
|
||||
)
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchOHLCV": True})
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchFundingRateHistory": True})
|
||||
type(api_mock).has = PropertyMock(
|
||||
return_value={
|
||||
"fetchFundingRateHistory": True,
|
||||
"fetchMarkOHLCV": True,
|
||||
"fetchOHLCV": True,
|
||||
}
|
||||
)
|
||||
mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["4h", "8h"]))
|
||||
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")
|
||||
@@ -6368,3 +6610,51 @@ def test_fetch_funding_rate(default_conf, mocker, exchange_name):
|
||||
|
||||
with pytest.raises(DependencyException, match=r"Pair XRP/ETH not available"):
|
||||
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)
|
||||
|
||||
@@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker):
|
||||
assert "type" in order
|
||||
|
||||
assert order["type"] == order_type
|
||||
assert order["price"] == 220
|
||||
assert order["price"] == 217.8
|
||||
assert order["stopPrice"] == 220
|
||||
assert order["amount"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -515,7 +515,8 @@ EXCHANGES = {
|
||||
],
|
||||
},
|
||||
"hyperliquid": {
|
||||
"pair": "UBTC/USDC",
|
||||
# TODO: Should be UBTC/USDC - probably needs a fix in ccxt
|
||||
"pair": "BTC/USDC",
|
||||
"stake_currency": "USDC",
|
||||
"hasQuoteVolume": False,
|
||||
"timeframe": "30m",
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
|
||||
from freqtrade.enums import CandleType
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
|
||||
from freqtrade.exchange.exchange import timeframe_to_msecs
|
||||
from freqtrade.exchange.exchange import Exchange, timeframe_to_msecs
|
||||
from freqtrade.util import dt_floor_day, dt_now, dt_ts
|
||||
from tests.exchange_online.conftest import EXCHANGE_FIXTURE_TYPE, EXCHANGES
|
||||
|
||||
@@ -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)["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_8h = timeframe_to_msecs("8h")
|
||||
now = timeframe_to_prev_date(timeframe, datetime.now(UTC))
|
||||
for offset in (360, 120, 30, 10, 5, 2):
|
||||
since = now - timedelta(days=offset)
|
||||
for offset_days in (360, 120, 30, 10, 5, 2):
|
||||
since = now - timedelta(days=offset_days)
|
||||
since_ms = int(since.timestamp() * 1000)
|
||||
|
||||
res = exchange.loop.run_until_complete(
|
||||
@@ -289,8 +292,15 @@ class TestCCXTExchange:
|
||||
candles = res[3]
|
||||
candle_count = exchange.ohlcv_candle_limit(timeframe, candle_type, since_ms) * factor
|
||||
candle_count1 = (now.timestamp() * 1000 - since_ms) // timeframe_ms * factor
|
||||
assert len(candles) >= min(candle_count, candle_count1), (
|
||||
f"{len(candles)} < {candle_count} in {timeframe}, Offset: {offset} {factor}"
|
||||
# funding fees can be 1h or 8h - depending on pair and time.
|
||||
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
|
||||
assert candles[0][0] == since_ms or (since_ms + timeframe_ms)
|
||||
@@ -309,6 +319,8 @@ class TestCCXTExchange:
|
||||
[
|
||||
CandleType.FUTURES,
|
||||
CandleType.FUNDING_RATE,
|
||||
CandleType.INDEX,
|
||||
CandleType.PREMIUMINDEX,
|
||||
CandleType.MARK,
|
||||
],
|
||||
)
|
||||
@@ -322,6 +334,10 @@ class TestCCXTExchange:
|
||||
timeframe = exchange._ft_has.get(
|
||||
"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(
|
||||
exchange,
|
||||
pair=pair,
|
||||
@@ -337,6 +353,7 @@ class TestCCXTExchange:
|
||||
timeframe_ff = exchange._ft_has.get(
|
||||
"funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"]
|
||||
)
|
||||
timeframe_ff_8h = "8h"
|
||||
pair_tf = (pair, timeframe_ff, CandleType.FUNDING_RATE)
|
||||
|
||||
funding_ohlcv = exchange.refresh_latest_ohlcv(
|
||||
@@ -350,14 +367,26 @@ class TestCCXTExchange:
|
||||
hour1 = timeframe_to_prev_date(timeframe_ff, this_hour - 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))
|
||||
val0 = rate[rate["date"] == this_hour].iloc[0]["open"]
|
||||
val1 = rate[rate["date"] == hour1].iloc[0]["open"]
|
||||
val2 = rate[rate["date"] == hour2].iloc[0]["open"]
|
||||
val3 = rate[rate["date"] == hour3].iloc[0]["open"]
|
||||
# Alternative 8h timeframe - funding fee timeframe is not stable.
|
||||
h8_this_hour = timeframe_to_prev_date(timeframe_ff_8h)
|
||||
h8_hour1 = timeframe_to_prev_date(timeframe_ff_8h, h8_this_hour - timedelta(minutes=1))
|
||||
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
|
||||
# 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.
|
||||
assert (
|
||||
rate["open"].max() != 0.0
|
||||
@@ -369,7 +398,10 @@ class TestCCXTExchange:
|
||||
exchange, exchangename = exchange_futures
|
||||
pair = EXCHANGES[exchangename].get("futures_pair", EXCHANGES[exchangename]["pair"])
|
||||
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)
|
||||
|
||||
@@ -422,15 +454,23 @@ class TestCCXTExchange:
|
||||
trades_orig = nvspy.call_args_list[2][0][0]
|
||||
assert len(trades_orig[-1].get("info")) > len(trades_orig[-2].get("info"))
|
||||
|
||||
def test_ccxt_get_fee(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
||||
exch, exchangename = exchange
|
||||
pair = EXCHANGES[exchangename]["pair"]
|
||||
def _ccxt_get_fee(self, exch: Exchange, pair: str):
|
||||
threshold = 0.01
|
||||
assert 0 < exch.get_fee(pair, "limit", "buy") < threshold
|
||||
assert 0 < exch.get_fee(pair, "limit", "sell") < threshold
|
||||
assert 0 < exch.get_fee(pair, "market", "buy") < threshold
|
||||
assert 0 < exch.get_fee(pair, "market", "sell") < threshold
|
||||
|
||||
def test_ccxt_get_fee_spot(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
||||
exch, exchangename = exchange
|
||||
pair = EXCHANGES[exchangename]["pair"]
|
||||
self._ccxt_get_fee(exch, pair)
|
||||
|
||||
def test_ccxt_get_fee_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE):
|
||||
exch, exchangename = exchange_futures
|
||||
pair = EXCHANGES[exchangename].get("futures_pair", EXCHANGES[exchangename]["pair"])
|
||||
self._ccxt_get_fee(exch, pair)
|
||||
|
||||
def test_ccxt_get_max_leverage_spot(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
||||
spot, spot_name = exchange
|
||||
if spot:
|
||||
|
||||
@@ -2548,9 +2548,9 @@ def test_manage_open_orders_exception(
|
||||
caplog.clear()
|
||||
freqtrade.manage_open_orders()
|
||||
assert log_has_re(
|
||||
r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30.00000000, "
|
||||
r"is_short=False, leverage=1.0, "
|
||||
r"open_rate=2.00000000, open_since="
|
||||
r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30, "
|
||||
r"is_short=False, leverage=1, "
|
||||
r"open_rate=2, open_since="
|
||||
f"{open_trade_usdt.open_date.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
r"\) due to Traceback \(most recent call last\):\n*",
|
||||
caplog,
|
||||
@@ -3092,7 +3092,7 @@ def test_execute_trade_exit_custom_exit_price(
|
||||
"exit_reason": "foo",
|
||||
"open_date": ANY,
|
||||
"close_date": ANY,
|
||||
"close_rate": ANY,
|
||||
"close_rate": 2.25, # the custom exit price
|
||||
"sub_trade": False,
|
||||
"cumulative_profit": 0.0,
|
||||
"stake_amount": pytest.approx(60),
|
||||
@@ -3751,8 +3751,8 @@ def test_get_real_amount_quote(
|
||||
# Amount is reduced by "fee"
|
||||
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == (amount * 0.001)
|
||||
assert log_has(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False,"
|
||||
" leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.008.",
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, is_short=False,"
|
||||
" leverage=1, open_rate=0.245441, open_since=closed), fee=0.008.",
|
||||
caplog,
|
||||
)
|
||||
|
||||
@@ -3805,8 +3805,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
|
||||
# Amount is reduced by "fee"
|
||||
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) is None
|
||||
assert log_has(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, "
|
||||
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed) failed: "
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
|
||||
"is_short=False, leverage=1, open_rate=0.245441, open_since=closed) failed: "
|
||||
"myTrade-dict empty found",
|
||||
caplog,
|
||||
)
|
||||
@@ -3825,8 +3825,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
|
||||
0,
|
||||
True,
|
||||
(
|
||||
"Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False, "
|
||||
"leverage=1.0, open_rate=0.24544100, open_since=closed) [buy]: 0.00094518 BNB -"
|
||||
"Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8, is_short=False, "
|
||||
"leverage=1, open_rate=0.245441, open_since=closed) [buy]: 0.00094518 BNB -"
|
||||
" rate: None"
|
||||
),
|
||||
),
|
||||
@@ -3836,8 +3836,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
|
||||
0.004,
|
||||
False,
|
||||
(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, "
|
||||
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.004."
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
|
||||
"is_short=False, leverage=1, open_rate=0.245441, open_since=closed), fee=0.004."
|
||||
),
|
||||
),
|
||||
# invalid, no currency in from fee dict
|
||||
@@ -3941,8 +3941,8 @@ def test_get_real_amount_multi(
|
||||
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == expected_amount
|
||||
assert log_has(
|
||||
(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, "
|
||||
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), "
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
|
||||
"is_short=False, leverage=1, open_rate=0.245441, open_since=closed), "
|
||||
f"fee={expected_amount}."
|
||||
),
|
||||
caplog,
|
||||
@@ -4525,6 +4525,7 @@ def test_check_for_open_trades(mocker, default_conf_usdt, fee, is_short):
|
||||
def test_startup_update_open_orders(mocker, default_conf_usdt, fee, caplog, is_short):
|
||||
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
|
||||
create_mock_trades(fee, is_short=is_short)
|
||||
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
|
||||
|
||||
freqtrade.startup_update_open_orders()
|
||||
assert not log_has_re(r"Error updating Order .*", caplog)
|
||||
|
||||
@@ -50,16 +50,20 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
|
||||
stoploss_order_mock = MagicMock(side_effect=stop_orders)
|
||||
# Sell 3rd trade (not called for the first trade)
|
||||
should_sell_mock = MagicMock(side_effect=[[], [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)]])
|
||||
cancel_order_mock = MagicMock()
|
||||
|
||||
def patch_stoploss(order_id, *args, **kwargs):
|
||||
slo = stoploss_order_open.copy()
|
||||
slo["id"] = order_id
|
||||
slo["status"] = "canceled"
|
||||
return slo
|
||||
|
||||
cancel_order_mock = MagicMock(side_effect=patch_stoploss)
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
create_stoploss=stoploss,
|
||||
fetch_ticker=ticker,
|
||||
get_fee=fee,
|
||||
amount_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(
|
||||
@@ -73,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)
|
||||
|
||||
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
|
||||
# Switch ordertype to market to close trade immediately
|
||||
freqtrade.strategy.order_types["exit"] = "market"
|
||||
@@ -793,9 +803,13 @@ def test_dca_handle_similar_open_order(
|
||||
# Should Create a new exit order
|
||||
freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=2)
|
||||
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-2)
|
||||
msg = r"Skipping cancelling stoploss on exchange for.*"
|
||||
|
||||
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
|
||||
assert not log_has_re(msg, caplog)
|
||||
freqtrade.process()
|
||||
assert log_has_re(msg, caplog)
|
||||
|
||||
trade = Trade.get_trades().first()
|
||||
|
||||
assert trade.orders[-2].status == "closed"
|
||||
|
||||
@@ -103,7 +103,7 @@ def test_handle_stoploss_on_exchange(
|
||||
trade.is_open = True
|
||||
|
||||
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
|
||||
hanging_stoploss_order.assert_called_once_with("13434334", trade.pair)
|
||||
@@ -116,7 +116,7 @@ def test_handle_stoploss_on_exchange(
|
||||
trade.is_open = True
|
||||
|
||||
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()
|
||||
amount_before = trade.amount
|
||||
|
||||
@@ -149,7 +149,7 @@ def test_handle_stoploss_on_exchange(
|
||||
"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)
|
||||
assert freqtrade.handle_stoploss_on_exchange(trade) is True
|
||||
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
|
||||
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
|
||||
freqtrade.handle_stoploss_on_exchange(trade)
|
||||
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
|
||||
stop_order_dict.update({"id": "105"})
|
||||
stoploss.reset_mock()
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", side_effect=InvalidOrderException())
|
||||
mocker.patch(f"{EXMS}.create_stoploss", stoploss)
|
||||
mocker.patch.multiple(
|
||||
freqtrade.exchange,
|
||||
fetch_stoploss_order=MagicMock(
|
||||
side_effect=InvalidOrderException(),
|
||||
),
|
||||
create_stoploss=stoploss,
|
||||
)
|
||||
freqtrade.handle_stoploss_on_exchange(trade)
|
||||
assert len(trade.open_sl_orders) == 1
|
||||
assert stoploss.call_count == 1
|
||||
@@ -179,8 +184,7 @@ def test_handle_stoploss_on_exchange(
|
||||
trade.is_open = False
|
||||
trade.open_sl_orders[-1].ft_is_open = False
|
||||
stoploss.reset_mock()
|
||||
mocker.patch(f"{EXMS}.fetch_order")
|
||||
mocker.patch(f"{EXMS}.create_stoploss", stoploss)
|
||||
mocker.patch.multiple(freqtrade.exchange, fetch_order=MagicMock(), create_stoploss=stoploss)
|
||||
assert freqtrade.handle_stoploss_on_exchange(trade) is False
|
||||
assert trade.has_open_sl_orders is False
|
||||
assert stoploss.call_count == 0
|
||||
@@ -252,9 +256,12 @@ def test_handle_stoploss_on_exchange_emergency(
|
||||
stoploss = MagicMock(side_effect=InvalidOrderException())
|
||||
assert trade.has_open_sl_orders is True
|
||||
Trade.commit()
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order_with_result", side_effect=InvalidOrderException())
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_order_cancelled)
|
||||
mocker.patch(f"{EXMS}.create_stoploss", stoploss)
|
||||
mocker.patch.multiple(
|
||||
freqtrade.exchange,
|
||||
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 trade.has_open_sl_orders is False
|
||||
assert trade.is_open is False
|
||||
@@ -311,7 +318,7 @@ def test_handle_stoploss_on_exchange_partial(
|
||||
"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
|
||||
# Stoploss filled partially ...
|
||||
assert trade.amount == 15
|
||||
@@ -383,8 +390,11 @@ def test_handle_stoploss_on_exchange_partial_cancel_here(
|
||||
"amount": enter_order["amount"],
|
||||
}
|
||||
)
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_order_hit)
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order_with_result", stoploss_order_cancel)
|
||||
mocker.patch.multiple(
|
||||
freqtrade.exchange,
|
||||
fetch_stoploss_order=stoploss_order_hit,
|
||||
cancel_stoploss_order_with_result=stoploss_order_cancel,
|
||||
)
|
||||
time_machine.shift(timedelta(minutes=15))
|
||||
|
||||
assert freqtrade.handle_stoploss_on_exchange(trade) is False
|
||||
@@ -408,20 +418,20 @@ def test_handle_sle_cancel_cant_recreate(
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
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(
|
||||
side_effect=[
|
||||
enter_order,
|
||||
exit_order,
|
||||
]
|
||||
),
|
||||
get_fee=fee,
|
||||
)
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
fetch_stoploss_order=MagicMock(return_value={"status": "canceled", "id": "100"}),
|
||||
create_stoploss=MagicMock(side_effect=ExchangeError()),
|
||||
)
|
||||
freqtrade = FreqtradeBot(default_conf_usdt)
|
||||
patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short)
|
||||
|
||||
freqtrade.enter_positions()
|
||||
@@ -644,8 +654,11 @@ def test_handle_stoploss_on_exchange_trailing(
|
||||
stoploss_order_cancel = deepcopy(stoploss_order_hanging)
|
||||
stoploss_order_cancel["status"] = "canceled"
|
||||
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", return_value=stoploss_order_hanging)
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value=stoploss_order_cancel)
|
||||
mocker.patch.multiple(
|
||||
freqtrade.exchange,
|
||||
fetch_stoploss_order=MagicMock(return_value=stoploss_order_hanging),
|
||||
cancel_stoploss_order=MagicMock(return_value=stoploss_order_cancel),
|
||||
)
|
||||
|
||||
# stoploss initially at 5%
|
||||
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}
|
||||
)
|
||||
stoploss_order_mock = MagicMock(return_value={"id": "so1", "status": "open"})
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order")
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", cancel_order_mock)
|
||||
mocker.patch(f"{EXMS}.create_stoploss", stoploss_order_mock)
|
||||
mocker.patch.multiple(
|
||||
freqtrade.exchange,
|
||||
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
|
||||
assert freqtrade.handle_trade(trade) is False
|
||||
@@ -711,8 +727,9 @@ def test_handle_stoploss_on_exchange_trailing(
|
||||
}
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{EXMS}.cancel_stoploss_order_with_result",
|
||||
mocker.patch.object(
|
||||
freqtrade.exchange,
|
||||
"cancel_stoploss_order_with_result",
|
||||
return_value={"id": "so1", "status": "canceled"},
|
||||
)
|
||||
assert len(trade.open_sl_orders) == 1
|
||||
@@ -786,8 +803,12 @@ def test_handle_stoploss_on_exchange_trailing_error(
|
||||
order_date=dt_now(),
|
||||
)
|
||||
)
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", side_effect=InvalidOrderException())
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", return_value=stoploss_order_hanging)
|
||||
mocker.patch.object(
|
||||
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))
|
||||
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)
|
||||
@@ -799,8 +820,8 @@ def test_handle_stoploss_on_exchange_trailing_error(
|
||||
|
||||
# Fail creating stoploss order
|
||||
caplog.clear()
|
||||
cancel_mock = mocker.patch(f"{EXMS}.cancel_stoploss_order")
|
||||
mocker.patch(f"{EXMS}.create_stoploss", side_effect=ExchangeError())
|
||||
cancel_mock = mocker.patch.object(freqtrade.exchange, "cancel_stoploss_order")
|
||||
mocker.patch.object(freqtrade.exchange, "create_stoploss", side_effect=ExchangeError())
|
||||
time_machine.shift(timedelta(minutes=50))
|
||||
freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging)
|
||||
assert cancel_mock.call_count == 2
|
||||
@@ -846,20 +867,9 @@ def test_handle_stoploss_on_exchange_custom_stop(
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
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,
|
||||
is_cancel_order_result_suitable=MagicMock(return_value=True),
|
||||
)
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
create_stoploss=stoploss,
|
||||
stoploss_adjust=MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
# enabling TSL
|
||||
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
|
||||
|
||||
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
|
||||
freqtrade.strategy.order_types["stoploss_on_exchange"] = True
|
||||
@@ -912,8 +933,11 @@ def test_handle_stoploss_on_exchange_custom_stop(
|
||||
x["id"] = order_id
|
||||
return x
|
||||
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", MagicMock(fetch_stoploss_order_mock))
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", return_value=slo_canceled)
|
||||
mocker.patch.multiple(
|
||||
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_stoploss_on_exchange(trade) is False
|
||||
@@ -932,8 +956,11 @@ def test_handle_stoploss_on_exchange_custom_stop(
|
||||
|
||||
cancel_order_mock = MagicMock()
|
||||
stoploss_order_mock = MagicMock(return_value={"id": "so1", "status": "open"})
|
||||
mocker.patch(f"{EXMS}.cancel_stoploss_order", cancel_order_mock)
|
||||
mocker.patch(f"{EXMS}.create_stoploss", stoploss_order_mock)
|
||||
mocker.patch.multiple(
|
||||
freqtrade.exchange,
|
||||
cancel_stoploss_order=cancel_order_mock,
|
||||
create_stoploss=stoploss_order_mock,
|
||||
)
|
||||
|
||||
# stoploss should not be updated as the interval is 60 seconds
|
||||
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
|
||||
) -> None:
|
||||
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))
|
||||
create_order_mock = MagicMock(
|
||||
side_effect=[
|
||||
@@ -1114,12 +1143,15 @@ def test_execute_trade_exit_with_stoploss_on_exchange(
|
||||
get_fee=fee,
|
||||
amount_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,
|
||||
cancel_stoploss_order=cancel_order,
|
||||
_dry_is_price_crossed=MagicMock(side_effect=[True, False]),
|
||||
)
|
||||
|
||||
freqtrade = FreqtradeBot(default_conf_usdt)
|
||||
freqtrade.strategy.order_types["stoploss_on_exchange"] = True
|
||||
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,
|
||||
}
|
||||
)
|
||||
mocker.patch(f"{EXMS}.fetch_stoploss_order", stoploss_executed)
|
||||
mocker.patch.object(freqtrade.exchange, "fetch_stoploss_order", stoploss_executed)
|
||||
|
||||
freqtrade.exit_positions(trades)
|
||||
assert trade.has_open_sl_orders is False
|
||||
|
||||
@@ -879,6 +879,10 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
|
||||
patch_exchange(mocker)
|
||||
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"))
|
||||
default_conf_usdt["unfilledtimeout"] = {
|
||||
"entry": 11,
|
||||
"exit": 30,
|
||||
}
|
||||
if use_detail:
|
||||
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"]
|
||||
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 "orders" in results.columns
|
||||
@@ -966,8 +970,8 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
|
||||
@pytest.mark.parametrize(
|
||||
"use_detail,exp_funding_fee, exp_ff_updates",
|
||||
[
|
||||
(True, -0.018054162, 10),
|
||||
(False, -0.01780296, 6),
|
||||
(True, -0.0180457882, 15),
|
||||
(False, -0.0178000543, 12),
|
||||
],
|
||||
)
|
||||
def test_backtest_one_detail_futures(
|
||||
@@ -1077,8 +1081,8 @@ def test_backtest_one_detail_futures(
|
||||
@pytest.mark.parametrize(
|
||||
"use_detail,entries,max_stake,ff_updates,expected_ff",
|
||||
[
|
||||
(True, 50, 3000, 55, -1.18038144),
|
||||
(False, 6, 360, 11, -0.14679994),
|
||||
(True, 50, 3000, 78, -1.17988972),
|
||||
(False, 6, 360, 34, -0.14673681),
|
||||
],
|
||||
)
|
||||
def test_backtest_one_detail_futures_funding_fees(
|
||||
@@ -1800,7 +1804,7 @@ def test_backtest_multi_pair_detail_simplified(
|
||||
if use_detail:
|
||||
# Backtest loop is called once per candle per pair
|
||||
# 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 len(evaluate_result_multi(results["results"], "1h", 3)) > 0
|
||||
else:
|
||||
@@ -2378,13 +2382,12 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, caplog, testda
|
||||
f"Using data directory: {testdatadir} ...",
|
||||
"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).",
|
||||
"XRP/USDT:USDT, funding_rate, 8h, data starts at 2021-11-18 00:00:00",
|
||||
"XRP/USDT:USDT, mark, 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",
|
||||
f"Running backtesting for Strategy {CURRENT_TEST_STRATEGY}",
|
||||
]
|
||||
|
||||
for line in exists:
|
||||
assert log_has(line, caplog)
|
||||
assert log_has(line, caplog), line
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "BACKTESTING REPORT" in captured.out
|
||||
@@ -2772,7 +2775,7 @@ def test_time_pair_generator_open_trades_first(mocker, default_conf, dynamic_pai
|
||||
dummy_row = (end_date, 1.0, 1.1, 0.9, 1.0, 0, 0, 0, 0, None, None)
|
||||
data = {pair: [dummy_row] for pair in pairs}
|
||||
|
||||
def mock_refresh(self):
|
||||
def mock_refresh(self, **kwargs):
|
||||
# Simulate shuffle
|
||||
self._whitelist = pairs[::-1] # ['ETH/BTC', 'NEO/BTC', 'LTC/BTC', 'XRP/BTC']
|
||||
|
||||
|
||||
@@ -372,8 +372,8 @@ def test_borrowed(fee, is_short, lev, borrowed, trading_mode):
|
||||
@pytest.mark.parametrize(
|
||||
"is_short,open_rate,close_rate,lev,profit,trading_mode",
|
||||
[
|
||||
(False, 2.0, 2.2, 1.0, 0.09451372, spot),
|
||||
(True, 2.2, 2.0, 3.0, 0.25894253, margin),
|
||||
(False, 2, 2.2, 1, 0.09451372, spot),
|
||||
(True, 2.2, 2.0, 3, 0.25894253, margin),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_persistence")
|
||||
@@ -493,8 +493,8 @@ def test_update_limit_order(
|
||||
assert trade.close_date is None
|
||||
assert log_has_re(
|
||||
f"LIMIT_{entry_side.upper()} has been fulfilled for "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, "
|
||||
r"open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
@@ -511,8 +511,8 @@ def test_update_limit_order(
|
||||
assert trade.close_date is not None
|
||||
assert log_has_re(
|
||||
f"LIMIT_{exit_side.upper()} has been fulfilled for "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, "
|
||||
r"open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
@@ -545,8 +545,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee,
|
||||
assert trade.close_date is None
|
||||
assert log_has_re(
|
||||
r"MARKET_BUY has been fulfilled for Trade\(id=1, "
|
||||
r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, "
|
||||
r"open_rate=2.00000000, open_since=.*\).",
|
||||
r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, "
|
||||
r"open_rate=2, open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
|
||||
@@ -561,8 +561,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee,
|
||||
assert trade.close_date is not None
|
||||
assert log_has_re(
|
||||
r"MARKET_SELL has been fulfilled for Trade\(id=1, "
|
||||
r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, "
|
||||
r"open_rate=2.00000000, open_since=.*\).",
|
||||
r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, "
|
||||
r"open_rate=2, open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
|
||||
@@ -1479,6 +1479,8 @@ def test_to_json(fee):
|
||||
"contract_size": 1,
|
||||
"orders": [],
|
||||
"has_open_orders": False,
|
||||
"nr_of_successful_entries": 0,
|
||||
"nr_of_successful_exits": 0,
|
||||
}
|
||||
|
||||
# Simulate dry_run entries
|
||||
@@ -1570,6 +1572,8 @@ def test_to_json(fee):
|
||||
"contract_size": 1,
|
||||
"orders": [],
|
||||
"has_open_orders": False,
|
||||
"nr_of_successful_entries": 0,
|
||||
"nr_of_successful_exits": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+13
-5
@@ -99,6 +99,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
|
||||
"contract_size": 1,
|
||||
"has_open_orders": False,
|
||||
"nr_of_successful_entries": ANY,
|
||||
"nr_of_successful_exits": ANY,
|
||||
"orders": [
|
||||
{
|
||||
"amount": 91.07468123,
|
||||
@@ -309,7 +310,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker, time_machine) -> No
|
||||
)
|
||||
assert "now" == result[0][2]
|
||||
assert "ETH/BTC" in result[0][1]
|
||||
assert "nan%" == result[0][3]
|
||||
assert "N/A" == result[0][3]
|
||||
assert isnan(fiat_profit_sum)
|
||||
|
||||
|
||||
@@ -385,11 +386,14 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short):
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
cancel_order=cancel_mock,
|
||||
cancel_stoploss_order=stoploss_mock,
|
||||
)
|
||||
|
||||
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
|
||||
create_mock_trades(fee, is_short)
|
||||
rpc = RPC(freqtradebot)
|
||||
@@ -425,13 +429,17 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short):
|
||||
assert stoploss_mock.call_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")
|
||||
assert stoploss_mock.call_count == 1
|
||||
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")
|
||||
assert cancel_mock.call_count == 1
|
||||
|
||||
@@ -1034,8 +1034,7 @@ def test_api_delete_trade(botclient, mocker, fee, markets, is_short):
|
||||
stoploss_mock = MagicMock()
|
||||
cancel_mock = MagicMock()
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
ftbot.exchange,
|
||||
cancel_order=cancel_mock,
|
||||
cancel_stoploss_order=stoploss_mock,
|
||||
)
|
||||
@@ -1605,6 +1604,8 @@ def test_api_status(
|
||||
"precision_mode": None,
|
||||
"orders": [ANY],
|
||||
"has_open_orders": True,
|
||||
"nr_of_successful_entries": ANY,
|
||||
"nr_of_successful_exits": ANY,
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
@@ -1817,6 +1818,8 @@ def test_api_force_entry(botclient, mocker, fee, endpoint):
|
||||
"price_precision": None,
|
||||
"precision_mode": None,
|
||||
"has_open_orders": False,
|
||||
"nr_of_successful_entries": ANY,
|
||||
"nr_of_successful_exits": ANY,
|
||||
"orders": [],
|
||||
}
|
||||
|
||||
@@ -1849,9 +1852,35 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets):
|
||||
Trade.rollback()
|
||||
|
||||
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 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"})
|
||||
assert_response(rc)
|
||||
assert rc.json() == {"result": "Created exit order for trade 5."}
|
||||
@@ -2754,12 +2783,12 @@ def test_list_available_pairs(botclient):
|
||||
rc = client_get(client, f"{BASE_URI}/available_pairs")
|
||||
|
||||
assert_response(rc)
|
||||
assert rc.json()["length"] == 12
|
||||
assert rc.json()["length"] == 14
|
||||
assert isinstance(rc.json()["pairs"], list)
|
||||
|
||||
rc = client_get(client, f"{BASE_URI}/available_pairs?timeframe=5m")
|
||||
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")
|
||||
assert_response(rc)
|
||||
@@ -3247,6 +3276,7 @@ def test_api_download_data(botclient, mocker, tmp_path):
|
||||
body = {
|
||||
"pairs": ["ETH/BTC", "XRP/BTC"],
|
||||
"timeframes": ["5m"],
|
||||
"candle_types": ["spot"],
|
||||
}
|
||||
|
||||
# Fail, already running
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user