Merge pull request #12673 from freqtrade/new_release
New release 2025.12
This commit is contained in:
+15
-8
@@ -2,7 +2,7 @@ version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
directories:
|
||||
- "/"
|
||||
- "/docker"
|
||||
@@ -16,7 +16,7 @@ updates:
|
||||
- package-ecosystem: devcontainers
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
@@ -24,13 +24,13 @@ updates:
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
exclude:
|
||||
- ccxt
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "03:00"
|
||||
timezone: "Etc/UTC"
|
||||
interval: "cron"
|
||||
# Monday at 03:00
|
||||
cronjob: "0 3 * * 1"
|
||||
open-pull-requests-limit: 15
|
||||
target-branch: develop
|
||||
groups:
|
||||
@@ -51,8 +51,15 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "cron"
|
||||
# Monday at 03:00
|
||||
cronjob: "0 3 * * 1"
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: develop
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
# Combine updates for github provided actions
|
||||
- "actions/*"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
environment:
|
||||
name: develop
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: python build_helpers/binance_update_lev_tiers.py
|
||||
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ secrets.REPO_SCOPED_TOKEN }}
|
||||
add-paths: freqtrade/exchange/binance_leverage_tiers.json
|
||||
|
||||
+20
-19
@@ -25,10 +25,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ "ubuntu-22.04", "ubuntu-24.04", "macos-14", "macos-15" , "windows-2022", "windows-2025" ]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
run: |
|
||||
pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc
|
||||
|
||||
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
@@ -87,12 +87,12 @@ jobs:
|
||||
rm -rf codecov codecov.SHA256SUM codecov.SHA256SUM.sig
|
||||
|
||||
- name: Run json schema extract
|
||||
# This should be kept before the repository check to ensure that the schema is up-to-date
|
||||
# This must be kept before the repository check to ensure that the schema is up-to-date
|
||||
run: |
|
||||
python build_helpers/extract_config_json_schema.py
|
||||
|
||||
- name: Run command docs partials extract
|
||||
# This should be kept before the repository check to ensure that the docs are up-to-date
|
||||
# This must be kept before the repository check to ensure that the docs are up-to-date
|
||||
if: ${{ (matrix.python-version == '3.13') }}
|
||||
run: |
|
||||
python build_helpers/create_command_partials.py
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check for repository changes - Windows
|
||||
if: ${{ runner.os == 'Windows' && (matrix.python-version != '3.13') }}
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: |
|
||||
if (git status --porcelain) {
|
||||
Write-Host "Repository is dirty, changes detected:"
|
||||
@@ -159,6 +159,7 @@ jobs:
|
||||
shell: powershell
|
||||
run: |
|
||||
$PSVersionTable
|
||||
Get-PSRepository | Format-List *
|
||||
Set-PSRepository psgallery -InstallationPolicy trusted
|
||||
Install-Module -Name Pester -RequiredVersion 5.3.1 -Confirm:$false -Force -SkipPublisherCheck
|
||||
$Error.clear()
|
||||
@@ -177,7 +178,7 @@ jobs:
|
||||
name: "Mypy Version Check"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -195,7 +196,7 @@ jobs:
|
||||
name: "Pre-commit checks"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -208,7 +209,7 @@ jobs:
|
||||
name: "Documentation build"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -240,7 +241,7 @@ jobs:
|
||||
name: "Tests and Linting - Online tests"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -250,7 +251,7 @@ jobs:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -320,7 +321,7 @@ jobs:
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -335,7 +336,7 @@ jobs:
|
||||
python -m build --sdist --wheel
|
||||
|
||||
- name: Upload artifacts 📦
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: freqtrade-build
|
||||
path: |
|
||||
@@ -348,7 +349,7 @@ jobs:
|
||||
python -m build --sdist --wheel ft_client
|
||||
|
||||
- name: Upload artifacts 📦
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: freqtrade-client-build
|
||||
path: |
|
||||
@@ -367,12 +368,12 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download artifact 📦
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: freqtrade*-build
|
||||
path: dist
|
||||
@@ -396,12 +397,12 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download artifact 📦
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: freqtrade*-build
|
||||
path: dist
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Deploy Docs through mike
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Login to GitHub Container Registry
|
||||
|
||||
@@ -33,10 +33,21 @@ jobs:
|
||||
if: github.repository == 'freqtrade/freqtrade'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Visualize disk usage before build
|
||||
run: df -h
|
||||
|
||||
- name: Cleanup some disk space
|
||||
run: |
|
||||
docker system prune -a --force || true
|
||||
docker builder prune -af || true
|
||||
|
||||
- name: Visualize disk usage after cleanup
|
||||
run: df -h
|
||||
|
||||
- name: Set docker tag names
|
||||
id: tags
|
||||
uses: ./.github/actions/docker-tags
|
||||
@@ -54,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3.12.0
|
||||
|
||||
- name: Available platforms
|
||||
run: echo ${PLATFORMS}
|
||||
@@ -142,6 +153,9 @@ jobs:
|
||||
run: |
|
||||
docker images
|
||||
|
||||
- name: Visualize disk usage after build
|
||||
run: df -h
|
||||
|
||||
deploy-arm:
|
||||
name: "Deploy Docker ARM64"
|
||||
permissions:
|
||||
@@ -152,7 +166,7 @@ jobs:
|
||||
if: github.repository == 'freqtrade/freqtrade'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -276,6 +290,7 @@ jobs:
|
||||
docker buildx imagetools create \
|
||||
--tag ${GHCR_IMAGE_NAME}:${TAG} \
|
||||
--tag ${GHCR_IMAGE_NAME}:latest \
|
||||
--tag ${IMAGE_NAME}:latest \
|
||||
${IMAGE_NAME}:${TAG}
|
||||
|
||||
- name: Docker images
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
dockerHubDescription:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
auto-update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Run auto-update
|
||||
run: pre-commit autoupdate
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ secrets.REPO_SCOPED_TOKEN }}
|
||||
add-paths: .pre-commit-config.yaml
|
||||
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
# actions: read # only needed for private repos
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0
|
||||
uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
|
||||
|
||||
@@ -21,7 +21,7 @@ repos:
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: "v1.18.2"
|
||||
rev: "v1.19.1"
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: build_helpers
|
||||
@@ -31,8 +31,8 @@ repos:
|
||||
- types-requests==2.32.4.20250913
|
||||
- types-tabulate==0.9.0.20241207
|
||||
- types-python-dateutil==2.9.0.20251115
|
||||
- scipy-stubs==1.16.3.0
|
||||
- SQLAlchemy==2.0.44
|
||||
- scipy-stubs==1.16.3.3
|
||||
- SQLAlchemy==2.0.45
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
@@ -44,7 +44,7 @@ repos:
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: 'v0.14.6'
|
||||
rev: 'v0.14.10'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
@@ -83,6 +83,6 @@ repos:
|
||||
|
||||
# Ensure github actions remain safe
|
||||
- repo: https://github.com/woodruffw/zizmor-pre-commit
|
||||
rev: v1.16.3
|
||||
rev: v1.19.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13.8-slim-bookworm AS base
|
||||
FROM python:3.13.11-slim-bookworm AS base
|
||||
|
||||
# Setup env
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
@@ -15,7 +15,7 @@ This software is for educational purposes only. Do not risk money which
|
||||
you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS
|
||||
AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS.
|
||||
|
||||
Always start by running a trading bot in Dry-run and do not engage money
|
||||
Always start by running a trading bot in Dry-Run and do not engage money
|
||||
before you understand how it works and what profit/loss you should
|
||||
expect.
|
||||
|
||||
@@ -24,7 +24,7 @@ hesitate to read the source code and understand the mechanism of this bot.
|
||||
|
||||
## Supported Exchange marketplaces
|
||||
|
||||
Please read the [exchange specific notes](docs/exchanges.md) to learn about eventual, special configurations needed for each exchange.
|
||||
Please read the [exchange-specific notes](docs/exchanges.md) to learn about special configurations that maybe needed for each exchange.
|
||||
|
||||
- [X] [Binance](https://www.binance.com/)
|
||||
- [X] [BingX](https://bingx.com/invite/0EM9RX)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import subprocess # noqa: S404, RUF100
|
||||
import sys
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -8,7 +10,20 @@ def _write_partial_file(filename: str, content: str):
|
||||
f.write(f"``` output\n{content}\n```\n")
|
||||
|
||||
|
||||
def _get_help_output(parser) -> str:
|
||||
"""Capture the help output from a parser."""
|
||||
output = StringIO()
|
||||
parser.print_help(file=output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def extract_command_partials():
|
||||
# Set terminal width to 80 columns for consistent output formatting
|
||||
os.environ["COLUMNS"] = "80"
|
||||
|
||||
# Import Arguments here to avoid circular imports and ensure COLUMNS is set
|
||||
from freqtrade.commands.arguments import Arguments
|
||||
|
||||
subcommands = [
|
||||
"trade",
|
||||
"create-userdir",
|
||||
@@ -46,16 +61,35 @@ def extract_command_partials():
|
||||
"recursive-analysis",
|
||||
]
|
||||
|
||||
result = subprocess.run(["freqtrade", "--help"], capture_output=True, text=True)
|
||||
# Build the Arguments class to get the parser with all subcommands
|
||||
args = Arguments(None)
|
||||
args._build_subcommands()
|
||||
|
||||
_write_partial_file("docs/commands/main.md", result.stdout)
|
||||
# Get main help output
|
||||
main_help = _get_help_output(args.parser)
|
||||
_write_partial_file("docs/commands/main.md", main_help)
|
||||
|
||||
# Get subparsers from the main parser
|
||||
# The subparsers are stored in _subparsers._group_actions[0].choices
|
||||
subparsers_action = None
|
||||
for action in args.parser._subparsers._group_actions:
|
||||
if hasattr(action, "choices"):
|
||||
subparsers_action = action
|
||||
break
|
||||
|
||||
if subparsers_action is None:
|
||||
raise RuntimeError("Could not find subparsers in the main parser")
|
||||
|
||||
for command in subcommands:
|
||||
print(f"Running for {command}")
|
||||
result = subprocess.run(["freqtrade", command, "--help"], capture_output=True, text=True)
|
||||
|
||||
_write_partial_file(f"docs/commands/{command}.md", result.stdout)
|
||||
if command in subparsers_action.choices:
|
||||
subparser = subparsers_action.choices[command]
|
||||
help_output = _get_help_output(subparser)
|
||||
_write_partial_file(f"docs/commands/{command}.md", help_output)
|
||||
else:
|
||||
print(f" Warning: subcommand '{command}' not found in parser")
|
||||
|
||||
# freqtrade-client still uses subprocess as requested
|
||||
print("Running for freqtrade-client")
|
||||
result_client = subprocess.run(["freqtrade-client", "--show"], capture_output=True, text=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11.13-slim-bookworm AS base
|
||||
FROM python:3.11.14-slim-bookworm AS base
|
||||
|
||||
# Setup env
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
@@ -43,7 +43,9 @@ options:
|
||||
separated.
|
||||
--eps, --enable-position-stacking
|
||||
Allow buying the same pair multiple times (position
|
||||
stacking).
|
||||
stacking). Only applicable to backtesting and
|
||||
hyperopt. Results archived by this cannot be
|
||||
reproduced in dry/live trading.
|
||||
--enable-protections, --enableprotections
|
||||
Enable protections for backtesting. Will slow
|
||||
backtesting down by a considerable amount, but will
|
||||
|
||||
@@ -11,6 +11,7 @@ usage: freqtrade download-data [-h] [-v] [--no-color] [--logfile FILE] [-V]
|
||||
[--data-format-ohlcv {json,jsongz,feather,parquet}]
|
||||
[--data-format-trades {json,jsongz,feather,parquet}]
|
||||
[--trading-mode {spot,margin,futures}]
|
||||
[--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]]
|
||||
[--prepend]
|
||||
|
||||
options:
|
||||
@@ -50,6 +51,11 @@ options:
|
||||
`feather`).
|
||||
--trading-mode, --tradingmode {spot,margin,futures}
|
||||
Select Trading mode
|
||||
--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]
|
||||
Select candle type to download. Defaults to the
|
||||
necessary candles for the selected trading mode (e.g.
|
||||
'spot' or ('futures', 'funding_rate' and 'mark') for
|
||||
futures).
|
||||
--prepend Allow data prepending. (Data-appending is disabled)
|
||||
|
||||
Common arguments:
|
||||
|
||||
@@ -41,7 +41,9 @@ options:
|
||||
functions.
|
||||
--eps, --enable-position-stacking
|
||||
Allow buying the same pair multiple times (position
|
||||
stacking).
|
||||
stacking). Only applicable to backtesting and
|
||||
hyperopt. Results archived by this cannot be
|
||||
reproduced in dry/live trading.
|
||||
--enable-protections, --enableprotections
|
||||
Enable protections for backtesting. Will slow
|
||||
backtesting down by a considerable amount, but will
|
||||
|
||||
@@ -60,6 +60,7 @@ freqtrade download-data --exchange binance --pairs ".*/USDT"
|
||||
* Given starting points are ignored if data is already available, downloading only missing data up to today.
|
||||
* Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data.
|
||||
* To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options.
|
||||
* When downloading futures data (`--trading-mode futures` or a configuration specifying futures mode), freqtrade will automatically download the necessary candle types (e.g. `mark` and `funding_rate` candles) unless specified otherwise via `--candle-types`.
|
||||
|
||||
??? Note "Permission denied errors"
|
||||
If your configuration directory `user_data` was made by docker, you may get the following error:
|
||||
|
||||
@@ -98,3 +98,50 @@ Please use configuration based [log setup](advanced-setup.md#advanced-logging) i
|
||||
|
||||
The edge module has been deprecated in 2023.9 and removed in 2025.6.
|
||||
All functionalities of edge have been removed, and having edge configured will result in an error.
|
||||
|
||||
## Adjustment to dynamic funding rate handling
|
||||
|
||||
With version 2025.12, the handling of dynamic funding rates has been adjusted to also support dynamic funding rates down to 1h funding intervals.
|
||||
As a consequence, the mark and funding rate timeframes have been changed to 1h for every supported futures exchange.
|
||||
|
||||
As the timeframe for both mark and funding_fee candles has changed (usually from 8h to 1h) - already downloaded data will have to be adjusted or partially re-downloaded.
|
||||
You can either re-download everything (`freqtrade download-data [...] --erase` - :warning: can take a long time) - or download the updated data selectively.
|
||||
|
||||
### Strategy
|
||||
|
||||
Most strategies should not need adjustments to continue to work as expected - however, strategies using `@informative("8h", candle_type="funding_rate")` or similar will have to switch the timeframe to 1h.
|
||||
The same is true for `dp.get_pair_dataframe(metadata["pair"], "8h", candle_type="funding_rate")` - which will need to be switched to 1h.
|
||||
|
||||
freqtrade will auto-adjust the timeframe and return `funding_rates` despite the wrongly given timeframe. It'll issue a warning - and may still break your strategy.
|
||||
|
||||
### Selective data re-download
|
||||
|
||||
The script below should serve as an example - you may need to adjust the timeframe and exchange to your needs!
|
||||
|
||||
``` bash
|
||||
# Cleanup no longer needed data
|
||||
rm user_data/data/<exchange>/futures/*-mark-*
|
||||
rm user_data/data/<exchange>/futures/*-funding_rate-*
|
||||
|
||||
# download new data (only required once to fix the mark and funding fee data)
|
||||
freqtrade download-data -t 1h --trading-mode futures --candle-types funding_rate mark [...] --timerange <full timerange you've got other data for>
|
||||
|
||||
```
|
||||
|
||||
The result of the above will be that your funding_rates and mark data will have the 1h timeframe.
|
||||
you can verify this with `freqtrade list-data --exchange <yourexchange> --show`.
|
||||
|
||||
!!! Note "Additional arguments"
|
||||
Additional arguments to the above commands may be necessary, like configuration files or explicit user_data if they deviate from the default.
|
||||
|
||||
**Hyperliquid** is a special case now - which will no longer require 1h mark data - but will use regular candles instead (this data never existed and is identical to 1h futures candles). As we don't support download-data for hyperliquid (they don't provide historic data) - there won't be actions necessary for hyperliquid users.
|
||||
|
||||
## Catboost models in freqAI
|
||||
|
||||
CatBoost models have been removed with version 2025.12 and are no longer actively supported.
|
||||
If you have existing bots using CatBoost models, you can still use them in your custom models by copy/pasting them from the git history (as linked below) and installing the Catboost library manually.
|
||||
We do however recommend switching to other supported model libraries like LightGBM or XGBoost for better support and future compatibility.
|
||||
|
||||
* [CatboostRegressor](https://github.com/freqtrade/freqtrade/blob/c6f3b0081927e161a16b116cc47fb663f7831d30/freqtrade/freqai/prediction_models/CatboostRegressor.py)
|
||||
* [CatboostClassifier](https://github.com/freqtrade/freqtrade/blob/c6f3b0081927e161a16b116cc47fb663f7831d30/freqtrade/freqai/prediction_models/CatboostClassifier.py)
|
||||
* [CatboostClassifierMultiTarget](https://github.com/freqtrade/freqtrade/blob/c6f3b0081927e161a16b116cc47fb663f7831d30/freqtrade/freqai/prediction_models/CatboostClassifierMultiTarget.py)
|
||||
|
||||
@@ -200,15 +200,15 @@ If this value is set, FreqAI will initially use the predictions from the trainin
|
||||
|
||||
## Using different prediction models
|
||||
|
||||
FreqAI has multiple example prediction model libraries that are ready to be used as is via the flag `--freqaimodel`. These libraries include `CatBoost`, `LightGBM`, and `XGBoost` regression, classification, and multi-target models, and can be found in `freqai/prediction_models/`.
|
||||
FreqAI has multiple example prediction model libraries that are ready to be used as is via the flag `--freqaimodel`. These libraries include `LightGBM`, and `XGBoost` regression, classification, and multi-target models, and can be found in `freqai/prediction_models/`.
|
||||
|
||||
Regression and classification models differ in what targets they predict - a regression model will predict a target of continuous values, for example what price BTC will be at tomorrow, whilst a classifier will predict a target of discrete values, for example if the price of BTC will go up tomorrow or not. This means that you have to specify your targets differently depending on which model type you are using (see details [below](#setting-model-targets)).
|
||||
|
||||
All of the aforementioned model libraries implement gradient boosted decision tree algorithms. They all work on the principle of ensemble learning, where predictions from multiple simple learners are combined to get a final prediction that is more stable and generalized. The simple learners in this case are decision trees. Gradient boosting refers to the method of learning, where each simple learner is built in sequence - the subsequent learner is used to improve on the error from the previous learner. If you want to learn more about the different model libraries you can find the information in their respective docs:
|
||||
|
||||
* CatBoost: https://catboost.ai/en/docs/
|
||||
* LightGBM: https://lightgbm.readthedocs.io/en/v3.3.2/#
|
||||
* XGBoost: https://xgboost.readthedocs.io/en/stable/#
|
||||
* LightGBM: <https://lightgbm.readthedocs.io/en/v3.3.2/#>
|
||||
* XGBoost: <https://xgboost.readthedocs.io/en/stable/#>
|
||||
* CatBoost: <https://catboost.ai/en/docs/> (No longer actively supported since 2025.12)
|
||||
|
||||
There are also numerous online articles describing and comparing the algorithms. Some relatively lightweight examples would be [CatBoost vs. LightGBM vs. XGBoost — Which is the best algorithm?](https://towardsdatascience.com/catboost-vs-lightgbm-vs-xgboost-c80f40662924#:~:text=In%20CatBoost%2C%20symmetric%20trees%2C%20or,the%20same%20depth%20can%20differ.) and [XGBoost, LightGBM or CatBoost — which boosting algorithm should I use?](https://medium.com/riskified-technology/xgboost-lightgbm-or-catboost-which-boosting-algorithm-should-i-use-e7fda7bb36bc). Keep in mind that the performance of each model is highly dependent on the application and so any reported metrics might not be true for your particular use of the model.
|
||||
|
||||
@@ -219,7 +219,7 @@ Make sure to use unique names to avoid overriding built-in models.
|
||||
|
||||
#### Regressors
|
||||
|
||||
If you are using a regressor, you need to specify a target that has continuous values. FreqAI includes a variety of regressors, such as the `CatboostRegressor`via the flag `--freqaimodel CatboostRegressor`. An example of how you could set a regression target for predicting the price 100 candles into the future would be
|
||||
If you are using a regressor, you need to specify a target that has continuous values. FreqAI includes a variety of regressors, such as the `LightGBMRegressor`via the flag `--freqaimodel LightGBMRegressor`. An example of how you could set a regression target for predicting the price 100 candles into the future would be
|
||||
|
||||
```python
|
||||
df['&s-close_price'] = df['close'].shift(-100)
|
||||
@@ -229,7 +229,7 @@ If you want to predict multiple targets, you need to define multiple labels usin
|
||||
|
||||
#### Classifiers
|
||||
|
||||
If you are using a classifier, you need to specify a target that has discrete values. FreqAI includes a variety of classifiers, such as the `CatboostClassifier` via the flag `--freqaimodel CatboostClassifier`. If you elects to use a classifier, the classes need to be set using strings. For example, if you want to predict if the price 100 candles into the future goes up or down you would set
|
||||
If you are using a classifier, you need to specify a target that has discrete values. FreqAI includes a variety of classifiers, such as the `LightGBMClassifier` via the flag `--freqaimodel LightGBMClassifier`. If you elects to use a classifier, the classes need to be set using strings. For example, if you want to predict if the price 100 candles into the future goes up or down you would set
|
||||
|
||||
```python
|
||||
df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down')
|
||||
|
||||
@@ -107,7 +107,6 @@ Mandatory parameters are marked as **Required** and have to be set in one of the
|
||||
| `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`.
|
||||
|
||||
|
||||
### Additional parameters
|
||||
|
||||
| Parameter | Description |
|
||||
@@ -116,3 +115,4 @@ Mandatory parameters are marked as **Required** and have to be set in one of the
|
||||
| `freqai.keras` | If the selected model makes use of Keras (typical for TensorFlow-based prediction models), this flag needs to be activated so that the model save/loading follows Keras standards. <br> **Datatype:** Boolean. <br> Default: `False`.
|
||||
| `freqai.conv_width` | The width of a neural network input tensor. This replaces the need for shifting candles (`include_shifted_candles`) by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction. <br> **Datatype:** Integer. <br> Default: `2`.
|
||||
| `freqai.reduce_df_footprint` | Recast all numeric columns to float32/int32, with the objective of reducing ram/disk usage and decreasing train/inference timing. This parameter is set in the main level of the Freqtrade configuration file (not inside FreqAI). <br> **Datatype:** Boolean. <br> Default: `False`.
|
||||
| `freqai.override_exchange_check` | Override the exchange check to force FreqAI to use exchanges that may not have enough historic data. Turn this to True if you know your FreqAI model and strategy do not require historical data. <br> **Datatype:** Boolean. <br> Default: `False`.
|
||||
|
||||
@@ -2,6 +2,6 @@ markdown==3.10
|
||||
mkdocs==1.6.1
|
||||
mkdocs-material==9.7.0
|
||||
mdx_truly_sane_lists==1.3
|
||||
pymdown-extensions==10.17.1
|
||||
pymdown-extensions==10.19.1
|
||||
jinja2==3.1.6
|
||||
mike==2.1.3
|
||||
|
||||
+6
-1
@@ -31,9 +31,14 @@ The Order-type will be ignored if only one mode is available.
|
||||
--8<-- "includes/exchange-features.md"
|
||||
|
||||
!!! Note "Tight stoploss"
|
||||
<ins>Do not set too low/tight stoploss value when using stop loss on exchange!</ins>
|
||||
Do not set too low/tight stoploss value when using stop loss on exchange!
|
||||
If set to low/tight you will have greater risk of missing fill on the order and stoploss will not work.
|
||||
|
||||
!!! Warning "Loose stoploss"
|
||||
Using stoploss on exchange with a very wide stoploss (e.g. -1) may fail to place the stoploss order on exchange due to exchange limitations.
|
||||
In that case, the bot will fallback to using the `emergency_exit` order type to place a market order as placing the stoploss order failed.
|
||||
Freqtrade currently does not implement a limitation to avoid this situation, so please ensure your stoploss values are within reasonable limits for your exchange or disable stoploss on exchange.
|
||||
|
||||
### stoploss_on_exchange and stoploss_on_exchange_limit_ratio
|
||||
|
||||
Enable or Disable stop loss on exchange.
|
||||
|
||||
@@ -634,7 +634,7 @@ class AwesomeStrategy(IStrategy):
|
||||
|
||||
## Custom order price rules
|
||||
|
||||
By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy.
|
||||
By default, freqtrade use the orderbook to automatically set an order price ([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy.
|
||||
|
||||
You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits.
|
||||
|
||||
@@ -644,7 +644,7 @@ Each of these methods are called right before placing an order on the exchange.
|
||||
If your custom pricing function return None or an invalid value, price will fall back to `proposed_rate`, which is based on the regular pricing configuration.
|
||||
|
||||
!!! Note
|
||||
Using custom_entry_price, the Trade object will be available as soon as the first entry order associated with the trade is created, for the first entry, `trade` parameter value will be `None`.
|
||||
When using `custom_entry_price()`, the Trade object will be available as soon as the first entry order associated with the trade is created, for the first entry, `trade` parameter value will be `None`.
|
||||
|
||||
### Custom order entry and exit price example
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Freqtrade bot"""
|
||||
|
||||
__version__ = "2025.11.2"
|
||||
__version__ = "2025.12"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -3,6 +3,7 @@ This module contains the argument manager class
|
||||
"""
|
||||
|
||||
from argparse import ArgumentParser, Namespace, _ArgumentGroup
|
||||
from copy import deepcopy
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -174,6 +175,7 @@ ARGS_DOWNLOAD_DATA = [
|
||||
"dataformat_ohlcv",
|
||||
"dataformat_trades",
|
||||
"trading_mode",
|
||||
"candle_types",
|
||||
"prepend_data",
|
||||
]
|
||||
|
||||
@@ -348,7 +350,11 @@ class Arguments:
|
||||
def _build_args(self, optionlist: list[str], parser: ArgumentParser | _ArgumentGroup) -> None:
|
||||
for val in optionlist:
|
||||
opt = AVAILABLE_CLI_OPTIONS[val]
|
||||
parser.add_argument(*opt.cli, dest=val, **opt.kwargs)
|
||||
options = deepcopy(opt.kwargs)
|
||||
help_text = options.pop("help", None)
|
||||
if opt.fthelp and isinstance(opt.fthelp, dict) and hasattr(parser, "prog"):
|
||||
help_text = opt.fthelp.get(parser.prog, help_text)
|
||||
parser.add_argument(*opt.cli, dest=val, help=help_text, **options)
|
||||
|
||||
def _build_subcommands(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -38,8 +38,14 @@ def check_int_nonzero(value: str) -> int:
|
||||
|
||||
class Arg:
|
||||
# Optional CLI arguments
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, fthelp: dict[str, str] | None = None, **kwargs):
|
||||
"""
|
||||
CLI Arguments - used to build subcommand parsers consistently.
|
||||
:param fthelp: dict - fthelp per command - should be "freqtrade <command>": help_text
|
||||
If not provided or not found, 'help' from kwargs is used instead.
|
||||
"""
|
||||
self.cli = args
|
||||
self.fthelp = fthelp
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
@@ -174,7 +180,11 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
"position_stacking": Arg(
|
||||
"--eps",
|
||||
"--enable-position-stacking",
|
||||
help="Allow buying the same pair multiple times (position stacking).",
|
||||
help=(
|
||||
"Allow buying the same pair multiple times (position stacking). "
|
||||
"Only applicable to backtesting and hyperopt. "
|
||||
"Results archived by this cannot be reproduced in dry/live trading."
|
||||
),
|
||||
action="store_true",
|
||||
default=False,
|
||||
),
|
||||
@@ -422,6 +432,14 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
),
|
||||
"candle_types": Arg(
|
||||
"--candle-types",
|
||||
fthelp={
|
||||
"freqtrade download-data": (
|
||||
"Select candle type to download. "
|
||||
"Defaults to the necessary candles for the selected trading mode "
|
||||
"(e.g. 'spot' or ('futures', 'funding_rate' and 'mark') for futures)."
|
||||
),
|
||||
"_": "Select candle type to convert. Defaults to all available types.",
|
||||
},
|
||||
help="Select candle type to convert. Defaults to all available types.",
|
||||
choices=[c.value for c in CandleType],
|
||||
nargs="+",
|
||||
|
||||
@@ -38,7 +38,8 @@ def ohlcv_to_dataframe(
|
||||
cols = DEFAULT_DATAFRAME_COLUMNS
|
||||
df = DataFrame(ohlcv, columns=cols)
|
||||
|
||||
df["date"] = to_datetime(df["date"], unit="ms", utc=True)
|
||||
# Floor date to seconds to account for exchange imprecisions
|
||||
df["date"] = to_datetime(df["date"], unit="ms", utc=True).dt.floor("s")
|
||||
|
||||
# Some exchanges return int values for Volume and even for OHLC.
|
||||
# Convert them since TA-LIB indicators used in the strategy assume floats
|
||||
|
||||
@@ -348,6 +348,22 @@ class DataProvider:
|
||||
)
|
||||
return total_candles
|
||||
|
||||
def __fix_funding_rate_timeframe(
|
||||
self, pair: str, timeframe: str | None, candle_type: str
|
||||
) -> str | None:
|
||||
if (
|
||||
candle_type == CandleType.FUNDING_RATE
|
||||
and (ff_tf := self.get_funding_rate_timeframe()) != timeframe
|
||||
):
|
||||
# TODO: does this message make sense? might be pointless as funding fees don't
|
||||
# have a timeframe
|
||||
logger.warning(
|
||||
f"{pair}, {timeframe} requested - funding rate timeframe not matching {ff_tf}."
|
||||
)
|
||||
return ff_tf
|
||||
|
||||
return timeframe
|
||||
|
||||
def get_pair_dataframe(
|
||||
self, pair: str, timeframe: str | None = None, candle_type: str = ""
|
||||
) -> DataFrame:
|
||||
@@ -361,6 +377,7 @@ class DataProvider:
|
||||
:return: Dataframe for this pair
|
||||
:param candle_type: '', mark, index, premiumIndex, or funding_rate
|
||||
"""
|
||||
timeframe = self.__fix_funding_rate_timeframe(pair, timeframe, candle_type)
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
# Get live OHLCV data.
|
||||
data = self.ohlcv(pair=pair, timeframe=timeframe, candle_type=candle_type)
|
||||
@@ -620,3 +637,12 @@ class DataProvider:
|
||||
except ExchangeError:
|
||||
logger.warning(f"Could not fetch market data for {pair}. Assuming no delisting.")
|
||||
return None
|
||||
|
||||
def get_funding_rate_timeframe(self) -> str:
|
||||
"""
|
||||
Get the funding rate timeframe from exchange options
|
||||
:return: Timeframe string
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return self._exchange.get_option("funding_fee_timeframe")
|
||||
|
||||
@@ -397,6 +397,9 @@ class IDataHandler(ABC):
|
||||
pairdf = self._ohlcv_load(
|
||||
pair, timeframe, timerange=timerange_startup, candle_type=candle_type
|
||||
)
|
||||
if not pairdf.empty and candle_type == CandleType.FUNDING_RATE:
|
||||
# Funding rate data is sometimes off by a couple of ms - floor to seconds
|
||||
pairdf["date"] = pairdf["date"].dt.floor("s")
|
||||
if self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data):
|
||||
return pairdf
|
||||
else:
|
||||
@@ -508,8 +511,15 @@ class IDataHandler(ABC):
|
||||
Applies to bybit and okx, where funding-fee and mark candles have different timeframes.
|
||||
"""
|
||||
paircombs = self.ohlcv_get_available_data(self._datadir, TradingMode.FUTURES)
|
||||
ff_timeframe_s = timeframe_to_seconds(ff_timeframe)
|
||||
|
||||
funding_rate_combs = [
|
||||
f for f in paircombs if f[2] == CandleType.FUNDING_RATE and f[1] != ff_timeframe
|
||||
f
|
||||
for f in paircombs
|
||||
if f[2] == CandleType.FUNDING_RATE
|
||||
and f[1] != ff_timeframe
|
||||
# Only allow smaller timeframes to move from smaller to larger timeframes
|
||||
and timeframe_to_seconds(f[1]) < ff_timeframe_s
|
||||
]
|
||||
|
||||
if funding_rate_combs:
|
||||
|
||||
@@ -308,11 +308,15 @@ def _download_pair_history(
|
||||
candle_type=candle_type,
|
||||
until_ms=until_ms if until_ms else None,
|
||||
)
|
||||
logger.info(f"Downloaded data for {pair} with length {len(new_dataframe)}.")
|
||||
logger.info(
|
||||
f"Downloaded data for {pair}, {timeframe}, {candle_type} with length "
|
||||
f"{len(new_dataframe)}."
|
||||
)
|
||||
else:
|
||||
new_dataframe = pair_candles
|
||||
logger.info(
|
||||
f"Downloaded data for {pair} with length {len(new_dataframe)}. Parallel Method."
|
||||
f"Downloaded data for {pair}, {timeframe}, {candle_type} with length "
|
||||
f"{len(new_dataframe)}. Parallel Method."
|
||||
)
|
||||
|
||||
if data.empty:
|
||||
@@ -349,6 +353,7 @@ def _download_pair_history(
|
||||
|
||||
def refresh_backtest_ohlcv_data(
|
||||
exchange: Exchange,
|
||||
*,
|
||||
pairs: list[str],
|
||||
timeframes: list[str],
|
||||
datadir: Path,
|
||||
@@ -359,6 +364,7 @@ def refresh_backtest_ohlcv_data(
|
||||
data_format: str | None = None,
|
||||
prepend: bool = False,
|
||||
progress_tracker: CustomProgress | None = None,
|
||||
candle_types: list[CandleType] | None = None,
|
||||
no_parallel_download: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
@@ -371,10 +377,44 @@ def refresh_backtest_ohlcv_data(
|
||||
pairs_not_available = []
|
||||
fast_candles: dict[PairWithTimeframe, DataFrame] = {}
|
||||
data_handler = get_datahandler(datadir, data_format)
|
||||
candle_type = CandleType.get_default(trading_mode)
|
||||
def_candletype = CandleType.SPOT if trading_mode != "futures" else CandleType.FUTURES
|
||||
if trading_mode != "futures":
|
||||
# Ignore user passed candle types for non-futures trading
|
||||
timeframes_with_candletype = [(tf, def_candletype) for tf in timeframes]
|
||||
else:
|
||||
# Filter out SPOT candle type for futures trading
|
||||
candle_types = (
|
||||
[ct for ct in candle_types if ct != CandleType.SPOT] if candle_types else None
|
||||
)
|
||||
fr_candle_type = CandleType.from_string(exchange.get_option("mark_ohlcv_price"))
|
||||
tf_funding_rate = exchange.get_option("funding_fee_timeframe")
|
||||
tf_mark = exchange.get_option("mark_ohlcv_timeframe")
|
||||
|
||||
if candle_types:
|
||||
for ct in candle_types:
|
||||
exchange.verify_candle_type_support(ct)
|
||||
timeframes_with_candletype = [
|
||||
(tf, ct)
|
||||
for ct in candle_types
|
||||
for tf in timeframes
|
||||
if ct != CandleType.FUNDING_RATE
|
||||
]
|
||||
else:
|
||||
# Default behavior
|
||||
timeframes_with_candletype = [(tf, def_candletype) for tf in timeframes]
|
||||
timeframes_with_candletype.append((tf_mark, fr_candle_type))
|
||||
if not candle_types or CandleType.FUNDING_RATE in candle_types:
|
||||
# All exchanges need FundingRate for futures trading.
|
||||
# The timeframe is aligned to the mark-price timeframe.
|
||||
timeframes_with_candletype.append((tf_funding_rate, CandleType.FUNDING_RATE))
|
||||
# Deduplicate list ...
|
||||
timeframes_with_candletype = list(dict.fromkeys(timeframes_with_candletype))
|
||||
logger.debug(
|
||||
"Downloading %s.", ", ".join(f'"{tf} {ct}"' for tf, ct in timeframes_with_candletype)
|
||||
)
|
||||
|
||||
with progress_tracker as progress:
|
||||
tf_length = len(timeframes) if trading_mode != "futures" else len(timeframes) + 2
|
||||
timeframe_task = progress.add_task("Timeframe", total=tf_length)
|
||||
timeframe_task = progress.add_task("Timeframe", total=len(timeframes_with_candletype))
|
||||
pair_task = progress.add_task("Downloading data...", total=len(pairs))
|
||||
|
||||
for pair in pairs:
|
||||
@@ -385,7 +425,7 @@ def refresh_backtest_ohlcv_data(
|
||||
pairs_not_available.append(f"{pair}: Pair not available on exchange.")
|
||||
logger.info(f"Skipping pair {pair}...")
|
||||
continue
|
||||
for timeframe in timeframes:
|
||||
for timeframe, candle_type in timeframes_with_candletype:
|
||||
# Get fast candles via parallel method on first loop through per timeframe
|
||||
# and candle type. Downloads all the pairs in the list and stores them.
|
||||
# Also skips if only 1 pair/timeframe combination is scheduled for download.
|
||||
@@ -412,7 +452,7 @@ def refresh_backtest_ohlcv_data(
|
||||
# get the already downloaded pair candles if they exist
|
||||
pair_candles = fast_candles.pop((pair, timeframe, candle_type), None)
|
||||
|
||||
progress.update(timeframe_task, description=f"Timeframe {timeframe}")
|
||||
progress.update(timeframe_task, description=f"Timeframe {timeframe} {candle_type}")
|
||||
logger.debug(f"Downloading pair {pair}, {candle_type}, interval {timeframe}.")
|
||||
_download_pair_history(
|
||||
pair=pair,
|
||||
@@ -428,33 +468,6 @@ def refresh_backtest_ohlcv_data(
|
||||
pair_candles=pair_candles, # optional pass of dataframe of parallel candles
|
||||
)
|
||||
progress.update(timeframe_task, advance=1)
|
||||
if trading_mode == "futures":
|
||||
# Predefined candletype (and timeframe) depending on exchange
|
||||
# Downloads what is necessary to backtest based on futures data.
|
||||
tf_mark = exchange.get_option("mark_ohlcv_timeframe")
|
||||
tf_funding_rate = exchange.get_option("funding_fee_timeframe")
|
||||
|
||||
fr_candle_type = CandleType.from_string(exchange.get_option("mark_ohlcv_price"))
|
||||
# All exchanges need FundingRate for futures trading.
|
||||
# The timeframe is aligned to the mark-price timeframe.
|
||||
combs = ((CandleType.FUNDING_RATE, tf_funding_rate), (fr_candle_type, tf_mark))
|
||||
for candle_type_f, tf in combs:
|
||||
logger.debug(f"Downloading pair {pair}, {candle_type_f}, interval {tf}.")
|
||||
_download_pair_history(
|
||||
pair=pair,
|
||||
datadir=datadir,
|
||||
exchange=exchange,
|
||||
timerange=timerange,
|
||||
data_handler=data_handler,
|
||||
timeframe=str(tf),
|
||||
new_pairs_days=new_pairs_days,
|
||||
candle_type=candle_type_f,
|
||||
erase=erase,
|
||||
prepend=prepend,
|
||||
)
|
||||
progress.update(
|
||||
timeframe_task, advance=1, description=f"Timeframe {candle_type_f}, {tf}"
|
||||
)
|
||||
|
||||
progress.update(pair_task, advance=1)
|
||||
progress.update(timeframe_task, description="Timeframe")
|
||||
@@ -800,6 +813,7 @@ def download_data(
|
||||
trading_mode=config.get("trading_mode", "spot"),
|
||||
prepend=config.get("prepend_data", False),
|
||||
progress_tracker=progress_tracker,
|
||||
candle_types=config.get("candle_types"),
|
||||
no_parallel_download=config.get("no_parallel_download", False),
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -74,9 +74,10 @@ def combined_dataframes_with_rel_mean(
|
||||
df_comb = combine_dataframes_by_column(data, column)
|
||||
# Trim dataframes to the given timeframe
|
||||
df_comb = df_comb.iloc[(df_comb.index >= fromdt) & (df_comb.index < todt)]
|
||||
rel_mean = df_comb.pct_change().mean(axis=1).fillna(0).cumsum()
|
||||
df_comb["count"] = df_comb.count(axis=1)
|
||||
df_comb["mean"] = df_comb.mean(axis=1)
|
||||
df_comb["rel_mean"] = df_comb["mean"].pct_change().fillna(0).cumsum()
|
||||
df_comb["rel_mean"] = rel_mean
|
||||
return df_comb[["mean", "rel_mean", "count"]]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS
|
||||
from freqtrade.exchange.exchange import Exchange
|
||||
|
||||
# isort: on
|
||||
from freqtrade.exchange.binance import Binance
|
||||
from freqtrade.exchange.binance import Binance, Binanceus, Binanceusdm
|
||||
from freqtrade.exchange.bingx import Bingx
|
||||
from freqtrade.exchange.bitget import Bitget
|
||||
from freqtrade.exchange.bitmart import Bitmart
|
||||
|
||||
@@ -17,7 +17,7 @@ from freqtrade.exchange.binance_public_data import (
|
||||
download_archive_trades,
|
||||
)
|
||||
from freqtrade.exchange.common import retrier
|
||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas, Tickers
|
||||
from freqtrade.exchange.exchange_types import FtHas, Tickers
|
||||
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
|
||||
from freqtrade.misc import deep_merge_dicts, json_load
|
||||
from freqtrade.util import FtTTLCache
|
||||
@@ -51,6 +51,8 @@ class Binance(Exchange):
|
||||
"funding_fee_candle_limit": 1000,
|
||||
"stoploss_order_types": {"limit": "stop", "market": "stop_market"},
|
||||
"stoploss_blocks_assets": False, # Stoploss orders do not block assets
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"stoploss_algo_order_info_id": "actualOrderId",
|
||||
"tickers_have_price": False,
|
||||
"floor_leverage": True,
|
||||
"fetch_orders_limit_minutes": 7 * 1440, # "fetch_orders" is limited to 7 days
|
||||
@@ -145,34 +147,6 @@ class Binance(Exchange):
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def fetch_stoploss_order(
|
||||
self, order_id: str, pair: str, params: dict | None = None
|
||||
) -> CcxtOrder:
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
params = params or {}
|
||||
params.update({"stop": True})
|
||||
order = self.fetch_order(order_id, pair, params)
|
||||
if self.trading_mode == TradingMode.FUTURES and order.get("status", "open") == "closed":
|
||||
# Places a real order - which we need to fetch explicitly.
|
||||
|
||||
if new_orderid := order.get("info", {}).get("actualOrderId"):
|
||||
order1 = self.fetch_order(order_id=new_orderid, pair=pair, params={})
|
||||
order1["id_stop"] = order1["id"]
|
||||
order1["id"] = order_id
|
||||
order1["type"] = "stoploss"
|
||||
order1["stopPrice"] = order.get("stopPrice")
|
||||
order1["status_stop"] = "triggered"
|
||||
|
||||
return order1
|
||||
|
||||
return order
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
params = params or {}
|
||||
params.update({"stop": True})
|
||||
return self.cancel_order(order_id=order_id, pair=pair, params=params)
|
||||
|
||||
def get_historic_ohlcv(
|
||||
self,
|
||||
pair: str,
|
||||
@@ -572,3 +546,26 @@ class Binance(Exchange):
|
||||
cache[ft_symbol] = delist_dt
|
||||
|
||||
return cache.get(pair, None)
|
||||
|
||||
|
||||
class Binanceusdm(Binance):
|
||||
"""Binacne USDM Exchange
|
||||
Same as Binance - only futures trading is supported (via ccxt).
|
||||
|
||||
Not actually necessary, binance should be preferred.
|
||||
"""
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
(TradingMode.FUTURES, MarginMode.CROSS),
|
||||
(TradingMode.FUTURES, MarginMode.ISOLATED),
|
||||
]
|
||||
|
||||
|
||||
class Binanceus(Binance):
|
||||
"""Binance US exchange class.
|
||||
Minimal adjustment to disable futures trading for the US subsidiary of Binance
|
||||
"""
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
(TradingMode.SPOT, MarginMode.NONE),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,11 +31,11 @@ class Bitget(Exchange):
|
||||
"stop_price_prop": "stopPrice",
|
||||
"stoploss_blocks_assets": False, # Stoploss orders do not block assets
|
||||
"stoploss_order_types": {"limit": "limit", "market": "market"},
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
|
||||
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
|
||||
}
|
||||
_ft_has_futures: FtHas = {
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_candle_limit": 100,
|
||||
"has_delisting": True,
|
||||
}
|
||||
@@ -129,9 +129,6 @@ class Bitget(Exchange):
|
||||
|
||||
return self._fetch_stop_order_fallback(order_id, pair)
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True})
|
||||
|
||||
@retrier
|
||||
def additional_exchange_init(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -38,8 +38,6 @@ class Bybit(Exchange):
|
||||
}
|
||||
_ft_has_futures: FtHas = {
|
||||
"ohlcv_has_history": True,
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_timeframe": "8h",
|
||||
"funding_fee_candle_limit": 200,
|
||||
"stoploss_on_exchange": True,
|
||||
"stoploss_order_types": {"limit": "limit", "market": "market"},
|
||||
|
||||
@@ -45,8 +45,6 @@ BAD_EXCHANGES = {
|
||||
}
|
||||
|
||||
MAP_EXCHANGE_CHILDCLASS = {
|
||||
"binanceus": "binance",
|
||||
"binanceusdm": "binance",
|
||||
"okex": "okx",
|
||||
"gateio": "gate",
|
||||
"huboi": "htx",
|
||||
@@ -54,6 +52,8 @@ MAP_EXCHANGE_CHILDCLASS = {
|
||||
|
||||
SUPPORTED_EXCHANGES = [
|
||||
"binance",
|
||||
"binanceus",
|
||||
"binanceusdm",
|
||||
"bingx",
|
||||
"bitmart",
|
||||
"bitget",
|
||||
@@ -97,6 +97,9 @@ EXCHANGE_HAS_OPTIONAL = [
|
||||
# 'fetchLeverageTiers', # Futures initialization
|
||||
# 'fetchMarketLeverageTiers', # Futures initialization
|
||||
# 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance...
|
||||
# "fetchPremiumIndexOHLCV", # Futures additional data
|
||||
# "fetchMarkOHLCV", # Futures additional data
|
||||
# "fetchIndexOHLCV", # Futures additional data
|
||||
# ccxt.pro
|
||||
"watchOHLCV",
|
||||
]
|
||||
|
||||
+152
-39
@@ -104,6 +104,7 @@ from freqtrade.misc import (
|
||||
deep_merge_dicts,
|
||||
file_dump_json,
|
||||
file_load_json,
|
||||
safe_value_fallback,
|
||||
safe_value_fallback2,
|
||||
)
|
||||
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
|
||||
@@ -131,6 +132,7 @@ class Exchange:
|
||||
"stop_price_prop": "stopLossPrice", # Used for stoploss_on_exchange response parsing
|
||||
"stoploss_order_types": {},
|
||||
"stoploss_blocks_assets": True, # By default stoploss orders block assets
|
||||
"stoploss_query_requires_stop_flag": False, # Require "stop": True" to fetch stop orders
|
||||
"order_time_in_force": ["GTC"],
|
||||
"ohlcv_params": {},
|
||||
"ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv
|
||||
@@ -152,8 +154,8 @@ class Exchange:
|
||||
"l2_limit_range_required": True, # Allow Empty L2 limit (kucoin)
|
||||
"l2_limit_upper": None, # Upper limit for L2 limit
|
||||
"mark_ohlcv_price": "mark",
|
||||
"mark_ohlcv_timeframe": "8h",
|
||||
"funding_fee_timeframe": "8h",
|
||||
"mark_ohlcv_timeframe": "1h",
|
||||
"funding_fee_timeframe": "1h",
|
||||
"ccxt_futures_name": "swap",
|
||||
"needs_trading_fees": False, # use fetch_trading_fees to cache fees
|
||||
"order_props_in_contracts": ["amount", "filled", "remaining"],
|
||||
@@ -707,7 +709,7 @@ class Exchange:
|
||||
self._markets = self._api_async.markets
|
||||
self._api.set_markets_from_exchange(self._api_async)
|
||||
# Assign options array, as it contains some temporary information from the exchange.
|
||||
# TODO: investigate with ccxt if it's safe to remove `.options`
|
||||
# ccxt does not implicitly copy options over in set_markets_from_exchange
|
||||
self._api.options = self._api_async.options
|
||||
if self._exchange_ws:
|
||||
# Set markets to avoid reloading on websocket api
|
||||
@@ -877,19 +879,20 @@ class Exchange:
|
||||
# Only allow 5 calls per pair to somewhat limit the impact
|
||||
raise ConfigurationError(
|
||||
f"This strategy requires {startup_candles} candles to start, "
|
||||
"which is more than 5x "
|
||||
f"which is more than 5x ({candle_limit * 5 - 1} candles) "
|
||||
f"the amount of candles {self.name} provides for {timeframe}."
|
||||
)
|
||||
elif required_candle_call_count > 1:
|
||||
raise ConfigurationError(
|
||||
f"This strategy requires {startup_candles} candles to start, which is more than "
|
||||
f"This strategy requires {startup_candles} candles to start, "
|
||||
f"which is more than ({candle_limit - 1} candles) "
|
||||
f"the amount of candles {self.name} provides for {timeframe}."
|
||||
)
|
||||
if required_candle_call_count > 1:
|
||||
logger.warning(
|
||||
f"Using {required_candle_call_count} calls to get OHLCV. "
|
||||
f"This can result in slower operations for the bot. Please check "
|
||||
f"if you really need {startup_candles} candles for your strategy"
|
||||
f"if you really need {startup_candles} candles for your strategy."
|
||||
)
|
||||
return required_candle_call_count
|
||||
|
||||
@@ -1119,6 +1122,7 @@ class Exchange:
|
||||
leverage: float,
|
||||
params: dict | None = None,
|
||||
stop_loss: bool = False,
|
||||
stop_price: float | None = None,
|
||||
) -> CcxtOrder:
|
||||
now = dt_now()
|
||||
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
|
||||
@@ -1145,7 +1149,7 @@ class Exchange:
|
||||
}
|
||||
if stop_loss:
|
||||
dry_order["info"] = {"stopPrice": dry_order["price"]}
|
||||
dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"]
|
||||
dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"]
|
||||
# Workaround to avoid filling stoploss orders immediately
|
||||
dry_order["ft_order_type"] = "stoploss"
|
||||
orderbook: OrderBook | None = None
|
||||
@@ -1163,7 +1167,11 @@ class Exchange:
|
||||
|
||||
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
|
||||
# Update market order pricing
|
||||
average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook)
|
||||
slippage = 0.05
|
||||
worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
|
||||
average = self.get_dry_market_fill_price(
|
||||
pair, side, amount, rate, worst_rate, orderbook
|
||||
)
|
||||
dry_order.update(
|
||||
{
|
||||
"average": average,
|
||||
@@ -1203,7 +1211,13 @@ class Exchange:
|
||||
return dry_order
|
||||
|
||||
def get_dry_market_fill_price(
|
||||
self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None
|
||||
self,
|
||||
pair: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
worst_rate: float,
|
||||
orderbook: OrderBook | None,
|
||||
) -> float:
|
||||
"""
|
||||
Get the market order fill price based on orderbook interpolation
|
||||
@@ -1212,8 +1226,6 @@ class Exchange:
|
||||
if not orderbook:
|
||||
orderbook = self.fetch_l2_order_book(pair, 20)
|
||||
ob_type: OBLiteral = "asks" if side == "buy" else "bids"
|
||||
slippage = 0.05
|
||||
max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
|
||||
|
||||
remaining_amount = amount
|
||||
filled_value = 0.0
|
||||
@@ -1237,11 +1249,10 @@ class Exchange:
|
||||
forecast_avg_filled_price = max(filled_value, 0) / amount
|
||||
# Limit max. slippage to specified value
|
||||
if side == "buy":
|
||||
forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val)
|
||||
forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate)
|
||||
|
||||
else:
|
||||
forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val)
|
||||
|
||||
forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate)
|
||||
return self.price_to_precision(pair, forecast_avg_filled_price)
|
||||
|
||||
return rate
|
||||
@@ -1253,13 +1264,15 @@ class Exchange:
|
||||
limit: float,
|
||||
orderbook: OrderBook | None = None,
|
||||
offset: float = 0.0,
|
||||
is_stop: bool = False,
|
||||
) -> bool:
|
||||
if not self.exchange_has("fetchL2OrderBook"):
|
||||
return True
|
||||
# True unless checking a stoploss order
|
||||
return not is_stop
|
||||
if not orderbook:
|
||||
orderbook = self.fetch_l2_order_book(pair, 1)
|
||||
try:
|
||||
if side == "buy":
|
||||
if (side == "buy" and not is_stop) or (side == "sell" and is_stop):
|
||||
price = orderbook["asks"][0][0]
|
||||
if limit * (1 - offset) >= price:
|
||||
return True
|
||||
@@ -1278,6 +1291,38 @@ class Exchange:
|
||||
"""
|
||||
Check dry-run limit order fill and update fee (if it filled).
|
||||
"""
|
||||
if order["status"] != "closed" and order.get("ft_order_type") == "stoploss":
|
||||
pair = order["symbol"]
|
||||
if not orderbook and self.exchange_has("fetchL2OrderBook"):
|
||||
orderbook = self.fetch_l2_order_book(pair, 20)
|
||||
price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price")
|
||||
crossed = self._dry_is_price_crossed(
|
||||
pair, order["side"], price, orderbook, is_stop=True
|
||||
)
|
||||
if crossed:
|
||||
average = self.get_dry_market_fill_price(
|
||||
pair,
|
||||
order["side"],
|
||||
order["amount"],
|
||||
price,
|
||||
worst_rate=order["price"],
|
||||
orderbook=orderbook,
|
||||
)
|
||||
order.update(
|
||||
{
|
||||
"status": "closed",
|
||||
"filled": order["amount"],
|
||||
"remaining": 0,
|
||||
"average": average,
|
||||
"cost": order["amount"] * average,
|
||||
}
|
||||
)
|
||||
self.add_dry_order_fee(
|
||||
pair,
|
||||
order,
|
||||
"taker" if immediate else "maker",
|
||||
)
|
||||
return order
|
||||
if (
|
||||
order["status"] != "closed"
|
||||
and order["type"] in ["limit"]
|
||||
@@ -1518,8 +1563,9 @@ class Exchange:
|
||||
ordertype,
|
||||
side,
|
||||
amount,
|
||||
stop_price_norm,
|
||||
limit_rate or stop_price_norm,
|
||||
stop_loss=True,
|
||||
stop_price=stop_price_norm,
|
||||
leverage=leverage,
|
||||
)
|
||||
return dry_order
|
||||
@@ -1643,7 +1689,24 @@ class Exchange:
|
||||
def fetch_stoploss_order(
|
||||
self, order_id: str, pair: str, params: dict | None = None
|
||||
) -> CcxtOrder:
|
||||
return self.fetch_order(order_id, pair, params)
|
||||
if self.get_option("stoploss_query_requires_stop_flag"):
|
||||
params = params or {}
|
||||
params["stop"] = True
|
||||
order = self.fetch_order(order_id, pair, params)
|
||||
val = self.get_option("stoploss_algo_order_info_id")
|
||||
if val and order.get("status", "open") == "closed":
|
||||
if new_orderid := order.get("info", {}).get(val):
|
||||
# Fetch real order, which was placed by the algo order.
|
||||
actual_order = self.fetch_order(order_id=new_orderid, pair=pair, params=None)
|
||||
actual_order["id_stop"] = actual_order["id"]
|
||||
actual_order["id"] = order_id
|
||||
actual_order["type"] = "stoploss"
|
||||
actual_order["stopPrice"] = order.get("stopPrice")
|
||||
actual_order["status_stop"] = "triggered"
|
||||
|
||||
return actual_order
|
||||
|
||||
return order
|
||||
|
||||
def fetch_order_or_stoploss_order(
|
||||
self, order_id: str, pair: str, stoploss_order: bool = False
|
||||
@@ -1697,6 +1760,9 @@ class Exchange:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
if self.get_option("stoploss_query_requires_stop_flag"):
|
||||
params = params or {}
|
||||
params["stop"] = True
|
||||
return self.cancel_order(order_id, pair, params)
|
||||
|
||||
def is_cancel_order_result_suitable(self, corder) -> TypeGuard[CcxtOrder]:
|
||||
@@ -1791,9 +1857,9 @@ class Exchange:
|
||||
if self._config["dry_run"] or self.trading_mode != TradingMode.FUTURES:
|
||||
return []
|
||||
try:
|
||||
symbols = []
|
||||
symbols = None
|
||||
if pair:
|
||||
symbols.append(pair)
|
||||
symbols = [pair]
|
||||
positions: list[CcxtPosition] = self._api.fetch_positions(symbols)
|
||||
self._log_exchange_response("fetch_positions", positions)
|
||||
return positions
|
||||
@@ -2647,24 +2713,25 @@ class Exchange:
|
||||
input_coroutines: list[Coroutine[Any, Any, OHLCVResponse]] = []
|
||||
cached_pairs = []
|
||||
for pair, timeframe, candle_type in set(pair_list):
|
||||
invalid_funding = (
|
||||
candle_type == CandleType.FUNDING_RATE
|
||||
and timeframe != self.get_option("funding_fee_timeframe")
|
||||
)
|
||||
if candle_type == CandleType.FUNDING_RATE and timeframe != (
|
||||
ff_tf := self.get_option("funding_fee_timeframe")
|
||||
):
|
||||
# TODO: does this message make sense? would docs be better?
|
||||
# if any, this should be cached to avoid log spam!
|
||||
logger.warning(
|
||||
f"Wrong funding rate timeframe {timeframe} for pair {pair}, "
|
||||
f"downloading {ff_tf} instead."
|
||||
)
|
||||
timeframe = ff_tf
|
||||
invalid_timeframe = timeframe not in self.timeframes and candle_type in (
|
||||
CandleType.SPOT,
|
||||
CandleType.FUTURES,
|
||||
)
|
||||
if invalid_timeframe or invalid_funding:
|
||||
timeframes_ = (
|
||||
", ".join(self.timeframes)
|
||||
if candle_type != CandleType.FUNDING_RATE
|
||||
else self.get_option("funding_fee_timeframe")
|
||||
)
|
||||
if invalid_timeframe:
|
||||
logger.warning(
|
||||
f"Cannot download ({pair}, {timeframe}, {candle_type}) combination as this "
|
||||
f"timeframe is not available on {self.name}. Available timeframes are "
|
||||
f"{timeframes_}."
|
||||
f"{', '.join(self.timeframes)}."
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -2701,7 +2768,11 @@ class Exchange:
|
||||
has_cache = cache and (pair, timeframe, c_type) in self._klines
|
||||
# in case of existing cache, fill_missing happens after concatenation
|
||||
ohlcv_df = ohlcv_to_dataframe(
|
||||
ticks, timeframe, pair=pair, fill_missing=not has_cache, drop_incomplete=drop_incomplete
|
||||
ticks,
|
||||
timeframe,
|
||||
pair=pair,
|
||||
fill_missing=not has_cache and c_type != CandleType.FUNDING_RATE,
|
||||
drop_incomplete=drop_incomplete,
|
||||
)
|
||||
# keeping parsed dataframe in cache
|
||||
if cache:
|
||||
@@ -2712,7 +2783,7 @@ class Exchange:
|
||||
concat([old, ohlcv_df], axis=0),
|
||||
timeframe,
|
||||
pair,
|
||||
fill_missing=True,
|
||||
fill_missing=c_type != CandleType.FUNDING_RATE,
|
||||
drop_incomplete=False,
|
||||
)
|
||||
candle_limit = self.ohlcv_candle_limit(timeframe, self._config["candle_type_def"])
|
||||
@@ -2847,9 +2918,10 @@ class Exchange:
|
||||
timeframe, candle_type=candle_type, since_ms=since_ms
|
||||
)
|
||||
|
||||
if candle_type and candle_type not in (CandleType.SPOT, CandleType.FUTURES):
|
||||
params.update({"price": candle_type.value})
|
||||
if candle_type != CandleType.FUNDING_RATE:
|
||||
if candle_type and candle_type not in (CandleType.SPOT, CandleType.FUTURES):
|
||||
self.verify_candle_type_support(candle_type)
|
||||
params.update({"price": str(candle_type)})
|
||||
data = await self._api_async.fetch_ohlcv(
|
||||
pair, timeframe=timeframe, since=since_ms, limit=candle_limit, params=params
|
||||
)
|
||||
@@ -2914,6 +2986,38 @@ class Exchange:
|
||||
data = [[x["timestamp"], x["fundingRate"], 0, 0, 0, 0] for x in data]
|
||||
return data
|
||||
|
||||
def check_candle_type_support(self, candle_type: CandleType) -> bool:
|
||||
"""
|
||||
Check that the exchange supports the given candle type.
|
||||
:param candle_type: CandleType to verify
|
||||
:return: True if supported, False otherwise
|
||||
"""
|
||||
if candle_type == CandleType.FUNDING_RATE:
|
||||
if not self.exchange_has("fetchFundingRateHistory"):
|
||||
return False
|
||||
elif candle_type not in (CandleType.SPOT, CandleType.FUTURES):
|
||||
mapping = {
|
||||
CandleType.MARK: "fetchMarkOHLCV",
|
||||
CandleType.INDEX: "fetchIndexOHLCV",
|
||||
CandleType.PREMIUMINDEX: "fetchPremiumIndexOHLCV",
|
||||
CandleType.FUNDING_RATE: "fetchFundingRateHistory",
|
||||
}
|
||||
_method = mapping.get(candle_type, "fetchOHLCV")
|
||||
if not self.exchange_has(_method):
|
||||
return False
|
||||
return True
|
||||
|
||||
def verify_candle_type_support(self, candle_type: CandleType) -> None:
|
||||
"""
|
||||
Verify that the exchange supports the given candle type.
|
||||
:param candle_type: CandleType to verify
|
||||
:raises OperationalException: if the candle type is not supported
|
||||
"""
|
||||
if not self.check_candle_type_support(candle_type):
|
||||
raise OperationalException(
|
||||
f"Exchange {self._api.name} does not support fetching {candle_type} candles."
|
||||
)
|
||||
|
||||
# fetch Trade data stuff
|
||||
|
||||
def needed_candle_for_trades_ms(self, timeframe: str, candle_type: CandleType) -> int:
|
||||
@@ -3741,10 +3845,11 @@ class Exchange:
|
||||
:param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price)
|
||||
:param futures_funding_rate: Fake funding rate to use if funding_rates are not available
|
||||
"""
|
||||
relevant_cols = ["date", "open_mark", "open_fund"]
|
||||
if futures_funding_rate is None:
|
||||
return mark_rates.merge(
|
||||
funding_rates, on="date", how="inner", suffixes=["_mark", "_fund"]
|
||||
)
|
||||
)[relevant_cols]
|
||||
else:
|
||||
if len(funding_rates) == 0:
|
||||
# No funding rate candles - full fillup with fallback variable
|
||||
@@ -3757,15 +3862,23 @@ class Exchange:
|
||||
"low": "low_mark",
|
||||
"volume": "volume_mark",
|
||||
}
|
||||
)
|
||||
)[relevant_cols]
|
||||
|
||||
else:
|
||||
# Fill up missing funding_rate candles with fallback value
|
||||
combined = mark_rates.merge(
|
||||
funding_rates, on="date", how="left", suffixes=["_mark", "_fund"]
|
||||
)
|
||||
combined["open_fund"] = combined["open_fund"].fillna(futures_funding_rate)
|
||||
return combined
|
||||
# Fill only leading missing funding rates so gaps stay untouched
|
||||
first_valid_idx = combined["open_fund"].first_valid_index()
|
||||
if first_valid_idx is None:
|
||||
combined["open_fund"] = futures_funding_rate
|
||||
else:
|
||||
is_leading_na = (combined.index <= first_valid_idx) & combined[
|
||||
"open_fund"
|
||||
].isna()
|
||||
combined.loc[is_leading_na, "open_fund"] = futures_funding_rate
|
||||
return combined[relevant_cols].dropna()
|
||||
|
||||
def calculate_funding_fees(
|
||||
self,
|
||||
|
||||
@@ -19,6 +19,8 @@ class FtHas(TypedDict, total=False):
|
||||
stop_price_type_value_mapping: dict
|
||||
stoploss_order_types: dict[str, str]
|
||||
stoploss_blocks_assets: bool
|
||||
stoploss_query_requires_stop_flag: bool
|
||||
stoploss_algo_order_info_id: str
|
||||
# ohlcv
|
||||
ohlcv_params: dict
|
||||
ohlcv_candle_limit: int
|
||||
|
||||
@@ -30,6 +30,8 @@ class Gate(Exchange):
|
||||
"stoploss_order_types": {"limit": "limit"},
|
||||
"stop_price_param": "stopPrice",
|
||||
"stop_price_prop": "stopPrice",
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"stoploss_algo_order_info_id": "fired_order_id",
|
||||
"l2_limit_upper": 1000,
|
||||
"marketOrderRequiresPrice": True,
|
||||
"trades_has_history": False, # Endpoint would support this - but ccxt doesn't.
|
||||
@@ -42,6 +44,7 @@ class Gate(Exchange):
|
||||
"stop_price_type_field": "price_type",
|
||||
"l2_limit_upper": 300,
|
||||
"stoploss_blocks_assets": False,
|
||||
"stoploss_algo_order_info_id": "trade_id",
|
||||
"stop_price_type_value_mapping": {
|
||||
PriceType.LAST: 0,
|
||||
PriceType.MARK: 1,
|
||||
@@ -132,25 +135,3 @@ class Gate(Exchange):
|
||||
|
||||
def get_order_id_conditional(self, order: CcxtOrder) -> str:
|
||||
return safe_value_fallback2(order, order, "id_stop", "id")
|
||||
|
||||
def fetch_stoploss_order(
|
||||
self, order_id: str, pair: str, params: dict | None = None
|
||||
) -> CcxtOrder:
|
||||
order = self.fetch_order(order_id=order_id, pair=pair, params={"stop": True})
|
||||
if order.get("status", "open") == "closed":
|
||||
# Places a real order - which we need to fetch explicitly.
|
||||
val = "trade_id" if self.trading_mode == TradingMode.FUTURES else "fired_order_id"
|
||||
|
||||
if new_orderid := order.get("info", {}).get(val):
|
||||
order1 = self.fetch_order(order_id=new_orderid, pair=pair, params=params)
|
||||
order1["id_stop"] = order1["id"]
|
||||
order1["id"] = order_id
|
||||
order1["type"] = "stoploss"
|
||||
order1["stopPrice"] = order.get("stopPrice")
|
||||
order1["status_stop"] = "triggered"
|
||||
|
||||
return order1
|
||||
return order
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True})
|
||||
|
||||
@@ -37,9 +37,9 @@ class Hyperliquid(Exchange):
|
||||
"stoploss_order_types": {"limit": "limit"},
|
||||
"stoploss_blocks_assets": False,
|
||||
"stop_price_prop": "stopPrice",
|
||||
"funding_fee_timeframe": "1h",
|
||||
"funding_fee_candle_limit": 500,
|
||||
"uses_leverage_tiers": False,
|
||||
"mark_ohlcv_price": "futures",
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
|
||||
@@ -35,7 +35,6 @@ class Kraken(Exchange):
|
||||
"trades_pagination_arg": "since",
|
||||
"trades_pagination_overlap": False,
|
||||
"trades_has_history": True,
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
|
||||
@@ -29,10 +29,9 @@ class Okx(Exchange):
|
||||
|
||||
_ft_has: FtHas = {
|
||||
"ohlcv_candle_limit": 100, # Warning, special case with data prior to X months
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_timeframe": "8h",
|
||||
"stoploss_order_types": {"limit": "limit"},
|
||||
"stoploss_on_exchange": True,
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"trades_has_history": False, # Endpoint doesn't have a "since" parameter
|
||||
"ws_enabled": True,
|
||||
}
|
||||
@@ -41,8 +40,8 @@ class Okx(Exchange):
|
||||
"stop_price_type_field": "slTriggerPxType",
|
||||
"stop_price_type_value_mapping": {
|
||||
PriceType.LAST: "last",
|
||||
PriceType.MARK: "index",
|
||||
PriceType.INDEX: "mark",
|
||||
PriceType.MARK: "mark",
|
||||
PriceType.INDEX: "index",
|
||||
},
|
||||
"stoploss_blocks_assets": False,
|
||||
"ws_enabled": True,
|
||||
@@ -265,16 +264,6 @@ class Okx(Exchange):
|
||||
return safe_value_fallback2(order, order, "id_stop", "id")
|
||||
return order["id"]
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
params1 = {"stop": True}
|
||||
# 'ordType': 'conditional'
|
||||
#
|
||||
return self.cancel_order(
|
||||
order_id=order_id,
|
||||
pair=pair,
|
||||
params=params1,
|
||||
)
|
||||
|
||||
def _fetch_orders_emulate(self, pair: str, since_ms: int) -> list[CcxtOrder]:
|
||||
orders = []
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class BaseClassifierModel(IFreqaiModel):
|
||||
"""
|
||||
Base class for regression type models (e.g. Catboost, LightGBM, XGboost etc.).
|
||||
User *must* inherit from this class and set fit(). See example scripts
|
||||
such as prediction_models/CatboostClassifier.py for guidance.
|
||||
such as prediction_models/XGBoostClassifier.py for guidance.
|
||||
"""
|
||||
|
||||
def train(self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
|
||||
@@ -18,7 +18,7 @@ class BaseRegressionModel(IFreqaiModel):
|
||||
"""
|
||||
Base class for regression type models (e.g. Catboost, LightGBM, XGboost etc.).
|
||||
User *must* inherit from this class and set fit(). See example scripts
|
||||
such as prediction_models/CatboostRegressor.py for guidance.
|
||||
such as prediction_models/XGBoostRegressor.py for guidance.
|
||||
"""
|
||||
|
||||
def train(self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
|
||||
@@ -948,7 +948,7 @@ class IFreqaiModel(ABC):
|
||||
return dk
|
||||
|
||||
# Following methods which are overridden by user made prediction models.
|
||||
# See freqai/prediction_models/CatboostPredictionModel.py for an example.
|
||||
# See freqai/prediction_models/XGBoostRegressor.py for an example.
|
||||
|
||||
@abstractmethod
|
||||
def train(self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
@@ -964,7 +964,7 @@ class IFreqaiModel(ABC):
|
||||
def fit(self, data_dictionary: dict[str, Any], dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
"""
|
||||
Most regressors use the same function names and arguments e.g. user
|
||||
can drop in LGBMRegressor in place of CatBoostRegressor and all data
|
||||
can drop in LGBMRegressor in place of XGBoostRegressor and all data
|
||||
management will be properly handled by Freqai.
|
||||
:param data_dictionary: Dict = the dictionary constructed by DataHandler to hold
|
||||
all the training and test data/labels.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from catboost import CatBoostClassifier, Pool
|
||||
|
||||
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CatboostClassifier(BaseClassifierModel):
|
||||
"""
|
||||
User created prediction model. The class inherits IFreqaiModel, which
|
||||
means it has full access to all Frequency AI functionality. Typically,
|
||||
users would use this to override the common `fit()`, `train()`, or
|
||||
`predict()` methods to add their custom data handling tools or change
|
||||
various aspects of the training that cannot be configured via the
|
||||
top level config.json file.
|
||||
"""
|
||||
|
||||
def fit(self, data_dictionary: dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
"""
|
||||
User sets up the training and test data to fit their desired model here
|
||||
:param data_dictionary: the dictionary holding all data for train, test,
|
||||
labels, weights
|
||||
:param dk: The datakitchen object for the current coin/model
|
||||
"""
|
||||
|
||||
train_data = Pool(
|
||||
data=data_dictionary["train_features"],
|
||||
label=data_dictionary["train_labels"],
|
||||
weight=data_dictionary["train_weights"],
|
||||
)
|
||||
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0:
|
||||
test_data = None
|
||||
else:
|
||||
test_data = Pool(
|
||||
data=data_dictionary["test_features"],
|
||||
label=data_dictionary["test_labels"],
|
||||
weight=data_dictionary["test_weights"],
|
||||
)
|
||||
|
||||
cbr = CatBoostClassifier(
|
||||
allow_writing_files=True,
|
||||
loss_function="MultiClass",
|
||||
train_dir=Path(dk.data_path),
|
||||
**self.model_training_parameters,
|
||||
)
|
||||
|
||||
init_model = self.get_init_model(dk.pair)
|
||||
|
||||
cbr.fit(
|
||||
X=train_data,
|
||||
eval_set=test_data,
|
||||
init_model=init_model,
|
||||
)
|
||||
|
||||
return cbr
|
||||
@@ -1,79 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from catboost import CatBoostClassifier, Pool
|
||||
|
||||
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
|
||||
from freqtrade.freqai.base_models.FreqaiMultiOutputClassifier import FreqaiMultiOutputClassifier
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CatboostClassifierMultiTarget(BaseClassifierModel):
|
||||
"""
|
||||
User created prediction model. The class inherits IFreqaiModel, which
|
||||
means it has full access to all Frequency AI functionality. Typically,
|
||||
users would use this to override the common `fit()`, `train()`, or
|
||||
`predict()` methods to add their custom data handling tools or change
|
||||
various aspects of the training that cannot be configured via the
|
||||
top level config.json file.
|
||||
"""
|
||||
|
||||
def fit(self, data_dictionary: dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
"""
|
||||
User sets up the training and test data to fit their desired model here
|
||||
:param data_dictionary: the dictionary holding all data for train, test,
|
||||
labels, weights
|
||||
:param dk: The datakitchen object for the current coin/model
|
||||
"""
|
||||
|
||||
cbc = CatBoostClassifier(
|
||||
allow_writing_files=True,
|
||||
loss_function="MultiClass",
|
||||
train_dir=Path(dk.data_path),
|
||||
**self.model_training_parameters,
|
||||
)
|
||||
|
||||
X = data_dictionary["train_features"]
|
||||
y = data_dictionary["train_labels"]
|
||||
|
||||
sample_weight = data_dictionary["train_weights"]
|
||||
|
||||
eval_sets = [None] * y.shape[1]
|
||||
|
||||
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) != 0:
|
||||
eval_sets = [None] * data_dictionary["test_labels"].shape[1]
|
||||
|
||||
for i in range(data_dictionary["test_labels"].shape[1]):
|
||||
eval_sets[i] = Pool(
|
||||
data=data_dictionary["test_features"],
|
||||
label=data_dictionary["test_labels"].iloc[:, i],
|
||||
weight=data_dictionary["test_weights"],
|
||||
)
|
||||
|
||||
init_model = self.get_init_model(dk.pair)
|
||||
|
||||
if init_model:
|
||||
init_models = init_model.estimators_
|
||||
else:
|
||||
init_models = [None] * y.shape[1]
|
||||
|
||||
fit_params = []
|
||||
for i in range(len(eval_sets)):
|
||||
fit_params.append(
|
||||
{
|
||||
"eval_set": eval_sets[i],
|
||||
"init_model": init_models[i],
|
||||
}
|
||||
)
|
||||
|
||||
model = FreqaiMultiOutputClassifier(estimator=cbc)
|
||||
thread_training = self.freqai_info.get("multitarget_parallel_training", False)
|
||||
if thread_training:
|
||||
model.n_jobs = y.shape[1]
|
||||
model.fit(X=X, y=y, sample_weight=sample_weight, fit_params=fit_params)
|
||||
|
||||
return model
|
||||
@@ -1,60 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from catboost import CatBoostRegressor, Pool
|
||||
|
||||
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CatboostRegressor(BaseRegressionModel):
|
||||
"""
|
||||
User created prediction model. The class inherits IFreqaiModel, which
|
||||
means it has full access to all Frequency AI functionality. Typically,
|
||||
users would use this to override the common `fit()`, `train()`, or
|
||||
`predict()` methods to add their custom data handling tools or change
|
||||
various aspects of the training that cannot be configured via the
|
||||
top level config.json file.
|
||||
"""
|
||||
|
||||
def fit(self, data_dictionary: dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
"""
|
||||
User sets up the training and test data to fit their desired model here
|
||||
:param data_dictionary: the dictionary holding all data for train, test,
|
||||
labels, weights
|
||||
:param dk: The datakitchen object for the current coin/model
|
||||
"""
|
||||
|
||||
train_data = Pool(
|
||||
data=data_dictionary["train_features"],
|
||||
label=data_dictionary["train_labels"],
|
||||
weight=data_dictionary["train_weights"],
|
||||
)
|
||||
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0:
|
||||
test_data = None
|
||||
else:
|
||||
test_data = Pool(
|
||||
data=data_dictionary["test_features"],
|
||||
label=data_dictionary["test_labels"],
|
||||
weight=data_dictionary["test_weights"],
|
||||
)
|
||||
|
||||
init_model = self.get_init_model(dk.pair)
|
||||
|
||||
model = CatBoostRegressor(
|
||||
allow_writing_files=True,
|
||||
train_dir=Path(dk.data_path),
|
||||
**self.model_training_parameters,
|
||||
)
|
||||
|
||||
model.fit(
|
||||
X=train_data,
|
||||
eval_set=test_data,
|
||||
init_model=init_model,
|
||||
)
|
||||
|
||||
return model
|
||||
@@ -1,78 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from catboost import CatBoostRegressor, Pool
|
||||
|
||||
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
|
||||
from freqtrade.freqai.base_models.FreqaiMultiOutputRegressor import FreqaiMultiOutputRegressor
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CatboostRegressorMultiTarget(BaseRegressionModel):
|
||||
"""
|
||||
User created prediction model. The class inherits IFreqaiModel, which
|
||||
means it has full access to all Frequency AI functionality. Typically,
|
||||
users would use this to override the common `fit()`, `train()`, or
|
||||
`predict()` methods to add their custom data handling tools or change
|
||||
various aspects of the training that cannot be configured via the
|
||||
top level config.json file.
|
||||
"""
|
||||
|
||||
def fit(self, data_dictionary: dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
|
||||
"""
|
||||
User sets up the training and test data to fit their desired model here
|
||||
:param data_dictionary: the dictionary holding all data for train, test,
|
||||
labels, weights
|
||||
:param dk: The datakitchen object for the current coin/model
|
||||
"""
|
||||
|
||||
cbr = CatBoostRegressor(
|
||||
allow_writing_files=True,
|
||||
train_dir=Path(dk.data_path),
|
||||
**self.model_training_parameters,
|
||||
)
|
||||
|
||||
X = data_dictionary["train_features"]
|
||||
y = data_dictionary["train_labels"]
|
||||
|
||||
sample_weight = data_dictionary["train_weights"]
|
||||
|
||||
eval_sets = [None] * y.shape[1]
|
||||
|
||||
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) != 0:
|
||||
eval_sets = [None] * data_dictionary["test_labels"].shape[1]
|
||||
|
||||
for i in range(data_dictionary["test_labels"].shape[1]):
|
||||
eval_sets[i] = Pool(
|
||||
data=data_dictionary["test_features"],
|
||||
label=data_dictionary["test_labels"].iloc[:, i],
|
||||
weight=data_dictionary["test_weights"],
|
||||
)
|
||||
|
||||
init_model = self.get_init_model(dk.pair)
|
||||
|
||||
if init_model:
|
||||
init_models = init_model.estimators_
|
||||
else:
|
||||
init_models = [None] * y.shape[1]
|
||||
|
||||
fit_params = []
|
||||
for i in range(len(eval_sets)):
|
||||
fit_params.append(
|
||||
{
|
||||
"eval_set": eval_sets[i],
|
||||
"init_model": init_models[i],
|
||||
}
|
||||
)
|
||||
|
||||
model = FreqaiMultiOutputRegressor(estimator=cbr)
|
||||
thread_training = self.freqai_info.get("multitarget_parallel_training", False)
|
||||
if thread_training:
|
||||
model.n_jobs = y.shape[1]
|
||||
model.fit(X=X, y=y, sample_weight=sample_weight, fit_params=fit_params)
|
||||
|
||||
return model
|
||||
@@ -97,7 +97,7 @@ def plot_feature_importance(
|
||||
"""
|
||||
Plot Best and worst features by importance for a single sub-train.
|
||||
:param model: Any = A model which was `fit` using a common library
|
||||
such as catboost or lightgbm
|
||||
such as XGBoost or lightgbm
|
||||
:param pair: str = pair e.g. BTC/USD
|
||||
:param dk: FreqaiDataKitchen = non-persistent data container for current coin/loop
|
||||
:param count_max: int = the amount of features to be loaded per column
|
||||
@@ -115,6 +115,8 @@ def plot_feature_importance(
|
||||
for label in models:
|
||||
mdl = models[label]
|
||||
if "catboost.core" in str(mdl.__class__):
|
||||
# CatBoost is no longer actively supported since 2025.12
|
||||
# However users can still use it in their custom models
|
||||
feature_importance = mdl.get_feature_importance()
|
||||
elif "lightgbm.sklearn" in str(mdl.__class__):
|
||||
feature_importance = mdl.feature_importances_
|
||||
|
||||
+39
-23
@@ -1064,7 +1064,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
return True
|
||||
|
||||
def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade:
|
||||
def cancel_stoploss_on_exchange(self, trade: Trade, allow_nonblocking: bool = False) -> Trade:
|
||||
"""
|
||||
Cancels on exchange stoploss orders for the given trade.
|
||||
:param trade: Trade for which to cancel stoploss order
|
||||
:param allow_nonblocking: If True, will skip cancelling stoploss on exchange
|
||||
if the exchange supports blocking stoploss orders.
|
||||
"""
|
||||
if allow_nonblocking and not self.exchange.get_option("stoploss_blocks_assets", True):
|
||||
logger.info(f"Skipping cancelling stoploss on exchange for {trade}.")
|
||||
return trade
|
||||
# First cancelling stoploss on exchange ...
|
||||
for oslo in trade.open_sl_orders:
|
||||
try:
|
||||
@@ -2003,14 +2012,14 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float:
|
||||
"""
|
||||
Get sellable amount.
|
||||
Get exitable amount.
|
||||
Should be trade.amount - but will fall back to the available amount if necessary.
|
||||
This should cover cases where get_real_amount() was not able to update the amount
|
||||
for whatever reason.
|
||||
:param trade: Trade we're working with
|
||||
:param pair: Pair we're trying to sell
|
||||
:param pair: Pair we're trying to exit
|
||||
:param amount: amount we expect to be available
|
||||
:return: amount to sell
|
||||
:return: amount to exit
|
||||
:raise: DependencyException: if available balance is not within 2% of the available amount.
|
||||
"""
|
||||
# Update wallets to ensure amounts tied up in a stoploss is now free!
|
||||
@@ -2046,11 +2055,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
exit_tag: str | None = None,
|
||||
ordertype: str | None = None,
|
||||
sub_trade_amt: float | None = None,
|
||||
skip_custom_exit_price: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Executes a trade exit for the given trade and limit
|
||||
:param trade: Trade instance
|
||||
:param limit: limit rate for the sell order
|
||||
:param limit: limit rate for the exit order
|
||||
:param exit_check: CheckTuple with signal and reason
|
||||
:return: True if it succeeds False
|
||||
"""
|
||||
@@ -2072,29 +2082,33 @@ class FreqtradeBot(LoggingMixin):
|
||||
):
|
||||
exit_type = "stoploss"
|
||||
|
||||
order_type = (
|
||||
(ordertype or self.strategy.order_types[exit_type])
|
||||
if exit_check.exit_type != ExitType.EMERGENCY_EXIT
|
||||
else self.strategy.order_types.get("emergency_exit", "market")
|
||||
)
|
||||
|
||||
# set custom_exit_price if available
|
||||
proposed_limit_rate = limit
|
||||
custom_exit_price = limit
|
||||
|
||||
current_profit = trade.calc_profit_ratio(limit)
|
||||
custom_exit_price = strategy_safe_wrapper(
|
||||
self.strategy.custom_exit_price, default_retval=proposed_limit_rate
|
||||
)(
|
||||
pair=trade.pair,
|
||||
trade=trade,
|
||||
current_time=datetime.now(UTC),
|
||||
proposed_rate=proposed_limit_rate,
|
||||
current_profit=current_profit,
|
||||
exit_tag=exit_reason,
|
||||
)
|
||||
if order_type == "limit" and not skip_custom_exit_price:
|
||||
custom_exit_price = strategy_safe_wrapper(
|
||||
self.strategy.custom_exit_price, default_retval=proposed_limit_rate
|
||||
)(
|
||||
pair=trade.pair,
|
||||
trade=trade,
|
||||
current_time=datetime.now(UTC),
|
||||
proposed_rate=proposed_limit_rate,
|
||||
current_profit=current_profit,
|
||||
exit_tag=exit_reason,
|
||||
)
|
||||
|
||||
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
|
||||
|
||||
# First cancelling stoploss on exchange ...
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
|
||||
order_type = ordertype or self.strategy.order_types[exit_type]
|
||||
if exit_check.exit_type == ExitType.EMERGENCY_EXIT:
|
||||
# Emergency sells (default to market!)
|
||||
order_type = self.strategy.order_types.get("emergency_exit", "market")
|
||||
trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True)
|
||||
|
||||
amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount)
|
||||
time_in_force = self.strategy.order_time_in_force["exit"]
|
||||
@@ -2122,7 +2136,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Execute sell and update trade record
|
||||
# Execute exit and update trade record
|
||||
order = self.exchange.create_order(
|
||||
pair=trade.pair,
|
||||
ordertype=order_type,
|
||||
@@ -2150,7 +2164,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.exit_reason = exit_reason
|
||||
|
||||
self._notify_exit(trade, order_type, sub_trade=bool(sub_trade_amt), order=order_obj)
|
||||
# In case of market sell orders the order can be closed immediately
|
||||
# In case of market exit orders the order can be closed immediately
|
||||
if order.get("status", "unknown") in ("closed", "expired"):
|
||||
self.update_trade_state(trade, order_obj.order_id, order)
|
||||
Trade.commit()
|
||||
@@ -2380,6 +2394,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
self.strategy.ft_stoploss_adjust(
|
||||
current_rate, trade, datetime.now(UTC), profit, 0, after_fill=True
|
||||
)
|
||||
if not trade.is_open:
|
||||
self.cancel_stoploss_on_exchange(trade)
|
||||
# Updating wallets when order is closed
|
||||
self.wallets.update()
|
||||
return trade
|
||||
|
||||
@@ -374,6 +374,7 @@ class Backtesting:
|
||||
timerange=self.timerange,
|
||||
startup_candles=0,
|
||||
fail_without_data=True,
|
||||
fill_up_missing=False,
|
||||
data_format=self.config["dataformat_ohlcv"],
|
||||
candle_type=CandleType.FUNDING_RATE,
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ from freqtrade.leverage import interest
|
||||
from freqtrade.misc import safe_value_fallback
|
||||
from freqtrade.persistence.base import ModelBase, SessionType
|
||||
from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData
|
||||
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none
|
||||
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none, round_value
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -654,9 +654,10 @@ class LocalTrade:
|
||||
)
|
||||
|
||||
return (
|
||||
f"Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, "
|
||||
f"is_short={self.is_short or False}, leverage={self.leverage or 1.0}, "
|
||||
f"open_rate={self.open_rate:.8f}, open_since={open_since})"
|
||||
f"Trade(id={self.id}, pair={self.pair}, amount={round_value(self.amount, 8)}, "
|
||||
f"is_short={self.is_short or False}, "
|
||||
f"leverage={round_value(self.leverage or 1.0, 1)}, "
|
||||
f"open_rate={round_value(self.open_rate, 8)}, open_since={open_since})"
|
||||
)
|
||||
|
||||
def to_json(self, minified: bool = False) -> dict[str, Any]:
|
||||
|
||||
@@ -52,29 +52,25 @@ def __run_backtest_bg(btconfig: Config):
|
||||
lastconfig = ApiBG.bt["last_config"]
|
||||
strat = StrategyResolver.load_strategy(btconfig)
|
||||
validate_config_consistency(btconfig)
|
||||
|
||||
if (
|
||||
not ApiBG.bt["bt"]
|
||||
or lastconfig.get("timeframe") != strat.timeframe
|
||||
time_settings_changed = (
|
||||
lastconfig.get("timeframe") != strat.timeframe
|
||||
or lastconfig.get("timeframe_detail") != btconfig.get("timeframe_detail")
|
||||
or lastconfig.get("timerange") != btconfig["timerange"]
|
||||
):
|
||||
)
|
||||
|
||||
if not ApiBG.bt["bt"] or time_settings_changed:
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
|
||||
ApiBG.bt["bt"] = Backtesting(btconfig)
|
||||
else:
|
||||
ApiBG.bt["bt"].config = deep_merge_dicts(btconfig, ApiBG.bt["bt"].config)
|
||||
ApiBG.bt["bt"].init_backtest()
|
||||
# Only reload data if timeframe changed.
|
||||
if (
|
||||
not ApiBG.bt["data"]
|
||||
or not ApiBG.bt["timerange"]
|
||||
or lastconfig.get("timeframe") != strat.timeframe
|
||||
or lastconfig.get("timerange") != btconfig["timerange"]
|
||||
):
|
||||
# Only reload data if timerange is open or settings changed
|
||||
if not ApiBG.bt["data"] or not ApiBG.bt["timerange"] or time_settings_changed:
|
||||
ApiBG.bt["data"], ApiBG.bt["timerange"] = ApiBG.bt["bt"].load_bt_data()
|
||||
|
||||
lastconfig["timerange"] = btconfig["timerange"]
|
||||
lastconfig["timeframe_detail"] = btconfig.get("timeframe_detail")
|
||||
lastconfig["timeframe"] = strat.timeframe
|
||||
lastconfig["enable_protections"] = btconfig.get("enable_protections")
|
||||
lastconfig["dry_run_wallet"] = btconfig.get("dry_run_wallet")
|
||||
|
||||
@@ -63,6 +63,8 @@ def pairlists_evaluate(
|
||||
config_loc["timeframes"] = payload.timeframes
|
||||
config_loc["erase"] = payload.erase
|
||||
config_loc["download_trades"] = payload.download_trades
|
||||
if payload.candle_types is not None:
|
||||
config_loc["candle_types"] = payload.candle_types
|
||||
|
||||
handleExchangePayload(payload, config_loc)
|
||||
|
||||
|
||||
@@ -426,6 +426,7 @@ class ForceExitPayload(BaseModel):
|
||||
tradeid: str | int
|
||||
ordertype: OrderTypeValues | None = None
|
||||
amount: float | None = None
|
||||
price: float | None = None
|
||||
|
||||
|
||||
class BlacklistPayload(BaseModel):
|
||||
@@ -506,6 +507,7 @@ class DownloadDataPayload(ExchangeModePayloadMixin, BaseModel):
|
||||
timerange: str | None = None
|
||||
erase: bool = False
|
||||
download_trades: bool = False
|
||||
candle_types: list[str] | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
def check_mutually_exclusive(cls, values):
|
||||
|
||||
@@ -91,7 +91,9 @@ logger = logging.getLogger(__name__)
|
||||
# 2.41: Add download-data endpoint
|
||||
# 2.42: Add /pair_history endpoint with live data
|
||||
# 2.43: Add /profit_all endpoint
|
||||
API_VERSION = 2.43
|
||||
# 2.44: Add candle_types parameter to download-data endpoint
|
||||
# 2.45: Add price to forceexit endpoint
|
||||
API_VERSION = 2.45
|
||||
|
||||
# Public API, requires no auth.
|
||||
router_public = APIRouter()
|
||||
@@ -324,7 +326,9 @@ def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)):
|
||||
@router.post("/forcesell", response_model=ResultMsg, tags=["trading"])
|
||||
def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)):
|
||||
ordertype = payload.ordertype.value if payload.ordertype else None
|
||||
return rpc._rpc_force_exit(str(payload.tradeid), ordertype, amount=payload.amount)
|
||||
return rpc._rpc_force_exit(
|
||||
str(payload.tradeid), ordertype, amount=payload.amount, price=payload.price
|
||||
)
|
||||
|
||||
|
||||
@router.get("/blacklist", response_model=BlacklistResponse, tags=["info", "pairlist"])
|
||||
|
||||
+26
-7
@@ -940,7 +940,11 @@ class RPC:
|
||||
return {"status": "Reloaded from orders from exchange"}
|
||||
|
||||
def __exec_force_exit(
|
||||
self, trade: Trade, ordertype: str | None, amount: float | None = None
|
||||
self,
|
||||
trade: Trade,
|
||||
ordertype: str | None,
|
||||
amount: float | None = None,
|
||||
price: float | None = None,
|
||||
) -> bool:
|
||||
# Check if there is there are open orders
|
||||
trade_entry_cancelation_registry = []
|
||||
@@ -964,8 +968,13 @@ class RPC:
|
||||
# Order cancellation failed, so we can't exit.
|
||||
return False
|
||||
# Get current rate and execute sell
|
||||
current_rate = self._freqtrade.exchange.get_rate(
|
||||
trade.pair, side="exit", is_short=trade.is_short, refresh=True
|
||||
|
||||
current_rate = (
|
||||
self._freqtrade.exchange.get_rate(
|
||||
trade.pair, side="exit", is_short=trade.is_short, refresh=True
|
||||
)
|
||||
if ordertype == "market" or price is None
|
||||
else price
|
||||
)
|
||||
exit_check = ExitCheckTuple(exit_type=ExitType.FORCE_EXIT)
|
||||
order_type = ordertype or self._freqtrade.strategy.order_types.get(
|
||||
@@ -983,18 +992,28 @@ class RPC:
|
||||
sub_amount = amount
|
||||
|
||||
self._freqtrade.execute_trade_exit(
|
||||
trade, current_rate, exit_check, ordertype=order_type, sub_trade_amt=sub_amount
|
||||
trade,
|
||||
current_rate,
|
||||
exit_check,
|
||||
ordertype=order_type,
|
||||
sub_trade_amt=sub_amount,
|
||||
skip_custom_exit_price=price is not None and ordertype == "limit",
|
||||
)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def _rpc_force_exit(
|
||||
self, trade_id: str, ordertype: str | None = None, *, amount: float | None = None
|
||||
self,
|
||||
trade_id: str,
|
||||
ordertype: str | None = None,
|
||||
*,
|
||||
amount: float | None = None,
|
||||
price: float | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Handler for forceexit <id>.
|
||||
Sells the given trade at current price
|
||||
exits the given trade. Uses current price if price is None.
|
||||
"""
|
||||
|
||||
if self._freqtrade.state == State.STOPPED:
|
||||
@@ -1024,7 +1043,7 @@ class RPC:
|
||||
logger.warning("force_exit: Invalid argument received")
|
||||
raise RPCException("invalid argument")
|
||||
|
||||
result = self.__exec_force_exit(trade, ordertype, amount)
|
||||
result = self.__exec_force_exit(trade, ordertype, amount, price)
|
||||
Trade.commit()
|
||||
self._freqtrade.wallets.update()
|
||||
if not result:
|
||||
|
||||
@@ -104,8 +104,11 @@ def _create_and_merge_informative_pair(
|
||||
):
|
||||
asset = inf_data.asset or ""
|
||||
timeframe = inf_data.timeframe
|
||||
timeframe1 = inf_data.timeframe
|
||||
fmt = inf_data.fmt
|
||||
candle_type = inf_data.candle_type
|
||||
if candle_type == CandleType.FUNDING_RATE:
|
||||
timeframe1 = strategy.dp.get_funding_rate_timeframe()
|
||||
|
||||
config = strategy.config
|
||||
|
||||
@@ -132,10 +135,10 @@ def _create_and_merge_informative_pair(
|
||||
fmt = "{base}_{quote}_" + fmt # Informatives of other pairs
|
||||
|
||||
inf_metadata = {"pair": asset, "timeframe": timeframe}
|
||||
inf_dataframe = strategy.dp.get_pair_dataframe(asset, timeframe, candle_type)
|
||||
inf_dataframe = strategy.dp.get_pair_dataframe(asset, timeframe1, candle_type)
|
||||
if inf_dataframe.empty:
|
||||
raise ValueError(
|
||||
f"Informative dataframe for ({asset}, {timeframe}, {candle_type}) is empty. "
|
||||
f"Informative dataframe for ({asset}, {timeframe1}, {candle_type}) is empty. "
|
||||
"Can't populate informative indicators."
|
||||
)
|
||||
inf_dataframe = populate_indicators_fn(strategy, inf_dataframe, inf_metadata)
|
||||
@@ -163,7 +166,7 @@ def _create_and_merge_informative_pair(
|
||||
dataframe,
|
||||
inf_dataframe,
|
||||
strategy.timeframe,
|
||||
timeframe,
|
||||
timeframe1,
|
||||
ffill=inf_data.ffill,
|
||||
append_timeframe=False,
|
||||
date_column=date_column,
|
||||
|
||||
@@ -1718,7 +1718,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
timeout_unit = self.config.get("unfilledtimeout", {}).get("unit", "minutes")
|
||||
timeout_kwargs = {timeout_unit: -timeout}
|
||||
timeout_threshold = current_time + timedelta(**timeout_kwargs)
|
||||
timedout = order.status == "open" and order.order_date_utc < timeout_threshold
|
||||
timedout = order.status == "open" and order.order_date_utc <= timeout_threshold
|
||||
if timedout:
|
||||
return True
|
||||
time_method = (
|
||||
|
||||
@@ -20,7 +20,7 @@ class FreqaiExampleHybridStrategy(IStrategy):
|
||||
Launching this strategy would be:
|
||||
|
||||
freqtrade trade --strategy FreqaiExampleHybridStrategy --strategy-path freqtrade/templates
|
||||
--freqaimodel CatboostClassifier --config config_examples/config_freqai.example.json
|
||||
--freqaimodel XGBoostClassifier --config config_examples/config_freqai.example.json
|
||||
|
||||
or the user simply adds this to their config:
|
||||
|
||||
|
||||
@@ -205,8 +205,8 @@ class FreqaiExampleStrategy(IStrategy):
|
||||
# If user wishes to use multiple targets, they can add more by
|
||||
# appending more columns with '&'. User should keep in mind that multi targets
|
||||
# requires a multioutput prediction model such as
|
||||
# freqai/prediction_models/CatboostRegressorMultiTarget.py,
|
||||
# freqtrade trade --freqaimodel CatboostRegressorMultiTarget
|
||||
# freqai/prediction_models/LightGBMClassifierMultiTarget.py,
|
||||
# freqtrade trade --freqaimodel LightGBMClassifierMultiTarget
|
||||
|
||||
# df["&-s_range"] = (
|
||||
# df["close"]
|
||||
|
||||
@@ -90,15 +90,16 @@ def dt_humanize_delta(dt: datetime):
|
||||
return humanize.naturaltime(dt)
|
||||
|
||||
|
||||
def format_date(date: datetime | None) -> str:
|
||||
def format_date(date: datetime | None, fallback: str = "") -> str:
|
||||
"""
|
||||
Return a formatted date string.
|
||||
Returns an empty string if date is None.
|
||||
:param date: datetime to format
|
||||
:param fallback: value to return if date is None
|
||||
"""
|
||||
if date:
|
||||
return date.strftime(DATETIME_PRINT_FORMAT)
|
||||
return ""
|
||||
return fallback
|
||||
|
||||
|
||||
def format_ms_time(date: int | float) -> str:
|
||||
|
||||
@@ -23,7 +23,7 @@ def strip_trailing_zeros(value: str) -> str:
|
||||
return value.rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str:
|
||||
def round_value(value: float | None, decimals: int, keep_trailing_zeros=False) -> str:
|
||||
"""
|
||||
Round value to given decimals
|
||||
:param value: Value to be rounded
|
||||
@@ -31,7 +31,7 @@ def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str:
|
||||
:param keep_trailing_zeros: Keep trailing zeros "222.200" vs. "222.2"
|
||||
:return: Rounded value as string
|
||||
"""
|
||||
if isnan(value):
|
||||
if value is None or isnan(value):
|
||||
return "N/A"
|
||||
val = f"{value:.{decimals}f}"
|
||||
if not keep_trailing_zeros:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from freqtrade_client.ft_rest_client import FtRestClient
|
||||
|
||||
|
||||
__version__ = "2025.11.2"
|
||||
__version__ = "2025.12"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Requirements for freqtrade client library
|
||||
requests==2.32.5
|
||||
python-rapidjson==1.22
|
||||
python-rapidjson==1.23
|
||||
|
||||
+1
-1
@@ -22,6 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: Unix",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
@@ -85,7 +86,6 @@ hyperopt = [
|
||||
freqai = [
|
||||
"scikit-learn",
|
||||
"joblib",
|
||||
"catboost; platform_machine != 'arm'",
|
||||
"lightgbm",
|
||||
"xgboost",
|
||||
"tensorboard",
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
-r requirements-freqai-rl.txt
|
||||
-r docs/requirements-docs.txt
|
||||
|
||||
ruff==0.14.5
|
||||
mypy==1.18.2
|
||||
pre-commit==4.4.0
|
||||
pytest==9.0.1
|
||||
ruff==0.14.9
|
||||
mypy==1.19.1
|
||||
pre-commit==4.5.1
|
||||
pytest==9.0.2
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-mock==3.15.1
|
||||
@@ -18,15 +18,16 @@ pytest-timeout==2.4.0
|
||||
pytest-xdist==3.8.0
|
||||
isort==7.0.0
|
||||
# For datetime mocking
|
||||
time-machine==3.0.0
|
||||
time-machine==3.2.0
|
||||
|
||||
# Convert jupyter notebooks to markdown documents
|
||||
nbconvert==7.16.6
|
||||
|
||||
# mypy types
|
||||
scipy-stubs==1.16.3.0 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
scipy-stubs==1.16.3.3 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
types-cachetools==6.2.0.20251022
|
||||
types-filelock==3.2.7
|
||||
types-requests==2.32.4.20250913
|
||||
types-tabulate==0.9.0.20241207
|
||||
types-python-dateutil==2.9.0.20251115
|
||||
pip-audit==2.10.0
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
torch==2.9.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
gymnasium==1.2.2
|
||||
# SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos
|
||||
stable_baselines3==2.7.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
stable_baselines3==2.7.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
sb3_contrib>=2.2.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
# Progress bar for stable-baselines3 and sb3-contrib
|
||||
tqdm==4.67.1
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
-r requirements-plot.txt
|
||||
|
||||
# Required for freqai
|
||||
scikit-learn==1.7.2
|
||||
joblib==1.5.2
|
||||
catboost==1.2.8; 'arm' not in platform_machine
|
||||
scikit-learn==1.8.0
|
||||
joblib==1.5.3
|
||||
lightgbm==4.6.0
|
||||
xgboost==3.1.1
|
||||
xgboost==3.1.2
|
||||
tensorboard==2.20.0
|
||||
datasieve==0.1.9
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# Required for hyperopt
|
||||
scipy==1.16.3
|
||||
scikit-learn==1.7.2
|
||||
filelock==3.20.0
|
||||
scikit-learn==1.8.0
|
||||
filelock==3.20.1
|
||||
optuna==4.6.0
|
||||
cmaes==0.12.0
|
||||
|
||||
+9
-9
@@ -7,38 +7,38 @@ ft-pandas-ta==0.3.16
|
||||
ta-lib==0.6.8
|
||||
technical==1.5.3
|
||||
|
||||
ccxt==4.5.27
|
||||
ccxt==4.5.29
|
||||
cryptography==46.0.3
|
||||
aiohttp==3.13.2
|
||||
SQLAlchemy==2.0.44
|
||||
SQLAlchemy==2.0.45
|
||||
python-telegram-bot==22.5
|
||||
# can't be hard-pinned due to telegram-bot pinning httpx with ~
|
||||
httpx>=0.24.1
|
||||
humanize==4.14.0
|
||||
cachetools==6.2.2
|
||||
cachetools==6.2.4
|
||||
requests==2.32.5
|
||||
urllib3==2.5.0
|
||||
urllib3==2.6.2
|
||||
certifi==2025.11.12
|
||||
jsonschema==4.25.1
|
||||
tabulate==0.9.0
|
||||
pycoingecko==3.2.0
|
||||
jinja2==3.1.6
|
||||
joblib==1.5.2
|
||||
joblib==1.5.3
|
||||
rich==14.2.0
|
||||
pyarrow==22.0.0; platform_machine != 'armv7l'
|
||||
|
||||
|
||||
# Load ticker files 30% faster
|
||||
python-rapidjson==1.22
|
||||
python-rapidjson==1.23
|
||||
# Properly format api responses
|
||||
orjson==3.11.4
|
||||
orjson==3.11.5
|
||||
|
||||
# Notify systemd
|
||||
sdnotify==0.3.2
|
||||
|
||||
# API Server
|
||||
fastapi==0.121.3
|
||||
pydantic==2.12.4
|
||||
fastapi==0.125.0
|
||||
pydantic==2.12.5
|
||||
uvicorn==0.38.0
|
||||
pyjwt==2.10.1
|
||||
aiofiles==25.1.0
|
||||
|
||||
@@ -1767,7 +1767,7 @@ def test_start_list_data(testdatadir, capsys):
|
||||
pargs["config"] = None
|
||||
start_list_data(pargs)
|
||||
captured = capsys.readouterr()
|
||||
assert "Found 16 pair / timeframe combinations." in captured.out
|
||||
assert "Found 18 pair / timeframe combinations." in captured.out
|
||||
assert re.search(r".*Pair.*Timeframe.*Type.*\n", captured.out)
|
||||
assert re.search(r"\n.* UNITTEST/BTC .* 1m, 5m, 8m, 30m .* spot |\n", captured.out)
|
||||
|
||||
@@ -1801,10 +1801,10 @@ def test_start_list_data(testdatadir, capsys):
|
||||
start_list_data(pargs)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Found 6 pair / timeframe combinations." in captured.out
|
||||
assert "Found 5 pair / timeframe combinations." in captured.out
|
||||
assert re.search(r".*Pair.*Timeframe.*Type.*\n", captured.out)
|
||||
assert re.search(r"\n.* XRP/USDT:USDT .* 5m, 1h .* futures |\n", captured.out)
|
||||
assert re.search(r"\n.* XRP/USDT:USDT .* 1h, 8h .* mark |\n", captured.out)
|
||||
assert re.search(r"\n.* XRP/USDT:USDT .* 1h.* mark |\n", captured.out)
|
||||
|
||||
args = [
|
||||
"list-data",
|
||||
|
||||
@@ -290,20 +290,23 @@ def test_combine_dataframes_with_mean(testdatadir):
|
||||
|
||||
|
||||
def test_combined_dataframes_with_rel_mean(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
pairs = ["BTC/USDT", "XRP/USDT"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
df = combined_dataframes_with_rel_mean(
|
||||
data, datetime(2018, 1, 12, tzinfo=UTC), datetime(2018, 1, 28, tzinfo=UTC)
|
||||
data,
|
||||
fromdt=data["BTC/USDT"].at[0, "date"],
|
||||
todt=data["BTC/USDT"].at[data["BTC/USDT"].index[-1], "date"],
|
||||
)
|
||||
assert isinstance(df, DataFrame)
|
||||
assert "ETH/BTC" not in df.columns
|
||||
assert "ADA/BTC" not in df.columns
|
||||
assert "BTC/USDT" not in df.columns
|
||||
assert "XRP/USDT" not in df.columns
|
||||
assert "mean" in df.columns
|
||||
assert "rel_mean" in df.columns
|
||||
assert "count" in df.columns
|
||||
assert df.iloc[0]["count"] == 2
|
||||
assert df.iloc[-1]["count"] == 2
|
||||
assert len(df) < len(data["ETH/BTC"])
|
||||
assert len(df) < len(data["BTC/USDT"])
|
||||
assert df["rel_mean"].between(-0.5, 0.5).all()
|
||||
|
||||
|
||||
def test_combine_dataframes_with_mean_no_data(testdatadir):
|
||||
|
||||
@@ -39,12 +39,6 @@ def populate_dataframe_with_trades_trades(testdatadir):
|
||||
return pd.read_feather(testdatadir / "orderflow/populate_dataframe_with_trades_TRADES.feather")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def candles(testdatadir):
|
||||
# TODO: this fixture isn't really necessary and could be removed
|
||||
return pd.read_json(testdatadir / "orderflow/candles.json").copy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_trades_list(testdatadir):
|
||||
return read_csv(testdatadir / "orderflow/public_trades_list.csv").copy()
|
||||
@@ -293,7 +287,7 @@ def test_public_trades_trades_mock_populate_dataframe_with_trades__check_trades(
|
||||
assert t["price"] == 234.72
|
||||
|
||||
|
||||
def test_public_trades_put_volume_profile_into_ohlcv_candles(public_trades_list_simple, candles):
|
||||
def test_public_trades_put_volume_profile_into_ohlcv_candles(public_trades_list_simple):
|
||||
"""
|
||||
Tests the integration of volume profile data into OHLCV candles.
|
||||
|
||||
@@ -412,13 +406,11 @@ def test_public_trades_config_max_trades(
|
||||
|
||||
|
||||
def test_public_trades_testdata_sanity(
|
||||
candles,
|
||||
public_trades_list,
|
||||
public_trades_list_simple,
|
||||
populate_dataframe_with_trades_dataframe,
|
||||
populate_dataframe_with_trades_trades,
|
||||
):
|
||||
assert 10999 == len(candles)
|
||||
assert 1000 == len(public_trades_list)
|
||||
assert 999 == len(populate_dataframe_with_trades_dataframe)
|
||||
assert 293532 == len(populate_dataframe_with_trades_trades)
|
||||
|
||||
@@ -40,6 +40,8 @@ def test_datahandler_ohlcv_get_pairs(testdatadir):
|
||||
"NXT/BTC",
|
||||
"DASH/BTC",
|
||||
"XRP/ETH",
|
||||
"BTC/USDT",
|
||||
"XRP/USDT",
|
||||
}
|
||||
|
||||
pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, "8m", candle_type=CandleType.SPOT)
|
||||
@@ -111,6 +113,8 @@ def test_datahandler_ohlcv_get_available_data(testdatadir):
|
||||
("DASH/BTC", "5m", CandleType.SPOT),
|
||||
("XRP/ETH", "1m", CandleType.SPOT),
|
||||
("XRP/ETH", "5m", CandleType.SPOT),
|
||||
("BTC/USDT", "5m", CandleType.SPOT),
|
||||
("XRP/USDT", "5m", CandleType.SPOT),
|
||||
("UNITTEST/BTC", "30m", CandleType.SPOT),
|
||||
("UNITTEST/BTC", "8m", CandleType.SPOT),
|
||||
}
|
||||
@@ -122,8 +126,7 @@ def test_datahandler_ohlcv_get_available_data(testdatadir):
|
||||
("XRP/USDT:USDT", "5m", "futures"),
|
||||
("XRP/USDT:USDT", "1h", "futures"),
|
||||
("XRP/USDT:USDT", "1h", "mark"),
|
||||
("XRP/USDT:USDT", "8h", "mark"),
|
||||
("XRP/USDT:USDT", "8h", "funding_rate"),
|
||||
("XRP/USDT:USDT", "1h", "funding_rate"),
|
||||
}
|
||||
|
||||
paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT)
|
||||
|
||||
@@ -9,7 +9,7 @@ from freqtrade.enums import CandleType, RunMode
|
||||
from freqtrade.exceptions import ExchangeError, OperationalException
|
||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||
from freqtrade.util import dt_utc
|
||||
from tests.conftest import EXMS, generate_test_data, get_patched_exchange
|
||||
from tests.conftest import EXMS, generate_test_data, get_patched_exchange, log_has_re
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -185,6 +185,28 @@ def test_get_pair_dataframe(mocker, default_conf, ohlcv_history, candle_type):
|
||||
assert len(df) == 2 # ohlcv_history is limited to 2 rows now
|
||||
|
||||
|
||||
def test_get_pair_dataframe_funding_rate(mocker, default_conf, ohlcv_history, caplog):
|
||||
default_conf["runmode"] = RunMode.DRY_RUN
|
||||
timeframe = "1h"
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
candletype = CandleType.FUNDING_RATE
|
||||
exchange._klines[("XRP/BTC", timeframe, candletype)] = ohlcv_history
|
||||
exchange._klines[("UNITTEST/BTC", timeframe, candletype)] = ohlcv_history
|
||||
|
||||
dp = DataProvider(default_conf, exchange)
|
||||
assert dp.runmode == RunMode.DRY_RUN
|
||||
assert ohlcv_history.equals(
|
||||
dp.get_pair_dataframe("UNITTEST/BTC", timeframe, candle_type="funding_rate")
|
||||
)
|
||||
msg = r".*funding rate timeframe not matching"
|
||||
assert not log_has_re(msg, caplog)
|
||||
|
||||
assert ohlcv_history.equals(
|
||||
dp.get_pair_dataframe("UNITTEST/BTC", "5h", candle_type="funding_rate")
|
||||
)
|
||||
assert log_has_re(msg, caplog)
|
||||
|
||||
|
||||
def test_available_pairs(mocker, default_conf, ohlcv_history):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
timeframe = default_conf["timeframe"]
|
||||
@@ -636,3 +658,21 @@ def test_check_delisting(mocker, default_conf_usdt):
|
||||
assert res == dt_utc(2025, 10, 2)
|
||||
|
||||
assert delist_mock2.call_count == 1
|
||||
|
||||
|
||||
def test_get_funding_rate_timeframe(mocker, default_conf_usdt):
|
||||
default_conf_usdt["trading_mode"] = "futures"
|
||||
default_conf_usdt["margin_mode"] = "isolated"
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
mock_get_option = mocker.spy(exchange, "get_option")
|
||||
dp = DataProvider(default_conf_usdt, exchange)
|
||||
|
||||
assert dp.get_funding_rate_timeframe() == "1h"
|
||||
mock_get_option.assert_called_once_with("funding_fee_timeframe")
|
||||
|
||||
|
||||
def test_get_funding_rate_timeframe_no_exchange(default_conf_usdt):
|
||||
dp = DataProvider(default_conf_usdt, None)
|
||||
|
||||
with pytest.raises(OperationalException, match=r"Exchange is not available to DataProvider."):
|
||||
dp.get_funding_rate_timeframe()
|
||||
|
||||
@@ -534,18 +534,19 @@ def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> No
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trademode,callcount",
|
||||
"trademode,callcount, callcount_parallel",
|
||||
[
|
||||
("spot", 4),
|
||||
("margin", 4),
|
||||
("futures", 8), # Called 8 times - 4 normal, 2 funding and 2 mark/index calls
|
||||
("spot", 4, 2),
|
||||
("margin", 4, 2),
|
||||
("futures", 8, 4), # Called 8 times - 4 normal, 2 funding and 2 mark/index calls
|
||||
],
|
||||
)
|
||||
def test_refresh_backtest_ohlcv_data(
|
||||
mocker, default_conf, markets, caplog, testdatadir, trademode, callcount
|
||||
mocker, default_conf, markets, caplog, testdatadir, trademode, callcount, callcount_parallel
|
||||
):
|
||||
caplog.set_level(logging.DEBUG)
|
||||
dl_mock = mocker.patch("freqtrade.data.history.history_utils._download_pair_history")
|
||||
mocker.patch(f"{EXMS}.verify_candle_type_support", MagicMock())
|
||||
|
||||
def parallel_mock(pairs, timeframe, candle_type, **kwargs):
|
||||
return {(pair, timeframe, candle_type): DataFrame() for pair in pairs}
|
||||
@@ -573,14 +574,15 @@ def test_refresh_backtest_ohlcv_data(
|
||||
)
|
||||
|
||||
# Called once per timeframe (as we return an empty dataframe)
|
||||
assert parallel_mock.call_count == 2
|
||||
# called twice for spot/margin and 4 times for futures
|
||||
assert parallel_mock.call_count == callcount_parallel
|
||||
assert dl_mock.call_count == callcount
|
||||
assert dl_mock.call_args[1]["timerange"].starttype == "date"
|
||||
|
||||
assert log_has_re(r"Downloading pair ETH/BTC, .* interval 1m\.", caplog)
|
||||
if trademode == "futures":
|
||||
assert log_has_re(r"Downloading pair ETH/BTC, funding_rate, interval 8h\.", caplog)
|
||||
assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 4h\.", caplog)
|
||||
assert log_has_re(r"Downloading pair ETH/BTC, funding_rate, interval 1h\.", caplog)
|
||||
assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 1h\.", caplog)
|
||||
|
||||
# Test with only one pair - no parallel download should happen 1 pair/timeframe combination
|
||||
# doesn't justify parallelization
|
||||
@@ -599,6 +601,24 @@ def test_refresh_backtest_ohlcv_data(
|
||||
)
|
||||
assert parallel_mock.call_count == 0
|
||||
|
||||
if trademode == "futures":
|
||||
dl_mock.reset_mock()
|
||||
refresh_backtest_ohlcv_data(
|
||||
exchange=ex,
|
||||
pairs=[
|
||||
"ETH/BTC",
|
||||
],
|
||||
timeframes=["5m", "1h"],
|
||||
datadir=testdatadir,
|
||||
timerange=timerange,
|
||||
erase=False,
|
||||
trading_mode=trademode,
|
||||
no_parallel_download=True,
|
||||
candle_types=["premiumIndex", "funding_rate"],
|
||||
)
|
||||
assert parallel_mock.call_count == 0
|
||||
assert dl_mock.call_count == 3 # 2 timeframes premiumIndex + 1x funding_rate
|
||||
|
||||
|
||||
def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir):
|
||||
dl_mock = mocker.patch(
|
||||
@@ -896,7 +916,7 @@ def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path,
|
||||
assert get_historic_ohlcv_mock.call_count == 0
|
||||
|
||||
# Verify the log message indicating parallel method was used (line 315-316)
|
||||
assert log_has("Downloaded data for TEST/BTC with length 3. Parallel Method.", caplog)
|
||||
assert log_has("Downloaded data for TEST/BTC, 5m, spot with length 3. Parallel Method.", caplog)
|
||||
|
||||
# Verify data was stored
|
||||
assert data_handler_mock.ohlcv_store.call_count == 1
|
||||
|
||||
@@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker):
|
||||
assert "type" in order
|
||||
|
||||
assert order["type"] == order_type
|
||||
assert order["price"] == 220
|
||||
assert order["price"] == 217.8
|
||||
assert order["stopPrice"] == 220
|
||||
assert order["amount"] == 1
|
||||
|
||||
|
||||
|
||||
+207
-19
@@ -1012,7 +1012,7 @@ def test_validate_required_startup_candles(default_conf, mocker, caplog):
|
||||
ex._ft_has["ohlcv_has_history"] = False
|
||||
with pytest.raises(
|
||||
OperationalException,
|
||||
match=r"This strategy requires 2500.*, " r"which is more than the amount.*",
|
||||
match=r"This strategy requires 2500.*, " r"which is more than .* the amount",
|
||||
):
|
||||
ex.validate_required_startup_candles(2500, "5m")
|
||||
|
||||
@@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side,limit,offset,expected",
|
||||
"side,limit,offset,is_stop,expected",
|
||||
[
|
||||
("buy", 46.0, 0.0, True),
|
||||
("buy", 26.0, 0.0, True),
|
||||
("buy", 25.55, 0.0, False),
|
||||
("buy", 1, 0.0, False), # Very far away
|
||||
("sell", 25.5, 0.0, True),
|
||||
("sell", 50, 0.0, False), # Very far away
|
||||
("sell", 25.58, 0.0, False),
|
||||
("sell", 25.563, 0.01, False),
|
||||
("sell", 5.563, 0.01, True),
|
||||
("buy", 46.0, 0.0, False, True),
|
||||
("buy", 46.0, 0.0, True, False),
|
||||
("buy", 26.0, 0.0, False, True),
|
||||
("buy", 26.0, 0.0, True, False), # Stop - didn't trigger
|
||||
("buy", 25.55, 0.0, False, False),
|
||||
("buy", 25.55, 0.0, True, True), # Stop - triggered
|
||||
("buy", 1, 0.0, False, False), # Very far away
|
||||
("buy", 1, 0.0, True, True), # Current price is above stop - triggered
|
||||
("sell", 25.5, 0.0, False, True),
|
||||
("sell", 50, 0.0, False, False), # Very far away
|
||||
("sell", 25.58, 0.0, False, False),
|
||||
("sell", 25.563, 0.01, False, False),
|
||||
("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid
|
||||
("sell", 25.566, 0.0, True, True), # stop order - triggered
|
||||
("sell", 26, 0.01, True, True), # stop order - triggered
|
||||
("sell", 5.563, 0.01, False, True),
|
||||
("sell", 5.563, 0.0, True, False), # stop order - not triggered
|
||||
],
|
||||
)
|
||||
def test__dry_is_price_crossed_with_orderbook(
|
||||
default_conf, mocker, order_book_l2_usd, side, limit, offset, expected
|
||||
default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected
|
||||
):
|
||||
# Best bid 25.563
|
||||
# Best ask 25.566
|
||||
@@ -1134,14 +1142,14 @@ def test__dry_is_price_crossed_with_orderbook(
|
||||
exchange.fetch_l2_order_book = order_book_l2_usd
|
||||
orderbook = order_book_l2_usd.return_value
|
||||
result = exchange._dry_is_price_crossed(
|
||||
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset
|
||||
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop
|
||||
)
|
||||
assert result is expected
|
||||
assert order_book_l2_usd.call_count == 0
|
||||
|
||||
# Test without passing orderbook
|
||||
order_book_l2_usd.reset_mock()
|
||||
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset)
|
||||
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop)
|
||||
assert result is expected
|
||||
|
||||
|
||||
@@ -1165,7 +1173,10 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
|
||||
exchange.fetch_l2_order_book = MagicMock()
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0)
|
||||
assert exchange.fetch_l2_order_book.call_count == 0
|
||||
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True)
|
||||
assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1176,7 +1187,7 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
|
||||
(False, False, "sell", 1.0, "open", None, 0, None),
|
||||
],
|
||||
)
|
||||
def test_check_dry_limit_order_filled_parametrized(
|
||||
def test_check_dry_limit_order_filled(
|
||||
default_conf,
|
||||
mocker,
|
||||
crossed,
|
||||
@@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized(
|
||||
assert fee_mock.call_count == expected_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"immediate,crossed,expected_status,expected_fee_type",
|
||||
[
|
||||
(True, True, "closed", "taker"),
|
||||
(False, True, "closed", "maker"),
|
||||
(True, False, "open", None),
|
||||
],
|
||||
)
|
||||
def test_check_dry_limit_order_filled_stoploss(
|
||||
default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd
|
||||
):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
exchange_has=MagicMock(return_value=True),
|
||||
_dry_is_price_crossed=MagicMock(return_value=crossed),
|
||||
fetch_l2_order_book=order_book_l2_usd,
|
||||
)
|
||||
average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25)
|
||||
fee_mock = mocker.patch(
|
||||
f"{EXMS}.add_dry_order_fee",
|
||||
autospec=True,
|
||||
side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order,
|
||||
)
|
||||
|
||||
amount = 1.75
|
||||
order = {
|
||||
"symbol": "LTC/USDT",
|
||||
"status": "open",
|
||||
"type": "limit",
|
||||
"side": "sell",
|
||||
"amount": amount,
|
||||
"filled": 0.0,
|
||||
"remaining": amount,
|
||||
"price": 25.0,
|
||||
"average": 0.0,
|
||||
"cost": 0.0,
|
||||
"fee": None,
|
||||
"ft_order_type": "stoploss",
|
||||
"stopLossPrice": 24.5,
|
||||
}
|
||||
|
||||
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
|
||||
|
||||
assert result["status"] == expected_status
|
||||
assert order_book_l2_usd.call_count == 1
|
||||
if crossed:
|
||||
assert result["filled"] == amount
|
||||
assert result["remaining"] == 0
|
||||
assert result["average"] == 24.25
|
||||
assert result["cost"] == pytest.approx(amount * 24.25)
|
||||
assert average_mock.call_count == 1
|
||||
assert fee_mock.call_count == 1
|
||||
assert fee_mock.call_args[0][1] == "LTC/USDT"
|
||||
assert fee_mock.call_args[0][3] == expected_fee_type
|
||||
else:
|
||||
assert result["filled"] == 0.0
|
||||
assert result["remaining"] == amount
|
||||
assert result["average"] == 0.0
|
||||
|
||||
assert average_mock.call_count == 0
|
||||
assert fee_mock.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side,price,filled,converted",
|
||||
[
|
||||
@@ -2314,6 +2389,7 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_
|
||||
]
|
||||
]
|
||||
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
|
||||
mocker.patch.object(exchange, "verify_candle_type_support")
|
||||
# Monkey-patch async function
|
||||
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
|
||||
|
||||
@@ -2364,6 +2440,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf_usdt, caplog, candle_type) ->
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
mocker.patch.object(exchange, "verify_candle_type_support")
|
||||
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
|
||||
|
||||
pairs = [("IOTA/USDT", "5m", candle_type), ("XRP/USDT", "5m", candle_type)]
|
||||
@@ -2614,6 +2691,7 @@ def test_refresh_latest_ohlcv_cache(mocker, default_conf, candle_type, time_mach
|
||||
time_machine.move_to(start + timedelta(hours=99, minutes=30))
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch.object(exchange, "verify_candle_type_support")
|
||||
exchange._set_startup_candle_count(default_conf)
|
||||
|
||||
mocker.patch(f"{EXMS}.ohlcv_candle_limit", return_value=100)
|
||||
@@ -2762,6 +2840,29 @@ def test_refresh_ohlcv_with_cache(mocker, default_conf, time_machine) -> None:
|
||||
assert ohlcv_mock.call_args_list[0][0][0] == pairs
|
||||
|
||||
|
||||
def test_refresh_latest_ohlcv_funding_rate(mocker, default_conf_usdt, caplog) -> None:
|
||||
ohlcv = generate_test_data_raw("1h", 24, "2025-01-02 12:00:00+00:00")
|
||||
funding_data = [{"timestamp": x[0], "fundingRate": x[1]} for x in ohlcv]
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
|
||||
exchange._api_async.fetch_funding_rate_history = get_mock_coro(funding_data)
|
||||
|
||||
pairs = [
|
||||
("IOTA/USDT:USDT", "8h", CandleType.FUNDING_RATE),
|
||||
("XRP/USDT:USDT", "1h", CandleType.FUNDING_RATE),
|
||||
]
|
||||
# empty dicts
|
||||
assert not exchange._klines
|
||||
res = exchange.refresh_latest_ohlcv(pairs, cache=False)
|
||||
|
||||
assert len(res) == len(pairs)
|
||||
assert log_has_re(r"Wrong funding rate timeframe 8h for pair IOTA/USDT:USDT", caplog)
|
||||
assert not log_has_re(r"Wrong funding rate timeframe 8h for pair XRP/USDT:USDT", caplog)
|
||||
assert exchange._api_async.fetch_ohlcv.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exchange_name", EXCHANGES)
|
||||
async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name):
|
||||
ohlcv = [
|
||||
@@ -5221,6 +5322,7 @@ def test_combine_funding_and_mark(
|
||||
{"date": trade_date, "open": mark_price},
|
||||
]
|
||||
)
|
||||
# Test fallback to futures funding rate for missing funding rates
|
||||
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
|
||||
|
||||
if futures_funding_rate is not None:
|
||||
@@ -5248,6 +5350,34 @@ def test_combine_funding_and_mark(
|
||||
|
||||
assert len(df) == 0
|
||||
|
||||
# Test fallback to futures funding rate for middle missing funding rate
|
||||
funding_rates = DataFrame(
|
||||
[
|
||||
{"date": prior2_date, "open": funding_rate},
|
||||
# missing 1 hour
|
||||
{"date": trade_date, "open": funding_rate},
|
||||
],
|
||||
)
|
||||
mark_rates = DataFrame(
|
||||
[
|
||||
{"date": prior2_date, "open": mark_price},
|
||||
{"date": prior_date, "open": mark_price},
|
||||
{"date": trade_date, "open": mark_price},
|
||||
]
|
||||
)
|
||||
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
|
||||
|
||||
if futures_funding_rate is not None:
|
||||
assert len(df) == 2
|
||||
assert df.iloc[0]["open_fund"] == funding_rate
|
||||
# assert df.iloc[1]["open_fund"] == futures_funding_rate
|
||||
assert df.iloc[-1]["open_fund"] == funding_rate
|
||||
# Mid-candle is dropped ...
|
||||
assert df["date"].to_list() == [prior2_date, trade_date]
|
||||
else:
|
||||
assert len(df) == 2
|
||||
assert df["date"].to_list() == [prior2_date, trade_date]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exchange,rate_start,rate_end,d1,d2,amount,expected_fees",
|
||||
@@ -5337,8 +5467,13 @@ def test__fetch_and_calculate_funding_fees(
|
||||
api_mock = MagicMock()
|
||||
api_mock.fetch_funding_rate_history = get_mock_coro(return_value=funding_rate_history)
|
||||
api_mock.fetch_ohlcv = get_mock_coro(return_value=mark_ohlcv)
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchOHLCV": True})
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchFundingRateHistory": True})
|
||||
type(api_mock).has = PropertyMock(
|
||||
return_value={
|
||||
"fetchFundingRateHistory": True,
|
||||
"fetchMarkOHLCV": True,
|
||||
"fetchOHLCV": True,
|
||||
}
|
||||
)
|
||||
|
||||
ex = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange)
|
||||
mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["1h", "4h", "8h"]))
|
||||
@@ -5382,8 +5517,13 @@ def test__fetch_and_calculate_funding_fees_datetime_called(
|
||||
api_mock.fetch_funding_rate_history = get_mock_coro(
|
||||
return_value=funding_rate_history_octohourly
|
||||
)
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchOHLCV": True})
|
||||
type(api_mock).has = PropertyMock(return_value={"fetchFundingRateHistory": True})
|
||||
type(api_mock).has = PropertyMock(
|
||||
return_value={
|
||||
"fetchFundingRateHistory": True,
|
||||
"fetchMarkOHLCV": True,
|
||||
"fetchOHLCV": True,
|
||||
}
|
||||
)
|
||||
mocker.patch(f"{EXMS}.timeframes", PropertyMock(return_value=["4h", "8h"]))
|
||||
exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange=exchange)
|
||||
d1 = datetime.strptime("2021-08-31 23:00:01 +0000", "%Y-%m-%d %H:%M:%S %z")
|
||||
@@ -6470,3 +6610,51 @@ def test_fetch_funding_rate(default_conf, mocker, exchange_name):
|
||||
|
||||
with pytest.raises(DependencyException, match=r"Pair XRP/ETH not available"):
|
||||
exchange.fetch_funding_rate(pair="XRP/ETH")
|
||||
|
||||
|
||||
def test_verify_candle_type_support(default_conf, mocker):
|
||||
api_mock = MagicMock()
|
||||
type(api_mock).has = PropertyMock(
|
||||
return_value={
|
||||
"fetchFundingRateHistory": True,
|
||||
"fetchIndexOHLCV": True,
|
||||
"fetchMarkOHLCV": True,
|
||||
"fetchPremiumIndexOHLCV": False,
|
||||
}
|
||||
)
|
||||
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||
|
||||
# Should pass
|
||||
exchange.verify_candle_type_support("futures")
|
||||
exchange.verify_candle_type_support(CandleType.FUTURES)
|
||||
exchange.verify_candle_type_support(CandleType.FUNDING_RATE)
|
||||
exchange.verify_candle_type_support(CandleType.SPOT)
|
||||
exchange.verify_candle_type_support(CandleType.MARK)
|
||||
|
||||
# Should fail:
|
||||
|
||||
with pytest.raises(
|
||||
OperationalException,
|
||||
match=r"Exchange .* does not support fetching premiumindex candles\.",
|
||||
):
|
||||
exchange.verify_candle_type_support(CandleType.PREMIUMINDEX)
|
||||
|
||||
type(api_mock).has = PropertyMock(
|
||||
return_value={
|
||||
"fetchFundingRateHistory": False,
|
||||
"fetchIndexOHLCV": False,
|
||||
"fetchMarkOHLCV": False,
|
||||
"fetchPremiumIndexOHLCV": True,
|
||||
}
|
||||
)
|
||||
for candle_type in [
|
||||
CandleType.FUNDING_RATE,
|
||||
CandleType.INDEX,
|
||||
CandleType.MARK,
|
||||
]:
|
||||
with pytest.raises(
|
||||
OperationalException,
|
||||
match=rf"Exchange .* does not support fetching {candle_type.value} candles\.",
|
||||
):
|
||||
exchange.verify_candle_type_support(candle_type)
|
||||
exchange.verify_candle_type_support(CandleType.PREMIUMINDEX)
|
||||
|
||||
@@ -16,9 +16,9 @@ def test_fetch_stoploss_order_gate(default_conf, mocker):
|
||||
|
||||
exchange.fetch_stoploss_order("1234", "ETH/BTC")
|
||||
assert fetch_order_mock.call_count == 1
|
||||
assert fetch_order_mock.call_args_list[0][1]["order_id"] == "1234"
|
||||
assert fetch_order_mock.call_args_list[0][1]["pair"] == "ETH/BTC"
|
||||
assert fetch_order_mock.call_args_list[0][1]["params"] == {"stop": True}
|
||||
assert fetch_order_mock.call_args_list[0][0][0] == "1234"
|
||||
assert fetch_order_mock.call_args_list[0][0][1] == "ETH/BTC"
|
||||
assert fetch_order_mock.call_args_list[0][0][2] == {"stop": True}
|
||||
|
||||
default_conf["trading_mode"] = "futures"
|
||||
default_conf["margin_mode"] = "isolated"
|
||||
@@ -36,21 +36,19 @@ def test_fetch_stoploss_order_gate(default_conf, mocker):
|
||||
|
||||
exchange.fetch_stoploss_order("1234", "ETH/BTC")
|
||||
assert exchange.fetch_order.call_count == 2
|
||||
assert exchange.fetch_order.call_args_list[0][1]["order_id"] == "1234"
|
||||
assert exchange.fetch_order.call_args_list[0][0][0] == "1234"
|
||||
assert exchange.fetch_order.call_args_list[1][1]["order_id"] == "222555"
|
||||
|
||||
|
||||
def test_cancel_stoploss_order_gate(default_conf, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf, exchange="gate")
|
||||
|
||||
cancel_order_mock = MagicMock()
|
||||
exchange.cancel_order = cancel_order_mock
|
||||
cancel_order_mock = mocker.patch.object(exchange, "cancel_order", autospec=True)
|
||||
|
||||
exchange.cancel_stoploss_order("1234", "ETH/BTC")
|
||||
assert cancel_order_mock.call_count == 1
|
||||
assert cancel_order_mock.call_args_list[0][1]["order_id"] == "1234"
|
||||
assert cancel_order_mock.call_args_list[0][1]["pair"] == "ETH/BTC"
|
||||
assert cancel_order_mock.call_args_list[0][1]["params"] == {"stop": True}
|
||||
assert cancel_order_mock.call_args_list[0][0][0] == "1234"
|
||||
assert cancel_order_mock.call_args_list[0][0][1] == "ETH/BTC"
|
||||
assert cancel_order_mock.call_args_list[0][0][2] == {"stop": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker):
|
||||
assert "type" in order
|
||||
|
||||
assert order["type"] == order_type
|
||||
assert order["price"] == 220
|
||||
assert order["price"] == 217.8
|
||||
assert order["stopPrice"] == 220
|
||||
assert order["amount"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -661,14 +661,14 @@ def test_stoploss_adjust_okx(mocker, default_conf, sl1, sl2, sl3, side):
|
||||
|
||||
def test_stoploss_cancel_okx(mocker, default_conf):
|
||||
exchange = get_patched_exchange(mocker, default_conf, exchange="okx")
|
||||
|
||||
exchange.cancel_order = MagicMock()
|
||||
co_mock = mocker.patch.object(exchange, "cancel_order", autospec=True)
|
||||
|
||||
exchange.cancel_stoploss_order("1234", "ETH/USDT")
|
||||
assert exchange.cancel_order.call_count == 1
|
||||
assert exchange.cancel_order.call_args_list[0][1]["order_id"] == "1234"
|
||||
assert exchange.cancel_order.call_args_list[0][1]["pair"] == "ETH/USDT"
|
||||
assert exchange.cancel_order.call_args_list[0][1]["params"] == {"stop": True}
|
||||
assert co_mock.call_count == 1
|
||||
args, _ = co_mock.call_args
|
||||
assert args[0] == "1234"
|
||||
assert args[1] == "ETH/USDT"
|
||||
assert args[2] == {"stop": True}
|
||||
|
||||
|
||||
def test__get_stop_params_okx(mocker, default_conf):
|
||||
|
||||
@@ -515,7 +515,6 @@ EXCHANGES = {
|
||||
],
|
||||
},
|
||||
"hyperliquid": {
|
||||
# TODO: Should be UBTC/USDC - probably needs a fix in ccxt
|
||||
"pair": "BTC/USDC",
|
||||
"stake_currency": "USDC",
|
||||
"hasQuoteVolume": False,
|
||||
|
||||
@@ -270,11 +270,14 @@ class TestCCXTExchange:
|
||||
assert exch.klines(pair_tf).iloc[-1]["date"] >= timeframe_to_prev_date(timeframe, now)
|
||||
assert exch.klines(pair_tf)["date"].astype(int).iloc[0] // 1e6 == since_ms
|
||||
|
||||
def _ccxt__async_get_candle_history(self, exchange, pair, timeframe, candle_type, factor=0.9):
|
||||
def _ccxt__async_get_candle_history(
|
||||
self, exchange, pair: str, timeframe: str, candle_type: CandleType, factor: float = 0.9
|
||||
):
|
||||
timeframe_ms = timeframe_to_msecs(timeframe)
|
||||
timeframe_ms_8h = timeframe_to_msecs("8h")
|
||||
now = timeframe_to_prev_date(timeframe, datetime.now(UTC))
|
||||
for offset in (360, 120, 30, 10, 5, 2):
|
||||
since = now - timedelta(days=offset)
|
||||
for offset_days in (360, 120, 30, 10, 5, 2):
|
||||
since = now - timedelta(days=offset_days)
|
||||
since_ms = int(since.timestamp() * 1000)
|
||||
|
||||
res = exchange.loop.run_until_complete(
|
||||
@@ -289,8 +292,15 @@ class TestCCXTExchange:
|
||||
candles = res[3]
|
||||
candle_count = exchange.ohlcv_candle_limit(timeframe, candle_type, since_ms) * factor
|
||||
candle_count1 = (now.timestamp() * 1000 - since_ms) // timeframe_ms * factor
|
||||
assert len(candles) >= min(candle_count, candle_count1), (
|
||||
f"{len(candles)} < {candle_count} in {timeframe}, Offset: {offset} {factor}"
|
||||
# funding fees can be 1h or 8h - depending on pair and time.
|
||||
candle_count2 = (now.timestamp() * 1000 - since_ms) // timeframe_ms_8h * factor
|
||||
min_value = min(
|
||||
candle_count,
|
||||
candle_count1,
|
||||
candle_count2 if candle_type == CandleType.FUNDING_RATE else candle_count1,
|
||||
)
|
||||
assert len(candles) >= min_value, (
|
||||
f"{len(candles)} < {candle_count} in {timeframe} {offset_days=} {factor=}"
|
||||
)
|
||||
# Check if first-timeframe is either the start, or start + 1
|
||||
assert candles[0][0] == since_ms or (since_ms + timeframe_ms)
|
||||
@@ -309,6 +319,8 @@ class TestCCXTExchange:
|
||||
[
|
||||
CandleType.FUTURES,
|
||||
CandleType.FUNDING_RATE,
|
||||
CandleType.INDEX,
|
||||
CandleType.PREMIUMINDEX,
|
||||
CandleType.MARK,
|
||||
],
|
||||
)
|
||||
@@ -322,6 +334,10 @@ class TestCCXTExchange:
|
||||
timeframe = exchange._ft_has.get(
|
||||
"funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"]
|
||||
)
|
||||
else:
|
||||
# never skip funding rate!
|
||||
if not exchange.check_candle_type_support(candle_type):
|
||||
pytest.skip(f"Exchange does not support candle type {candle_type}")
|
||||
self._ccxt__async_get_candle_history(
|
||||
exchange,
|
||||
pair=pair,
|
||||
@@ -337,6 +353,7 @@ class TestCCXTExchange:
|
||||
timeframe_ff = exchange._ft_has.get(
|
||||
"funding_fee_timeframe", exchange._ft_has["mark_ohlcv_timeframe"]
|
||||
)
|
||||
timeframe_ff_8h = "8h"
|
||||
pair_tf = (pair, timeframe_ff, CandleType.FUNDING_RATE)
|
||||
|
||||
funding_ohlcv = exchange.refresh_latest_ohlcv(
|
||||
@@ -350,14 +367,26 @@ class TestCCXTExchange:
|
||||
hour1 = timeframe_to_prev_date(timeframe_ff, this_hour - timedelta(minutes=1))
|
||||
hour2 = timeframe_to_prev_date(timeframe_ff, hour1 - timedelta(minutes=1))
|
||||
hour3 = timeframe_to_prev_date(timeframe_ff, hour2 - timedelta(minutes=1))
|
||||
val0 = rate[rate["date"] == this_hour].iloc[0]["open"]
|
||||
val1 = rate[rate["date"] == hour1].iloc[0]["open"]
|
||||
val2 = rate[rate["date"] == hour2].iloc[0]["open"]
|
||||
val3 = rate[rate["date"] == hour3].iloc[0]["open"]
|
||||
# Alternative 8h timeframe - funding fee timeframe is not stable.
|
||||
h8_this_hour = timeframe_to_prev_date(timeframe_ff_8h)
|
||||
h8_hour1 = timeframe_to_prev_date(timeframe_ff_8h, h8_this_hour - timedelta(minutes=1))
|
||||
h8_hour2 = timeframe_to_prev_date(timeframe_ff_8h, h8_hour1 - timedelta(minutes=1))
|
||||
h8_hour3 = timeframe_to_prev_date(timeframe_ff_8h, h8_hour2 - timedelta(minutes=1))
|
||||
row0 = rate.iloc[-1]
|
||||
row1 = rate.iloc[-2]
|
||||
row2 = rate.iloc[-3]
|
||||
row3 = rate.iloc[-4]
|
||||
|
||||
assert row0["date"] == this_hour or row0["date"] == h8_this_hour
|
||||
assert row1["date"] == hour1 or row1["date"] == h8_hour1
|
||||
assert row2["date"] == hour2 or row2["date"] == h8_hour2
|
||||
assert row3["date"] == hour3 or row3["date"] == h8_hour3
|
||||
|
||||
# Test For last 4 hours
|
||||
# Avoids random test-failure when funding-fees are 0 for a few hours.
|
||||
assert val0 != 0.0 or val1 != 0.0 or val2 != 0.0 or val3 != 0.0
|
||||
assert (
|
||||
row0["open"] != 0.0 or row1["open"] != 0.0 or row2["open"] != 0.0 or row3["open"] != 0.0
|
||||
)
|
||||
# We expect funding rates to be different from 0.0 - or moving around.
|
||||
assert (
|
||||
rate["open"].max() != 0.0
|
||||
@@ -369,7 +398,10 @@ class TestCCXTExchange:
|
||||
exchange, exchangename = exchange_futures
|
||||
pair = EXCHANGES[exchangename].get("futures_pair", EXCHANGES[exchangename]["pair"])
|
||||
since = int((datetime.now(UTC) - timedelta(days=5)).timestamp() * 1000)
|
||||
pair_tf = (pair, "1h", CandleType.MARK)
|
||||
candle_type = CandleType.from_string(
|
||||
exchange.get_option("mark_ohlcv_price", default=CandleType.MARK)
|
||||
)
|
||||
pair_tf = (pair, "1h", candle_type)
|
||||
|
||||
mark_ohlcv = exchange.refresh_latest_ohlcv([pair_tf], since_ms=since, drop_incomplete=False)
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ from tests.freqai.conftest import (
|
||||
def can_run_model(model: str) -> None:
|
||||
is_pytorch_model = "Reinforcement" in model or "PyTorch" in model
|
||||
|
||||
if is_arm() and "Catboost" in model:
|
||||
pytest.skip("CatBoost is not supported on ARM.")
|
||||
|
||||
if is_pytorch_model and is_mac():
|
||||
pytest.skip("Reinforcement learning / PyTorch module not available on intel based Mac OS.")
|
||||
|
||||
@@ -44,7 +41,6 @@ def can_run_model(model: str) -> None:
|
||||
("LightGBMRegressor", True, False, True, True, False, 0, 0),
|
||||
("XGBoostRegressor", False, True, False, True, False, 10, 0.05),
|
||||
("XGBoostRFRegressor", False, False, False, True, False, 0, 0),
|
||||
("CatboostRegressor", False, False, False, True, True, 0, 0),
|
||||
("PyTorchMLPRegressor", False, False, False, False, False, 0, 0),
|
||||
("PyTorchTransformerRegressor", False, False, False, False, False, 0, 0),
|
||||
("ReinforcementLearner", False, True, False, True, False, 0, 0),
|
||||
@@ -138,9 +134,7 @@ def test_extract_data_and_train_model_Standard(
|
||||
[
|
||||
("LightGBMRegressorMultiTarget", "freqai_test_multimodel_strat"),
|
||||
("XGBoostRegressorMultiTarget", "freqai_test_multimodel_strat"),
|
||||
("CatboostRegressorMultiTarget", "freqai_test_multimodel_strat"),
|
||||
("LightGBMClassifierMultiTarget", "freqai_test_multimodel_classifier_strat"),
|
||||
("CatboostClassifierMultiTarget", "freqai_test_multimodel_classifier_strat"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.filterwarnings(r"ignore:.*__sklearn_tags__.*:DeprecationWarning")
|
||||
@@ -184,7 +178,6 @@ def test_extract_data_and_train_model_MultiTargets(mocker, freqai_conf, model, s
|
||||
"model",
|
||||
[
|
||||
"LightGBMClassifier",
|
||||
"CatboostClassifier",
|
||||
"XGBoostClassifier",
|
||||
"XGBoostRFClassifier",
|
||||
"SKLearnRandomForestClassifier",
|
||||
@@ -246,13 +239,11 @@ def test_extract_data_and_train_model_Classifiers(mocker, freqai_conf, model):
|
||||
[
|
||||
("LightGBMRegressor", 2, "freqai_test_strat"),
|
||||
("XGBoostRegressor", 2, "freqai_test_strat"),
|
||||
("CatboostRegressor", 2, "freqai_test_strat"),
|
||||
("PyTorchMLPRegressor", 2, "freqai_test_strat"),
|
||||
("PyTorchTransformerRegressor", 2, "freqai_test_strat"),
|
||||
("ReinforcementLearner", 3, "freqai_rl_test_strat"),
|
||||
("XGBoostClassifier", 2, "freqai_test_classifier"),
|
||||
("LightGBMClassifier", 2, "freqai_test_classifier"),
|
||||
("CatboostClassifier", 2, "freqai_test_classifier"),
|
||||
("PyTorchMLPClassifier", 2, "freqai_test_classifier"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2548,9 +2548,9 @@ def test_manage_open_orders_exception(
|
||||
caplog.clear()
|
||||
freqtrade.manage_open_orders()
|
||||
assert log_has_re(
|
||||
r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30.00000000, "
|
||||
r"is_short=False, leverage=1.0, "
|
||||
r"open_rate=2.00000000, open_since="
|
||||
r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30, "
|
||||
r"is_short=False, leverage=1, "
|
||||
r"open_rate=2, open_since="
|
||||
f"{open_trade_usdt.open_date.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
r"\) due to Traceback \(most recent call last\):\n*",
|
||||
caplog,
|
||||
@@ -3092,7 +3092,7 @@ def test_execute_trade_exit_custom_exit_price(
|
||||
"exit_reason": "foo",
|
||||
"open_date": ANY,
|
||||
"close_date": ANY,
|
||||
"close_rate": ANY,
|
||||
"close_rate": 2.25, # the custom exit price
|
||||
"sub_trade": False,
|
||||
"cumulative_profit": 0.0,
|
||||
"stake_amount": pytest.approx(60),
|
||||
@@ -3751,8 +3751,8 @@ def test_get_real_amount_quote(
|
||||
# Amount is reduced by "fee"
|
||||
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == (amount * 0.001)
|
||||
assert log_has(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False,"
|
||||
" leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.008.",
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, is_short=False,"
|
||||
" leverage=1, open_rate=0.245441, open_since=closed), fee=0.008.",
|
||||
caplog,
|
||||
)
|
||||
|
||||
@@ -3805,8 +3805,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
|
||||
# Amount is reduced by "fee"
|
||||
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) is None
|
||||
assert log_has(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, "
|
||||
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed) failed: "
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
|
||||
"is_short=False, leverage=1, open_rate=0.245441, open_since=closed) failed: "
|
||||
"myTrade-dict empty found",
|
||||
caplog,
|
||||
)
|
||||
@@ -3825,8 +3825,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
|
||||
0,
|
||||
True,
|
||||
(
|
||||
"Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False, "
|
||||
"leverage=1.0, open_rate=0.24544100, open_since=closed) [buy]: 0.00094518 BNB -"
|
||||
"Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8, is_short=False, "
|
||||
"leverage=1, open_rate=0.245441, open_since=closed) [buy]: 0.00094518 BNB -"
|
||||
" rate: None"
|
||||
),
|
||||
),
|
||||
@@ -3836,8 +3836,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
|
||||
0.004,
|
||||
False,
|
||||
(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, "
|
||||
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.004."
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
|
||||
"is_short=False, leverage=1, open_rate=0.245441, open_since=closed), fee=0.004."
|
||||
),
|
||||
),
|
||||
# invalid, no currency in from fee dict
|
||||
@@ -3941,8 +3941,8 @@ def test_get_real_amount_multi(
|
||||
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == expected_amount
|
||||
assert log_has(
|
||||
(
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, "
|
||||
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), "
|
||||
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
|
||||
"is_short=False, leverage=1, open_rate=0.245441, open_since=closed), "
|
||||
f"fee={expected_amount}."
|
||||
),
|
||||
caplog,
|
||||
|
||||
@@ -50,7 +50,14 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
|
||||
stoploss_order_mock = MagicMock(side_effect=stop_orders)
|
||||
# Sell 3rd trade (not called for the first trade)
|
||||
should_sell_mock = MagicMock(side_effect=[[], [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)]])
|
||||
cancel_order_mock = MagicMock()
|
||||
|
||||
def patch_stoploss(order_id, *args, **kwargs):
|
||||
slo = stoploss_order_open.copy()
|
||||
slo["id"] = order_id
|
||||
slo["status"] = "canceled"
|
||||
return slo
|
||||
|
||||
cancel_order_mock = MagicMock(side_effect=patch_stoploss)
|
||||
mocker.patch.multiple(
|
||||
EXMS,
|
||||
fetch_ticker=ticker,
|
||||
@@ -796,9 +803,13 @@ def test_dca_handle_similar_open_order(
|
||||
# Should Create a new exit order
|
||||
freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=2)
|
||||
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-2)
|
||||
msg = r"Skipping cancelling stoploss on exchange for.*"
|
||||
|
||||
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
|
||||
assert not log_has_re(msg, caplog)
|
||||
freqtrade.process()
|
||||
assert log_has_re(msg, caplog)
|
||||
|
||||
trade = Trade.get_trades().first()
|
||||
|
||||
assert trade.orders[-2].status == "closed"
|
||||
|
||||
@@ -879,6 +879,10 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
|
||||
patch_exchange(mocker)
|
||||
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
|
||||
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
|
||||
default_conf_usdt["unfilledtimeout"] = {
|
||||
"entry": 11,
|
||||
"exit": 30,
|
||||
}
|
||||
if use_detail:
|
||||
default_conf_usdt["timeframe_detail"] = "1m"
|
||||
|
||||
@@ -916,7 +920,7 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
|
||||
)
|
||||
results = result["results"]
|
||||
assert not results.empty
|
||||
# Timeout settings from default_conf = entry: 10, exit: 30
|
||||
# Timeout settings from = entry: 11, exit: 30
|
||||
assert len(results) == (2 if use_detail else 3)
|
||||
|
||||
assert "orders" in results.columns
|
||||
@@ -966,8 +970,8 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
|
||||
@pytest.mark.parametrize(
|
||||
"use_detail,exp_funding_fee, exp_ff_updates",
|
||||
[
|
||||
(True, -0.018054162, 10),
|
||||
(False, -0.01780296, 6),
|
||||
(True, -0.0180457882, 15),
|
||||
(False, -0.0178000543, 12),
|
||||
],
|
||||
)
|
||||
def test_backtest_one_detail_futures(
|
||||
@@ -1077,8 +1081,8 @@ def test_backtest_one_detail_futures(
|
||||
@pytest.mark.parametrize(
|
||||
"use_detail,entries,max_stake,ff_updates,expected_ff",
|
||||
[
|
||||
(True, 50, 3000, 55, -1.18038144),
|
||||
(False, 6, 360, 11, -0.14679994),
|
||||
(True, 50, 3000, 78, -1.17988972),
|
||||
(False, 6, 360, 34, -0.14673681),
|
||||
],
|
||||
)
|
||||
def test_backtest_one_detail_futures_funding_fees(
|
||||
@@ -1800,7 +1804,7 @@ def test_backtest_multi_pair_detail_simplified(
|
||||
if use_detail:
|
||||
# Backtest loop is called once per candle per pair
|
||||
# Exact numbers depend on trade state - but should be around 2_600
|
||||
assert bl_spy.call_count > 2_170
|
||||
assert bl_spy.call_count > 2_159
|
||||
assert bl_spy.call_count < 2_800
|
||||
assert len(evaluate_result_multi(results["results"], "1h", 3)) > 0
|
||||
else:
|
||||
@@ -2378,13 +2382,12 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, caplog, testda
|
||||
f"Using data directory: {testdatadir} ...",
|
||||
"Loading data from 2021-11-17 01:00:00 up to 2021-11-21 04:00:00 (4 days).",
|
||||
"Backtesting with data from 2021-11-17 21:00:00 up to 2021-11-21 04:00:00 (3 days).",
|
||||
"XRP/USDT:USDT, funding_rate, 8h, data starts at 2021-11-18 00:00:00",
|
||||
"XRP/USDT:USDT, mark, 8h, data starts at 2021-11-18 00:00:00",
|
||||
"XRP/USDT:USDT, funding_rate, 1h, data starts at 2021-11-18 00:00:00",
|
||||
f"Running backtesting for Strategy {CURRENT_TEST_STRATEGY}",
|
||||
]
|
||||
|
||||
for line in exists:
|
||||
assert log_has(line, caplog)
|
||||
assert log_has(line, caplog), line
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "BACKTESTING REPORT" in captured.out
|
||||
|
||||
@@ -372,8 +372,8 @@ def test_borrowed(fee, is_short, lev, borrowed, trading_mode):
|
||||
@pytest.mark.parametrize(
|
||||
"is_short,open_rate,close_rate,lev,profit,trading_mode",
|
||||
[
|
||||
(False, 2.0, 2.2, 1.0, 0.09451372, spot),
|
||||
(True, 2.2, 2.0, 3.0, 0.25894253, margin),
|
||||
(False, 2, 2.2, 1, 0.09451372, spot),
|
||||
(True, 2.2, 2.0, 3, 0.25894253, margin),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_persistence")
|
||||
@@ -493,8 +493,8 @@ def test_update_limit_order(
|
||||
assert trade.close_date is None
|
||||
assert log_has_re(
|
||||
f"LIMIT_{entry_side.upper()} has been fulfilled for "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, "
|
||||
r"open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
@@ -511,8 +511,8 @@ def test_update_limit_order(
|
||||
assert trade.close_date is not None
|
||||
assert log_has_re(
|
||||
f"LIMIT_{exit_side.upper()} has been fulfilled for "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, "
|
||||
r"Trade\(id=2, pair=ADA/USDT, amount=30, "
|
||||
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, "
|
||||
r"open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
@@ -545,8 +545,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee,
|
||||
assert trade.close_date is None
|
||||
assert log_has_re(
|
||||
r"MARKET_BUY has been fulfilled for Trade\(id=1, "
|
||||
r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, "
|
||||
r"open_rate=2.00000000, open_since=.*\).",
|
||||
r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, "
|
||||
r"open_rate=2, open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
|
||||
@@ -561,8 +561,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee,
|
||||
assert trade.close_date is not None
|
||||
assert log_has_re(
|
||||
r"MARKET_SELL has been fulfilled for Trade\(id=1, "
|
||||
r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, "
|
||||
r"open_rate=2.00000000, open_since=.*\).",
|
||||
r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, "
|
||||
r"open_rate=2, open_since=.*\).",
|
||||
caplog,
|
||||
)
|
||||
|
||||
|
||||
@@ -1852,9 +1852,35 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets):
|
||||
Trade.rollback()
|
||||
|
||||
trade = Trade.get_trades([Trade.id == 5]).first()
|
||||
last_order = trade.orders[-1]
|
||||
|
||||
assert last_order.side == "sell"
|
||||
assert last_order.status == "closed"
|
||||
assert last_order.order_type == "market"
|
||||
assert last_order.amount == 23
|
||||
assert pytest.approx(trade.amount) == 100
|
||||
assert trade.is_open is True
|
||||
|
||||
# Test with explicit price
|
||||
rc = client_post(
|
||||
client,
|
||||
f"{BASE_URI}/forceexit",
|
||||
data={"tradeid": "5", "ordertype": "limit", "amount": 25, "price": 0.12345},
|
||||
)
|
||||
assert_response(rc)
|
||||
assert rc.json() == {"result": "Created exit order for trade 5."}
|
||||
Trade.rollback()
|
||||
|
||||
trade = Trade.get_trades([Trade.id == 5]).first()
|
||||
last_order = trade.orders[-1]
|
||||
assert last_order.status == "closed"
|
||||
assert last_order.order_type == "limit"
|
||||
assert pytest.approx(last_order.safe_price) == 0.12345
|
||||
assert pytest.approx(last_order.amount) == 25
|
||||
|
||||
assert pytest.approx(trade.amount) == 75
|
||||
assert trade.is_open is True
|
||||
|
||||
rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5"})
|
||||
assert_response(rc)
|
||||
assert rc.json() == {"result": "Created exit order for trade 5."}
|
||||
@@ -2757,12 +2783,12 @@ def test_list_available_pairs(botclient):
|
||||
rc = client_get(client, f"{BASE_URI}/available_pairs")
|
||||
|
||||
assert_response(rc)
|
||||
assert rc.json()["length"] == 12
|
||||
assert rc.json()["length"] == 14
|
||||
assert isinstance(rc.json()["pairs"], list)
|
||||
|
||||
rc = client_get(client, f"{BASE_URI}/available_pairs?timeframe=5m")
|
||||
assert_response(rc)
|
||||
assert rc.json()["length"] == 12
|
||||
assert rc.json()["length"] == 14
|
||||
|
||||
rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH")
|
||||
assert_response(rc)
|
||||
@@ -3250,6 +3276,7 @@ def test_api_download_data(botclient, mocker, tmp_path):
|
||||
body = {
|
||||
"pairs": ["ETH/BTC", "XRP/BTC"],
|
||||
"timeframes": ["5m"],
|
||||
"candle_types": ["spot"],
|
||||
}
|
||||
|
||||
# Fail, already running
|
||||
|
||||
@@ -64,7 +64,6 @@ def test_hyperopt_real_parameter():
|
||||
|
||||
def test_hyperopt_decimal_parameter():
|
||||
HyperoptStateContainer.set_state(HyperoptState.INDICATORS)
|
||||
# TODO: Check for get_space??
|
||||
from freqtrade.optimize.space import SKDecimal
|
||||
|
||||
with pytest.raises(OperationalException, match=r"DecimalParameter space must be.*"):
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Run pip audit to check for known security vulnerabilities in installed packages.
|
||||
Original Idea and base for this implementation by Michael Kennedy's blog:
|
||||
https://mkennedy.codes/posts/python-supply-chain-security-made-easy/
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
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: 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
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip_audit",
|
||||
# "--format=json",
|
||||
"--progress-spinner=off",
|
||||
"--ignore-vuln",
|
||||
"CVE-2025-53000",
|
||||
"--skip-editable",
|
||||
]
|
||||
|
||||
# Run pip-audit with JSON output for easier parsing
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 minute timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail("pip-audit command timed out after 120 seconds")
|
||||
except FileNotFoundError:
|
||||
pytest.fail("pip-audit not installed or not accessible")
|
||||
|
||||
# Check if pip-audit found any vulnerabilities
|
||||
if result.returncode != 0:
|
||||
# pip-audit returns non-zero when vulnerabilities are found
|
||||
error_output = result.stdout + "\n" + result.stderr
|
||||
|
||||
# Check if it's an actual vulnerability vs an error
|
||||
if "vulnerabilities found" in error_output.lower() or '"dependencies"' in result.stdout:
|
||||
pytest.fail(
|
||||
f"pip-audit detected security vulnerabilities!\n\n"
|
||||
f"Output:\n{result.stdout}\n\n"
|
||||
f"Please review and update vulnerable packages.\n"
|
||||
f"Run manually with: {' '.join(command)}"
|
||||
)
|
||||
else:
|
||||
# Some other error occurred
|
||||
pytest.fail(
|
||||
f"pip-audit failed to run properly:\n\nReturn code: {result.returncode}\n"
|
||||
f"Output: {error_output}\n"
|
||||
)
|
||||
|
||||
# Success - no vulnerabilities found
|
||||
assert result.returncode == 0, "pip-audit should return 0 when no vulnerabilities are found"
|
||||
|
||||
|
||||
def test_pip_audit_runs_successfully():
|
||||
"""
|
||||
Verify that pip-audit can run successfully (even if vulnerabilities are found).
|
||||
|
||||
This is a smoke test to ensure pip-audit is properly installed and functional.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip_audit", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
assert result.returncode == 0, f"pip-audit --version failed: {result.stderr}"
|
||||
assert "pip-audit" in result.stdout.lower(), "pip-audit version output unexpected"
|
||||
except FileNotFoundError:
|
||||
pytest.fail("pip-audit not installed")
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail("pip-audit --version timed out")
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user