Merge pull request #13003 from freqtrade/new_release
New release 2026.3
This commit is contained in:
@@ -61,5 +61,7 @@ updates:
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
# Combine updates for github provided actions
|
||||
- "actions/*"
|
||||
docker:
|
||||
patterns:
|
||||
- "docker/*"
|
||||
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: false
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install ccxt
|
||||
run: uv pip install ccxt orjson
|
||||
run: uv pip install $(grep -E "^ccxt==" requirements.txt) $(grep -E "^orjson==" requirements.txt)
|
||||
|
||||
- name: Run leverage tier update
|
||||
env:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
Dependencies
|
||||
branch: update/binance-leverage-tiers
|
||||
title: Update Binance Leverage Tiers
|
||||
commit-message: "chore: update pre-commit hooks"
|
||||
commit-message: "chore: update binance leverage tiers"
|
||||
committer: Freqtrade Bot <154552126+freqtrade-bot@users.noreply.github.com>
|
||||
author: Freqtrade Bot <154552126+freqtrade-bot@users.noreply.github.com>
|
||||
body: Update binance leverage tiers.
|
||||
|
||||
+63
-54
@@ -32,13 +32,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -55,7 +55,6 @@ jobs:
|
||||
|
||||
- name: Installation (python)
|
||||
run: |
|
||||
uv pip install --upgrade wheel
|
||||
uv pip install -r requirements-dev.txt
|
||||
uv pip install -e ft_client/
|
||||
uv pip install -e .
|
||||
@@ -78,7 +77,7 @@ jobs:
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] Intentionally not using environment variable.
|
||||
|
||||
- name: Cleanup codecov dirty state files
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
|
||||
@@ -137,10 +136,6 @@ jobs:
|
||||
freqtrade create-userdir --userdir user_data
|
||||
freqtrade hyperopt --datadir tests/testdata -e 6 --strategy SampleStrategy --hyperopt-loss SharpeHyperOptLossDaily --print-all
|
||||
|
||||
- name: Sort imports (isort)
|
||||
run: |
|
||||
isort --check .
|
||||
|
||||
- name: Run Ruff
|
||||
run: |
|
||||
ruff check --output-format=github
|
||||
@@ -167,12 +162,12 @@ jobs:
|
||||
if ($Error.Length -gt 0) {exit 1}
|
||||
|
||||
- name: Discord notification
|
||||
uses: rjstone/discord-webhook-notify@c2597273488aeda841dd1e891321952b51f7996f #v2.2.1
|
||||
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
|
||||
if: ${{ failure() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) }}
|
||||
with:
|
||||
severity: error
|
||||
details: Freqtrade CI failed on ${{ matrix.os }} with Python ${{ matrix.python-version }}!
|
||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
color: '#FF0000' # red
|
||||
title: Freqtrade CI failed on ${{ matrix.os }} with Python ${{ matrix.python-version }}!
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }} # zizmor: ignore[secrets-outside-env] Intentionally not using environment variable.
|
||||
|
||||
mypy-version-check:
|
||||
name: "Mypy Version Check"
|
||||
@@ -182,14 +177,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 #v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "3.13"
|
||||
|
||||
- name: pre-commit dependencies
|
||||
run: |
|
||||
pip install pyaml
|
||||
uv pip install $(grep -E "^pyyaml==" requirements-dev.txt)
|
||||
python build_helpers/pre_commit_update.py
|
||||
|
||||
pre-commit:
|
||||
@@ -202,7 +203,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
|
||||
docs-check:
|
||||
@@ -217,51 +219,59 @@ jobs:
|
||||
run: |
|
||||
./tests/test_docs.sh
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Documentation build
|
||||
run: |
|
||||
pip install -r docs/requirements-docs.txt
|
||||
uv pip install -r docs/requirements-docs.txt
|
||||
mkdocs build
|
||||
|
||||
- name: Discord notification
|
||||
uses: rjstone/discord-webhook-notify@c2597273488aeda841dd1e891321952b51f7996f #v2.2.1
|
||||
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
|
||||
if: failure() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
|
||||
with:
|
||||
severity: error
|
||||
details: Freqtrade doc test failed!
|
||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
color: '#FF0000' # red
|
||||
title: Freqtrade doc test failed!
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }} # zizmor: ignore[secrets-outside-env] Intentionally not using environment variable.
|
||||
|
||||
|
||||
build-linux-online:
|
||||
# Run pytest with "live" checks
|
||||
name: "Tests and Linting - Online tests"
|
||||
name: "Online / live tests"
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
cache-dependency-glob: "requirements**.txt"
|
||||
cache-suffix: "3.12"
|
||||
|
||||
- name: Installation - *nix
|
||||
run: |
|
||||
uv pip install --upgrade wheel
|
||||
uv pip install -r requirements-dev.txt
|
||||
uv pip install -e ft_client/
|
||||
uv pip install -e .
|
||||
@@ -285,22 +295,13 @@ jobs:
|
||||
if: github.event_name != 'schedule' && github.repository == 'freqtrade/freqtrade'
|
||||
steps:
|
||||
|
||||
- name: Check user permission
|
||||
id: check
|
||||
continue-on-error: true
|
||||
uses: prince-chrismc/check-actor-permissions-action@d504e74ba31658f4cdf4fcfeb509d4c09736d88e # v3.0.2
|
||||
with:
|
||||
permission: "write"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Discord notification
|
||||
uses: rjstone/discord-webhook-notify@c2597273488aeda841dd1e891321952b51f7996f #v2.2.1
|
||||
if: steps.check.outputs.permitted == 'true' && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
|
||||
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
|
||||
with:
|
||||
severity: info
|
||||
details: Test Completed!
|
||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
color: '#00FF00' # green
|
||||
title: Test Completed!
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }} # zizmor: ignore[secrets-outside-env] Intentionally not using environment variable.
|
||||
|
||||
build:
|
||||
if: always()
|
||||
@@ -312,6 +313,9 @@ jobs:
|
||||
pre-commit,
|
||||
]
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.13"]
|
||||
|
||||
steps:
|
||||
|
||||
@@ -324,18 +328,24 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- name: Build distribution
|
||||
run: |
|
||||
pip install -U build
|
||||
uv pip install $(grep -E "^build==" requirements-dev.txt)
|
||||
python -m build --sdist --wheel
|
||||
|
||||
- name: Upload artifacts 📦
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.1.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: freqtrade-build
|
||||
path: |
|
||||
@@ -344,11 +354,10 @@ jobs:
|
||||
|
||||
- name: Build Client distribution
|
||||
run: |
|
||||
pip install -U build
|
||||
python -m build --sdist --wheel ft_client
|
||||
|
||||
- name: Upload artifacts 📦
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.1.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: freqtrade-client-build
|
||||
path: |
|
||||
@@ -372,7 +381,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download artifact 📦
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: freqtrade*-build
|
||||
path: dist
|
||||
@@ -401,7 +410,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download artifact 📦
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: freqtrade*-build
|
||||
path: dist
|
||||
@@ -422,9 +431,9 @@ jobs:
|
||||
packages: write # Needed to push package versions
|
||||
contents: read
|
||||
secrets:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} # zizmor: ignore[secrets-outside-env] Intentionally not using environment variable.
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
packages-cleanup:
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -3,9 +3,9 @@ name: Docker Build and Deploy
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
DOCKER_PASSWORD:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
DOCKER_USERNAME:
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
DISCORD_WEBHOOK:
|
||||
required: false
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
name: "Deploy Docker x64 and armv7l"
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.repository == 'freqtrade/freqtrade'
|
||||
environment:
|
||||
name: docker
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -57,19 +59,19 @@ jobs:
|
||||
uses: ./.github/actions/docker-tags
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||
|
||||
- name: Available platforms
|
||||
run: echo ${PLATFORMS}
|
||||
@@ -168,6 +170,8 @@ jobs:
|
||||
# Only run on 64bit machines
|
||||
runs-on: [self-hosted, linux, ARM64]
|
||||
if: github.repository == 'freqtrade/freqtrade'
|
||||
environment:
|
||||
name: docker
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -179,13 +183,13 @@ jobs:
|
||||
uses: ./.github/actions/docker-tags
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to github
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -306,9 +310,8 @@ jobs:
|
||||
docker image prune -a --force --filter "until=24h"
|
||||
|
||||
- name: Discord notification
|
||||
uses: rjstone/discord-webhook-notify@c2597273488aeda841dd1e891321952b51f7996f #v2.2.1
|
||||
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
|
||||
if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) && (github.event_name != 'schedule')
|
||||
with:
|
||||
severity: info
|
||||
details: Deploy Succeeded!
|
||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
title: Deploy Succeeded!
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- stable
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
@@ -15,6 +16,8 @@ jobs:
|
||||
dockerHubDescription:
|
||||
name: "Update Docker Hub Description"
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: docker
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -23,6 +26,6 @@ jobs:
|
||||
- name: Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: freqtradeorg/freqtrade
|
||||
|
||||
@@ -17,6 +17,9 @@ jobs:
|
||||
auto-update:
|
||||
name: Auto-update pre-commit hooks
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: develop
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -24,11 +27,16 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit
|
||||
run: uv pip install $(grep -E "^pre-commit==" requirements-dev.txt)
|
||||
|
||||
- name: Run auto-update
|
||||
run: pre-commit autoupdate
|
||||
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
|
||||
+7
-20
@@ -13,12 +13,6 @@ repos:
|
||||
pass_filenames: false
|
||||
additional_dependencies: ["python-rapidjson", "jsonschema"]
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: "7.3.0"
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [Flake8-pyproject]
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: "v1.19.1"
|
||||
@@ -29,22 +23,15 @@ repos:
|
||||
- types-cachetools==6.2.0.20251022
|
||||
- types-filelock==3.2.7
|
||||
- types-requests==2.32.4.20260107
|
||||
- types-tabulate==0.9.0.20241207
|
||||
- types-python-dateutil==2.9.0.20260124
|
||||
- scipy-stubs==1.17.0.2
|
||||
- SQLAlchemy==2.0.46
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: "8.0.0"
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- types-tabulate==0.10.0.20260308
|
||||
- types-python-dateutil==2.9.0.20260305
|
||||
- scipy-stubs==1.17.1.2
|
||||
- SQLAlchemy==2.0.48
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: 'v0.15.2'
|
||||
rev: 'v0.15.7'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
@@ -75,7 +62,7 @@ repos:
|
||||
- id: strip-exif
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.1
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies:
|
||||
@@ -83,6 +70,6 @@ repos:
|
||||
|
||||
# Ensure github actions remain safe
|
||||
- repo: https://github.com/woodruffw/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
rev: v1.23.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[](https://doi.org/10.21105/joss.04864)
|
||||
[](https://codecov.io/gh/freqtrade/freqtrade)
|
||||
[](https://www.freqtrade.io)
|
||||
[](https://discord.gg/p7nuUNVfP7)
|
||||
[](https://discord.gg/p7nuUNVfP7)
|
||||
|
||||
Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram or webUI. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning.
|
||||
|
||||
@@ -50,6 +50,7 @@ Please read the [exchange-specific notes](https://www.freqtrade.io/en/stable/exc
|
||||
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
|
||||
- [X] [OKX](https://okx.com/)
|
||||
- [X] [Bybit](https://bybit.com/)
|
||||
- [X] [Kraken](https://www.kraken.com/features/futures)
|
||||
|
||||
Please make sure to read the [exchange specific notes](https://www.freqtrade.io/en/stable/exchanges/), as well as the [trading with leverage](https://www.freqtrade.io/en/stable/leverage/) documentation before diving in.
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -649,6 +649,7 @@
|
||||
"ProducerPairList",
|
||||
"RemotePairList",
|
||||
"MarketCapPairList",
|
||||
"CrossMarketPairList",
|
||||
"AgeFilter",
|
||||
"DelistFilter",
|
||||
"FullTradesFilter",
|
||||
|
||||
@@ -191,7 +191,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
|
||||
| | **Unfilled timeout**
|
||||
| `unfilledtimeout.entry` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled entry order to complete, after which the order will be cancelled. [Strategy Override](#parameters-in-the-strategy).<br> **Datatype:** Integer
|
||||
| `unfilledtimeout.exit` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled exit order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).<br> **Datatype:** Integer
|
||||
| `unfilledtimeout.unit` | Unit to use in unfilledtimeout setting. Note: If you set unfilledtimeout.unit to "seconds", "internals.process_throttle_secs" must be inferior or equal to timeout [Strategy Override](#parameters-in-the-strategy). <br> *Defaults to `"minutes"`.* <br> **Datatype:** String
|
||||
| `unfilledtimeout.unit` | Unit to use in unfilledtimeout setting. Note: If you set `unfilledtimeout.unit` to "seconds", "internals.process_throttle_secs" must be inferior or equal to timeout [Strategy Override](#parameters-in-the-strategy). <br> *Defaults to `"minutes"`.* <br> **Datatype:** String
|
||||
| `unfilledtimeout.exit_timeout_count` | How many times can exit orders time out. Once this number of timeouts is reached, an emergency exit is triggered. 0 to disable and allow unlimited order cancels. [Strategy Override](#parameters-in-the-strategy).<br>*Defaults to `0`.* <br> **Datatype:** Integer
|
||||
| | **Pricing**
|
||||
| `entry_pricing.price_side` | Select the side of the spread the bot should look at to get the entry rate. [More information below](#entry-price).<br> *Defaults to `"same"`.* <br> **Datatype:** String (either `ask`, `bid`, `same` or `other`).
|
||||
@@ -229,7 +229,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
|
||||
| `exchange.enable_ws` | Enable the usage of Websockets for the exchange. <br>[More information](#consuming-exchange-websockets).<br>*Defaults to `true`.* <br> **Datatype:** Boolean
|
||||
| `exchange.markets_refresh_interval` | The interval in minutes in which markets are reloaded. <br>*Defaults to `60` minutes.* <br> **Datatype:** Positive Integer
|
||||
| `exchange.skip_open_order_update` | Skips open order updates on startup should the exchange cause problems. Only relevant in live conditions.<br>*Defaults to `false`*<br> **Datatype:** Boolean
|
||||
| `exchange.unknown_fee_rate` | Fallback value to use when calculating trading fees. This can be useful for exchanges which have fees in non-tradable currencies. The value provided here will be multiplied with the "fee cost".<br>*Defaults to `None`<br> **Datatype:** float
|
||||
| `exchange.unknown_fee_rate` | Fallback value to use when calculating trading fees. This can be useful for exchanges which have fees in non-tradable currencies. The value provided here will be multiplied with the "fee cost".<br>*Defaults to `None`*<br> **Datatype:** float
|
||||
| `exchange.log_responses` | Log relevant exchange responses. For debug mode only - use with care.<br>*Defaults to `false`*<br> **Datatype:** Boolean
|
||||
| `exchange.only_from_ccxt` | Prevent data-download from data.binance.vision. Leaving this as false can greatly speed up downloads, but may be problematic if the site is not available.<br>*Defaults to `false`*<br> **Datatype:** Boolean
|
||||
| `experimental.block_bad_exchanges` | Block exchanges known to not work with freqtrade. Leave on default unless you want to test if that exchange works now. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
|
||||
@@ -240,7 +240,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
|
||||
| `telegram.token` | Your Telegram bot token. Only required if `telegram.enabled` is `true`. <br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String
|
||||
| `telegram.chat_id` | Your personal Telegram account id. Only required if `telegram.enabled` is `true`. <br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String
|
||||
| `telegram.balance_dust_level` | Dust-level (in stake currency) - currencies with a balance below this will not be shown by `/balance`. <br> **Datatype:** float
|
||||
| `telegram.reload` | Allow "reload" buttons on telegram messages. <br>*Defaults to `true`.<br> **Datatype:** boolean
|
||||
| `telegram.reload` | Allow "reload" buttons on telegram messages. <br>*Defaults to `true`.*<br> **Datatype:** boolean
|
||||
| `telegram.notification_settings.*` | Detailed notification settings. Refer to the [telegram documentation](telegram-usage.md) for details.<br> **Datatype:** dictionary
|
||||
| `telegram.allow_custom_messages` | Enable the sending of Telegram messages from strategies via the dataprovider.send_msg() function. <br> **Datatype:** Boolean
|
||||
| | **Webhook**
|
||||
@@ -280,8 +280,8 @@ Mandatory parameters are marked as **Required**, which means that they are requi
|
||||
| `add_config_files` | Additional config files. These files will be loaded and merged with the current config file. The files are resolved relative to the initial file.<br> *Defaults to `[]`*. <br> **Datatype:** List of strings
|
||||
| `dataformat_ohlcv` | Data format to use to store historical candle (OHLCV) data. <br> *Defaults to `feather`*. <br> **Datatype:** String
|
||||
| `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `feather`*. <br> **Datatype:** String
|
||||
| `reduce_df_footprint` | Recast all numeric columns to float32/int32, with the objective of reducing ram/disk usage (and decreasing train/inference timing backtesting/hyperopt and in FreqAI). <br> **Datatype:** Boolean. <br> Default: `False`.
|
||||
| `log_config` | Dictionary containing the log config for python logging. [more info](advanced-setup.md#advanced-logging) <br> **Datatype:** dict. <br> Default: `FtRichHandler`
|
||||
| `reduce_df_footprint` | Recast all numeric columns to float32/int32, with the objective of reducing ram/disk usage (and decreasing train/inference timing backtesting/hyperopt and in FreqAI). <br> Default: `False`. <br> **Datatype:** Boolean.
|
||||
| `log_config` | Dictionary containing the log config for python logging. [more info](advanced-setup.md#advanced-logging) <br> Default: `FtRichHandler` <br> **Datatype:** dict.
|
||||
|
||||
### Parameters in the strategy
|
||||
|
||||
|
||||
@@ -269,6 +269,8 @@ If `--convert` is also provided, the resample step will happen automatically and
|
||||
!!! Note "Kraken user"
|
||||
Kraken users should read [this](exchanges.md#historic-kraken-data) before starting to download data.
|
||||
|
||||
Kraken Futures uses standard OHLCV downloads and does not require `--dl-trades`.
|
||||
|
||||
Example call:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -217,6 +217,32 @@ freqtrade download-data --exchange kraken --dl-trades -p BTC/EUR BCH/EUR
|
||||
Please pay attention that rateLimit configuration entry holds delay in milliseconds between requests, NOT requests/sec rate.
|
||||
So, in order to mitigate Kraken API "Rate limit exceeded" exception, this configuration should be increased, NOT decreased.
|
||||
|
||||
## Kraken Futures
|
||||
|
||||
Kraken Futures uses the exchange id `krakenfutures` and supports isolated futures mode.
|
||||
|
||||
```jsonc
|
||||
"exchange": {
|
||||
"name": "krakenfutures",
|
||||
"key": "your_exchange_key",
|
||||
"secret": "your_exchange_secret"
|
||||
},
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"stake_currency": "USD"
|
||||
```
|
||||
|
||||
!!! Tip "Stoploss on Exchange"
|
||||
Kraken Futures supports `stoploss_on_exchange` with both `limit` and `market` stop orders.
|
||||
Use `order_types.stoploss_price_type` to select the trigger price source (`mark`, `last`, or `index`).
|
||||
|
||||
!!! Note "Collateral"
|
||||
Kraken Futures is USD-settled. Use USD as your stake currency.
|
||||
|
||||
!!! Note "Flex (Multi-collateral) Accounts"
|
||||
Kraken Futures flex accounts allow collateral in multiple currencies, while trading remains USD-settled.
|
||||
Freqtrade derives the `USD` balance from Kraken margin fields, so keep `stake_currency` set to `USD`.
|
||||
|
||||
## Kucoin
|
||||
|
||||
Kucoin requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows:
|
||||
@@ -368,6 +394,11 @@ On startup, freqtrade will set the position mode to "One-way Mode" for the whole
|
||||
!!! Tip "Stoploss on Exchange"
|
||||
Hyperliquid supports `stoploss_on_exchange` and uses `stop-loss-limit` orders. It provides great advantages, so we recommend to benefit from it.
|
||||
|
||||
!!! Warning "Unified accounts"
|
||||
Hyperliquid unified accounts are supported - though this relies freqtrade's assumption of "owning" the account, and being the only one trading on it (in this case, extended to both spot and futures).
|
||||
We hence recommend the usage of subaccounts where possible, and to avoid manual trading on the same account while the bot is running.
|
||||
Freqtrade will attempt to detect the account type on startup - changing the account type mid-trading is not supported and may lead to exceptions and errors.
|
||||
|
||||
Hyperliquid is a Decentralized Exchange (DEX). Decentralized exchanges work a bit different compared to normal exchanges. Instead of authenticating private API calls using an API key, private API calls need to be signed with the private key of your wallet (We recommend using an api Wallet for this, generated either on Hyperliquid or in your wallet of choice).
|
||||
This needs to be configured like this:
|
||||
|
||||
@@ -423,6 +454,7 @@ Your balance and trades will now be used from your vault / subaccount - and no l
|
||||
!!! Note
|
||||
You can only use either a vault or a subaccount - not both at the same time.
|
||||
|
||||
|
||||
### Historic Hyperliquid data
|
||||
|
||||
The Hyperliquid API does not provide historic data beyond the single call to fetch current data, so downloading data is not possible, as the downloaded data would not constitute proper historic data.
|
||||
|
||||
@@ -260,6 +260,10 @@ freqtrade trade --config config_examples/config_freqai.example.json --strategy F
|
||||
|
||||
PyTorch dropped support for macOS x64 (intel based Apple devices) in version 2.3. Subsequently, freqtrade also dropped support for PyTorch on this platform.
|
||||
|
||||
!!! Danger "Security notice"
|
||||
Loading saved models from disk can cause security issues if using remote model files (files you downloaded from the internet or received from an untrusted source) due to having the necessity to have `weights_only=False`, which can cause security problems.
|
||||
As long as you only load models that you have trained yourself, there is no risk.
|
||||
|
||||
### Structure
|
||||
|
||||
#### Model
|
||||
|
||||
@@ -106,6 +106,7 @@ Mandatory parameters are marked as **Required** and have to be set in one of the
|
||||
| `n_epochs` | The `n_epochs` parameter is a crucial setting in the PyTorch training loop that determines the number of times the entire training dataset will be used to update the model's parameters. An epoch represents one full pass through the entire training dataset. Overrides `n_steps`. Either `n_epochs` or `n_steps` must be set. <br><br> **Datatype:** int. optional. <br> Default: `10`.
|
||||
| `n_steps` | An alternative way of setting `n_epochs` - the number of training iterations to run. Iteration here refer to the number of times we call `optimizer.step()`. Ignored if `n_epochs` is set. A simplified version of the function: <br><br> n_epochs = n_steps / (n_obs / batch_size) <br><br> The motivation here is that `n_steps` is easier to optimize and keep stable across different n_obs - the number of data points. <br> <br> **Datatype:** int. optional. <br> Default: `None`.
|
||||
| `batch_size` | The size of the batches to use during training. <br><br> **Datatype:** int. <br> Default: `64`.
|
||||
| `early_stopping_patience` | Number of epochs with no improvement in validation loss before training is stopped early. This helps prevent overfitting by halting training when the model stops improving. Set to `0` to disable early stopping. Requires a test/validation split (`test_size > 0`). <br><br> **Datatype:** int. <br> Default: `0` (disabled).
|
||||
|
||||
### Additional parameters
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ To save the models generated during a particular backtest so that you can start
|
||||
To ensure that the model can be reused, freqAI will call your strategy with a dataframe of length 1.
|
||||
If your strategy requires more data than this to generate the same features, you can't reuse backtest predictions for live deployment and need to update your `identifier` for each new backtest.
|
||||
|
||||
!!! Danger "Security notice"
|
||||
Loading saved models from disk can cause security issues if using remote model files (files you downloaded from the internet or received from an untrusted source) due to having the necessity to have `weights_only=False`, which can cause security problems.
|
||||
As long as you only load models that you have trained yourself, there is no risk.
|
||||
|
||||
### Backtest live collected predictions
|
||||
|
||||
FreqAI allow you to reuse live historic predictions through the backtest parameter `--freqai-backtest-live-models`. This can be useful when you want to reuse predictions generated in dry/run for comparison or other study.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| [Hyperliquid](exchanges.md#hyperliquid) | spot | | ❌ (not supported) |
|
||||
| [Hyperliquid](exchanges.md#hyperliquid) | futures | isolated, cross | limit |
|
||||
| [Kraken](exchanges.md#kraken) | spot | | market, limit |
|
||||
| [Kraken](exchanges.md#kraken-futures) | futures | isolated | market, limit |
|
||||
| [OKX](exchanges.md#okx) | spot | | limit |
|
||||
| [OKX](exchanges.md#okx) | futures | isolated | limit |
|
||||
| [Bitvavo](exchanges.md#bitvavo) | spot | | ❌ (not supported) |
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. They are configured in the `pairlists` section of the configuration settings.
|
||||
|
||||
In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers).
|
||||
In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list), [`CrossMarketPairList`](#crossmarketpairlist), [`MarketCapPairlist`](#marketcappairlist) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers).
|
||||
|
||||
Additionally, [`AgeFilter`](#agefilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist.
|
||||
|
||||
If multiple Pairlist Handlers are used, they are chained and a combination of all Pairlist Handlers forms the resulting pairlist the bot uses for trading and backtesting. Pairlist Handlers are executed in the sequence they are configured. You can define either `StaticPairList`, `VolumePairList`, `ProducerPairList`, `RemotePairList`, `MarketCapPairList` or `PercentChangePairList` as the starting Pairlist Handler.
|
||||
If multiple Pairlist Handlers are used, they are chained and a combination of all Pairlist Handlers forms the resulting pairlist the bot uses for trading and backtesting. Pairlist Handlers are executed in the sequence they are configured. You can define either `StaticPairList`, `VolumePairList`, `ProducerPairList`, `RemotePairList`, `MarketCapPairList`, `PercentChangePairList` or `CrossMarketPairList` as the starting Pairlist Handler.
|
||||
|
||||
Inactive markets are always removed from the resulting pairlist. Explicitly blacklisted pairs (those in the `pair_blacklist` configuration setting) are also always removed from the resulting pairlist.
|
||||
|
||||
@@ -26,6 +26,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged
|
||||
* [`ProducerPairList`](#producerpairlist)
|
||||
* [`RemotePairList`](#remotepairlist)
|
||||
* [`MarketCapPairList`](#marketcappairlist)
|
||||
* [`CrossMarketPairList`](#crossmarketpairlist)
|
||||
* [`AgeFilter`](#agefilter)
|
||||
* [`DelistFilter`](#delistfilter)
|
||||
* [`FullTradesFilter`](#fulltradesfilter)
|
||||
@@ -303,6 +304,8 @@ The optional `mode` option specifies if the pairlist should be used as a `blackl
|
||||
|
||||
The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append". The default value is "filter".
|
||||
|
||||
The optional `number_assets` option in the RemotePairList configuration determines how many pairs will be returned if used in whitelist `mode`. By default, all pairs will be returned. In blacklist `mode`, this option will be ignored.
|
||||
|
||||
In "filter" mode, the retrieved pairlist is used as a filter. Only the pairs present in both the original pairlist and the retrieved pairlist are included in the final pairlist. Other pairs are filtered out.
|
||||
|
||||
In "append" mode, the retrieved pairlist is added to the original pairlist. All pairs from both lists are included in the final pairlist without any filtering.
|
||||
@@ -402,6 +405,12 @@ Coins like 1000PEPE/USDT or KPEPE/USDT:USDT are detected on a best effort basis,
|
||||
!!! Danger "Duplicate symbols in coingecko"
|
||||
Coingecko often has duplicate symbols, where the same symbol is used for different coins. Freqtrade will use the symbol as is and try to search for it on the exchange. If the symbol exists - it will be used. Freqtrade will however not check if the _intended_ symbol is the one coingecko meant. This can sometimes lead to unexpected results, especially on low volume coins or with meme coin categories.
|
||||
|
||||
#### CrossMarketPairList
|
||||
|
||||
Generate or filter pairs based of their availability on the opposite market.
|
||||
|
||||
The `pairs_exist_on` setting defines whether the pairs should exists on both spot and futures market (`both_markets`) or only exist on the specified trading mode (`current_market_only`). By default, the plugin will be in `both_markets` setting, which means whitelisted pairs have to exists on both spot and futures markets.
|
||||
|
||||
#### AgeFilter
|
||||
|
||||
Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity).
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@
|
||||
[](https://doi.org/10.21105/joss.04864)
|
||||
[](https://codecov.io/gh/freqtrade/freqtrade)
|
||||
[](https://www.freqtrade.io)
|
||||
[](https://discord.gg/p7nuUNVfP7)
|
||||
[](https://discord.gg/p7nuUNVfP7)
|
||||
|
||||
<!-- GitHub action buttons -->
|
||||
[:octicons-star-16: Star](https://github.com/freqtrade/freqtrade){ .md-button .md-button--sm }
|
||||
@@ -62,6 +62,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual,
|
||||
- [X] [Gate.io](https://www.gate.io/ref/6266643)
|
||||
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
|
||||
- [X] [OKX](https://okx.com/)
|
||||
- [X] [Kraken](https://www.kraken.com/features/futures)
|
||||
|
||||
Please make sure to read the [exchange specific notes](exchanges.md), as well as the [trading with leverage](leverage.md) documentation before diving in.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
markdown==3.10.2
|
||||
mkdocs==1.6.1
|
||||
mkdocs-material==9.7.1
|
||||
mkdocs-material==9.7.5
|
||||
mdx_truly_sane_lists==1.3
|
||||
pymdown-extensions==10.21
|
||||
jinja2==3.1.6
|
||||
mike==2.1.3
|
||||
mike==2.1.4
|
||||
|
||||
@@ -104,7 +104,7 @@ WHERE id=31;
|
||||
### Remove trade from the database
|
||||
|
||||
!!! Tip "Use RPC Methods to delete trades"
|
||||
Consider using `/delete <tradeid>` via telegram or rest API. That's the recommended way to deleting trades.
|
||||
Consider using `/delete <tradeid>` via telegram or rest API. That's the recommended way to deleting trades, as it will also remove the corresponding orders and custom data, and it will also trigger the necessary events in the bot to keep everything in sync.
|
||||
|
||||
If you'd still like to remove a trade from the database directly, you can use the below query.
|
||||
|
||||
@@ -113,9 +113,14 @@ If you'd still like to remove a trade from the database directly, you can use th
|
||||
|
||||
```sql
|
||||
DELETE FROM trades WHERE id = <tradeid>;
|
||||
DELETE FROM orders WHERE ft_trade_id = <tradeid>;
|
||||
DELETE FROM trade_custom_data WHERE ft_trade_id = <tradeid>;
|
||||
|
||||
|
||||
DELETE FROM trades WHERE id = 31;
|
||||
DELETE FROM orders WHERE ft_trade_id = 31;
|
||||
DELETE FROM trade_custom_data WHERE ft_trade_id = 31;
|
||||
```
|
||||
|
||||
!!! Warning
|
||||
This will remove this trade from the database. Please make sure you got the correct id and **NEVER** run this query without the `where` clause.
|
||||
This will remove the specified trade from the database. Please make sure you got the correct id and **NEVER** run this query without the `where` clause.
|
||||
|
||||
+3
-2
@@ -66,9 +66,10 @@ This same logic will reapply a stoploss order on the exchange should you cancel
|
||||
### stoploss_price_type
|
||||
|
||||
!!! Warning "Only applies to futures"
|
||||
`stoploss_price_type` only applies to futures markets (on exchanges where it's available).
|
||||
`stoploss_price_type` only applies to futures markets (on exchanges where it's available).
|
||||
Freqtrade will perform a validation of this setting on startup, failing to start if an invalid setting for your exchange has been selected.
|
||||
Supported price types are gonna differs between each exchanges. Please check with your exchange on which price types it supports.
|
||||
Supported price types are gonna differs between each exchanges. Please check with your exchange on which price types it supports.
|
||||
In spot markets, this setting is ignored and not validated, as most exchanges only support one price type for stoploss orders on spot markets.
|
||||
|
||||
Stoploss on exchange on futures markets can trigger on different price types.
|
||||
The naming for these prices in exchange terminology often varies, but is usually something around "last" (or "contract price" ), "mark" and "index".
|
||||
|
||||
@@ -33,7 +33,7 @@ class AwesomeStrategy(IStrategy):
|
||||
trade_entry_type = trade.get_custom_data(key='entry_type')
|
||||
if trade_entry_type is None:
|
||||
trade_entry_type = 'breakout' if 'entry_1' in trade.enter_tag else 'dip'
|
||||
elif fills > 1:
|
||||
elif len(fills) > 1:
|
||||
trade_entry_type = 'buy_up'
|
||||
trade.set_custom_data(key='entry_type', value=trade_entry_type)
|
||||
return super().bot_loop_start(**kwargs)
|
||||
|
||||
@@ -696,6 +696,9 @@ However, freqtrade also offers a custom callback for both order types, which all
|
||||
Backtesting fills orders if their price falls within the candle's low/high range.
|
||||
The below callbacks will be called once per (detail) candle for orders that don't fill immediately (which use custom pricing).
|
||||
|
||||
!!! Tip "Replacing orders"
|
||||
If you'd like to replace an order with a different price instead of just cancelling it, you might want to look at [`adjust_order_price()`](#adjust-order-price) instead, which will allow you to both cancel the order, as well as replace it with a new price.
|
||||
|
||||
### Custom order timeout example
|
||||
|
||||
Called for every open order until that order is either filled or cancelled.
|
||||
|
||||
+1
-1
@@ -416,6 +416,6 @@ Your original strategy will remain available in the `user_data/strategies_orig_u
|
||||
|
||||
!!! Warning "Conversion results"
|
||||
Strategy updater will work on a "best effort" approach. Please do your due diligence and verify the results of the conversion.
|
||||
We also recommend to run a python formatter (e.g. `black`) to format results in a sane manner.
|
||||
We also recommend to run a python formatter (e.g. `ruff format`) to format results in a sane manner.
|
||||
|
||||
--8<-- "commands/strategy-updater.md"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Freqtrade bot"""
|
||||
|
||||
__version__ = "2026.2"
|
||||
__version__ = "2026.3"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -410,7 +410,7 @@ class Configuration:
|
||||
("include_inactive", "Detected --include-inactive-pairs: {}"),
|
||||
("no_parallel_download", "Detected --no-parallel-download: {}"),
|
||||
("download_trades", "Detected --dl-trades: {}"),
|
||||
("convert_trades", "Detected --convert: {} - Converting Trade data to OHCV {}"),
|
||||
("convert_trades", "Detected --convert: {} - Converting trade data to OHLCV."),
|
||||
("dataformat_ohlcv", 'Using "{}" to store OHLCV data.'),
|
||||
("dataformat_trades", 'Using "{}" to store trades data.'),
|
||||
("show_timerange", "Detected --show-timerange"),
|
||||
|
||||
@@ -61,6 +61,7 @@ AVAILABLE_PAIRLISTS = [
|
||||
"ProducerPairList",
|
||||
"RemotePairList",
|
||||
"MarketCapPairList",
|
||||
"CrossMarketPairList",
|
||||
"AgeFilter",
|
||||
"DelistFilter",
|
||||
"FullTradesFilter",
|
||||
|
||||
@@ -39,7 +39,11 @@ def ohlcv_to_dataframe(
|
||||
df = DataFrame(ohlcv, columns=cols)
|
||||
|
||||
# Floor date to seconds to account for exchange imprecisions
|
||||
df["date"] = to_datetime(df["date"], unit="ms", utc=True).dt.floor("s")
|
||||
from freqtrade.exchange import timeframe_to_floor_freq
|
||||
|
||||
resample_interval = timeframe_to_floor_freq(timeframe)
|
||||
|
||||
df["date"] = to_datetime(df["date"], unit="ms", utc=True).dt.floor(resample_interval)
|
||||
|
||||
# Some exchanges return int values for Volume and even for OHLC.
|
||||
# Convert them since TA-LIB indicators used in the strategy assume floats
|
||||
@@ -59,14 +63,14 @@ def ohlcv_to_dataframe(
|
||||
|
||||
|
||||
def clean_ohlcv_dataframe(
|
||||
data: DataFrame, timeframe: str, pair: str, *, fill_missing: bool, drop_incomplete: bool
|
||||
dataframe: DataFrame, timeframe: str, pair: str, *, fill_missing: bool, drop_incomplete: bool
|
||||
) -> DataFrame:
|
||||
"""
|
||||
Cleanse a OHLCV dataframe by
|
||||
* Grouping it by date (removes duplicate tics)
|
||||
* dropping last candles if requested
|
||||
* Filling up missing data (if requested)
|
||||
:param data: DataFrame containing candle (OHLCV) data.
|
||||
:param dataframe: DataFrame containing candle (OHLCV) data.
|
||||
:param timeframe: timeframe (e.g. 5m). Used to fill up eventual missing data
|
||||
:param pair: Pair this data is for (used to warn if fillup was necessary)
|
||||
:param fill_missing: fill up missing candles with 0 candles
|
||||
@@ -75,7 +79,7 @@ def clean_ohlcv_dataframe(
|
||||
:return: DataFrame
|
||||
"""
|
||||
# group by index and aggregate results to eliminate duplicate ticks
|
||||
data = data.groupby(by="date", as_index=False, sort=True).agg(
|
||||
dataframe = dataframe.groupby(by="date", as_index=False, sort=True).agg(
|
||||
{
|
||||
"open": "first",
|
||||
"high": "max",
|
||||
@@ -86,13 +90,13 @@ def clean_ohlcv_dataframe(
|
||||
)
|
||||
# eliminate partial candle
|
||||
if drop_incomplete:
|
||||
data.drop(data.tail(1).index, inplace=True)
|
||||
dataframe.drop(dataframe.tail(1).index, inplace=True)
|
||||
logger.debug("Dropping last candle")
|
||||
|
||||
if fill_missing:
|
||||
return ohlcv_fill_up_missing_data(data, timeframe, pair)
|
||||
return ohlcv_fill_up_missing_data(dataframe, timeframe, pair)
|
||||
else:
|
||||
return data
|
||||
return dataframe
|
||||
|
||||
|
||||
def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str) -> DataFrame:
|
||||
|
||||
@@ -31,8 +31,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IDataHandler(ABC):
|
||||
_OHLCV_REGEX = r"^([a-zA-Z_\d-]+)\-(\d+[a-zA-Z]{1,2})\-?([a-zA-Z_]*)?(?=\.)"
|
||||
_TRADES_REGEX = r"^([a-zA-Z_\d-]+)\-(trades)?(?=\.)"
|
||||
_OHLCV_REGEX = r"^([\w-]+)\-(\d+[a-zA-Z]{1,2})\-?([a-zA-Z_]*)?(?=\.)"
|
||||
_TRADES_REGEX = r"^([\w-]+)\-(trades)?(?=\.)"
|
||||
|
||||
def __init__(self, datadir: Path) -> None:
|
||||
self._datadir = datadir
|
||||
@@ -336,11 +336,10 @@ class IDataHandler(ABC):
|
||||
def rebuild_pair_from_filename(pair: str) -> str:
|
||||
"""
|
||||
Rebuild pair name from filename
|
||||
Assumes a asset name of max. 7 length to also support BTC-PERP and BTC-PERP:USD names.
|
||||
Replaces the first '_' with '/' and the second '_' (if present) with ':'.
|
||||
e.g. BTC_USDT -> BTC/USDT, BTC_USDT_USDT -> BTC/USDT:USDT
|
||||
"""
|
||||
res = re.sub(r"^(([A-Za-z\d]{1,10})|^([A-Za-z\-]{1,6}))(_)", r"\g<1>/", pair, count=1)
|
||||
res = re.sub("_", ":", res, count=1)
|
||||
return res
|
||||
return pair.replace("_", "/", 1).replace("_", ":", 1)
|
||||
|
||||
def ohlcv_load(
|
||||
self,
|
||||
|
||||
@@ -296,7 +296,7 @@ def calculate_cagr(days_passed: int, starting_balance: float, final_balance: flo
|
||||
:param final_balance: Final balance to calculate CAGR against
|
||||
:return: CAGR
|
||||
"""
|
||||
if final_balance < 0:
|
||||
if (final_balance < 0) or (starting_balance <= 0) or (days_passed <= 0):
|
||||
# With leveraged trades, final_balance can become negative.
|
||||
return 0
|
||||
return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1
|
||||
|
||||
@@ -30,6 +30,7 @@ from freqtrade.exchange.exchange_utils import (
|
||||
validate_exchange,
|
||||
)
|
||||
from freqtrade.exchange.exchange_utils_timeframe import (
|
||||
timeframe_to_floor_freq,
|
||||
timeframe_to_minutes,
|
||||
timeframe_to_msecs,
|
||||
timeframe_to_next_date,
|
||||
@@ -43,6 +44,7 @@ from freqtrade.exchange.htx import Htx
|
||||
from freqtrade.exchange.hyperliquid import Hyperliquid
|
||||
from freqtrade.exchange.idex import Idex
|
||||
from freqtrade.exchange.kraken import Kraken
|
||||
from freqtrade.exchange.krakenfutures import Krakenfutures
|
||||
from freqtrade.exchange.kucoin import Kucoin
|
||||
from freqtrade.exchange.lbank import Lbank
|
||||
from freqtrade.exchange.luno import Luno
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
|
||||
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, PriceType, TradingMode
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
OperationalException,
|
||||
@@ -34,6 +34,11 @@ class Bitget(Exchange):
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
|
||||
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
|
||||
"stop_price_type_field": "triggerType",
|
||||
"stop_price_type_value_mapping": {
|
||||
PriceType.LAST: "fill_price",
|
||||
PriceType.MARK: "mark_price",
|
||||
},
|
||||
}
|
||||
_ft_has_futures: FtHas = {
|
||||
"funding_fee_candle_limit": 100,
|
||||
|
||||
@@ -39,7 +39,6 @@ BAD_EXCHANGES = {
|
||||
"bitmex": "Various reasons",
|
||||
"probit": "Requires additional, regular calls to `signIn()`",
|
||||
"poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders",
|
||||
"krakenfutures": "Unsupported futures exchange",
|
||||
"kucoinfutures": "Unsupported futures exchange",
|
||||
"poloniexfutures": "Unsupported futures exchange",
|
||||
"binancecoinm": "Unsupported futures exchange",
|
||||
@@ -63,6 +62,7 @@ SUPPORTED_EXCHANGES = [
|
||||
"htx",
|
||||
"hyperliquid",
|
||||
"kraken",
|
||||
"krakenfutures",
|
||||
"okx",
|
||||
"myokx",
|
||||
]
|
||||
|
||||
@@ -825,7 +825,8 @@ class Exchange:
|
||||
and order_types["stoploss_price_type"] not in price_mapping
|
||||
):
|
||||
raise ConfigurationError(
|
||||
f"On exchange stoploss price type is not supported for {self.name}."
|
||||
f"On exchange stoploss price type '{order_types['stoploss_price_type']}' "
|
||||
f"is not supported for {self.name}."
|
||||
)
|
||||
|
||||
def validate_pricing(self, pricing: dict) -> None:
|
||||
@@ -1896,9 +1897,12 @@ class Exchange:
|
||||
orders = []
|
||||
if self.exchange_has("fetchClosedOrders"):
|
||||
orders = self._api.fetch_closed_orders(pair, since=since_ms)
|
||||
if self.exchange_has("fetchOpenOrders"):
|
||||
orders_open = self._api.fetch_open_orders(pair, since=since_ms)
|
||||
orders.extend(orders_open)
|
||||
if self.exchange_has("fetchCanceledOrders"):
|
||||
orders_canceled = self._api.fetch_canceled_orders(pair, since=since_ms)
|
||||
orders.extend(orders_canceled)
|
||||
if self.exchange_has("fetchOpenOrders"):
|
||||
orders_open = self._api.fetch_open_orders(pair, since=since_ms)
|
||||
orders.extend(orders_open)
|
||||
return orders
|
||||
|
||||
@retrier(retries=0)
|
||||
@@ -3931,7 +3935,6 @@ class Exchange:
|
||||
is_short: bool,
|
||||
open_date: datetime,
|
||||
close_date: datetime,
|
||||
time_in_ratio: float | None = None,
|
||||
) -> float:
|
||||
"""
|
||||
calculates the sum of all funding fees that occurred for a pair during a futures trade
|
||||
@@ -3941,7 +3944,6 @@ class Exchange:
|
||||
:param is_short: trade direction
|
||||
:param open_date: The date and time that the trade started
|
||||
:param close_date: The date and time that the trade ended
|
||||
:param time_in_ratio: Not used by most exchange classes
|
||||
"""
|
||||
fees: float = 0
|
||||
|
||||
|
||||
@@ -29,6 +29,21 @@ def timeframe_to_msecs(timeframe: str) -> int:
|
||||
return ccxt.Exchange.parse_timeframe(timeframe) * 1000
|
||||
|
||||
|
||||
def timeframe_to_floor_freq(timeframe: str) -> str:
|
||||
"""
|
||||
Translates the timeframe interval value written in the human readable
|
||||
form ('1m', '5m', '1h', '1d', '1w', etc.) to the desired floor frequency used by pandas
|
||||
("1m", "5m", "1h", "1d", "1w", etc.).
|
||||
Will use minute for most higher timeframes.
|
||||
"""
|
||||
timeframe_seconds = timeframe_to_seconds(timeframe)
|
||||
timeframe_minutes = timeframe_seconds // 60
|
||||
if timeframe_minutes <= 1:
|
||||
return "1s"
|
||||
else:
|
||||
return "1min"
|
||||
|
||||
|
||||
def timeframe_to_resample_freq(timeframe: str) -> str:
|
||||
"""
|
||||
Translates the timeframe interval value written in the human readable
|
||||
|
||||
@@ -5,11 +5,20 @@ from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
from freqtrade.enums.runmode import NON_UTIL_MODES
|
||||
from freqtrade.exceptions import ConfigurationError, ExchangeError, OperationalException
|
||||
from freqtrade.exceptions import (
|
||||
ConfigurationError,
|
||||
DDosProtection,
|
||||
ExchangeError,
|
||||
OperationalException,
|
||||
TemporaryError,
|
||||
)
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import retrier
|
||||
from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, CcxtPosition, FtHas
|
||||
from freqtrade.util.datetime_helpers import dt_from_ts
|
||||
|
||||
@@ -22,6 +31,8 @@ class Hyperliquid(Exchange):
|
||||
Contains adjustments needed for Freqtrade to work with this exchange.
|
||||
"""
|
||||
|
||||
unified_account = False
|
||||
|
||||
_ft_has: FtHas = {
|
||||
"ohlcv_has_history": False,
|
||||
"l2_limit_range": [20],
|
||||
@@ -58,6 +69,38 @@ class Hyperliquid(Exchange):
|
||||
config.update(super()._ccxt_config)
|
||||
return config
|
||||
|
||||
@retrier
|
||||
def additional_exchange_init(self) -> None:
|
||||
"""
|
||||
Additional exchange initialization logic.
|
||||
.api will be available at this point.
|
||||
Query User account Account Type to determine unified account status
|
||||
https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#query-a-users-abstraction-state
|
||||
"""
|
||||
|
||||
try:
|
||||
if self.trading_mode == TradingMode.FUTURES and not self._config["dry_run"]:
|
||||
# Determine account status
|
||||
# Unified accounts must use the spot endpoint for balances
|
||||
request = {
|
||||
"type": "userAbstraction",
|
||||
"user": self._api.walletAddress,
|
||||
}
|
||||
response = self._api.publicPostInfo(request)
|
||||
self.unified_account = response in ('"unifiedAccount"', '"portfolioMargin"')
|
||||
if self.unified_account:
|
||||
logger.info("Unified Hyperliquid account detected.")
|
||||
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f"Error in additional_exchange_init due to {e.__class__.__name__}. Message: {e}"
|
||||
) from e
|
||||
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def _get_configured_hip3_dexes(self) -> list[str]:
|
||||
"""Get list of configured HIP-3 DEXes."""
|
||||
return self._config.get("exchange", {}).get("hip3_dexes", [])
|
||||
@@ -122,28 +165,33 @@ class Hyperliquid(Exchange):
|
||||
This override is not absolutely necessary and is only there for correct used / total values
|
||||
which are however not used by Freqtrade in futures mode at the moment.
|
||||
"""
|
||||
balances = super().get_balances()
|
||||
dexes = self._get_configured_hip3_dexes()
|
||||
for dex in dexes:
|
||||
try:
|
||||
dex_balance = super().get_balances(params={"dex": dex})
|
||||
params = params or {}
|
||||
if self.unified_account:
|
||||
params["type"] = "spot"
|
||||
balances = super().get_balances(params)
|
||||
if not self.unified_account:
|
||||
# In unified accounts, the balance already includes all DEXes
|
||||
dexes = self._get_configured_hip3_dexes()
|
||||
for dex in dexes:
|
||||
try:
|
||||
dex_balance = super().get_balances(params={"dex": dex})
|
||||
|
||||
for currency, amount_info in dex_balance.items():
|
||||
if currency in ["info", "free", "used", "total", "datetime", "timestamp"]:
|
||||
continue
|
||||
for currency, amount_info in dex_balance.items():
|
||||
if currency in ["info", "free", "used", "total", "datetime", "timestamp"]:
|
||||
continue
|
||||
|
||||
if currency not in balances:
|
||||
balances[currency] = amount_info
|
||||
else:
|
||||
balances[currency]["free"] += amount_info["free"]
|
||||
balances[currency]["used"] += amount_info["used"]
|
||||
balances[currency]["total"] += amount_info["total"]
|
||||
if currency not in balances:
|
||||
balances[currency] = amount_info
|
||||
else:
|
||||
balances[currency]["free"] += amount_info["free"]
|
||||
balances[currency]["used"] += amount_info["used"]
|
||||
balances[currency]["total"] += amount_info["total"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not fetch balance for HIP-3 DEX '{dex}': {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not fetch balance for HIP-3 DEX '{dex}': {e}")
|
||||
|
||||
if dexes:
|
||||
self._log_exchange_response("fetch_balance", balances, add_info="combined")
|
||||
if dexes:
|
||||
self._log_exchange_response("fetch_balance", balances, add_info="combined")
|
||||
return balances
|
||||
|
||||
def fetch_positions(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Kraken exchange subclass"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import ccxt
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
@@ -40,7 +38,6 @@ class Kraken(Exchange):
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
(TradingMode.SPOT, MarginMode.NONE),
|
||||
# (TradingMode.MARGIN, MarginMode.CROSS),
|
||||
# (TradingMode.FUTURES, MarginMode.CROSS)
|
||||
]
|
||||
|
||||
def market_is_tradable(self, market: dict[str, Any]) -> bool:
|
||||
@@ -114,18 +111,6 @@ class Kraken(Exchange):
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def _set_leverage(
|
||||
self,
|
||||
leverage: float,
|
||||
pair: str | None = None,
|
||||
accept_fail: bool = False,
|
||||
):
|
||||
"""
|
||||
Kraken set's the leverage as an option in the order object, so we need to
|
||||
add it to params
|
||||
"""
|
||||
return
|
||||
|
||||
def _get_params(
|
||||
self,
|
||||
side: BuySell,
|
||||
@@ -148,41 +133,6 @@ class Kraken(Exchange):
|
||||
params["postOnly"] = True
|
||||
return params
|
||||
|
||||
def calculate_funding_fees(
|
||||
self,
|
||||
df: DataFrame,
|
||||
amount: float,
|
||||
is_short: bool,
|
||||
open_date: datetime,
|
||||
close_date: datetime,
|
||||
time_in_ratio: float | None = None,
|
||||
) -> float:
|
||||
"""
|
||||
# ! This method will always error when run by Freqtrade because time_in_ratio is never
|
||||
# ! passed to _get_funding_fee. For kraken futures to work in dry run and backtesting
|
||||
# ! functionality must be added that passes the parameter time_in_ratio to
|
||||
# ! _get_funding_fee when using Kraken
|
||||
calculates the sum of all funding fees that occurred for a pair during a futures trade
|
||||
:param df: Dataframe containing combined funding and mark rates
|
||||
as `open_fund` and `open_mark`.
|
||||
:param amount: The quantity of the trade
|
||||
:param is_short: trade direction
|
||||
:param open_date: The date and time that the trade started
|
||||
:param close_date: The date and time that the trade ended
|
||||
:param time_in_ratio: Not used by most exchange classes
|
||||
"""
|
||||
if not time_in_ratio:
|
||||
raise OperationalException(
|
||||
f"time_in_ratio is required for {self.name}._get_funding_fee"
|
||||
)
|
||||
fees: float = 0
|
||||
|
||||
if not df.empty:
|
||||
df = df[(df["date"] >= open_date) & (df["date"] <= close_date)]
|
||||
fees = sum(df["open_fund"] * df["open_mark"] * amount * time_in_ratio)
|
||||
|
||||
return fees if is_short else -fees
|
||||
|
||||
def _get_trade_pagination_next_value(self, trades: list[dict]):
|
||||
"""
|
||||
Extract pagination id for the next "from_id" value
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Kraken Futures exchange subclass"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import ccxt
|
||||
|
||||
from freqtrade.enums import MarginMode, PriceType, TradingMode
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
ExchangeError,
|
||||
InvalidOrderException,
|
||||
OperationalException,
|
||||
TemporaryError,
|
||||
)
|
||||
from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier
|
||||
from freqtrade.exchange.exchange import Exchange
|
||||
from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas
|
||||
from freqtrade.misc import safe_value_nested
|
||||
from freqtrade.util.datetime_helpers import dt_from_ts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Krakenfutures(Exchange):
|
||||
"""Kraken Futures exchange class.
|
||||
|
||||
Contains adjustments needed for Freqtrade to work with this exchange.
|
||||
|
||||
Key differences from spot Kraken:
|
||||
- Stop orders use triggerPrice/triggerSignal instead of stopPrice
|
||||
- Flex (multi-collateral) accounts need USD balance synthesis
|
||||
"""
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
(TradingMode.FUTURES, MarginMode.ISOLATED),
|
||||
]
|
||||
|
||||
_ft_has: FtHas = {
|
||||
"tickers_have_quoteVolume": False,
|
||||
"stoploss_on_exchange": True,
|
||||
"stoploss_order_types": {
|
||||
"limit": "limit",
|
||||
"market": "market",
|
||||
},
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"stop_price_param": "triggerPrice",
|
||||
"stop_price_prop": "stopPrice",
|
||||
"stop_price_type_field": "triggerSignal",
|
||||
"stop_price_type_value_mapping": {
|
||||
PriceType.LAST: "last",
|
||||
PriceType.MARK: "mark",
|
||||
PriceType.INDEX: "index",
|
||||
},
|
||||
"exchange_has_overrides": {"fetchOrders": False},
|
||||
}
|
||||
|
||||
@retrier
|
||||
def get_balances(self, params: dict | None = None) -> CcxtBalances:
|
||||
"""
|
||||
Fetch balances with USD synthesis for flex (multi-collateral) accounts.
|
||||
|
||||
Kraken Futures flex accounts hold multiple currencies as collateral.
|
||||
CCXT returns per-currency balances but doesn't expose margin values
|
||||
as a USD balance. This override synthesizes a USD entry from flex account data
|
||||
when stake_currency is USD.
|
||||
|
||||
Field mapping (margin-centric for internal consistency):
|
||||
- free: availableMargin (margin available for new positions)
|
||||
- total: marginEquity (haircut-adjusted collateral + unrealized P&L)
|
||||
- used: total - free (margin currently in use)
|
||||
|
||||
Fallback chain for total: marginEquity -> portfolioValue -> balanceValue
|
||||
"""
|
||||
try:
|
||||
balances = self._api.fetch_balance(params or {})
|
||||
|
||||
# Only synthesize USD if stake_currency is USD
|
||||
stake = str(self._config.get("stake_currency", "")).upper()
|
||||
if stake == "USD":
|
||||
# Only synthesize if USD stake - flex only applies for these currencies.
|
||||
# For flex accounts, synthesize USD balance from margin values
|
||||
info = balances.get("info", {})
|
||||
accounts = info.get("accounts", {}) if isinstance(info, dict) else {}
|
||||
flex = accounts.get("flex", {}) if isinstance(accounts, dict) else {}
|
||||
|
||||
if flex:
|
||||
usd_free = self._safe_float(flex.get("availableMargin"))
|
||||
# Prefer marginEquity for consistency (same basis as availableMargin)
|
||||
raw_total = (
|
||||
flex.get("marginEquity")
|
||||
or flex.get("portfolioValue")
|
||||
or flex.get("balanceValue")
|
||||
)
|
||||
usd_total = self._safe_float(raw_total)
|
||||
if usd_free is not None or usd_total is not None:
|
||||
# Use available value for both if only one is present
|
||||
usd_free_value = usd_free if usd_free is not None else usd_total
|
||||
usd_total_value = usd_total if usd_total is not None else usd_free
|
||||
if usd_free_value is not None and usd_total_value is not None:
|
||||
usd_used = max(0.0, usd_total_value - usd_free_value)
|
||||
balances["USD"] = {
|
||||
"free": usd_free_value,
|
||||
"used": usd_used,
|
||||
"total": usd_total_value,
|
||||
}
|
||||
|
||||
# Remove additional info from ccxt results (same as base class)
|
||||
balances.pop("info", None)
|
||||
balances.pop("free", None)
|
||||
balances.pop("total", None)
|
||||
balances.pop("used", None)
|
||||
|
||||
self._log_exchange_response("fetch_balance", balances, add_info=params)
|
||||
return balances
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f"Could not get balance due to {e.__class__.__name__}. Message: {e}"
|
||||
) from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
"""Convert value to float, returning None if conversion fails."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder:
|
||||
"""Normalize order and apply Kraken Futures-specific order corrections."""
|
||||
order = super()._order_contracts_to_amount(order)
|
||||
return self._adjust_krakenfutures_order(order)
|
||||
|
||||
def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder:
|
||||
"""Apply Kraken Futures-specific order corrections.
|
||||
|
||||
For filled terminal orders, always fetch trades and compute VWAP because
|
||||
CCXT's average is still unreliable.
|
||||
|
||||
See: https://github.com/ccxt/ccxt/issues/27996
|
||||
"""
|
||||
if order.get("status") == "canceled" and order.get("filled") is None:
|
||||
# Workaround for missing filled parsing - https://github.com/ccxt/ccxt/issues/28210
|
||||
order["filled"] = safe_value_nested(order, "info.order.filled", default_value=None)
|
||||
|
||||
filled = self._safe_float(order.get("filled")) or 0.0
|
||||
if order.get("status") in ("canceled", "closed") and filled > 0:
|
||||
# Compute VWAP and cost for filled orders.
|
||||
trades = self.get_trades_for_order(
|
||||
order["id"], order["symbol"], since=dt_from_ts(order["timestamp"])
|
||||
)
|
||||
if trades:
|
||||
total_amount = sum(t["amount"] for t in trades)
|
||||
if total_amount:
|
||||
# Compute VWAP
|
||||
order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount
|
||||
trade_costs = [t["cost"] for t in trades if t.get("cost") is not None]
|
||||
if trade_costs:
|
||||
order["cost"] = sum(trade_costs)
|
||||
return order
|
||||
|
||||
def get_trades_for_order(
|
||||
self, order_id: str, pair: str, since: datetime, params: dict | None = None
|
||||
) -> list:
|
||||
"""Fetch trades and enrich with calculated fees.
|
||||
|
||||
Kraken Futures' /fills endpoint does not include fee amounts — only
|
||||
fillType (maker/taker). This enriches each trade with a calculated fee
|
||||
using the market's fee schedule so Freqtrade's fee detection works.
|
||||
"""
|
||||
trades = super().get_trades_for_order(order_id, pair, since, params)
|
||||
for trade in trades:
|
||||
if trade.get("fee") is None or trade["fee"].get("cost") is None:
|
||||
taker_or_maker = trade.get("takerOrMaker", "taker")
|
||||
symbol = trade.get("symbol", pair)
|
||||
market = self.markets.get(symbol, {})
|
||||
fee_rate = market.get(taker_or_maker, market.get("taker", 0.0005))
|
||||
cost = trade.get("cost")
|
||||
if cost is not None and fee_rate is not None:
|
||||
trade["fee"] = {
|
||||
"cost": cost * fee_rate,
|
||||
"currency": market.get("quote", "USD"),
|
||||
"rate": fee_rate,
|
||||
}
|
||||
return trades
|
||||
|
||||
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
|
||||
def fetch_order(
|
||||
self, order_id: str, pair: str, params: dict[str, Any] | None = None
|
||||
) -> CcxtOrder:
|
||||
"""Fetch order with direct CCXT call and fallback to history endpoints."""
|
||||
if self._config.get("dry_run"):
|
||||
return self.fetch_dry_run_order(order_id)
|
||||
|
||||
params = params or {}
|
||||
status_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")}
|
||||
try:
|
||||
order = self._api.fetch_order(order_id, pair, params=status_params)
|
||||
self._log_exchange_response("fetch_order", order)
|
||||
return self._order_contracts_to_amount(order)
|
||||
except ccxt.OrderNotFound:
|
||||
# Expected for older Kraken Futures orders not visible in orders/status.
|
||||
pass
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except ccxt.InvalidOrder as e:
|
||||
msg = f"Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}"
|
||||
raise InvalidOrderException(msg) from e
|
||||
except (ccxt.OperationFailed, ccxt.ExchangeError):
|
||||
# Fallback to history endpoints for temporary/status endpoint gaps.
|
||||
pass
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
order = self._fetch_order_fallback(order_id, pair, params)
|
||||
if order is not None:
|
||||
return order
|
||||
|
||||
# Order not in status, open, closed, or canceled endpoints - genuinely gone.
|
||||
# Raise non-retrying InvalidOrderException (Kraken has limited history retention).
|
||||
raise InvalidOrderException(
|
||||
f"Order not found in any endpoint (pair: {pair} id: {order_id})"
|
||||
)
|
||||
|
||||
def _fetch_order_fallback(
|
||||
self, order_id: str, pair: str, params: dict[str, Any]
|
||||
) -> CcxtOrder | None:
|
||||
"""Search open, closed, and canceled order endpoints for order_id.
|
||||
|
||||
Kraken Futures' orders/status endpoint only returns currently open orders.
|
||||
Older orders require querying history endpoints (closed/canceled).
|
||||
For stoploss (trigger) orders, the caller should pass stop=True in params
|
||||
(handled automatically via stoploss_query_requires_stop_flag in _ft_has)
|
||||
so that closed/canceled queries hit the trigger history endpoint.
|
||||
"""
|
||||
order_id_str = str(order_id)
|
||||
|
||||
# Open orders include triggers by default. Avoid passing trigger/stop flags
|
||||
# to prevent endpoint/filter mismatches.
|
||||
open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")}
|
||||
order = self._find_order_in_list(
|
||||
self._api.fetch_open_orders, pair, open_params, order_id_str
|
||||
)
|
||||
if order is not None:
|
||||
return order
|
||||
|
||||
# Closed/canceled: pass params through (including stop=True for stoploss orders,
|
||||
# which CCXT maps to the trigger history endpoint).
|
||||
for fetch_fn in (self._api.fetch_closed_orders, self._api.fetch_canceled_orders):
|
||||
order = self._find_order_in_list(fetch_fn, pair, params, order_id_str)
|
||||
if order is not None:
|
||||
return order
|
||||
|
||||
return None
|
||||
|
||||
def _find_order_in_list(
|
||||
self,
|
||||
fetch_fn,
|
||||
symbol: str | None,
|
||||
params: dict[str, Any],
|
||||
order_id_str: str,
|
||||
) -> CcxtOrder | None:
|
||||
"""Fetch orders and return matching order_id, or None."""
|
||||
try:
|
||||
orders = fetch_fn(symbol, params=params) or []
|
||||
self._log_exchange_response(fetch_fn.__name__, orders)
|
||||
for order in orders:
|
||||
if str(order.get("id")) == order_id_str:
|
||||
self._log_exchange_response("fetch_order_fallback", order)
|
||||
|
||||
return self._order_contracts_to_amount(order)
|
||||
except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e:
|
||||
logger.debug(f"{fetch_fn.__name__} failed: {e}")
|
||||
return None
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f"Could not get order due to {e.__class__.__name__}. Message: {e}"
|
||||
) from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
return None
|
||||
|
||||
def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float:
|
||||
"""Fetch funding fees, returning 0.0 if retrieval fails."""
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
try:
|
||||
return self._fetch_and_calculate_funding_fees(pair, amount, is_short, open_date)
|
||||
except ExchangeError:
|
||||
logger.warning(f"Could not update funding fees for {pair}.")
|
||||
return 0.0
|
||||
@@ -614,9 +614,13 @@ class FreqaiDataDrawer:
|
||||
elif self.model_type == "pytorch":
|
||||
import torch
|
||||
|
||||
zipfile = torch.load(dk.data_path / f"{dk.model_filename}_model.zip")
|
||||
model = zipfile["pytrainer"]
|
||||
model = model.load_from_checkpoint(zipfile)
|
||||
zipfile = torch.load(
|
||||
dk.data_path / f"{dk.model_filename}_model.zip",
|
||||
weights_only=False,
|
||||
)
|
||||
# weights_only is necessary due to pytrainer being a serialized python object.
|
||||
_trainer = zipfile["pytrainer"]
|
||||
model = _trainer.load_from_checkpoint(zipfile)
|
||||
|
||||
if not model:
|
||||
raise OperationalException(
|
||||
|
||||
@@ -428,22 +428,28 @@ class FreqaiDataKitchen:
|
||||
Get backtest prediction from current backtest period
|
||||
"""
|
||||
|
||||
append_df = DataFrame()
|
||||
# Build dict first and construct DataFrame once to avoid
|
||||
# column-by-column assignment which causes DataFrame fragmentation
|
||||
# and PerformanceWarning on large prediction sets.
|
||||
append_dict: dict[str, Any] = {}
|
||||
|
||||
for label in predictions.columns:
|
||||
append_df[label] = predictions[label]
|
||||
if append_df[label].dtype == object:
|
||||
append_dict[label] = predictions[label]
|
||||
if predictions[label].dtype == object:
|
||||
continue
|
||||
if "labels_mean" in self.data:
|
||||
append_df[f"{label}_mean"] = self.data["labels_mean"][label]
|
||||
if "labels_std" in self.data:
|
||||
append_df[f"{label}_std"] = self.data["labels_std"][label]
|
||||
if "labels_mean" in self.data and label in self.data["labels_mean"]:
|
||||
append_dict[f"{label}_mean"] = self.data["labels_mean"][label]
|
||||
if "labels_std" in self.data and label in self.data["labels_std"]:
|
||||
append_dict[f"{label}_std"] = self.data["labels_std"][label]
|
||||
|
||||
for extra_col in self.data["extra_returns_per_train"]:
|
||||
append_df[f"{extra_col}"] = self.data["extra_returns_per_train"][extra_col]
|
||||
append_dict[f"{extra_col}"] = self.data["extra_returns_per_train"][extra_col]
|
||||
|
||||
append_df["do_predict"] = do_predict
|
||||
append_dict["do_predict"] = do_predict
|
||||
if self.freqai_config["feature_parameters"].get("DI_threshold", 0) > 0:
|
||||
append_df["DI_values"] = self.DI_values
|
||||
append_dict["DI_values"] = self.DI_values
|
||||
|
||||
append_df = DataFrame(append_dict)
|
||||
|
||||
user_cols = [col for col in dataframe_backtest.columns if col.startswith("%%")]
|
||||
cols = ["date"]
|
||||
|
||||
@@ -63,6 +63,11 @@ class PyTorchModelTrainer(PyTorchTrainerInterface):
|
||||
self.tb_logger = tb_logger
|
||||
self.test_batch_counter = 0
|
||||
|
||||
# Early stopping parameters
|
||||
self.early_stopping_patience: int = kwargs.get("early_stopping_patience", 0)
|
||||
self.best_val_loss: float = float("inf")
|
||||
self.patience_counter: int = 0
|
||||
|
||||
def fit(self, data_dictionary: dict[str, pd.DataFrame], splits: list[str]):
|
||||
"""
|
||||
:param data_dictionary: the dictionary constructed by DataHandler to hold
|
||||
@@ -99,15 +104,40 @@ class PyTorchModelTrainer(PyTorchTrainerInterface):
|
||||
|
||||
# evaluation
|
||||
if "test" in splits:
|
||||
self.estimate_loss(data_loaders_dictionary, "test")
|
||||
val_loss = self.estimate_loss(data_loaders_dictionary, "test")
|
||||
|
||||
# Early stopping check
|
||||
if self.early_stopping_patience > 0 and val_loss is not None:
|
||||
if val_loss < self.best_val_loss:
|
||||
self.best_val_loss = val_loss
|
||||
self.patience_counter = 0
|
||||
else:
|
||||
self.patience_counter += 1
|
||||
if self.patience_counter >= self.early_stopping_patience:
|
||||
logger.info(
|
||||
f"Early stopping triggered after {self.patience_counter} "
|
||||
f"epochs without improvement. "
|
||||
f"Best val_loss: {self.best_val_loss:.6f}"
|
||||
)
|
||||
break
|
||||
|
||||
@torch.no_grad()
|
||||
def estimate_loss(
|
||||
self,
|
||||
data_loader_dictionary: dict[str, DataLoader],
|
||||
split: str,
|
||||
) -> None:
|
||||
) -> float | None:
|
||||
"""
|
||||
Estimate loss on a data split.
|
||||
|
||||
:param data_loader_dictionary: dictionary of data loaders.
|
||||
:param split: split to estimate loss on (e.g. "test").
|
||||
:return: average loss over all batches, or None if no batches.
|
||||
"""
|
||||
self.model.eval()
|
||||
total_loss = 0.0
|
||||
num_batches = 0
|
||||
|
||||
for _, batch_data in enumerate(data_loader_dictionary[split]):
|
||||
xb, yb = batch_data
|
||||
xb = xb.to(self.device)
|
||||
@@ -115,11 +145,17 @@ class PyTorchModelTrainer(PyTorchTrainerInterface):
|
||||
|
||||
yb_pred = self.model(xb)
|
||||
loss = self.criterion(yb_pred, yb)
|
||||
total_loss += loss.item()
|
||||
num_batches += 1
|
||||
self.tb_logger.log_scalar(f"{split}_loss", loss.item(), self.test_batch_counter)
|
||||
self.test_batch_counter += 1
|
||||
|
||||
self.model.train()
|
||||
|
||||
if num_batches > 0:
|
||||
return total_loss / num_batches
|
||||
return None
|
||||
|
||||
def create_data_loaders_dictionary(
|
||||
self, data_dictionary: dict[str, pd.DataFrame], splits: list[str]
|
||||
) -> dict[str, DataLoader]:
|
||||
@@ -179,16 +215,12 @@ class PyTorchModelTrainer(PyTorchTrainerInterface):
|
||||
path,
|
||||
)
|
||||
|
||||
def load(self, path: Path):
|
||||
checkpoint = torch.load(path)
|
||||
return self.load_from_checkpoint(checkpoint)
|
||||
|
||||
def load_from_checkpoint(self, checkpoint: dict):
|
||||
"""
|
||||
when using continual_learning, DataDrawer will load the dictionary
|
||||
(containing state dicts and model_meta_data) by calling torch.load(path).
|
||||
you can access this dict from any class that inherits IFreqaiModel by calling
|
||||
get_init_model method.
|
||||
the get_init_model method.
|
||||
"""
|
||||
self.model.load_state_dict(checkpoint["model_state_dict"])
|
||||
self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
||||
|
||||
@@ -555,7 +555,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
if trade.base_currency
|
||||
else 0
|
||||
)
|
||||
if total < trade.amount:
|
||||
if total < trade.amount or (total == 0 and trade.amount == 0):
|
||||
if trade.fully_canceled_entry_order_count == len(trade.orders):
|
||||
logger.warning(
|
||||
f"Trade only had fully canceled entry orders. "
|
||||
|
||||
+3
-5
@@ -6,7 +6,6 @@ Read the documentation to know what cli arguments you need.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
# check min. python version
|
||||
@@ -35,7 +34,7 @@ def main(sysargv: list[str] | None = None) -> None:
|
||||
:return: None
|
||||
"""
|
||||
|
||||
return_code: Any = 1
|
||||
return_code: int | None = None
|
||||
try:
|
||||
setup_logging_pre()
|
||||
asyncio_setup()
|
||||
@@ -62,11 +61,9 @@ def main(sysargv: list[str] | None = None) -> None:
|
||||
"`freqtrade --help` or `freqtrade <command> --help`."
|
||||
)
|
||||
|
||||
except SystemExit as e: # pragma: no cover
|
||||
return_code = e
|
||||
except KeyboardInterrupt:
|
||||
logger.info("SIGINT received, aborting ...")
|
||||
return_code = 0
|
||||
return_code = 130
|
||||
except ConfigurationError as e:
|
||||
logger.error(
|
||||
f"Configuration error: {e}\n"
|
||||
@@ -77,6 +74,7 @@ def main(sysargv: list[str] | None = None) -> None:
|
||||
return_code = 2
|
||||
except Exception:
|
||||
logger.exception("Fatal exception!")
|
||||
return_code = 1
|
||||
finally:
|
||||
sys.exit(return_code)
|
||||
|
||||
|
||||
@@ -848,9 +848,7 @@ class Backtesting:
|
||||
exit_tag=exit_reason,
|
||||
)
|
||||
if rate is not None and rate != close_rate:
|
||||
close_rate = price_to_precision(
|
||||
rate, trade.price_precision, trade.precision_mode_price
|
||||
)
|
||||
close_rate = rate
|
||||
# We can't place orders lower than current low.
|
||||
# freqtrade does not support this in live, and the order would fill immediately
|
||||
if trade.is_short:
|
||||
@@ -892,6 +890,9 @@ class Backtesting:
|
||||
self.order_id_counter += 1
|
||||
exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
|
||||
order_type = self.strategy.order_types["exit"]
|
||||
close_rate = price_to_precision(
|
||||
close_rate, trade.price_precision, trade.precision_mode_price
|
||||
)
|
||||
# amount = amount or trade.amount
|
||||
amount = amount_to_contract_precision(
|
||||
amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size
|
||||
|
||||
@@ -51,15 +51,6 @@ class AgeFilter(IPairList):
|
||||
f"({candle_limit})"
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Cross Market pair list filter"""
|
||||
|
||||
import logging
|
||||
|
||||
from freqtrade.constants import PairPrefixes
|
||||
from freqtrade.exchange.exchange_types import Tickers
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import FtTTLCache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrossMarketPairList(IPairList):
|
||||
is_pairlist_generator = True
|
||||
supports_backtesting = SupportsBacktesting.BIASED
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._pairs_exist_on: str = self._pairlistconfig.get("pairs_exist_on", "both_markets")
|
||||
self._stake_currency: str = self._config["stake_currency"]
|
||||
self._target_mode = "spot" if self._config["trading_mode"] == "futures" else "futures"
|
||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
|
||||
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
"""
|
||||
pairs_exist_on = self._pairs_exist_on
|
||||
msg = f"{self.name} - Pairs that exists on {pairs_exist_on.capitalize()}."
|
||||
return msg
|
||||
|
||||
@staticmethod
|
||||
def description() -> str:
|
||||
return "Filter pairs if they exist or not on another market."
|
||||
|
||||
@staticmethod
|
||||
def available_parameters() -> dict[str, PairlistParameter]:
|
||||
return {
|
||||
"pairs_exist_on": {
|
||||
"type": "option",
|
||||
"default": "both_markets",
|
||||
"options": ["current_market_only", "both_markets"],
|
||||
"description": "Mode of operation",
|
||||
"help": "Mode of operation (current_market_only/both_markets)",
|
||||
},
|
||||
**IPairList.refresh_period_parameter(),
|
||||
}
|
||||
|
||||
def get_base_list(self) -> list[str]:
|
||||
target_mode = self._target_mode
|
||||
spot_only = True if target_mode == "spot" else False
|
||||
futures_only = True if target_mode == "futures" else False
|
||||
bases = [
|
||||
v.get("base", "")
|
||||
for _, v in self._exchange.get_markets(
|
||||
quote_currencies=[self._stake_currency],
|
||||
tradable_only=False,
|
||||
active_only=True,
|
||||
spot_only=spot_only,
|
||||
futures_only=futures_only,
|
||||
).items()
|
||||
]
|
||||
return bases
|
||||
|
||||
def gen_pairlist(self, tickers: Tickers) -> list[str]:
|
||||
"""
|
||||
Generate the pairlist
|
||||
:param tickers: Tickers (from exchange.get_tickers). May be cached.
|
||||
:return: List of pairs
|
||||
"""
|
||||
# Generate dynamic whitelist
|
||||
# Must always run if this pairlist is the first in the list.
|
||||
pairlist = self._pair_cache.get("pairlist")
|
||||
if pairlist:
|
||||
# Item found - no refresh necessary
|
||||
return pairlist.copy()
|
||||
else:
|
||||
# Use fresh pairlist
|
||||
# Check if pair quote currency equals to the stake currency.
|
||||
_pairlist = [
|
||||
k
|
||||
for k in self._exchange.get_markets(
|
||||
quote_currencies=[self._stake_currency], tradable_only=True, active_only=True
|
||||
).keys()
|
||||
]
|
||||
|
||||
_pairlist = self.verify_blacklist(_pairlist, logger.info)
|
||||
|
||||
pairlist = self.filter_pairlist(_pairlist, tickers)
|
||||
self._pair_cache["pairlist"] = pairlist.copy()
|
||||
|
||||
return pairlist
|
||||
|
||||
def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]:
|
||||
bases = self.get_base_list()
|
||||
pairs_exist_on = self._pairs_exist_on
|
||||
is_whitelist_mode = pairs_exist_on == "both_markets"
|
||||
whitelisted_pairlist: list[str] = []
|
||||
filtered_pairlist = pairlist.copy()
|
||||
|
||||
for pair in pairlist:
|
||||
base = self._exchange.get_pair_base_currency(pair)
|
||||
if not base:
|
||||
self.log_once(
|
||||
f"Unable to get base currency for pair {pair}, skipping it.", logger.warning
|
||||
)
|
||||
filtered_pairlist.remove(pair)
|
||||
continue
|
||||
found_in_bases = base in bases
|
||||
if not found_in_bases:
|
||||
for prefix in PairPrefixes:
|
||||
# Check in case of PEPE needs to be changed into 1000PEPE for example
|
||||
test_prefix = f"{prefix}{base}"
|
||||
found_in_bases = test_prefix in bases
|
||||
if found_in_bases:
|
||||
break
|
||||
|
||||
# Avoid false positive since there are KAVA and AVA pairs, which aren't related
|
||||
if prefix != "K":
|
||||
# Check in case of 1000PEPE needs to be changed into PEPE for example
|
||||
if base.startswith(prefix):
|
||||
temp_base = base.removeprefix(prefix)
|
||||
found_in_bases = temp_base in bases
|
||||
if found_in_bases:
|
||||
break
|
||||
if found_in_bases:
|
||||
whitelisted_pairlist.append(pair)
|
||||
filtered_pairlist.remove(pair)
|
||||
|
||||
return whitelisted_pairlist if is_whitelist_mode else filtered_pairlist
|
||||
@@ -28,15 +28,6 @@ class DelistFilter(IPairList):
|
||||
"DelistFilter doesn't support this exchange and trading mode combination.",
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -15,15 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
class FullTradesFilter(IPairList):
|
||||
supports_backtesting = SupportsBacktesting.NO_ACTION
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty List is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short allowlist method description - used for startup-messages
|
||||
|
||||
@@ -107,7 +107,6 @@ class IPairList(LoggingMixin, ABC):
|
||||
return self.__class__.__name__
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
|
||||
@@ -65,15 +65,6 @@ class MarketCapPairList(IPairList):
|
||||
"Please ensure this value is necessary for your use case.",
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -24,15 +24,6 @@ class OffsetFilter(IPairList):
|
||||
if self._offset < 0:
|
||||
raise OperationalException("OffsetFilter requires offset to be >= 0")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -25,15 +25,6 @@ class PerformanceFilter(IPairList):
|
||||
self._minutes = self._pairlistconfig.get("minutes", 0)
|
||||
self._min_profit = self._pairlistconfig.get("min_profit")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty List is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short allowlist method description - used for startup-messages
|
||||
|
||||
@@ -42,15 +42,6 @@ class ProducerPairList(IPairList):
|
||||
"ProducerPairList requires external_message_consumer to be enabled."
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -31,12 +31,6 @@ class RemotePairList(IPairList):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if "number_assets" not in self._pairlistconfig:
|
||||
raise OperationalException(
|
||||
"`number_assets` not specified. Please check your configuration "
|
||||
'for "pairlist.config.number_assets"'
|
||||
)
|
||||
|
||||
if "pairlist_url" not in self._pairlistconfig:
|
||||
raise OperationalException(
|
||||
"`pairlist_url` not specified. Please check your configuration "
|
||||
@@ -45,7 +39,7 @@ class RemotePairList(IPairList):
|
||||
|
||||
self._mode = self._pairlistconfig.get("mode", "whitelist")
|
||||
self._processing_mode = self._pairlistconfig.get("processing_mode", "filter")
|
||||
self._number_pairs = self._pairlistconfig["number_assets"]
|
||||
self._number_pairs: int | None = self._pairlistconfig.get("number_assets", None)
|
||||
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: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
@@ -72,20 +66,11 @@ class RemotePairList(IPairList):
|
||||
"position of your pairlist."
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
"""
|
||||
return f"{self.name} - {self._pairlistconfig['number_assets']} pairs from RemotePairlist."
|
||||
return f"{self.name} - {self._number_pairs or 'all'} pairs from RemotePairlist."
|
||||
|
||||
@staticmethod
|
||||
def description() -> str:
|
||||
@@ -102,7 +87,7 @@ class RemotePairList(IPairList):
|
||||
},
|
||||
"number_assets": {
|
||||
"type": "number",
|
||||
"default": 30,
|
||||
"default": None,
|
||||
"description": "Number of assets",
|
||||
"help": "Number of assets to use from the pairlist.",
|
||||
},
|
||||
@@ -257,7 +242,8 @@ class RemotePairList(IPairList):
|
||||
|
||||
pairlist = expand_pairlist(pairlist, list(self._exchange.get_markets().keys()))
|
||||
pairlist = self._whitelist_for_active_markets(pairlist)
|
||||
pairlist = pairlist[: self._number_pairs]
|
||||
if self._number_pairs and (self._mode == "whitelist"):
|
||||
pairlist = pairlist[: self._number_pairs]
|
||||
|
||||
if pairlist:
|
||||
self._pair_cache["pairlist"] = pairlist.copy()
|
||||
@@ -314,5 +300,6 @@ class RemotePairList(IPairList):
|
||||
if filtered:
|
||||
self.log_once(f"Blacklist - Filtered out pairs: {filtered}", logger.info)
|
||||
|
||||
merged_list = merged_list[: self._number_pairs]
|
||||
if self._number_pairs and (self._mode == "whitelist"):
|
||||
merged_list = merged_list[: self._number_pairs]
|
||||
return merged_list
|
||||
|
||||
@@ -39,15 +39,6 @@ class ShuffleFilter(IPairList):
|
||||
maxsize=1000, ttl=timeframe_to_seconds(self._config["timeframe"])
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -28,15 +28,6 @@ class StaticPairList(IPairList):
|
||||
# Pair cache - only used for optimize modes
|
||||
self._bt_pair_cache: LRUCache = LRUCache(maxsize=1)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -53,15 +53,6 @@ class VolatilityFilter(IPairList):
|
||||
"either None (undefined), 'asc' or 'desc'"
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty List is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -28,7 +28,11 @@ def expand_pairlist(
|
||||
raise ValueError(f"Wildcard error in {pair_wc}, {err}")
|
||||
|
||||
# Remove wildcard pairs that didn't have a match.
|
||||
result = [element for element in result if re.fullmatch(r"^[A-Za-z0-9:/-]+$", element)]
|
||||
result = [
|
||||
element
|
||||
for element in result
|
||||
if re.fullmatch(r"^[\w:/-]+$", element) and "_" not in element
|
||||
]
|
||||
|
||||
else:
|
||||
for pair_wc in wildcardpl:
|
||||
|
||||
@@ -47,15 +47,6 @@ class RangeStabilityFilter(IPairList):
|
||||
"either None (undefined), 'asc' or 'desc'"
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty List is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
|
||||
@@ -697,9 +697,25 @@ class MarketResponse(BaseModel):
|
||||
exchange_id: str
|
||||
|
||||
|
||||
class CpuInfo(BaseModel):
|
||||
cpu: int
|
||||
pct: float
|
||||
|
||||
|
||||
class SysInfo(BaseModel):
|
||||
cpu_pct: list[float]
|
||||
ram_pct: float
|
||||
"""Information about the system running the bot based on psutil output/measurements
|
||||
|
||||
Note: cpu_pct is deprecated and may be removed in a future release. Use cpu_load instead.
|
||||
"""
|
||||
|
||||
cpu_pct: list[float] = Field(
|
||||
default=[], deprecated=True, description="Use cpu_load object instead"
|
||||
)
|
||||
cpu_load: list[CpuInfo]
|
||||
cpu_load_avg: dict[str, float]
|
||||
cpu_count: int = Field(description="Number of logical CPUs as provided by psutil")
|
||||
cpu_avg: float = Field(description="Average CPU load across all cores as provided by psutil")
|
||||
ram_pct: float = Field(description="RAM usage percentage as provided by psutil")
|
||||
|
||||
|
||||
class Health(BaseModel):
|
||||
|
||||
@@ -77,9 +77,13 @@ router_public = APIRouter()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router_public.api_route("/ping", methods=["GET", "HEAD"], response_model=Ping, tags=["Info"])
|
||||
@router_public.get("/ping", response_model=Ping, tags=["Info"])
|
||||
@router_public.head("/ping", response_model=Ping, tags=["Info"])
|
||||
def ping():
|
||||
"""simple ping"""
|
||||
"""simple ping to check if API is responsive
|
||||
|
||||
Performs no internal checks, just returns pong.
|
||||
"""
|
||||
return {"status": "pong"}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
|
||||
@@ -100,6 +101,19 @@ class FTJSONResponse(JSONResponse):
|
||||
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup logic
|
||||
if not ApiServer._message_stream:
|
||||
# Creates the MessageStream class on startup so it has access to the same event loop
|
||||
# as uvicorn
|
||||
ApiServer._message_stream = MessageStream()
|
||||
yield
|
||||
# Shutdown logic
|
||||
if ApiServer._message_stream:
|
||||
ApiServer._message_stream = None
|
||||
|
||||
|
||||
class ApiServer(RPCHandler):
|
||||
__instance = None
|
||||
__initialized = False
|
||||
@@ -137,6 +151,7 @@ class ApiServer(RPCHandler):
|
||||
redoc_url=None,
|
||||
default_response_class=FTJSONResponse,
|
||||
openapi_tags=_OPENAPI_TAGS,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
self.configure_app(self.app, self._config)
|
||||
self.start_api()
|
||||
@@ -261,24 +276,6 @@ class ApiServer(RPCHandler):
|
||||
)
|
||||
|
||||
app.add_exception_handler(RPCException, self.handle_rpc_exception)
|
||||
app.add_event_handler(event_type="startup", func=self._api_startup_event)
|
||||
app.add_event_handler(event_type="shutdown", func=self._api_shutdown_event)
|
||||
|
||||
async def _api_startup_event(self):
|
||||
"""
|
||||
Creates the MessageStream class on startup
|
||||
so it has access to the same event loop
|
||||
as uvicorn
|
||||
"""
|
||||
if not ApiServer._message_stream:
|
||||
ApiServer._message_stream = MessageStream()
|
||||
|
||||
async def _api_shutdown_event(self):
|
||||
"""
|
||||
Removes the MessageStream class on shutdown
|
||||
"""
|
||||
if ApiServer._message_stream:
|
||||
ApiServer._message_stream = None
|
||||
|
||||
def start_api(self):
|
||||
"""
|
||||
|
||||
+17
-1
@@ -1736,8 +1736,24 @@ class RPC:
|
||||
|
||||
@staticmethod
|
||||
def _rpc_sysinfo() -> dict[str, Any]:
|
||||
cpu_pct = psutil.cpu_percent(interval=0.1, percpu=True)
|
||||
load_avg = psutil.getloadavg()
|
||||
return {
|
||||
"cpu_pct": psutil.cpu_percent(interval=1, percpu=True),
|
||||
"cpu_pct": cpu_pct, # Deprecated, use cpu_load instead
|
||||
"cpu_load": [
|
||||
{
|
||||
"cpu": idx,
|
||||
"pct": pct,
|
||||
}
|
||||
for idx, pct in enumerate(cpu_pct)
|
||||
],
|
||||
"cpu_avg": sum(cpu_pct) / len(cpu_pct) if cpu_pct else 0.0,
|
||||
"cpu_load_avg": {
|
||||
"1m": load_avg[0],
|
||||
"5m": load_avg[1],
|
||||
"15m": load_avg[2],
|
||||
},
|
||||
"cpu_count": len(cpu_pct),
|
||||
"ram_pct": psutil.virtual_memory().percent,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from freqtrade_client.ft_rest_client import FtRestClient
|
||||
|
||||
|
||||
__version__ = "2026.2"
|
||||
__version__ = "2026.3"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Requirements for freqtrade client library
|
||||
requests==2.32.5
|
||||
requests==2.33.0
|
||||
python-rapidjson==1.23
|
||||
|
||||
+12
-43
@@ -77,29 +77,28 @@ dependencies = [
|
||||
# Requirements used for submodules
|
||||
plot = ["plotly>=4.0"]
|
||||
hyperopt = [
|
||||
"scipy",
|
||||
"scikit-learn",
|
||||
"optuna > 4.0.0",
|
||||
"cmaes",
|
||||
"filelock",
|
||||
"optuna > 4.0.0",
|
||||
"scikit-learn",
|
||||
"scipy",
|
||||
]
|
||||
freqai = [
|
||||
"scikit-learn",
|
||||
"datasieve>=0.1.5",
|
||||
"joblib",
|
||||
"lightgbm",
|
||||
"xgboost",
|
||||
"scikit-learn",
|
||||
"tensorboard",
|
||||
"datasieve>=0.1.5",
|
||||
"xgboost",
|
||||
]
|
||||
freqai_rl = [
|
||||
"torch; sys_platform != 'darwin' or platform_machine != 'x86_64'",
|
||||
"gymnasium",
|
||||
"stable-baselines3; sys_platform != 'darwin' or platform_machine != 'x86_64'",
|
||||
"sb3-contrib; sys_platform != 'darwin' or platform_machine != 'x86_64'",
|
||||
"stable-baselines3; sys_platform != 'darwin' or platform_machine != 'x86_64'",
|
||||
"torch; sys_platform != 'darwin' or platform_machine != 'x86_64'",
|
||||
"tqdm",
|
||||
]
|
||||
develop = [
|
||||
"isort",
|
||||
"mypy",
|
||||
"pre-commit",
|
||||
"pytest-asyncio",
|
||||
@@ -119,10 +118,10 @@ develop = [
|
||||
"types-tabulate",
|
||||
]
|
||||
jupyter = [
|
||||
"jupyter",
|
||||
"nbstripout",
|
||||
"ipykernel",
|
||||
"jupyter",
|
||||
"nbconvert",
|
||||
"nbstripout",
|
||||
]
|
||||
all = [
|
||||
"freqtrade[plot,hyperopt,freqai,freqai_rl,jupyter]",
|
||||
@@ -153,43 +152,13 @@ namespaces = true
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "freqtrade.__version__"}
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
exclude = '''
|
||||
(
|
||||
/(
|
||||
\.eggs # exclude a few common directories in the
|
||||
| \.git # root of the project
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| _build
|
||||
| buck-out
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
# Exclude vendor directory
|
||||
| vendor
|
||||
)
|
||||
'''
|
||||
|
||||
[tool.isort]
|
||||
line_length = 100
|
||||
profile = "black"
|
||||
# multi_line_output=3
|
||||
lines_after_imports=2
|
||||
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
|
||||
[tool.pytest]
|
||||
log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
addopts = ["--dist", "loadscope"]
|
||||
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = "--dist loadscope"
|
||||
|
||||
[tool.mypy]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-r requirements-freqai-rl.txt
|
||||
-r docs/requirements-docs.txt
|
||||
|
||||
ruff==0.15.1
|
||||
ruff==0.15.6
|
||||
mypy==1.19.1
|
||||
pre-commit==4.5.1
|
||||
pytest==9.0.2
|
||||
@@ -16,7 +16,6 @@ pytest-mock==3.15.1
|
||||
pytest-random-order==1.2.0
|
||||
pytest-timeout==2.4.0
|
||||
pytest-xdist==3.8.0
|
||||
isort==7.0.0
|
||||
# For datetime mocking
|
||||
time-machine==3.2.0
|
||||
|
||||
@@ -24,10 +23,14 @@ time-machine==3.2.0
|
||||
nbconvert==7.17.0
|
||||
|
||||
# mypy types
|
||||
scipy-stubs==1.17.0.2 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
scipy-stubs==1.17.1.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.20260107
|
||||
types-tabulate==0.9.0.20241207
|
||||
types-python-dateutil==2.9.0.20260124
|
||||
types-tabulate==0.10.0.20260308
|
||||
types-python-dateutil==2.9.0.20260305
|
||||
pip-audit==2.10.0
|
||||
# For build step in CI
|
||||
build==1.4.2
|
||||
# For pre-commit-update check
|
||||
pyyaml==6.0.3
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Required for hyperopt
|
||||
scipy==1.17.0
|
||||
scipy==1.17.1
|
||||
scikit-learn==1.8.0
|
||||
filelock==3.24.2
|
||||
filelock==3.25.2
|
||||
optuna==4.7.0
|
||||
cmaes==0.12.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Include all requirements to run the bot.
|
||||
-r requirements.txt
|
||||
|
||||
plotly==6.5.2
|
||||
plotly==6.6.0
|
||||
|
||||
+15
-15
@@ -1,4 +1,4 @@
|
||||
numpy==2.4.2
|
||||
numpy==2.4.3
|
||||
pandas==2.3.3
|
||||
bottleneck==1.6.0
|
||||
numexpr==2.14.1
|
||||
@@ -7,25 +7,25 @@ ft-pandas-ta==0.3.16
|
||||
ta-lib==0.6.8
|
||||
technical==1.5.4
|
||||
|
||||
ccxt==4.5.39
|
||||
cryptography==46.0.5
|
||||
ccxt==4.5.44
|
||||
cryptography==46.0.6
|
||||
aiohttp==3.13.3
|
||||
SQLAlchemy==2.0.46
|
||||
SQLAlchemy==2.0.48
|
||||
python-telegram-bot==22.6
|
||||
# can't be hard-pinned due to telegram-bot pinning httpx with ~
|
||||
httpx>=0.24.1
|
||||
humanize==4.15.0
|
||||
cachetools==7.0.1
|
||||
requests==2.32.5
|
||||
cachetools==7.0.5
|
||||
requests==2.33.0
|
||||
urllib3==2.6.3
|
||||
certifi==2026.1.4
|
||||
certifi==2026.2.25
|
||||
jsonschema==4.26.0
|
||||
tabulate==0.9.0
|
||||
tabulate==0.10.0
|
||||
pycoingecko==3.2.0
|
||||
jinja2==3.1.6
|
||||
joblib==1.5.3
|
||||
rich==14.3.2
|
||||
pyarrow==23.0.0; platform_machine != 'armv7l'
|
||||
rich==14.3.3
|
||||
pyarrow==23.0.1; platform_machine != 'armv7l'
|
||||
|
||||
|
||||
# Load ticker files 30% faster
|
||||
@@ -37,10 +37,10 @@ orjson==3.11.7
|
||||
sdnotify==0.3.2
|
||||
|
||||
# API Server
|
||||
fastapi==0.129.0
|
||||
fastapi==0.135.1
|
||||
pydantic==2.12.5
|
||||
uvicorn==0.40.0
|
||||
pyjwt==2.11.0
|
||||
uvicorn==0.41.0
|
||||
pyjwt==2.12.1
|
||||
aiofiles==25.1.0
|
||||
psutil==7.2.2
|
||||
|
||||
@@ -49,7 +49,7 @@ questionary==2.1.1
|
||||
prompt-toolkit==3.0.52
|
||||
# Extensions to datetime library
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.2
|
||||
pytz==2026.1.post1
|
||||
|
||||
#Futures
|
||||
schedule==1.2.2
|
||||
@@ -58,5 +58,5 @@ schedule==1.2.2
|
||||
websockets==16.0
|
||||
janus==2.0.0
|
||||
|
||||
ast-comments==1.2.3
|
||||
ast-comments==1.3.0
|
||||
packaging==26.0
|
||||
|
||||
@@ -83,6 +83,30 @@ def test_setup_utils_configuration():
|
||||
assert config["dry_run"] is False
|
||||
|
||||
|
||||
def test_setup_utils_configuration_download_convert_flag():
|
||||
args = [
|
||||
"download-data",
|
||||
"--exchange",
|
||||
"kraken",
|
||||
"--pairs",
|
||||
"ETH/USDT",
|
||||
"--dl-trades",
|
||||
"--convert",
|
||||
"-t",
|
||||
"1m",
|
||||
"--timerange",
|
||||
"20260101-",
|
||||
"--config",
|
||||
"tests/testdata/testconfigs/main_test_config.json",
|
||||
]
|
||||
|
||||
config = setup_utils_configuration(get_args(args), RunMode.UTIL_EXCHANGE)
|
||||
|
||||
assert config["download_trades"] is True
|
||||
assert config["convert_trades"] is True
|
||||
assert config["timeframes"] == ["1m"]
|
||||
|
||||
|
||||
def test_start_trading_fail(mocker, caplog):
|
||||
mocker.patch("freqtrade.worker.Worker.run", MagicMock(side_effect=OperationalException))
|
||||
|
||||
@@ -186,7 +210,8 @@ def test_list_exchanges(capsys):
|
||||
captured = capsys.readouterr()
|
||||
assert re.search(r"Exchanges available for Freqtrade.*", captured.out)
|
||||
assert re.search(r".*binance.*", captured.out)
|
||||
assert not re.search(r".*kraken.*", captured.out)
|
||||
assert re.search(r"\bkrakenfutures\b", captured.out)
|
||||
assert not re.search(r"\bmyokx\b", captured.out)
|
||||
|
||||
|
||||
def test_list_timeframes(mocker, capsys):
|
||||
|
||||
@@ -516,6 +516,8 @@ def test_calculate_sqn_cases(profits, starting_balance, expected_sqn, descriptio
|
||||
(1000, 1500, 365, 0.5),
|
||||
(1000, 1500, 100, 3.3927), # sub year
|
||||
(0.01000000, 0.01762792, 120, 4.6087), # sub year BTC values
|
||||
(1000, 1010, 0, 0.0), # zero days
|
||||
(-100, 100, 365, 0.0), # negative starting balance
|
||||
],
|
||||
)
|
||||
def test_calculate_cagr(start, end, days, expected):
|
||||
|
||||
@@ -56,9 +56,9 @@ def test_datahandler_ohlcv_regex(filename, pair, timeframe, candletype):
|
||||
("USDT_BUSD", "USDT/BUSD"),
|
||||
("BTC_USDT_USDT", "BTC/USDT:USDT"), # Futures
|
||||
("XRP_USDT_USDT", "XRP/USDT:USDT"), # futures
|
||||
("BTC-PERP", "BTC-PERP"),
|
||||
("BTC-PERP_USDT", "BTC-PERP:USDT"),
|
||||
("XYZ-XRP_USDT_USDT", "XYZ-XRP/USDT:USDT"), # hip3 futures
|
||||
("UNITTEST_USDT", "UNITTEST/USDT"),
|
||||
("币安人生_USDT_USDT", "币安人生/USDT:USDT"), # futures
|
||||
],
|
||||
)
|
||||
def test_rebuild_pair_from_filename(pair, expected):
|
||||
|
||||
@@ -960,7 +960,8 @@ def test_validate_ordertypes_stop_advanced(default_conf, mocker, exchange_name,
|
||||
ExchangeResolver.load_exchange(default_conf)
|
||||
else:
|
||||
with pytest.raises(
|
||||
OperationalException, match=r"On exchange stoploss price type is not supported for .*"
|
||||
OperationalException,
|
||||
match=r"On exchange stoploss price type '.*' is not supported for .*",
|
||||
):
|
||||
ExchangeResolver.load_exchange(default_conf)
|
||||
|
||||
@@ -1968,6 +1969,7 @@ def test_fetch_orders(default_conf, mocker, exchange_name, limit_order):
|
||||
|
||||
api_mock.fetch_orders = MagicMock(side_effect=return_value)
|
||||
api_mock.fetch_open_orders = MagicMock(return_value=[limit_order["buy"]])
|
||||
api_mock.fetch_canceled_orders = MagicMock(return_value=[])
|
||||
api_mock.fetch_closed_orders = MagicMock(return_value=[limit_order["buy"]])
|
||||
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
@@ -2000,6 +2002,8 @@ def test_fetch_orders(default_conf, mocker, exchange_name, limit_order):
|
||||
return True
|
||||
if endpoint == "fetchOpenOrders":
|
||||
return True
|
||||
if endpoint == "fetchCanceledOrders":
|
||||
return True
|
||||
|
||||
if exchange_name == "okx":
|
||||
# Special OKX case is tested separately
|
||||
@@ -2012,6 +2016,7 @@ def test_fetch_orders(default_conf, mocker, exchange_name, limit_order):
|
||||
assert api_mock.fetch_orders.call_count == 0
|
||||
assert api_mock.fetch_open_orders.call_count == expected
|
||||
assert api_mock.fetch_closed_orders.call_count == expected
|
||||
assert api_mock.fetch_canceled_orders.call_count == expected
|
||||
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
|
||||
@@ -2031,12 +2036,77 @@ def test_fetch_orders(default_conf, mocker, exchange_name, limit_order):
|
||||
api_mock.fetch_orders = MagicMock(side_effect=ccxt.NotSupported())
|
||||
api_mock.fetch_open_orders.reset_mock()
|
||||
api_mock.fetch_closed_orders.reset_mock()
|
||||
api_mock.fetch_canceled_orders.reset_mock()
|
||||
|
||||
exchange.fetch_orders("mocked", start_time)
|
||||
|
||||
assert api_mock.fetch_orders.call_count == expected
|
||||
assert api_mock.fetch_open_orders.call_count == expected
|
||||
assert api_mock.fetch_closed_orders.call_count == expected
|
||||
assert api_mock.fetch_canceled_orders.call_count == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exchange_name", [ex for ex in EXCHANGES if ex != "bybit"])
|
||||
@pytest.mark.parametrize(
|
||||
"call_config, expected",
|
||||
[
|
||||
# call_config: (fetch_orders, fetch_open, fetch_closed, fetch_canceled)
|
||||
# expected: (fetch_orders_calls, fetch_open_calls, fetch_closed_calls, fetch_canceled_calls)
|
||||
((True, False, False, False), (1, 0, 0, 0)),
|
||||
((False, True, True, False), (0, 1, 1, 0)),
|
||||
((False, True, False, True), (0, 1, 0, 1)),
|
||||
((False, True, True, True), (0, 1, 1, 1)),
|
||||
],
|
||||
)
|
||||
def test_fetch_orders_multi(
|
||||
default_conf, mocker, exchange_name, limit_order, call_config, expected
|
||||
):
|
||||
default_conf["dry_run"] = False
|
||||
api_mock = MagicMock()
|
||||
call_count = 1
|
||||
|
||||
def return_value(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 2
|
||||
return [
|
||||
{**limit_order["buy"], "id": call_count},
|
||||
{**limit_order["sell"], "id": call_count + 1},
|
||||
]
|
||||
|
||||
api_mock.fetch_orders = MagicMock(side_effect=return_value)
|
||||
api_mock.fetch_open_orders = MagicMock(return_value=[limit_order["buy"]])
|
||||
api_mock.fetch_canceled_orders = MagicMock(return_value=[limit_order["sell"]])
|
||||
api_mock.fetch_closed_orders = MagicMock(return_value=[limit_order["buy"]])
|
||||
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
start_time = datetime.now(UTC) - timedelta(days=20)
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange_name)
|
||||
|
||||
def has_resp(_, endpoint):
|
||||
|
||||
if endpoint == "fetchOrders":
|
||||
return call_config[0]
|
||||
if endpoint == "fetchClosedOrders":
|
||||
return call_config[2]
|
||||
if endpoint == "fetchOpenOrders":
|
||||
return call_config[1]
|
||||
if endpoint == "fetchCanceledOrders":
|
||||
return call_config[3]
|
||||
|
||||
if exchange_name == "okx":
|
||||
# Special OKX case is tested separately
|
||||
return
|
||||
|
||||
mocker.patch(f"{EXMS}.exchange_has", has_resp)
|
||||
|
||||
#
|
||||
resp = exchange.fetch_orders("mocked", start_time)
|
||||
assert api_mock.fetch_orders.call_count == expected[0]
|
||||
assert api_mock.fetch_open_orders.call_count == expected[1]
|
||||
assert api_mock.fetch_closed_orders.call_count == expected[2]
|
||||
assert api_mock.fetch_canceled_orders.call_count == expected[3]
|
||||
assert len(resp) == 2 * expected[0] + expected[1] + expected[2] + expected[3]
|
||||
|
||||
|
||||
def test_fetch_trading_fees(default_conf, mocker):
|
||||
@@ -5274,19 +5344,16 @@ def test_get_max_leverage_from_margin(default_conf, mocker, pair, nominal_value,
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"size,funding_rate,mark_price,time_in_ratio,funding_fee,kraken_fee",
|
||||
"size,funding_rate,mark_price,funding_fee",
|
||||
[
|
||||
(10, 0.0001, 2.0, 1.0, 0.002, 0.002),
|
||||
(10, 0.0002, 2.0, 0.01, 0.004, 0.00004),
|
||||
(10, 0.0002, 2.5, None, 0.005, None),
|
||||
(10, 0.0002, nan, None, 0.0, None),
|
||||
(10, 0.0001, 2.0, 0.002),
|
||||
(10, 0.0002, 2.0, 0.004),
|
||||
(10, 0.0002, 2.5, 0.005),
|
||||
(10, 0.0002, nan, 0.0),
|
||||
],
|
||||
)
|
||||
def test_calculate_funding_fees(
|
||||
default_conf, mocker, size, funding_rate, mark_price, funding_fee, kraken_fee, time_in_ratio
|
||||
):
|
||||
def test_calculate_funding_fees(default_conf, mocker, size, funding_rate, mark_price, funding_fee):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
kraken = get_patched_exchange(mocker, default_conf, exchange="kraken")
|
||||
prior_date = timeframe_to_prev_date("1h", datetime.now(UTC) - timedelta(hours=1))
|
||||
trade_date = timeframe_to_prev_date("1h", datetime.now(UTC))
|
||||
funding_rates = DataFrame(
|
||||
@@ -5310,35 +5377,10 @@ def test_calculate_funding_fees(
|
||||
is_short=True,
|
||||
open_date=trade_date,
|
||||
close_date=trade_date,
|
||||
time_in_ratio=time_in_ratio,
|
||||
)
|
||||
== funding_fee
|
||||
)
|
||||
|
||||
if kraken_fee is None:
|
||||
with pytest.raises(OperationalException):
|
||||
kraken.calculate_funding_fees(
|
||||
df,
|
||||
amount=size,
|
||||
is_short=True,
|
||||
open_date=trade_date,
|
||||
close_date=trade_date,
|
||||
time_in_ratio=time_in_ratio,
|
||||
)
|
||||
|
||||
else:
|
||||
assert (
|
||||
kraken.calculate_funding_fees(
|
||||
df,
|
||||
amount=size,
|
||||
is_short=True,
|
||||
open_date=trade_date,
|
||||
close_date=trade_date,
|
||||
time_in_ratio=time_in_ratio,
|
||||
)
|
||||
== kraken_fee
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mark_price,funding_rate,futures_funding_rate",
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
"""Tests for Kraken Futures exchange class"""
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
import ccxt
|
||||
import pytest
|
||||
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
ExchangeError,
|
||||
InvalidOrderException,
|
||||
OperationalException,
|
||||
TemporaryError,
|
||||
)
|
||||
from freqtrade.exchange.krakenfutures import Krakenfutures
|
||||
from tests.conftest import EXMS, get_patched_exchange
|
||||
|
||||
|
||||
ExchangeBase = Krakenfutures.__mro__[1] # freqtrade.exchange.exchange.Exchange
|
||||
|
||||
|
||||
# --- _ft_has and OHLCV tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_ft_has_overrides():
|
||||
"""Test that _ft_has contains Kraken Futures stoploss settings."""
|
||||
ft_has = Krakenfutures._ft_has
|
||||
assert ft_has["stoploss_on_exchange"] is True
|
||||
assert ft_has["stoploss_order_types"] == {"limit": "limit", "market": "market"}
|
||||
assert ft_has["stoploss_query_requires_stop_flag"] is True
|
||||
assert ft_has["stop_price_prop"] == "stopPrice"
|
||||
assert ft_has["stop_price_param"] == "triggerPrice"
|
||||
assert ft_has["stop_price_type_field"] == "triggerSignal"
|
||||
|
||||
|
||||
# --- _adjust_krakenfutures_order average price tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_adjust_order_computes_average_from_trades(mocker, default_conf):
|
||||
"""Compute VWAP average price from trades when CCXT returns None."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
order = {
|
||||
"id": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"status": "closed",
|
||||
"filled": 0.0004,
|
||||
"average": None,
|
||||
"timestamp": 1771354195241,
|
||||
}
|
||||
trades = [
|
||||
{
|
||||
"amount": 0.0002,
|
||||
"price": 67800.0,
|
||||
"cost": 13.56,
|
||||
"takerOrMaker": "taker",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"fee": None,
|
||||
},
|
||||
{
|
||||
"amount": 0.0002,
|
||||
"price": 67900.0,
|
||||
"cost": 13.58,
|
||||
"takerOrMaker": "taker",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"fee": None,
|
||||
},
|
||||
]
|
||||
mocker.patch.object(ex, "get_trades_for_order", return_value=trades)
|
||||
|
||||
result = ex._adjust_krakenfutures_order(order)
|
||||
assert result["average"] == pytest.approx(67850.0)
|
||||
assert result["cost"] == pytest.approx(27.14)
|
||||
|
||||
|
||||
def test_krakenfutures_adjust_order_skips_open_orders(mocker, default_conf):
|
||||
"""Don't fetch trades for open orders."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
order = {
|
||||
"id": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"status": "open",
|
||||
"filled": 0,
|
||||
"average": None,
|
||||
"timestamp": 1771354195241,
|
||||
}
|
||||
trades_mock = mocker.patch.object(ex, "get_trades_for_order")
|
||||
|
||||
result = ex._adjust_krakenfutures_order(order)
|
||||
assert result["average"] is None
|
||||
trades_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_krakenfutures_adjust_order_handles_none_filled(mocker, default_conf):
|
||||
"""Don't crash or fetch trades when filled is None."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
order = {
|
||||
"id": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"status": "closed",
|
||||
"filled": None,
|
||||
"average": None,
|
||||
"timestamp": 1771354195241,
|
||||
}
|
||||
trades_mock = mocker.patch.object(ex, "get_trades_for_order")
|
||||
|
||||
result = ex._adjust_krakenfutures_order(order)
|
||||
assert result["average"] is None
|
||||
trades_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_krakenfutures_adjust_order_recomputes_existing_average(mocker, default_conf):
|
||||
"""Recompute average from fills even when CCXT already provided one."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
order = {
|
||||
"id": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"status": "closed",
|
||||
"filled": 0.0004,
|
||||
"average": 67843.0,
|
||||
"timestamp": 1771354195241,
|
||||
}
|
||||
trades = [
|
||||
{
|
||||
"amount": 0.0002,
|
||||
"price": 67800.0,
|
||||
"cost": 13.56,
|
||||
"takerOrMaker": "taker",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"fee": None,
|
||||
},
|
||||
{
|
||||
"amount": 0.0002,
|
||||
"price": 67900.0,
|
||||
"cost": 13.58,
|
||||
"takerOrMaker": "taker",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"fee": None,
|
||||
},
|
||||
]
|
||||
trades_mock = mocker.patch.object(ex, "get_trades_for_order", return_value=trades)
|
||||
|
||||
result = ex._adjust_krakenfutures_order(order)
|
||||
assert result["average"] == pytest.approx(67850.0)
|
||||
assert result["cost"] == pytest.approx(27.14)
|
||||
trades_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_krakenfutures_adjust_order_no_trades_found(mocker, default_conf):
|
||||
"""Leave average as None when no trades are found."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
order = {
|
||||
"id": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"status": "closed",
|
||||
"filled": 0.0004,
|
||||
"average": None,
|
||||
"timestamp": 1771354195241,
|
||||
}
|
||||
mocker.patch.object(ex, "get_trades_for_order", return_value=[])
|
||||
|
||||
result = ex._adjust_krakenfutures_order(order)
|
||||
assert result["average"] is None
|
||||
|
||||
|
||||
# --- get_trades_for_order fee enrichment tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_get_trades_enriches_fees(mocker, default_conf):
|
||||
"""Calculate fees from market fee schedule when CCXT returns fee: None."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
raw_trades = [
|
||||
{
|
||||
"amount": 0.0004,
|
||||
"price": 67843.0,
|
||||
"cost": 27.14,
|
||||
"order": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"takerOrMaker": "taker",
|
||||
"fee": {"cost": None, "currency": None},
|
||||
},
|
||||
]
|
||||
mocker.patch.object(
|
||||
ExchangeBase,
|
||||
"get_trades_for_order",
|
||||
return_value=raw_trades,
|
||||
)
|
||||
# Re-patch markets property with fee rates for BTC/USD:USD
|
||||
kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}}
|
||||
mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets))
|
||||
|
||||
result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock())
|
||||
assert len(result) == 1
|
||||
assert result[0]["fee"]["cost"] == pytest.approx(27.14 * 0.0005)
|
||||
assert result[0]["fee"]["currency"] == "USD"
|
||||
assert result[0]["fee"]["rate"] == 0.0005
|
||||
|
||||
|
||||
def test_krakenfutures_get_trades_uses_maker_rate(mocker, default_conf):
|
||||
"""Use maker fee rate when fillType is maker."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
raw_trades = [
|
||||
{
|
||||
"amount": 0.0004,
|
||||
"price": 67843.0,
|
||||
"cost": 27.14,
|
||||
"order": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"takerOrMaker": "maker",
|
||||
"fee": None,
|
||||
},
|
||||
]
|
||||
mocker.patch.object(
|
||||
ExchangeBase,
|
||||
"get_trades_for_order",
|
||||
return_value=raw_trades,
|
||||
)
|
||||
kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}}
|
||||
mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets))
|
||||
|
||||
result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock())
|
||||
assert result[0]["fee"]["cost"] == pytest.approx(27.14 * 0.0002)
|
||||
assert result[0]["fee"]["rate"] == 0.0002
|
||||
|
||||
|
||||
def test_krakenfutures_get_trades_preserves_existing_fees(mocker, default_conf):
|
||||
"""Don't overwrite fees if CCXT already provided them."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
existing_fee = {"cost": 0.01, "currency": "USD", "rate": 0.0005}
|
||||
raw_trades = [
|
||||
{
|
||||
"amount": 0.0004,
|
||||
"price": 67843.0,
|
||||
"cost": 27.14,
|
||||
"order": "abc",
|
||||
"symbol": "BTC/USD:USD",
|
||||
"takerOrMaker": "taker",
|
||||
"fee": existing_fee,
|
||||
},
|
||||
]
|
||||
mocker.patch.object(
|
||||
ExchangeBase,
|
||||
"get_trades_for_order",
|
||||
return_value=raw_trades,
|
||||
)
|
||||
kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}}
|
||||
mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets))
|
||||
|
||||
result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock())
|
||||
# Should keep existing fee, not recalculate
|
||||
assert result[0]["fee"] == existing_fee
|
||||
|
||||
|
||||
# --- fetch_order fallback tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_conf):
|
||||
"""Fallback to fetch_closed_orders when fetch_order can't find the order."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found"))
|
||||
open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
closed_fetch = mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_closed_orders",
|
||||
return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "closed"}],
|
||||
create=True,
|
||||
)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
|
||||
res = ex.fetch_order("abc", "BTC/USD:USD")
|
||||
assert res["id"] == "abc"
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default_conf):
|
||||
"""Fallback to fetch_canceled_orders when closed orders don't contain the order."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.ExchangeError("UUID too large"))
|
||||
open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
canceled_fetch = mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_canceled_orders",
|
||||
return_value=[{"id": "def", "symbol": "BTC/USD:USD", "status": "canceled"}],
|
||||
create=True,
|
||||
)
|
||||
canceled_fetch.__name__ = "fetch_canceled_orders"
|
||||
|
||||
res = ex.fetch_order("def", "BTC/USD:USD")
|
||||
assert res["id"] == "def"
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_returns_direct_ccxt_result(mocker, default_conf):
|
||||
"""Use direct CCXT fetch_order result when available."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
ccxt_order = {"id": "live-123", "symbol": "BTC/USD:USD", "status": "open"}
|
||||
converted = {"id": "live-123", "status": "open"}
|
||||
mocker.patch.object(ex._api, "fetch_order", return_value=ccxt_order)
|
||||
converter = mocker.patch.object(ex, "_order_contracts_to_amount", return_value=converted)
|
||||
fallback = mocker.patch.object(ex, "_fetch_order_fallback")
|
||||
|
||||
res = ex.fetch_order("live-123", "BTC/USD:USD")
|
||||
|
||||
assert res == converted
|
||||
converter.assert_called_once_with(ccxt_order)
|
||||
fallback.assert_not_called()
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_strips_stop_from_status_query(mocker, default_conf):
|
||||
"""Direct CCXT fetch_order status lookup should not receive stop/trigger params."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
ccxt_order = {"id": "order-1", "symbol": "BTC/USD:USD", "status": "open"}
|
||||
fetch_order = mocker.patch.object(ex._api, "fetch_order", return_value=ccxt_order)
|
||||
|
||||
# Simulate call from base fetch_stoploss_order which adds stop=True
|
||||
ex.fetch_order("order-1", "BTC/USD:USD", params={"stop": True})
|
||||
|
||||
# stop should be stripped from the direct CCXT status call
|
||||
fetch_order.assert_called_once_with("order-1", "BTC/USD:USD", params={})
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_raises_invalid_when_not_found(mocker, default_conf):
|
||||
"""Raise InvalidOrderException (non-retrying) when order is not in any endpoint."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found"))
|
||||
mocker.patch.object(ex, "_fetch_order_fallback", return_value=None)
|
||||
|
||||
with pytest.raises(InvalidOrderException, match="Order not found in any endpoint"):
|
||||
ex.fetch_order("abc", "BTC/USD:USD", count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_invalid_order_maps_exception(mocker, default_conf):
|
||||
"""Map ccxt.InvalidOrder to InvalidOrderException."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.InvalidOrder("bad order"))
|
||||
|
||||
with pytest.raises(InvalidOrderException, match="bad order"):
|
||||
ex.fetch_order("abc", "BTC/USD:USD", count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_ddos_maps_exception(mocker, default_conf):
|
||||
"""Map ccxt.DDoSProtection to DDosProtection."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.DDoSProtection("ratelimit"))
|
||||
|
||||
with pytest.raises(DDosProtection):
|
||||
ex.fetch_order("abc", "BTC/USD:USD", count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_baseerror_maps_exception(mocker, default_conf):
|
||||
"""Map generic ccxt.BaseError to OperationalException."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.BaseError("unexpected"))
|
||||
|
||||
with pytest.raises(OperationalException):
|
||||
ex.fetch_order("abc", "BTC/USD:USD", count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_fallback_returns_none(mocker, default_conf):
|
||||
"""Return None when order is not found in any endpoint."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
canceled_fetch = mocker.patch.object(
|
||||
ex._api, "fetch_canceled_orders", return_value=[], create=True
|
||||
)
|
||||
canceled_fetch.__name__ = "fetch_canceled_orders"
|
||||
|
||||
res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {})
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_fallback_returns_open_order_first(mocker, default_conf):
|
||||
"""Return immediately when order is found in open orders."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
open_fetch = mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_open_orders",
|
||||
return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "open"}],
|
||||
create=True,
|
||||
)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
canceled_fetch = mocker.patch.object(
|
||||
ex._api, "fetch_canceled_orders", return_value=[], create=True
|
||||
)
|
||||
canceled_fetch.__name__ = "fetch_canceled_orders"
|
||||
|
||||
res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {})
|
||||
|
||||
assert res is not None
|
||||
assert res["id"] == "abc"
|
||||
open_fetch.assert_called_once()
|
||||
closed_fetch.assert_not_called()
|
||||
canceled_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_dry_run(mocker, default_conf):
|
||||
"""Test fetch_order uses dry_run order in dry_run mode."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = True
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
dry_order = {"id": "dry-123", "status": "open"}
|
||||
mocker.patch.object(ex, "fetch_dry_run_order", return_value=dry_order)
|
||||
|
||||
res = ex.fetch_order("dry-123", "BTC/USD:USD")
|
||||
assert res["id"] == "dry-123"
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_finds_stoploss_via_stop_param(mocker, default_conf):
|
||||
"""Test fetch_order finds stoploss orders via closed orders fallback with stop=True."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found"))
|
||||
open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
# With stop=True, CCXT queries trigger history endpoint
|
||||
closed_fetch = mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_closed_orders",
|
||||
return_value=[
|
||||
{"id": "trigger-123", "symbol": "BTC/USD:USD", "status": "closed"},
|
||||
],
|
||||
create=True,
|
||||
)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
|
||||
# Simulate what base class fetch_stoploss_order does (adds stop=True)
|
||||
res = ex.fetch_order("trigger-123", "BTC/USD:USD", params={"stop": True})
|
||||
assert res["id"] == "trigger-123"
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_fallback_passes_stop_to_history(mocker, default_conf):
|
||||
"""Stoploss query (stop=True) should pass through to closed/canceled endpoints."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
|
||||
closed_order = {"id": "sl-123", "symbol": "BTC/USD:USD", "status": "closed"}
|
||||
closed_fetch = mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_closed_orders",
|
||||
return_value=[closed_order],
|
||||
create=True,
|
||||
)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
|
||||
res = ex._fetch_order_fallback("sl-123", "BTC/USD:USD", {"stop": True})
|
||||
|
||||
assert res is not None
|
||||
assert res["id"] == "sl-123"
|
||||
# Verify stop=True was passed to closed orders (CCXT maps stop→trigger)
|
||||
closed_fetch.assert_called_once_with("BTC/USD:USD", params={"stop": True})
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_fallback_strips_stop_from_open_orders(mocker, default_conf):
|
||||
"""Open orders query should not receive stop/trigger flags."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
closed_fetch.__name__ = "fetch_closed_orders"
|
||||
canceled_fetch = mocker.patch.object(
|
||||
ex._api, "fetch_canceled_orders", return_value=[], create=True
|
||||
)
|
||||
canceled_fetch.__name__ = "fetch_canceled_orders"
|
||||
|
||||
ex._fetch_order_fallback("abc", "BTC/USD:USD", {"stop": True})
|
||||
|
||||
# stop should be stripped from open orders call
|
||||
open_fetch.assert_called_once_with("BTC/USD:USD", params={})
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_propagates_exchange_errors_from_fallback(mocker, default_conf):
|
||||
"""Fallback list fetch should not hide exchange-level failures."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found"))
|
||||
open_fetch = mocker.patch.object(
|
||||
ex._api, "fetch_open_orders", side_effect=ccxt.ExchangeError("service unavailable")
|
||||
)
|
||||
open_fetch.__name__ = "fetch_open_orders"
|
||||
|
||||
with pytest.raises(TemporaryError):
|
||||
ex.fetch_order("abc", "BTC/USD:USD", count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_order_exchangeerror_uses_fallback(mocker, default_conf):
|
||||
"""ExchangeError from fetch_order should trigger fallback lookup."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
fallback_order = {"id": "abc", "symbol": "BTC/USD:USD", "status": "closed"}
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.ExchangeError("temporary"))
|
||||
fallback = mocker.patch.object(ex, "_fetch_order_fallback", return_value=fallback_order)
|
||||
|
||||
result = ex.fetch_order("abc", "BTC/USD:USD", count=0)
|
||||
|
||||
assert result == fallback_order
|
||||
fallback.assert_called_once_with("abc", "BTC/USD:USD", {})
|
||||
|
||||
|
||||
def test_krakenfutures_find_order_in_list_handles_ordernotfound(mocker, default_conf):
|
||||
"""OrderNotFound in list fetch is treated as a missing order."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
def raise_order_not_found(_symbol, params=None):
|
||||
raise ccxt.OrderNotFound("missing")
|
||||
|
||||
assert ex._find_order_in_list(raise_order_not_found, "BTC/USD:USD", {}, "abc") is None
|
||||
|
||||
|
||||
def test_krakenfutures_find_order_in_list_maps_ddos(mocker, default_conf):
|
||||
"""DDoS errors from list fetch are mapped to DDosProtection."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
def raise_ddos(_symbol, params=None):
|
||||
raise ccxt.DDoSProtection("ratelimit")
|
||||
|
||||
with pytest.raises(DDosProtection):
|
||||
ex._find_order_in_list(raise_ddos, "BTC/USD:USD", {}, "abc")
|
||||
|
||||
|
||||
def test_krakenfutures_find_order_in_list_maps_temporary(mocker, default_conf):
|
||||
"""OperationFailed/ExchangeError from list fetch map to TemporaryError."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
def raise_temp(_symbol, params=None):
|
||||
raise ccxt.OperationFailed("temporary")
|
||||
|
||||
with pytest.raises(TemporaryError):
|
||||
ex._find_order_in_list(raise_temp, "BTC/USD:USD", {}, "abc")
|
||||
|
||||
|
||||
def test_krakenfutures_find_order_in_list_maps_operational(mocker, default_conf):
|
||||
"""Unexpected BaseError from list fetch maps to OperationalException."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
def raise_base(_symbol, params=None):
|
||||
raise ccxt.BaseError("unexpected")
|
||||
|
||||
with pytest.raises(OperationalException):
|
||||
ex._find_order_in_list(raise_base, "BTC/USD:USD", {}, "abc")
|
||||
|
||||
|
||||
# --- Stoploss tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf):
|
||||
"""Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly."""
|
||||
api_mock = MagicMock()
|
||||
api_mock.create_order = MagicMock(return_value={"id": "order-id", "info": {"foo": "bar"}})
|
||||
|
||||
conf = deepcopy(default_conf)
|
||||
conf["dry_run"] = False
|
||||
conf["trading_mode"] = TradingMode.FUTURES
|
||||
conf["margin_mode"] = MarginMode.ISOLATED
|
||||
|
||||
mocker.patch(f"{EXMS}.amount_to_precision", lambda s, x, y: y)
|
||||
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
|
||||
|
||||
ex = get_patched_exchange(mocker, conf, api_mock, exchange="krakenfutures")
|
||||
|
||||
ex.create_stoploss(
|
||||
pair="ETH/BTC",
|
||||
amount=1,
|
||||
stop_price=90000.0,
|
||||
side="sell",
|
||||
order_types={"stoploss": "market", "stoploss_price_type": "mark"},
|
||||
leverage=1.0,
|
||||
)
|
||||
|
||||
call_args = api_mock.create_order.call_args
|
||||
params = call_args[1].get("params") if call_args[1] else call_args[0][5]
|
||||
|
||||
assert params["triggerPrice"] == 90000.0
|
||||
assert params["triggerSignal"] == "mark"
|
||||
assert params["reduceOnly"] is True
|
||||
|
||||
|
||||
# --- Funding fees tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf):
|
||||
"""Use funding fee helper in futures mode."""
|
||||
conf = dict(default_conf)
|
||||
conf["trading_mode"] = TradingMode.FUTURES
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
helper = mocker.patch.object(ex, "_fetch_and_calculate_funding_fees", return_value=1.23)
|
||||
open_date = datetime.now(UTC)
|
||||
|
||||
assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, open_date) == 1.23
|
||||
helper.assert_called_once_with("BTC/USD:USD", 0.1, False, open_date)
|
||||
|
||||
|
||||
def test_krakenfutures_get_funding_fees_futures_exchange_error(mocker, default_conf):
|
||||
"""Return 0.0 when funding fee retrieval fails."""
|
||||
conf = dict(default_conf)
|
||||
conf["trading_mode"] = TradingMode.FUTURES
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex, "_fetch_and_calculate_funding_fees", side_effect=ExchangeError("fail"))
|
||||
|
||||
assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, None) == 0.0
|
||||
|
||||
|
||||
def test_krakenfutures_get_funding_fees_spot_returns_zero(mocker, default_conf):
|
||||
"""Return 0.0 outside futures mode without calling the helper."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
helper = mocker.patch.object(ex, "_fetch_and_calculate_funding_fees")
|
||||
|
||||
assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, None) == 0.0
|
||||
helper.assert_not_called()
|
||||
|
||||
|
||||
# --- Balance tests (flex account USD synthesis) ---
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_flex_account_synthesizes_usd(mocker, default_conf):
|
||||
"""Test that flex account availableMargin/portfolioValue are synthesized as USD balance."""
|
||||
default_conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
flex_response = {
|
||||
"EUR": {"free": 100.0, "used": 0.0, "total": 100.0},
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"availableMargin": "950.50",
|
||||
"marginEquity": "1000.00",
|
||||
"portfolioValue": "1050.00", # Should be ignored, marginEquity preferred
|
||||
"currencies": {"EUR": {"quantity": "100", "value": "105.00"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
"free": {"EUR": 100.0},
|
||||
"used": {"EUR": 0.0},
|
||||
"total": {"EUR": 100.0},
|
||||
}
|
||||
mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response)
|
||||
|
||||
balances = ex.get_balances()
|
||||
|
||||
# USD should be synthesized from flex account
|
||||
assert "USD" in balances
|
||||
assert balances["USD"]["free"] == 950.50
|
||||
assert balances["USD"]["total"] == 1000.00
|
||||
# used = total - free = 1000.00 - 950.50 = 49.50
|
||||
assert balances["USD"]["used"] == 49.50
|
||||
# EUR should still be present
|
||||
assert "EUR" in balances
|
||||
# info, free, total, used dicts should be removed
|
||||
assert "info" not in balances
|
||||
assert "free" not in balances
|
||||
assert "total" not in balances
|
||||
assert "used" not in balances
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_no_flex_account(mocker, default_conf):
|
||||
"""Test that non-flex accounts work without USD synthesis."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
standard_response = {
|
||||
"USD": {"free": 500.0, "used": 100.0, "total": 600.0},
|
||||
"info": {"type": "cashAccount"},
|
||||
"free": {"USD": 500.0},
|
||||
"used": {"USD": 100.0},
|
||||
"total": {"USD": 600.0},
|
||||
}
|
||||
mocker.patch.object(ex._api, "fetch_balance", return_value=standard_response)
|
||||
|
||||
balances = ex.get_balances()
|
||||
|
||||
# USD should be preserved as-is
|
||||
assert balances["USD"]["free"] == 500.0
|
||||
assert balances["USD"]["total"] == 600.0
|
||||
# info, free, total, used dicts should be removed
|
||||
assert "info" not in balances
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_flex_fallback_chain(mocker, default_conf):
|
||||
"""Test fallback chain: marginEquity -> portfolioValue -> balanceValue."""
|
||||
default_conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
# Test fallback to balanceValue (no marginEquity or portfolioValue)
|
||||
flex_response = {
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"availableMargin": "800.00",
|
||||
"balanceValue": "850.00",
|
||||
}
|
||||
}
|
||||
},
|
||||
"free": {},
|
||||
"used": {},
|
||||
"total": {},
|
||||
}
|
||||
mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response)
|
||||
|
||||
balances = ex.get_balances()
|
||||
|
||||
assert balances["USD"]["free"] == 800.00
|
||||
assert balances["USD"]["total"] == 850.00
|
||||
# used = total - free = 850.00 - 800.00 = 50.00
|
||||
assert balances["USD"]["used"] == 50.00
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_flex_zero_free_calculates_used(mocker, default_conf):
|
||||
"""Test used margin is correct when availableMargin is 0.0."""
|
||||
default_conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
flex_response = {
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"availableMargin": "0.00",
|
||||
"marginEquity": "125.00",
|
||||
}
|
||||
}
|
||||
},
|
||||
"free": {},
|
||||
"used": {},
|
||||
"total": {},
|
||||
}
|
||||
mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response)
|
||||
|
||||
balances = ex.get_balances()
|
||||
|
||||
assert balances["USD"]["free"] == 0.00
|
||||
assert balances["USD"]["total"] == 125.00
|
||||
assert balances["USD"]["used"] == 125.00
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_flex_missing_free_uses_total(mocker, default_conf):
|
||||
"""When availableMargin is missing, free falls back to total and used is 0.0."""
|
||||
default_conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
flex_response = {
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"marginEquity": "250.00",
|
||||
}
|
||||
}
|
||||
},
|
||||
"free": {},
|
||||
"used": {},
|
||||
"total": {},
|
||||
}
|
||||
mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response)
|
||||
|
||||
balances = ex.get_balances()
|
||||
|
||||
assert balances["USD"]["free"] == 250.00
|
||||
assert balances["USD"]["total"] == 250.00
|
||||
assert balances["USD"]["used"] == 0.00
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_skips_synthesis_for_non_usd_stake(mocker, default_conf):
|
||||
"""Test that USD synthesis is skipped when stake_currency is not USD."""
|
||||
default_conf["stake_currency"] = "EUR"
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
flex_response = {
|
||||
"EUR": {"free": 100.0, "used": 0.0, "total": 100.0},
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"availableMargin": "950.50",
|
||||
"portfolioValue": "1000.00",
|
||||
}
|
||||
}
|
||||
},
|
||||
"free": {"EUR": 100.0},
|
||||
"used": {"EUR": 0.0},
|
||||
"total": {"EUR": 100.0},
|
||||
}
|
||||
mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response)
|
||||
|
||||
balances = ex.get_balances()
|
||||
|
||||
# USD should NOT be synthesized since stake_currency is EUR
|
||||
assert "USD" not in balances
|
||||
# EUR should still be present
|
||||
assert "EUR" in balances
|
||||
assert balances["EUR"]["free"] == 100.0
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_maps_ddos(mocker, default_conf):
|
||||
"""Map ccxt.DDoSProtection from fetch_balance to DDosProtection."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex._api, "fetch_balance", side_effect=ccxt.DDoSProtection("ratelimit"))
|
||||
|
||||
with pytest.raises(DDosProtection):
|
||||
ex.get_balances(count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_maps_temporary(mocker, default_conf):
|
||||
"""Map ccxt.OperationFailed/ExchangeError from fetch_balance to TemporaryError."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex._api, "fetch_balance", side_effect=ccxt.OperationFailed("temporary"))
|
||||
|
||||
with pytest.raises(TemporaryError):
|
||||
ex.get_balances(count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_maps_operational(mocker, default_conf):
|
||||
"""Map unexpected ccxt.BaseError from fetch_balance to OperationalException."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex._api, "fetch_balance", side_effect=ccxt.BaseError("unexpected"))
|
||||
|
||||
with pytest.raises(OperationalException):
|
||||
ex.get_balances(count=0)
|
||||
|
||||
|
||||
def test_krakenfutures_safe_float():
|
||||
"""Test _safe_float handles various input types."""
|
||||
assert Krakenfutures._safe_float("123.45") == 123.45
|
||||
assert Krakenfutures._safe_float(100) == 100.0
|
||||
assert Krakenfutures._safe_float(None) is None
|
||||
assert Krakenfutures._safe_float("invalid") is None
|
||||
assert Krakenfutures._safe_float({}) is None
|
||||
|
||||
|
||||
# --- Stoploss via base class (stoploss_query_requires_stop_flag) ---
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_stoploss_order_uses_base_class(mocker, default_conf):
|
||||
"""Base class fetch_stoploss_order should add stop=True and delegate to fetch_order."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
expected_order = {"id": "sl-order-1", "status": "open", "info": {}}
|
||||
fetch_order = mocker.patch.object(ex, "fetch_order", return_value=expected_order)
|
||||
|
||||
result = ex.fetch_stoploss_order("sl-order-1", "BTC/USD:USD")
|
||||
|
||||
assert result["id"] == "sl-order-1"
|
||||
# Base class should pass stop=True
|
||||
fetch_order.assert_called_once()
|
||||
call_params = fetch_order.call_args[0][2] if len(fetch_order.call_args[0]) > 2 else {}
|
||||
assert call_params.get("stop") is True
|
||||
|
||||
|
||||
def test_krakenfutures_cancel_stoploss_order_uses_base_class(mocker, default_conf):
|
||||
"""Base class cancel_stoploss_order should add stop=True and delegate to cancel_order."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
expected_order = {"id": "sl-cancel-1", "status": "canceled"}
|
||||
cancel_order = mocker.patch.object(ex, "cancel_order", return_value=expected_order)
|
||||
|
||||
result = ex.cancel_stoploss_order("sl-cancel-1", "BTC/USD:USD")
|
||||
|
||||
assert result["id"] == "sl-cancel-1"
|
||||
# Base class should pass stop=True
|
||||
cancel_order.assert_called_once()
|
||||
call_params = cancel_order.call_args[0][2] if len(cancel_order.call_args[0]) > 2 else {}
|
||||
assert call_params.get("stop") is True
|
||||
@@ -18,6 +18,7 @@ class TestExchangeOnlineSetup(TypedDict):
|
||||
timeframe: str
|
||||
candle_count: int
|
||||
futures: bool
|
||||
futures_only: bool | None
|
||||
futures_pair: str | None
|
||||
candle_count_futures: int | None
|
||||
hasQuoteVolumeFutures: bool | None
|
||||
@@ -26,6 +27,7 @@ class TestExchangeOnlineSetup(TypedDict):
|
||||
trades_lookback_hours: int
|
||||
private_methods: list[str] | None
|
||||
sample_order: list[dict[str, Any]] | None
|
||||
sample_order_futures: list[dict[str, Any]] | None
|
||||
sample_my_trades: list[dict[str, Any]] | None
|
||||
skip_ws_tests: bool | None
|
||||
|
||||
@@ -77,11 +79,12 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "3551312894",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"side": "buy",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
@@ -107,11 +110,12 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "3551312894",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"side": "buy",
|
||||
"status": "closed",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
@@ -148,9 +152,9 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "3551312894",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"amount": 1.1,
|
||||
@@ -203,12 +207,12 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"amount": 1.1,
|
||||
"id": "63d6742d0adc5570001d2bbf7",
|
||||
"timestamp": None,
|
||||
"datetime": None,
|
||||
"price": None,
|
||||
"status": None,
|
||||
"amount": None,
|
||||
},
|
||||
}, # create order
|
||||
{
|
||||
@@ -247,11 +251,11 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "63d6742d0adc5570001d2bbf7",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
@@ -304,11 +308,12 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "276266139423",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"average": 15.5,
|
||||
"status": "closed",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
@@ -347,11 +352,11 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "276401180529",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"status": "canceled",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
@@ -420,10 +425,11 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "1274754916287346280",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"side": "buy",
|
||||
"status": "open",
|
||||
"amount": 1.1,
|
||||
},
|
||||
@@ -499,18 +505,19 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "1762393630149869568",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"average": 15.5,
|
||||
"status": "closed",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"exchange_response": {
|
||||
"symbol": "SOL-USDT",
|
||||
"orderId": "1762393630149869568",
|
||||
"orderId": "1762393630149869567",
|
||||
"transactTime": "1674493798550",
|
||||
"price": "15.5",
|
||||
"stopPrice": "0",
|
||||
@@ -525,11 +532,12 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
"pair": "SOL/USDT",
|
||||
"expected": {
|
||||
"symbol": "SOL/USDT",
|
||||
"orderId": "3551312894",
|
||||
"id": "1762393630149869567",
|
||||
"timestamp": 1674493798550,
|
||||
"datetime": "2023-03-25T15:49:58.550Z",
|
||||
"datetime": "2023-01-23T17:09:58.550Z",
|
||||
"price": 15.5,
|
||||
"status": "open",
|
||||
"average": 15.5,
|
||||
"status": "closed",
|
||||
"amount": 1.1,
|
||||
},
|
||||
},
|
||||
@@ -552,9 +560,137 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
||||
# TODO: re-enable hyperliquid websocket tests
|
||||
"skip_ws_tests": True,
|
||||
},
|
||||
"krakenfutures": {
|
||||
"pair": "BTC/USD:USD",
|
||||
"stake_currency": "USD",
|
||||
"hasQuoteVolume": False,
|
||||
"skip_ws_tests": True,
|
||||
"timeframe": "1h",
|
||||
"futures": True,
|
||||
"futures_only": True,
|
||||
"candle_count": 2000,
|
||||
"futures_pair": "BTC/USD:USD",
|
||||
"hasQuoteVolumeFutures": False,
|
||||
"leverage_tiers_public": True,
|
||||
"sample_order_futures": [
|
||||
{
|
||||
# Regular market order
|
||||
"exchange_response": {
|
||||
"uid": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e",
|
||||
"accountUid": "cabdb242-5111-4dac-bac-76f33395d76d",
|
||||
"tradeable": "PF_XBTUSD",
|
||||
"direction": "Sell",
|
||||
"quantity": "0",
|
||||
"filled": "0.0004",
|
||||
"timestamp": 1771354195241,
|
||||
"limitPrice": "67164.00",
|
||||
"orderType": "IoC",
|
||||
"clientId": "",
|
||||
"reduceOnly": False,
|
||||
"lastUpdateTimestamp": 1771354195241,
|
||||
"regulatoryExternalUid": "ae198dd6-6be0-4014-8af-ebd472190648",
|
||||
"status": "closed",
|
||||
},
|
||||
"pair": "BTC/USD:USD",
|
||||
"expected": {
|
||||
"symbol": "BTC/USD:USD",
|
||||
"id": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e",
|
||||
"timestamp": 1771354195241,
|
||||
"datetime": "2026-02-17T18:49:55.241Z",
|
||||
"price": None,
|
||||
# Average should be None (it's not correct for market orders)
|
||||
"average": None,
|
||||
"status": "closed",
|
||||
"type": "market",
|
||||
"amount": 0.0004,
|
||||
"side": "sell",
|
||||
"triggerPrice": None,
|
||||
"stopPrice": None,
|
||||
"stopLossPrice": None,
|
||||
},
|
||||
},
|
||||
{
|
||||
# Trigger order
|
||||
"exchange_response": {
|
||||
"order": {
|
||||
"type": "TRIGGER_ORDER",
|
||||
"orderId": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e",
|
||||
"cliOrdId": None,
|
||||
"symbol": "PF_XBTUSD",
|
||||
"side": "buy",
|
||||
"quantity": 0.0004,
|
||||
"limitPrice": 71712,
|
||||
"reduceOnly": True,
|
||||
"timestamp": "2026-02-17T16:26:02.918Z",
|
||||
"lastUpdateTimestamp": "2026-02-17T16:26:02.918Z",
|
||||
"priceTriggerOptions": {
|
||||
"triggerPrice": 71641,
|
||||
"triggerSignal": "LAST_PRICE",
|
||||
"triggerSide": "TRIGGER_ABOVE",
|
||||
"limitPriceOffsetValue": None,
|
||||
"limitPriceOffsetUnit": None,
|
||||
},
|
||||
},
|
||||
"status": "TRIGGER_PLACED",
|
||||
"updateReason": None,
|
||||
"error": None,
|
||||
},
|
||||
"pair": "BTC/USD:USD",
|
||||
"expected": {
|
||||
"symbol": "BTC/USD:USD",
|
||||
"id": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e",
|
||||
"timestamp": 1771345562918,
|
||||
"datetime": "2026-02-17T16:26:02.918Z",
|
||||
# TODO: re-verify this ...
|
||||
# "price": 71712.0,
|
||||
"price": None,
|
||||
"status": "open",
|
||||
"amount": 0.0004,
|
||||
"side": "buy",
|
||||
"triggerPrice": 71641.0,
|
||||
"stopPrice": 71641.0,
|
||||
# krakenfutures uses stopPrice - so this is fine.
|
||||
"stopLossPrice": None,
|
||||
},
|
||||
},
|
||||
{
|
||||
# Canceled order
|
||||
"exchange_response": {
|
||||
"order": {
|
||||
"type": "ORDER",
|
||||
"orderId": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e",
|
||||
"cliOrdId": None,
|
||||
"symbol": "PF_XBTUSD",
|
||||
"side": "buy",
|
||||
"quantity": 0.0022,
|
||||
"filled": 0,
|
||||
"limitPrice": 68000,
|
||||
"reduceOnly": False,
|
||||
"timestamp": "2026-03-21T07:32:21.555Z",
|
||||
"lastUpdateTimestamp": "2026-03-21T07:32:21.555Z",
|
||||
},
|
||||
"status": "CANCELLED",
|
||||
"updateReason": "CANCELLED_BY_USER",
|
||||
"error": None,
|
||||
},
|
||||
"pair": "BTC/USD:USD",
|
||||
"expected": {
|
||||
"symbol": "BTC/USD:USD",
|
||||
"id": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e",
|
||||
"timestamp": 1774078341555,
|
||||
"datetime": "2026-03-21T07:32:21.555Z",
|
||||
"price": None,
|
||||
"status": "canceled",
|
||||
# TODO: filled should be 0, not None.
|
||||
"filled": None,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
EXCHANGES_FUTURES = [exch for exch, params in EXCHANGES.items() if params.get("futures")]
|
||||
EXCHANGES_SPOT = [exch for exch, params in EXCHANGES.items() if not params.get("futures_only")]
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
@@ -584,11 +720,12 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config:
|
||||
return config
|
||||
|
||||
|
||||
def get_exchange(exchange_name, exchange_conf):
|
||||
def get_exchange(exchange_name, exchange_conf, class_mocker):
|
||||
exchange_params = EXCHANGES[exchange_name]
|
||||
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
||||
exchange_conf["exchange"]["name"] = exchange_name
|
||||
exchange_conf["stake_currency"] = exchange_params["stake_currency"]
|
||||
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
|
||||
exchange = ExchangeResolver.load_exchange(
|
||||
exchange_conf, validate=True, load_leverage_tiers=True
|
||||
)
|
||||
@@ -601,25 +738,28 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker):
|
||||
|
||||
if exchange_params.get("futures") is not True:
|
||||
pytest.skip(f"Exchange {exchange_name} does not support futures.")
|
||||
else:
|
||||
exchange_conf = deepcopy(exchange_conf)
|
||||
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
||||
exchange_conf["trading_mode"] = "futures"
|
||||
exchange_conf["margin_mode"] = "isolated"
|
||||
exchange_conf = deepcopy(exchange_conf)
|
||||
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
||||
exchange_conf["exchange"]["name"] = exchange_name
|
||||
exchange_conf["stake_currency"] = exchange_params["stake_currency"]
|
||||
exchange_conf["trading_mode"] = "futures"
|
||||
exchange_conf["margin_mode"] = "isolated"
|
||||
|
||||
class_mocker.patch("freqtrade.exchange.binance.Binance.fill_leverage_tiers")
|
||||
class_mocker.patch(f"{EXMS}.fetch_trading_fees")
|
||||
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
|
||||
class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None)
|
||||
class_mocker.patch(f"{EXMS}.cache_leverage_tiers")
|
||||
|
||||
return get_exchange(exchange_name, exchange_conf)
|
||||
|
||||
|
||||
@pytest.fixture(params=EXCHANGES, scope="class")
|
||||
def exchange(request, exchange_conf, class_mocker):
|
||||
class_mocker.patch("freqtrade.exchange.binance.Binance.fill_leverage_tiers")
|
||||
class_mocker.patch(f"{EXMS}.fetch_trading_fees")
|
||||
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
|
||||
exchange, name, exchange_params = get_exchange(request.param, exchange_conf)
|
||||
class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None)
|
||||
class_mocker.patch(f"{EXMS}.cache_leverage_tiers")
|
||||
|
||||
exchange = ExchangeResolver.load_exchange(
|
||||
exchange_conf, validate=True, load_leverage_tiers=True
|
||||
)
|
||||
return exchange, exchange_name, exchange_params
|
||||
|
||||
|
||||
@pytest.fixture(params=EXCHANGES_SPOT, scope="class")
|
||||
def exchange(request, exchange_conf, class_mocker):
|
||||
exchange, name, exchange_params = get_exchange(request.param, exchange_conf, class_mocker)
|
||||
yield exchange, name, exchange_params
|
||||
exchange.close()
|
||||
|
||||
@@ -640,13 +780,12 @@ def exchange_mode(request):
|
||||
|
||||
@pytest.fixture(params=EXCHANGES, scope="class")
|
||||
def exchange_ws(request, exchange_conf, exchange_mode, class_mocker):
|
||||
class_mocker.patch("freqtrade.exchange.bybit.Bybit.additional_exchange_init")
|
||||
exchange_conf["exchange"]["enable_ws"] = True
|
||||
exchange_param = EXCHANGES[request.param]
|
||||
if exchange_param.get("skip_ws_tests"):
|
||||
pytest.skip(f"{request.param} does not support websocket tests.")
|
||||
if exchange_mode == "spot":
|
||||
exchange, name, _ = get_exchange(request.param, exchange_conf)
|
||||
exchange, name, _ = get_exchange(request.param, exchange_conf, class_mocker)
|
||||
pair = exchange_param["pair"]
|
||||
elif exchange_param.get("futures"):
|
||||
exchange, name, _ = get_futures_exchange(
|
||||
|
||||
@@ -90,23 +90,38 @@ class TestCCXTExchange:
|
||||
expected = order["expected"]
|
||||
assert isinstance(po["id"], str)
|
||||
assert po["id"] is not None
|
||||
if len(exchange_response.keys()) < 5:
|
||||
# Kucoin case
|
||||
assert po["status"] is None
|
||||
continue
|
||||
assert po["timestamp"] == expected["timestamp"]
|
||||
assert isinstance(po["datetime"], str)
|
||||
assert isinstance(po["timestamp"], int)
|
||||
assert isinstance(po["price"], float)
|
||||
assert po["price"] == expected["price"]
|
||||
if po["status"] == "closed":
|
||||
# Filled orders should have average assigned.
|
||||
assert isinstance(po["average"], float)
|
||||
assert po["average"] == 15.5
|
||||
assert po["symbol"] == pair
|
||||
assert isinstance(po["amount"], float)
|
||||
assert po["amount"] == expected["amount"]
|
||||
assert isinstance(po["status"], str)
|
||||
|
||||
# Generic comparison which works for all fields
|
||||
for key, value in expected.items():
|
||||
assert key in po, f"Expected key {key} not found in parsed order"
|
||||
assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}"
|
||||
assert isinstance(po[key], type(value)), (
|
||||
f"Expected {key} to be of type {type(value)}, got {type(po[key])}"
|
||||
)
|
||||
else:
|
||||
pytest.skip(f"No sample order available for exchange {exchangename}")
|
||||
|
||||
def test_ccxt_order_parse_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE):
|
||||
exch, exchangename, exchange_params = exchange_futures
|
||||
if orders := exchange_params.get("sample_order_futures"):
|
||||
for order in orders:
|
||||
pair = order["pair"]
|
||||
exchange_response: dict = order["exchange_response"]
|
||||
|
||||
market = exch._api.markets[pair]
|
||||
po = exch._api.parse_order(exchange_response, market)
|
||||
expected = order["expected"]
|
||||
assert isinstance(po["id"], str)
|
||||
assert po["id"] is not None
|
||||
|
||||
# Generic comparison which works for all fields
|
||||
for key, value in expected.items():
|
||||
assert key in po, f"Expected key {key} not found in parsed order"
|
||||
assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}"
|
||||
assert isinstance(po[key], type(value)), (
|
||||
f"Expected {key} to be of type {type(value)}, got {type(po[key])}"
|
||||
)
|
||||
|
||||
else:
|
||||
pytest.skip(f"No sample order available for exchange {exchangename}")
|
||||
|
||||
|
||||
@@ -1888,7 +1888,9 @@ def test_backtest_multi_pair_long_short_switch(
|
||||
if use_detail:
|
||||
default_conf_usdt["timeframe_detail"] = "1m"
|
||||
|
||||
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
|
||||
mocker.patch(
|
||||
"freqtrade.optimize.backtesting.price_to_precision", lambda price, *args, **kwargs: price
|
||||
)
|
||||
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
|
||||
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
|
||||
mocker.patch(f"{EXMS}.get_fee", fee)
|
||||
|
||||
@@ -72,8 +72,8 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) ->
|
||||
"initial_stop_loss_ratio": [-0.1, -0.1],
|
||||
"stop_loss_abs": [0.0940005, 0.092722365],
|
||||
"stop_loss_ratio": [-0.1, -0.1],
|
||||
"min_rate": [0.10370188, 0.10300000000000001],
|
||||
"max_rate": [0.10481985, 0.10388887000000001],
|
||||
"min_rate": [0.10370188, 0.103000000],
|
||||
"max_rate": [0.10481985, 0.103888870],
|
||||
"is_open": [False, False],
|
||||
"enter_tag": ["", ""],
|
||||
"leverage": [1.0, 1.0],
|
||||
|
||||
@@ -2584,6 +2584,207 @@ def test_MarketCapPairList_exceptions(mocker, default_conf_usdt, caplog):
|
||||
PairListManager(exchange, default_conf_usdt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pairlists,trade_mode,result",
|
||||
[
|
||||
(
|
||||
[
|
||||
# Spot pairs that exist on both markets
|
||||
{"method": "StaticPairList", "allow_inactive": True},
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
|
||||
],
|
||||
"spot",
|
||||
["ETH/USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# Spot pairs that exist only on spot market
|
||||
{"method": "StaticPairList", "allow_inactive": True},
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
|
||||
],
|
||||
"spot",
|
||||
["LTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT", "BTC/USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# Futures pairs that exist on both markets
|
||||
{"method": "StaticPairList", "allow_inactive": True},
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
|
||||
],
|
||||
"futures",
|
||||
["ETH/USDT:USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# Futures pairs that exist only on futures market
|
||||
{"method": "StaticPairList", "allow_inactive": True},
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
|
||||
],
|
||||
"futures",
|
||||
["ADA/USDT:USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# CrossMarketPairList as generator, spot market, pairs that exist on both markets
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
|
||||
],
|
||||
"spot",
|
||||
["ETH/USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# CrossMarketPairList as generator, spot pairs that exist only on spot market
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
|
||||
],
|
||||
"spot",
|
||||
["BTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# CrossMarketPairList as generator, futures pairs that exist on both markets
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
|
||||
],
|
||||
"futures",
|
||||
["ETH/USDT:USDT"],
|
||||
),
|
||||
(
|
||||
[
|
||||
# CrossMarketPairList as generator, futures pairs that exist only on futures market
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
|
||||
],
|
||||
"futures",
|
||||
["ADA/USDT:USDT"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_CrossMarketPairlist_filter(
|
||||
mocker, default_conf_usdt, trade_mode, markets, pairlists, result
|
||||
):
|
||||
default_conf_usdt["trading_mode"] = trade_mode
|
||||
if trade_mode == "spot":
|
||||
default_conf_usdt["exchange"]["pair_whitelist"].extend(["BTC/USDT", "ETC/USDT", "ADA/USDT"])
|
||||
else:
|
||||
default_conf_usdt["exchange"]["pair_whitelist"] = [
|
||||
"BTC/USDT:USDT",
|
||||
"ETH/USDT:USDT",
|
||||
"ETC/USDT:USDT",
|
||||
"ADA/USDT:USDT",
|
||||
]
|
||||
default_conf_usdt["pairlists"] = pairlists
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
|
||||
pm = PairListManager(exchange, default_conf_usdt)
|
||||
pm.refresh_pairlist()
|
||||
|
||||
assert pm.whitelist == result
|
||||
|
||||
|
||||
def test_CrossMarketPairlist_gen_pairlist_uses_cache(mocker, default_conf_usdt, markets):
|
||||
default_conf_usdt["trading_mode"] = "spot"
|
||||
default_conf_usdt["pairlists"] = [
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}
|
||||
]
|
||||
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
pm = PairListManager(exchange, default_conf_usdt)
|
||||
pl = pm._pairlist_handlers[0]
|
||||
|
||||
pl._pair_cache["pairlist"] = ["ETH/USDT", "ADA/USDT"]
|
||||
pl._exchange.get_markets = MagicMock(
|
||||
side_effect=AssertionError("get_markets should not be called")
|
||||
)
|
||||
|
||||
result = pl.gen_pairlist({})
|
||||
|
||||
assert result == ["ETH/USDT", "ADA/USDT"]
|
||||
# Make sure the returned list is a copy, not the cached one
|
||||
assert result is not pl._pair_cache["pairlist"]
|
||||
result.append("BTC/USDT")
|
||||
# Make sure the cache is not modified
|
||||
assert pl._pair_cache["pairlist"] == ["ETH/USDT", "ADA/USDT"]
|
||||
|
||||
|
||||
def test_CrossMarketPairList_breaks_prefix_loop_on_match(mocker, default_conf_usdt, markets):
|
||||
default_conf_usdt["trading_mode"] = "spot"
|
||||
default_conf_usdt["pairlists"] = [
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}
|
||||
]
|
||||
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
pm = PairListManager(exchange, default_conf_usdt)
|
||||
pl = pm._pairlist_handlers[0]
|
||||
|
||||
# Force base lookup path
|
||||
mocker.patch.object(pl, "get_base_list", return_value=["1000PEPE"])
|
||||
mocker.patch.object(pl._exchange, "get_pair_base_currency", return_value="PEPE")
|
||||
|
||||
def prefix_generator():
|
||||
yield "1000" # first prefix => match via "1000PEPE"
|
||||
raise AssertionError("Prefix loop did not break after match")
|
||||
|
||||
mocker.patch(
|
||||
"freqtrade.plugins.pairlist.CrossMarketPairList.PairPrefixes",
|
||||
new=prefix_generator(),
|
||||
)
|
||||
|
||||
result = pl.filter_pairlist(["PEPE/USDT"], {})
|
||||
assert result == ["PEPE/USDT"]
|
||||
|
||||
|
||||
def test_CrossMarketPairList_breaks_prefix_loop_on_delayed_match(
|
||||
mocker, default_conf_usdt, markets
|
||||
):
|
||||
default_conf_usdt["trading_mode"] = "spot"
|
||||
default_conf_usdt["pairlists"] = [
|
||||
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}
|
||||
]
|
||||
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
pm = PairListManager(exchange, default_conf_usdt)
|
||||
pl = pm._pairlist_handlers[0]
|
||||
|
||||
# Force second matching path: base startswith prefix and removeprefix() matches bases.
|
||||
mocker.patch.object(pl, "get_base_list", return_value=["PEPE"])
|
||||
mocker.patch.object(pl._exchange, "get_pair_base_currency", return_value="1000PEPE")
|
||||
|
||||
def prefix_generator():
|
||||
yield "X" # no match, loop should continue
|
||||
yield "1000" # second path should match via removeprefix -> PEPE and break
|
||||
raise AssertionError("Prefix loop did not break after delayed match")
|
||||
|
||||
mocker.patch(
|
||||
"freqtrade.plugins.pairlist.CrossMarketPairList.PairPrefixes",
|
||||
new=prefix_generator(),
|
||||
)
|
||||
|
||||
result = pl.filter_pairlist(["1000PEPE/USDT"], {})
|
||||
assert result == ["1000PEPE/USDT"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pairlists,expected_error,expected_warning",
|
||||
[
|
||||
|
||||
@@ -137,25 +137,6 @@ def test_remote_pairlist_init_no_pairlist_url(mocker, rpl_config):
|
||||
get_patched_freqtradebot(mocker, rpl_config)
|
||||
|
||||
|
||||
def test_remote_pairlist_init_no_number_assets(mocker, rpl_config):
|
||||
rpl_config["pairlists"] = [
|
||||
{
|
||||
"method": "RemotePairList",
|
||||
"pairlist_url": "http://example.com/pairlist",
|
||||
"keep_pairlist_on_failure": True,
|
||||
}
|
||||
]
|
||||
|
||||
get_patched_exchange(mocker, rpl_config)
|
||||
|
||||
with pytest.raises(
|
||||
OperationalException,
|
||||
match=r"`number_assets` not specified. "
|
||||
'Please check your configuration for "pairlist.config.number_assets"',
|
||||
):
|
||||
get_patched_freqtradebot(mocker, rpl_config)
|
||||
|
||||
|
||||
def test_fetch_pairlist_mock_response_valid(mocker, rpl_config):
|
||||
rpl_config["pairlists"] = [
|
||||
{
|
||||
@@ -341,3 +322,60 @@ def test_remote_pairlist_whitelist(mocker, rpl_config, processing_mode, markets,
|
||||
|
||||
whitelist = remote_pairlist.filter_pairlist(rpl_config["exchange"]["pair_whitelist"], {})
|
||||
assert whitelist == (["XRP/USDT"] if processing_mode == "filter" else ["ETH/USDT", "XRP/USDT"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"number_assets, result",
|
||||
[
|
||||
(1, ["ETH/USDT"]),
|
||||
(2, ["ETH/USDT", "XRP/USDT"]),
|
||||
(500, ["ETH/USDT", "XRP/USDT"]),
|
||||
(None, ["ETH/USDT", "XRP/USDT"]),
|
||||
],
|
||||
)
|
||||
def test_remote_pairlist_whitelist_number_assets(
|
||||
mocker, rpl_config, number_assets, result, markets, tickers
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
|
||||
mock_response.json.return_value = {
|
||||
"pairs": ["ETH/USDT", "XRP/USDT", "TKN/USDT"],
|
||||
"refresh_period": 60,
|
||||
}
|
||||
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
|
||||
rpl_config["pairlists"] = [
|
||||
{
|
||||
"method": "RemotePairList",
|
||||
"mode": "whitelist",
|
||||
"pairlist_url": "http://example.com/pairlist",
|
||||
"number_assets": number_assets,
|
||||
},
|
||||
]
|
||||
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
markets=PropertyMock(return_value=markets),
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
get_tickers=tickers,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"freqtrade.plugins.pairlist.RemotePairList.requests.get", return_value=mock_response
|
||||
)
|
||||
|
||||
exchange = get_patched_exchange(mocker, rpl_config)
|
||||
|
||||
pairlistmanager = PairListManager(exchange, rpl_config)
|
||||
|
||||
remote_pairlist = RemotePairList(
|
||||
exchange, pairlistmanager, rpl_config, rpl_config["pairlists"][0], 0
|
||||
)
|
||||
|
||||
pairs, _ = remote_pairlist.fetch_pairlist()
|
||||
|
||||
assert pairs == ["ETH/USDT", "XRP/USDT", "TKN/USDT"]
|
||||
|
||||
whitelist = remote_pairlist.filter_pairlist(rpl_config["exchange"]["pair_whitelist"], {})
|
||||
assert whitelist == result
|
||||
|
||||
@@ -2967,6 +2967,15 @@ def test_sysinfo(botclient):
|
||||
result = rc.json()
|
||||
assert "cpu_pct" in result
|
||||
assert "ram_pct" in result
|
||||
assert "cpu_load" in result
|
||||
assert "cpu_count" in result
|
||||
assert "cpu_load_avg" in result
|
||||
assert "1m" in result["cpu_load_avg"]
|
||||
assert "5m" in result["cpu_load_avg"]
|
||||
assert "15m" in result["cpu_load_avg"]
|
||||
|
||||
assert isinstance(result["cpu_load"], list)
|
||||
assert isinstance(result["cpu_load"][0], dict)
|
||||
|
||||
|
||||
def test_api_backtesting(botclient, mocker, fee, caplog, tmp_path):
|
||||
|
||||
@@ -1047,7 +1047,7 @@ def test_pandas_warning_direct(ohlcv_history, function, raises, recwarn):
|
||||
# Fixed in 2.2.x
|
||||
getattr(_STRATEGY, function)(df, {"pair": "ETH/BTC"})
|
||||
else:
|
||||
assert len(recwarn) == 0, f"warnings: {', '.join(recwarn.list)}"
|
||||
assert len(recwarn) == 0, f"warnings: {', '.join(str(w) for w in recwarn.list)}"
|
||||
|
||||
getattr(_STRATEGY, function)(df, {"pair": "ETH/BTC"})
|
||||
|
||||
@@ -1055,4 +1055,4 @@ def test_pandas_warning_direct(ohlcv_history, function, raises, recwarn):
|
||||
def test_pandas_warning_through_analyze_pair(ohlcv_history, mocker, recwarn):
|
||||
mocker.patch.object(_STRATEGY.dp, "ohlcv", return_value=ohlcv_history)
|
||||
_STRATEGY.analyze_pair("ETH/BTC")
|
||||
assert len(recwarn) == 0, f"warnings: {', '.join(recwarn.list)}"
|
||||
assert len(recwarn) == 0, f"warnings: {', '.join(str(w) for w in recwarn.list)}"
|
||||
|
||||
@@ -23,9 +23,11 @@ def test_pip_audit_no_vulnerabilities():
|
||||
Run pip-audit to check for known security vulnerabilities.
|
||||
|
||||
This test will fail if any vulnerabilities are detected in the installed packages.
|
||||
Note: Document ignores here if vulnerabilities are acceptable.
|
||||
|
||||
pygments: CVE-2026-4539 - https://github.com/pygments/pygments/issues/3065
|
||||
not considered a security vulnerability by pygments.
|
||||
|
||||
Note: CVE-2025-53000 (nbconvert Windows vulnerability) is ignored as it only affects
|
||||
Windows platforms and is a known acceptable risk for this project.
|
||||
"""
|
||||
# Get the project root directory
|
||||
project_root = Path(__file__).parent.parent
|
||||
@@ -36,7 +38,7 @@ def test_pip_audit_no_vulnerabilities():
|
||||
# "--format=json",
|
||||
"--progress-spinner=off",
|
||||
"--ignore-vuln",
|
||||
"CVE-2025-53000",
|
||||
"CVE-2026-4539",
|
||||
"--skip-editable",
|
||||
]
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -78,12 +78,15 @@ def test_shorten_date() -> None:
|
||||
|
||||
|
||||
def test_dt_humanize() -> None:
|
||||
assert dt_humanize_delta(dt_now()) == "now"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(minutes=50)) == "50 minutes ago"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(hours=16)) == "16 hours ago"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(hours=16, minutes=30)) == "16 hours ago"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(days=16, hours=10, minutes=25)) == "a month ago"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(minutes=50)) == "50 minutes ago"
|
||||
with time_machine.travel("2026-03-25 10:01:00 +00:00", tick=False):
|
||||
assert dt_humanize_delta(dt_now()) == "now"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(minutes=50)) == "50 minutes ago"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(hours=16)) == "16 hours ago"
|
||||
assert dt_humanize_delta(dt_now() - timedelta(hours=16, minutes=30)) == "16 hours ago"
|
||||
assert (
|
||||
dt_humanize_delta(dt_now() - timedelta(days=16, hours=10, minutes=25)) == "a month ago"
|
||||
)
|
||||
assert dt_humanize_delta(dt_now() - timedelta(minutes=50)) == "50 minutes ago"
|
||||
|
||||
|
||||
def test_format_ms_time() -> None:
|
||||
|
||||
Reference in New Issue
Block a user