Compare commits

..

12 Commits

Author SHA1 Message Date
Matthias dc2abe32a3 Merge pull request #13003 from freqtrade/new_release
New release 2026.3
2026-03-30 19:11:38 +02:00
Matthias 43bb0f7cb1 chore: bump version to 2026.3 2026-03-30 06:38:56 +02:00
Matthias 9ee87cb6ba Merge branch 'stable' into new_release 2026-03-30 06:38:42 +02:00
Matthias 0e2313be7b Merge pull request #12875 from freqtrade/new_release
New release 2026.2
2026-02-28 12:25:06 +01:00
Matthias f535c4cff4 chore: bump version to 2026.2 2026-02-28 08:02:58 +01:00
Matthias 5f901d837c Merge branch 'stable' into new_release 2026-02-28 08:02:41 +01:00
Matthias c86484b152 Merge pull request #12758 from freqtrade/new_release
New release 2026.1
2026-01-31 13:06:44 +01:00
Matthias a33eb51f36 chore: bump version to 2026.1 2026-01-31 08:23:44 +01:00
Matthias 373cd8141c Merge branch 'stable' into new_release 2026-01-31 08:23:11 +01:00
Matthias 9f00a1d0d2 Merge pull request #12673 from freqtrade/new_release
New release 2025.12
2025-12-30 08:19:19 +01:00
Matthias 9a37d7bfbb chore: bump version to 2025.12 2025-12-29 13:19:31 +01:00
Matthias c9c08906e5 Merge branch 'stable' into new_release 2025-12-29 13:17:12 +01:00
133 changed files with 9200 additions and 16877 deletions
+4 -10
View File
@@ -1,10 +1,8 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: docker # zizmor: ignore[dependabot-cooldown] Docker does not support cooldowns at the moment. - package-ecosystem: docker
# Docker does not support cooldowns at the moment. cooldown:
# https://github.com/dependabot/dependabot-core/issues/14044 default-days: 7
# cooldown:
# default-days: 7
directories: directories:
- "/" - "/"
- "/docker" - "/docker"
@@ -49,11 +47,7 @@ updates:
patterns: patterns:
- "scipy" - "scipy"
- "scipy-stubs" - "scipy-stubs"
gymnasium:
patterns:
- "gymnasium"
- "stable-baselines3"
- "sb3-contrib"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
cooldown: cooldown:
@@ -2,7 +2,7 @@ name: Binance Leverage tiers update
on: on:
schedule: schedule:
- cron: "25 2 * * 4" - cron: "25 3 * * 4"
# on demand # on demand
workflow_dispatch: workflow_dispatch:
@@ -24,8 +24,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv and Python 🐍 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
enable-cache: false enable-cache: false
@@ -42,7 +46,7 @@ jobs:
run: python build_helpers/binance_update_lev_tiers.py run: python build_helpers/binance_update_lev_tiers.py
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with: with:
token: ${{ secrets.REPO_SCOPED_TOKEN }} token: ${{ secrets.REPO_SCOPED_TOKEN }}
add-paths: freqtrade/exchange/binance_leverage_tiers.json add-paths: freqtrade/exchange/binance_leverage_tiers.json
+42 -21
View File
@@ -32,8 +32,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv and Python 🐍 - name: Set up Python 🐍
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
enable-cache: true enable-cache: true
@@ -68,7 +73,7 @@ jobs:
run: | run: |
pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04') if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
with: with:
fail_ci_if_error: true fail_ci_if_error: true
@@ -150,10 +155,7 @@ jobs:
run: | run: |
$PSVersionTable $PSVersionTable
Get-PSRepository | Format-List * Get-PSRepository | Format-List *
if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { Set-PSRepository psgallery -InstallationPolicy trusted
Register-PSRepository -Default
}
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name Pester -RequiredVersion 5.7.1 -Confirm:$false -Force -SkipPublisherCheck Install-Module -Name Pester -RequiredVersion 5.7.1 -Confirm:$false -Force -SkipPublisherCheck
$Error.clear() $Error.clear()
Invoke-Pester -Path "tests" -CI Invoke-Pester -Path "tests" -CI
@@ -175,8 +177,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv and Python 🐍 - name: Set up Python 🐍
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 #v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
python-version: "3.13" python-version: "3.13"
@@ -194,8 +201,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 🐍 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.13" python-version: "3.13"
@@ -213,8 +219,13 @@ jobs:
run: | run: |
./tests/test_docs.sh ./tests/test_docs.sh
- name: Install uv and Python 🐍 - name: Set up Python 🐍
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
python-version: "3.13" python-version: "3.13"
@@ -245,8 +256,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv and Python 🐍 - name: Set up Python 🐍
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
enable-cache: true enable-cache: true
@@ -312,8 +328,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv and Python 🐍 - name: Set up Python 🐍
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
@@ -324,7 +345,7 @@ jobs:
python -m build --sdist --wheel python -m build --sdist --wheel
- name: Upload artifacts 📦 - name: Upload artifacts 📦
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: freqtrade-build name: freqtrade-build
path: | path: |
@@ -336,7 +357,7 @@ jobs:
python -m build --sdist --wheel ft_client python -m build --sdist --wheel ft_client
- name: Upload artifacts 📦 - name: Upload artifacts 📦
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: freqtrade-client-build name: freqtrade-client-build
path: | path: |
@@ -367,7 +388,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Publish to PyPI (Test) - name: Publish to PyPI (Test)
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with: with:
repository-url: https://test.pypi.org/legacy/ repository-url: https://test.pypi.org/legacy/
@@ -396,7 +417,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Publish to PyPI - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
docker-build: docker-build:
+5 -5
View File
@@ -26,15 +26,15 @@ jobs:
with: with:
persist-credentials: true persist-credentials: true
- name: Install uv and Python 🐍 - name: Set up Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
activate-environment: true python-version: '3.12'
python-version: '3.13'
- name: Install dependencies - name: Install dependencies
run: | run: |
uv pip install -r docs/requirements-docs.txt python -m pip install --upgrade pip
pip install -r docs/requirements-docs.txt
- name: Fetch gh-pages branch - name: Fetch gh-pages branch
run: | run: |
+2 -2
View File
@@ -31,13 +31,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image - name: Pre-build dev container image
uses: devcontainers/ci@b63b30de439b47a52267f241112c5b453b673db5 # v0.3.1900000449 uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.19
with: with:
subFolder: .github subFolder: .github
imageName: ghcr.io/${{ github.repository }}-devcontainer imageName: ghcr.io/${{ github.repository }}-devcontainer
+3 -3
View File
@@ -59,7 +59,7 @@ jobs:
uses: ./.github/actions/docker-tags uses: ./.github/actions/docker-tags
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -183,13 +183,13 @@ jobs:
uses: ./.github/actions/docker-tags uses: ./.github/actions/docker-tags
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to github - name: Login to github
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -1,53 +0,0 @@
name: Pre-commit Types update
on:
pull_request:
branches:
- "develop"
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}"
cancel-in-progress: true
permissions: {}
jobs:
mypy-version-update:
name: "Pre-commit mypy type versions update"
runs-on: ubuntu-24.04
# Only run this job for pull requests created by dependabot[bot]
if: >
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.repository == github.event.pull_request.head.repo.full_name &&
github.event_name == 'pull_request' &&
startsWith(github.head_ref, 'dependabot/')
environment:
name: dependabot-pulls
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: true
token: ${{ secrets.REPO_SCOPED_TOKEN_DEP }}
ref: ${{ github.head_ref || github.ref }}
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
activate-environment: true
python-version: "3.13"
- name: Install PyYAML
run: |
- name: pre-commit dependencies
run: |
uv pip install $(grep -E "^pyyaml==" requirements-dev.txt)
python build_helpers/pre_commit_update.py --update
- uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7
with:
commit_message: "chore(deps): Apply pre-commit types update"
commit_user_name: Freqtrade Bot
commit_user_email: 154552126+freqtrade-bot@users.noreply.github.com
commit_author: Freqtrade Bot <154552126+freqtrade-bot@users.noreply.github.com>
+8 -4
View File
@@ -2,7 +2,7 @@ name: Pre-commit auto-update
on: on:
schedule: schedule:
- cron: "13 1 * * 2" - cron: "0 3 * * 2"
# on demand # on demand
workflow_dispatch: workflow_dispatch:
@@ -25,8 +25,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv and Python 🐍 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
activate-environment: true activate-environment: true
python-version: "3.13" python-version: "3.13"
@@ -37,7 +41,7 @@ jobs:
- name: Run auto-update - name: Run auto-update
run: pre-commit autoupdate run: pre-commit autoupdate
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with: with:
token: ${{ secrets.REPO_SCOPED_TOKEN }} token: ${{ secrets.REPO_SCOPED_TOKEN }}
add-paths: .pre-commit-config.yaml add-paths: .pre-commit-config.yaml
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor 🌈 - name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
+9 -9
View File
@@ -15,23 +15,23 @@ repos:
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: "v2.1.0" rev: "v1.19.1"
hooks: hooks:
- id: mypy - id: mypy
exclude: build_helpers exclude: build_helpers
additional_dependencies: additional_dependencies:
- types-cachetools==7.0.0.20260518 - types-cachetools==6.2.0.20251022
- types-filelock==3.2.7 - types-filelock==3.2.7
- types-requests==2.33.0.20260518 - types-requests==2.32.4.20260107
- types-tabulate==0.10.0.20260508 - types-tabulate==0.10.0.20260308
- types-python-dateutil==2.9.0.20260518 - types-python-dateutil==2.9.0.20260305
- scipy-stubs==1.17.1.4 - scipy-stubs==1.17.1.2
- SQLAlchemy==2.0.49 - SQLAlchemy==2.0.48
# stages: [push] # stages: [push]
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: 'v0.15.13' rev: 'v0.15.7'
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
@@ -70,6 +70,6 @@ repos:
# Ensure github actions remain safe # Ensure github actions remain safe
- repo: https://github.com/woodruffw/zizmor-pre-commit - repo: https://github.com/woodruffw/zizmor-pre-commit
rev: v1.24.1 rev: v1.23.1
hooks: hooks:
- id: zizmor - id: zizmor
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14.5-slim-trixie AS base FROM python:3.13.12-slim-trixie AS base
# Setup env # Setup env
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
+1 -1
View File
@@ -87,7 +87,7 @@ def extract_command_partials():
help_output = _get_help_output(subparser) help_output = _get_help_output(subparser)
_write_partial_file(f"docs/commands/{command}.md", help_output) _write_partial_file(f"docs/commands/{command}.md", help_output)
else: else:
print(f" Warning: subcommand '{command}' not found in parser") print(f" Warning: subcommand '{command}' not found in parser")
# freqtrade-client still uses subprocess as requested # freqtrade-client still uses subprocess as requested
print("Running for freqtrade-client") print("Running for freqtrade-client")
+2 -42
View File
@@ -1,7 +1,5 @@
# File used in CI to ensure pre-commit dependencies are kept up-to-date. # File used in CI to ensure pre-commit dependencies are kept up-to-date.
import argparse
import re
import sys import sys
from pathlib import Path from pathlib import Path
@@ -12,24 +10,6 @@ pre_commit_file = Path(".pre-commit-config.yaml")
require_dev = Path("requirements-dev.txt") require_dev = Path("requirements-dev.txt")
require = Path("requirements.txt") require = Path("requirements.txt")
parser = argparse.ArgumentParser()
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
def replace_dependency_version(pre_commit_text: str, dependency: str) -> tuple[str, bool]:
"""
Regex-based replacement of a dependency version in the pre-commit config file.
using regex here ensures we only replace the version of the dependency while
keeping the overall file intact.
"""
package_name = dependency.split("==", 1)[0]
pattern = re.compile(rf"^(\s*-\s+){re.escape(package_name)}==.*$", re.MULTILINE)
updated_text, replacements = pattern.subn(rf"\1{dependency}", pre_commit_text, count=1)
return updated_text, replacements > 0 and updated_text != pre_commit_text
with require_dev.open("r") as rfile: with require_dev.open("r") as rfile:
requirements = rfile.readlines() requirements = rfile.readlines()
@@ -43,18 +23,6 @@ supported = ("types-", "SQLAlchemy", "scipy-stubs")
# Only keep the first part of the line up to the first space # Only keep the first part of the line up to the first space
type_reqs = [r.strip("\n").split()[0] for r in requirements if r.startswith(supported)] type_reqs = [r.strip("\n").split()[0] for r in requirements if r.startswith(supported)]
with pre_commit_file.open("r") as file:
pre_commit_text = file.read()
updated = False
for req in type_reqs:
pre_commit_text, req_updated = replace_dependency_version(pre_commit_text, req)
updated = updated or req_updated
if args.update and updated:
with pre_commit_file.open("w") as file:
file.write(pre_commit_text)
with pre_commit_file.open("r") as file: with pre_commit_file.open("r") as file:
f = yaml.load(file, Loader=yaml.SafeLoader) f = yaml.load(file, Loader=yaml.SafeLoader)
@@ -72,20 +40,12 @@ for hook in hooks:
for req in type_reqs: for req in type_reqs:
if req not in hooks: if req not in hooks:
errors.append(f"{req} is missing in pre-commit config file.") errors.append(f"{req} is missing in pre-config file.")
if updated:
if args.update:
errors.append(".pre-commit-config.yaml was updated to match the requirements files.")
else:
errors.append(
".pre-commit-config.yaml is outdated. Run build_helpers/pre_commit_update.py --update."
)
if errors: if errors:
for e in errors: for e in errors:
print(e) print(e)
sys.exit(1 if not (args.update and updated) else 0) sys.exit(1)
sys.exit(0) sys.exit(0)
+1 -6
View File
@@ -283,10 +283,6 @@
"month" "month"
] ]
}, },
"skip_wallet_history_migration": {
"description": "Disable wallet history migration.",
"type": "boolean"
},
"hyperopt_path": { "hyperopt_path": {
"description": "Specify additional lookup path for Hyperopt Loss functions.", "description": "Specify additional lookup path for Hyperopt Loss functions.",
"type": "string" "type": "string"
@@ -1063,8 +1059,7 @@
"jwt_secret_key": { "jwt_secret_key": {
"description": "Secret key for JWT authentication.", "description": "Secret key for JWT authentication.",
"type": "string", "type": "string",
"default": "somethingRandomSomethingRandom123", "default": "somethingRandomSomethingRandom123"
"minLength": 32
}, },
"CORS_origins": { "CORS_origins": {
"description": "List of allowed CORS origins.", "description": "List of allowed CORS origins.",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11.15-slim-bookworm AS base FROM python:3.11.14-slim-bookworm AS base
# Setup env # Setup env
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
+162 -203
View File
@@ -160,131 +160,117 @@ The most important in the backtesting is to understand the result.
A backtesting result will look like that: A backtesting result will look like that:
``` ```
BACKTESTING REPORT BACKTESTING REPORT
┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ ┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
│ LTC/USDT:USDT │ 16 │ 1.01 │ 56.882 │ 5.69 │ 16:16:00 │ 16 0 0 100 │ │ LTC/USDT:USDT │ 16 │ 1.0 │ 56.176 │ 5.62 │ 16:16:00 │ 16 0 0 100 │
│ ETC/USDT:USDT │ 12 │ 0.7331.513 │ 3.15 │ 9:55:00 │ 11 0 1 91.7 │ │ ETC/USDT:USDT │ 12 │ 0.72 30.936 │ 3.09 │ 9:55:00 │ 11 0 1 91.7 │
│ ETH/USDT:USDT │ 8 │ 0.6918.659 │ 1.87 │ 1 day, 13:55:00 │ 7 0 1 87.5 │ │ ETH/USDT:USDT │ 8 │ 0.66 17.864 │ 1.79 │ 1 day, 13:55:00 │ 7 0 1 87.5 │
│ XLM/USDT:USDT │ 10 │ 0.3 │ 10.694 │ 1.07 │ 12:08:00 │ 9 0 1 90.0 │ │ XLM/USDT:USDT │ 10 │ 0.31 11.054 │ 1.11 │ 12:08:00 │ 9 0 1 90.0 │
│ BTC/USDT:USDT │ 8 │ 0.22 │ 7.502 │ 0.75 │ 3 days, 1:24:00 │ 6 0 2 75.0 │ │ BTC/USDT:USDT │ 8 │ 0.21 7.289 │ 0.73 │ 3 days, 1:24:00 │ 6 0 2 75.0 │
│ XRP/USDT:USDT │ 9 │ -0.13-6.837 │ -0.68 │ 21:18:00 │ 8 0 1 88.9 │ │ XRP/USDT:USDT │ 9 │ -0.14 -7.261 │ -0.73 │ 21:18:00 │ 8 0 1 88.9 │
│ DOT/USDT:USDT │ 6 │ -0.39 │ -9.169 │ -0.92 │ 5:35:00 │ 4 0 2 66.7 │ │ DOT/USDT:USDT │ 6 │ -0.4 │ -9.187 │ -0.92 │ 5:35:00 │ 4 0 2 66.7 │
│ ADA/USDT:USDT │ 8 │ -1.75 │ -52.089 │ -5.21 │ 11:38:00 │ 6 0 2 75.0 │ │ ADA/USDT:USDT │ 8 │ -1.76 -52.098 │ -5.21 │ 11:38:00 │ 6 0 2 75.0 │
│ TOTAL │ 77 │ 0.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ │ TOTAL │ 77 │ 0.22 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
└───────────────┴────────┴──────────────┴─────────────┴──────────────┴─────────────────┴────────────────────────┘ └───────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
LEFT OPEN TRADES REPORT LEFT OPEN TRADES REPORT
┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ ┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
│ BTC/USDT:USDT │ 1 │ -4.14 │ -9.930 │ -0.99 │ 17 days, 8:00:00 │ 0 0 1 0 │ │ BTC/USDT:USDT │ 1 │ -4.14 │ -9.930 │ -0.99 │ 17 days, 8:00:00 │ 0 0 1 0 │
│ ETC/USDT:USDT │ 1 │ -4.24 │ -15.365 │ -1.54 │ 10:40:00 │ 0 0 1 0 │ │ ETC/USDT:USDT │ 1 │ -4.24 │ -15.365 │ -1.54 │ 10:40:00 │ 0 0 1 0 │
│ DOT/USDT:USDT │ 1 │ -5.29 │ -19.166 │ -1.92 │ 11:30:00 │ 0 0 1 0 │ │ DOT/USDT:USDT │ 1 │ -5.29 │ -19.125 │ -1.91 │ 11:30:00 │ 0 0 1 0 │
│ TOTAL │ 3 │ -4.56 │ -44.461 │ -4.45 │ 6 days, 2:03:00 │ 0 0 3 0 │ │ TOTAL │ 3 │ -4.56 │ -44.420 │ -4.44 │ 6 days, 2:03:00 │ 0 0 3 0 │
└───────────────┴────────┴──────────────┴─────────────┴──────────────┴──────────────────┴────────────────────────┘ └───────────────┴────────┴──────────────┴─────────────────┴──────────────┴──────────────────┴────────────────────────┘
ENTER TAG STATS ENTER TAG STATS
┏━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Enter Tag ┃ Entries ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ ┃ Enter Tag ┃ Entries ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
┡━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
│ OTHER │ 77 │ 0.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ │ OTHER │ 77 │ 0.22 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
│ TOTAL │ 77 │ 0.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ │ TOTAL │ 77 │ 0.22 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
└───────────┴─────────┴──────────────┴─────────────┴──────────────┴──────────────┴────────────────────────┘ └───────────┴─────────┴──────────────┴─────────────────┴──────────────┴──────────────┴────────────────────────┘
EXIT REASON STATS EXIT REASON STATS
┏━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Exit Reason ┃ Exits ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ ┃ Exit Reason ┃ Exits ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
┡━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
│ roi │ 67 │ 1.06 │ 245.117 │ 24.51 │ 15:49:00 │ 67 0 0 100 │ │ roi │ 67 │ 1.05 242.179 │ 24.22 │ 15:49:00 │ 67 0 0 100 │
│ exit_signal │ 4 │ -2.23 │ -31.226 │ -3.12 │ 1 day, 8:38:00 │ 0 0 4 0 │ │ exit_signal │ 4 │ -2.23 │ -31.217 │ -3.12 │ 1 day, 8:38:00 │ 0 0 4 0 │
│ force_exit │ 3 │ -4.56 │ -44.461 │ -4.45 │ 6 days, 2:03:00 │ 0 0 3 0 │ │ force_exit │ 3 │ -4.56 │ -44.420 │ -4.44 │ 6 days, 2:03:00 │ 0 0 3 0 │
│ stop_loss │ 3 │ -10.14 │ -112.273 │ -11.23 │ 1 day, 3:05:00 │ 0 0 3 0 │ │ stop_loss │ 3 │ -10.14 │ -111.768 │ -11.18 │ 1 day, 3:05:00 │ 0 0 3 0 │
│ TOTAL │ 77 │ 0.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ │ TOTAL │ 77 │ 0.22 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
└─────────────┴───────┴──────────────┴─────────────┴──────────────┴─────────────────┴────────────────────────┘ └─────────────┴───────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
MIXED TAG STATS MIXED TAG STATS
┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Enter Tag ┃ Exit Reason ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ ┃ Enter Tag ┃ Exit Reason ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
│ │ roi │ 67 │ 1.06 │ 245.117 │ 24.51 │ 15:49:00 │ 67 0 0 100 │ │ │ roi │ 67 │ 1.05 242.179 │ 24.22 │ 15:49:00 │ 67 0 0 100 │
│ │ exit_signal │ 4 │ -2.23 │ -31.226 │ -3.12 │ 1 day, 8:38:00 │ 0 0 4 0 │ │ │ exit_signal │ 4 │ -2.23 │ -31.217 │ -3.12 │ 1 day, 8:38:00 │ 0 0 4 0 │
│ │ force_exit │ 3 │ -4.56 │ -44.461 │ -4.45 │ 6 days, 2:03:00 │ 0 0 3 0 │ │ │ force_exit │ 3 │ -4.56 │ -44.420 │ -4.44 │ 6 days, 2:03:00 │ 0 0 3 0 │
│ │ stop_loss │ 3 │ -10.14 │ -112.273 │ -11.23 │ 1 day, 3:05:00 │ 0 0 3 0 │ │ │ stop_loss │ 3 │ -10.14 │ -111.768 │ -11.18 │ 1 day, 3:05:00 │ 0 0 3 0 │
│ TOTAL │ │ 77 │ 0.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ │ TOTAL │ │ 77 │ 0.22 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
└───────────┴─────────────┴────────┴──────────────┴─────────────┴──────────────┴─────────────────┴────────────────────────┘ └───────────┴─────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
SUMMARY METRICS SUMMARY METRICS
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Metric ┃ Value ┃ Metric ┃ Value
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Backtesting from │ 2025-07-01 00:00:00 │ Backtesting from │ 2025-07-01 00:00:00 │
│ Backtesting to │ 2025-08-01 00:00:00 │ Backtesting to │ 2025-08-01 00:00:00 │
│ Trading Mode │ Isolated Futures │ Trading Mode │ Isolated Futures │
│ Max open trades │ 3 │ Max open trades │ 3
│ Total/Daily Avg Trades │ 77 / 2.48 │ Total/Daily Avg Trades │ 77 / 2.48 │
│ Starting balance │ 1000 USDT │ Starting balance │ 1000 USDT │
│ Final balance │ 1057.157 USDT │ Final balance │ 1054.774 USDT
│ Absolute profit │ 57.157 USDT │ Absolute profit │ 54.774 USDT
│ Total profit % │ 5.72% │ Total profit % │ 5.48%
│ CAGR % │ 92.41% │ CAGR % │ 87.36%
│ Sharpe (closed trades)3.89 │ Sortino 2.48
│ Sortino (closed trades)2.57 │ Sharpe 3.75
│ Calmar (closed trades) │ 43.03 │ Calmar │ 40.99
│ SQN │ 0.71 │ SQN │ 0.69
│ Profit factor │ 1.30 │ Profit factor │ 1.29
│ Expectancy (Ratio) │ 0.74 (0.04) │ Expectancy (Ratio) │ 0.71 (0.04) │
│ Avg. daily profit │ 1.844 USDT │ Avg. daily profit │ 1.767 USDT │
│ Avg. stake amount │ 345.478 USDT │ Avg. stake amount │ 345.016 USDT │
Market change │ 30.51% Total trade volume │ 53316.954 USDT
Total trade volume53390.788 USDT
Long / Short trades │ 67 / 10
│ Long / Short trades │ 67 / 10 │ Long / Short profit % │ 8.94% / -3.47%
│ Long / Short profit % │ 9.19% / -3.48% │ Long / Short profit USDT89.425 / -34.651
Long / Short profit USDT91.940 / -34.783
Best PairLTC/USDT:USDT 5.62%
Best Pair │ LTC/USDT:USDT 5.69% Worst Pair │ ADA/USDT:USDT -5.21%
Worst Pair ADA/USDT:USDT -5.21% Best tradeETC/USDT:USDT 2.00%
Best trade │ XRP/USDT:USDT 2.00% Worst trade │ ADA/USDT:USDT -10.17%
Worst trade │ ADA/USDT:USDT -10.17% Best day │ 26.91 USDT
Best day │ 27.031 USDT Worst day │ -47.741 USDT
Worst day │ -47.826 USDT Days win/draw/lose │ 20 / 6 / 5
Days win/draw/lose │ 20 / 6 / 5 Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49
│ Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49 │ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00
│ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │ Max Consecutive Wins / Loss │ 36 / 3
Max Consecutive Wins / Loss │ 36 / 3 Rejected Entry signals │ 258
Rejected Entry signals │ 258 Entry/Exit Timeouts │ 0 / 0
Entry/Exit Timeouts 0 / 0 │ │
Min balance1003.168 USDT
Min/Max balance (closed trades) │ 1003.205 USDT / 1151.425 USDT │ │ Max balance 1149.421 USDT
│ Max % of account underwater │ 8.19% │ Max % of account underwater │ 8.23%
│ Absolute drawdown │ 94.268 USDT (8.19%) │ Absolute drawdown │ 94.647 USDT (8.23%)
│ Drawdown duration │ 9 days 08:50:00 │ Drawdown duration │ 9 days 08:50:00 │
│ Profit at drawdown start │ 151.425 USDT │ Profit at drawdown start │ 149.421 USDT │
│ Profit at drawdown end │ 57.157 USDT │ Profit at drawdown end │ 54.774 USDT
│ Drawdown start │ 2025-07-22 15:10:00 │ Drawdown start │ 2025-07-22 15:10:00 │
│ Drawdown end │ 2025-08-01 00:00:00 │ Drawdown end │ 2025-08-01 00:00:00 │
Market change │ 30.51%
│ Wallet based Metrics │ │ └───────────────────────────────┴─────────────────────────────────┘
│ Min/Max balance (wallet balance) │ 1000 USDT / 1151.425 USDT │
│ Min/Max balance dates (wallet balance) │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00 │
│ Max % of account underwater (balance) │ 5.01% │
│ Absolute drawdown (wallet balance) │ 54.76 USDT (4.76%) │
│ Drawdown duration │ 7 days 20:35:00 │
│ Profit at drawdown start │ 151.425 USDT │
│ Profit at drawdown end │ 96.664 USDT │
│ Drawdown start │ 2025-07-22 15:15:00 │
│ Drawdown end │ 2025-07-30 11:50:00 │
│ Sharpe (daily wallet balance) │ 4.42 │
│ Sortino (daily wallet balance) │ 4.35 │
│ Calmar (daily wallet balance) │ 136.07 │
└────────────────────────────────────────┴───────────────────────────────────────────┘
Backtested 2025-07-01 00:00:00 -> 2025-08-01 00:00:00 | Max open trades : 3 Backtested 2025-07-01 00:00:00 -> 2025-08-01 00:00:00 | Max open trades : 3
STRATEGY SUMMARY STRATEGY SUMMARY
┏━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━
┃ Strategy ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ Drawdown ┃ ┃ Strategy ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ Drawdown ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ ┡━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━
│ SampleStrategy │ 77 │ 0.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ 94.268 8.19% │ │ SampleStrategy │ 77 │ 0.22 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │ 94.647 USDT 8.23% │
└────────────────┴────────┴──────────────┴─────────────┴──────────────┴──────────────┴────────────────────────┴────────────────┘ └────────────────┴────────┴──────────────┴─────────────────┴──────────────┴──────────────┴────────────────────────┴────────────────────
``` ```
### Backtesting report table ### Backtesting report table
@@ -343,72 +329,54 @@ The last element of the backtest report is the summary metrics table.
It contains key metrics about the performance of your strategy on backtesting data. It contains key metrics about the performance of your strategy on backtesting data.
``` ```
SUMMARY METRICS ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Metric ┃ Value ┃
┃ Metric ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Backtesting from │ 2025-07-01 00:00:00 │
│ Backtesting from │ 2025-07-01 00:00:00 │ Backtesting to │ 2025-08-01 00:00:00 │
Backtesting to │ 2025-08-01 00:00:00 Trading Mode │ Isolated Futures
Trading Mode │ Isolated Futures Max open trades │ 3
Max open trades 3 │ │
Total/Daily Avg Trades │ 72 / 2.32
Total/Daily Avg Trades │ 77 / 2.48 Starting balance │ 1000 USDT
Starting balance │ 1000 USDT Final balance │ 1106.734 USDT
Final balance │ 1057.157 USDT Absolute profit │ 106.734 USDT
Absolute profit 57.157 USDT Total profit %10.67%
Total profit % 5.72% CAGR % │ 230.04%
CAGR % │ 92.41% Sortino │ 4.99
│ Sharpe (closed trades) │ 3.89 │ Sharpe │ 8.00
Sortino (closed trades) │ 2.57 Calmar │ 77.76
Calmar (closed trades)43.03 SQN 1.52
SQN 0.71 Profit factor1.79
Profit factor │ 1.30 Expectancy (Ratio) │ 1.48 (0.07)
Expectancy (Ratio) │ 0.74 (0.04) Avg. daily profit │ 3.443 USDT
│ Avg. daily profit 1.844 USDT │ Avg. stake amount363.133 USDT
Avg. stake amount │ 345.478 USDT Total trade volume │ 52466.174 USDT
Market change 30.51% │ │
Total trade volume53390.788 USDT Best PairLTC/USDT:USDT 4.48%
Worst PairADA/USDT:USDT -1.78%
Long / Short trades67 / 10 Best trade │ ETC/USDT:USDT 2.00%
Long / Short profit %9.19% / -3.48% Worst trade ADA/USDT:USDT -10.17%
Long / Short profit USDT │ 91.940 / -34.783 Best day │ 23.535 USDT
Worst day-49.813 USDT
Best Pair │ LTC/USDT:USDT 5.69% Days win/draw/lose │ 21 / 6 / 4
Worst Pair │ ADA/USDT:USDT -5.21% Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:30
Best trade │ XRP/USDT:USDT 2.00% Min/Max/Avg. Duration Losers │ 0d 12:00 / 17d 08:00 / 3d 23:28
Worst trade │ ADA/USDT:USDT -10.17% Max Consecutive Wins / Loss │ 58 / 4
Best day │ 27.031 USDT Rejected Entry signals │ 254
Worst day │ -47.826 USDT Entry/Exit Timeouts │ 0 / 0
Days win/draw/lose20 / 6 / 5 │ │
│ Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49 │ Min balance │ 1003.168 USDT
│ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │ Max balance │ 1209 USDT
│ Max Consecutive Wins / Loss │ 36 / 3 │ Max % of account underwater │ 8.46%
Rejected Entry signals │ 258 Absolute drawdown │ 102.266 USDT (8.46%)
Entry/Exit Timeouts │ 0 / 0 Drawdown duration │ 9 days 08:50:00
Profit at drawdown start │ 209 USDT
Min/Max balance (closed trades) │ 1003.205 USDT / 1151.425 USDT Profit at drawdown end │ 106.734 USDT
Max % of account underwater │ 8.19% Drawdown start │ 2025-07-22 15:10:00
Absolute drawdown │ 94.268 USDT (8.19%) Drawdown end │ 2025-08-01 00:00:00
Drawdown duration │ 9 days 08:50:00 Market change │ 30.51%
│ Profit at drawdown start │ 151.425 USDT │ └───────────────────────────────┴─────────────────────────────────┘
│ Profit at drawdown end │ 57.157 USDT │
│ Drawdown start │ 2025-07-22 15:10:00 │
│ Drawdown end │ 2025-08-01 00:00:00 │
│ │ │
│ Wallet based Metrics │ │
│ Min/Max balance (wallet balance) │ 1000 USDT / 1151.425 USDT │
│ Min/Max balance dates (wallet balance) │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00 │
│ Max % of account underwater (balance) │ 5.01% │
│ Absolute drawdown (wallet balance) │ 54.76 USDT (4.76%) │
│ Drawdown duration │ 7 days 20:35:00 │
│ Profit at drawdown start │ 151.425 USDT │
│ Profit at drawdown end │ 96.664 USDT │
│ Drawdown start │ 2025-07-22 15:15:00 │
│ Drawdown end │ 2025-07-30 11:50:00 │
│ Sharpe (daily wallet balance) │ 4.42 │
│ Sortino (daily wallet balance) │ 4.35 │
│ Calmar (daily wallet balance) │ 136.07 │
└────────────────────────────────────────┴───────────────────────────────────────────┘
``` ```
- `Backtesting from` / `Backtesting to`: Backtesting range (usually defined with the `--timerange` option). - `Backtesting from` / `Backtesting to`: Backtesting range (usually defined with the `--timerange` option).
@@ -420,15 +388,14 @@ It contains key metrics about the performance of your strategy on backtesting da
- `Absolute profit`: Profit made in stake currency. - `Absolute profit`: Profit made in stake currency.
- `Total profit %`: Total profit. Aligned to the `TOTAL` row's `Tot Profit %` from the first table. Calculated as `(End capital Starting capital) / Starting capital`. - `Total profit %`: Total profit. Aligned to the `TOTAL` row's `Tot Profit %` from the first table. Calculated as `(End capital Starting capital) / Starting capital`.
- `CAGR %`: Compound annual growth rate. - `CAGR %`: Compound annual growth rate.
- `Sharpe (closed trades)`: Annualized Sharpe ratio including only closed trades (ignoring open trades with profits or losses). - `Sortino`: Annualized Sortino ratio.
- `Sortino (closed trades)`: Annualized Sortino ratio including only closed trades (ignoring open trades with profits or losses). - `Sharpe`: Annualized Sharpe ratio.
- `Calmar (closed trades)`: Annualized Calmar ratio including only closed trades (ignoring open trades with profits or losses). - `Calmar`: Annualized Calmar ratio.
- `SQN`: System Quality Number (SQN) - by Van Tharp. - `SQN`: System Quality Number (SQN) - by Van Tharp.
- `Profit factor`: Sum of the profits of all winning trades divided by the sum of the losses of all losing trades. - `Profit factor`: Sum of the profits of all winning trades divided by the sum of the losses of all losing trades.
- `Expectancy (Ratio)`: Expectancy ratio, which is the average profit or loss per trade. A negative expectancy ratio means that your strategy is not profitable. - `Expectancy (Ratio)`: Expectancy ratio, which is the average profit or loss per trade. A negative expectancy ratio means that your strategy is not profitable.
- `Avg. daily profit`: Average profit per day, calculated as `(Total Profit / Backtest Days)`. - `Avg. daily profit`: Average profit per day, calculated as `(Total Profit / Backtest Days)`.
- `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount. - `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount.
- `Market change`: Change of the market during the backtest period. Calculated as the average of all pairs' changes from the first to the last candle using the "close" column.
- `Total trade volume`: Volume generated on the exchange to reach the above profit. - `Total trade volume`: Volume generated on the exchange to reach the above profit.
- `Long / Short trades`: Split long/short trade counts (only shown when short trades were made). - `Long / Short trades`: Split long/short trade counts (only shown when short trades were made).
- `Long / Short profit %`: Profit percentage for long and short trades (only shown when short trades were made). - `Long / Short profit %`: Profit percentage for long and short trades (only shown when short trades were made).
@@ -442,21 +409,13 @@ It contains key metrics about the performance of your strategy on backtesting da
- `Max Consecutive Wins / Loss`: Maximum consecutive wins/losses in a row. - `Max Consecutive Wins / Loss`: Maximum consecutive wins/losses in a row.
- `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached. - `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached.
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
- `Min/Max balance (closed trades)`: Lowest and Highest Wallet balance during the backtest period based on closed trades trades. - `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
- `Max % of account underwater`: Maximum percentage your account has decreased from the top since the simulation started. Calculated as the maximum of `(Max Balance - Current Balance) / (Max Balance)`. - `Max % of account underwater`: Maximum percentage your account has decreased from the top since the simulation started. Calculated as the maximum of `(Max Balance - Current Balance) / (Max Balance)`.
- `Absolute drawdown`: Maximum absolute drawdown experienced, including percentage relative to the account calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`.. - `Absolute drawdown`: Maximum absolute drawdown experienced, including percentage relative to the account calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`..
- `Absolute drawdown (wallet balance)`: Maximum absolute drawdown experienced based on the unrealized balance, including percentage relative to the account calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`.
- `Drawdown duration`: Duration of the largest drawdown period. - `Drawdown duration`: Duration of the largest drawdown period.
- `Profit at drawdown start` / `Profit at drawdown end`: Profit at the beginning and end of the largest drawdown period. - `Profit at drawdown start` / `Profit at drawdown end`: Profit at the beginning and end of the largest drawdown period.
- `Drawdown start` / `Drawdown end`: Start and end datetime for the largest drawdown (can also be visualized via the `plot-dataframe` sub-command). - `Drawdown start` / `Drawdown end`: Start and end datetime for the largest drawdown (can also be visualized via the `plot-dataframe` sub-command).
- `Min/Max balance (wallet balance)`: Lowest and Highest Wallet balance during the backtest period - including capital tied in open trades. - `Market change`: Change of the market during the backtest period. Calculated as the average of all pairs' changes from the first to the last candle using the "close" column.
- `Min/Max balance dates (wallet balance)`: Dates when the minimum and maximum unrealized balance occurred.
- `Sharpe (wallet balance)` Annualized Sharpe ratio calculation including unrealized profits.
- `Sortino (wallet balance)` Annualized Sortino ratio calculation including unrealized profits.
- `Calmar (wallet balance)` Annualized Calmar ratio calculation including unrealized profits.
!!! Tip "Wallet based Metrics"
The metrics under the "Wallet based Metrics" section are calculated based on the unrealized balance, which includes the capital tied in open trades. This provides a more comprehensive view of the strategy's performance, as it accounts for both realized and unrealized profits and losses.
### Daily / Weekly / Monthly / Yearly breakdown ### Daily / Weekly / Monthly / Yearly breakdown
+22 -50
View File
@@ -345,15 +345,6 @@ API Keys for live futures trading must have the following permissions:
We do strongly recommend to limit all API keys to the IP you're going to use it from. We do strongly recommend to limit all API keys to the IP you're going to use it from.
### Bybit Demo Mode
Bybit has a [demo mode](https://learn.bybit.com/en/bybit-guide/how-to-use-bybit-demo-trading) - which can be activated by setting `exchange.demo_trading` to `true` in the configuration.
Bybit uses live markets to simulate your trades (without market impact) - making it work very similar to freqtrade's dry-run mode.
You'll need to use separate API keys for demo trading, which you can create on bybit's demo page.
Demo mode is incompatible with dry-run.
## Bitmart ## Bitmart
Bitmart requires the API key Memo (the name you give the API key) to go along with the exchange key and secret. Bitmart requires the API key Memo (the name you give the API key) to go along with the exchange key and secret.
@@ -438,50 +429,31 @@ Hyperliquid handles deposits and withdrawals on the Arbitrum One chain, a Layer
* Create a different software wallet, only transfer the funds you want to trade with to that wallet, and use that wallet to trade on Hyperliquid. * Create a different software wallet, only transfer the funds you want to trade with to that wallet, and use that wallet to trade on Hyperliquid.
* If you have funds you don't want to use for trading (after making a profit for example), transfer them back to your hardware wallet. * If you have funds you don't want to use for trading (after making a profit for example), transfer them back to your hardware wallet.
### Hyperliquid Vault / Subaccount
!!! Warning "Vaults and Subaccounts" Hyperliquid allows you to create either a vault or a subaccount.
To use these with Freqtrade, you will need to use the following configuration pattern:
``` json
"exchange": {
"name": "hyperliquid",
"walletAddress": "your_master_wallet_address", // Your master wallet address (not the API wallet address and not the vault/subaccount address).
"privateKey": "your_api_private_key", // API wallet private key (see https://app.hyperliquid.xyz/API). You'll only need the private key.
"ccxt_config": {
"options": {
"vaultAddress": "your_vault_address", // Optional, only if you want to use a vault ...
"subAccountAddress": "your_subaccount_address" // OR optional, only if you want to use a subaccount
}
},
// ...
}
```
Your balance and trades will now be used from your vault / subaccount - and no longer from your main account.
!!! Note
You can only use either a vault or a subaccount - not both at the same time. You can only use either a vault or a subaccount - not both at the same time.
### Hyperliquid Subaccount
Hyperliquid allows you to create subaccounts with sufficient previous trading volume.
To use subaccounts with Freqtrade, you will need to use the following configuration pattern:
``` json
"exchange": {
"name": "hyperliquid",
"walletAddress": "your_master_wallet_address", // Your master wallet address (not the API wallet or vault address - but not subaccount address).
"privateKey": "your_api_private_key", // API wallet private key (see https://app.hyperliquid.xyz/API). You'll only need the private key.
"ccxt_config": {
"options": {
"subAccountAddress": "your_subaccount_address" // Required if you want to use a subaccount.
}
},
// ...
}
```
Your balance and trades will now be used from your subaccount - and no longer from your main account.
### Hyperliquid Vault
Hyperliquid allows you to create vaults. To use vaults with Freqtrade, you will need to use the following configuration pattern:
``` json
"exchange": {
"name": "hyperliquid",
"walletAddress": "your_vault_address", // Your vault wallet address (Must also be added below in the ccxt_config.options.vaultAddress field)
"privateKey": "your_api_private_key", // API wallet private key (see https://app.hyperliquid.xyz/API). You'll only need the private key.
"ccxt_config": {
"options": {
"vaultAddress": "your_vault_address", // Optional, only if you want to use a vault ... (vault address must also be added to walletAdress)
}
},
// ...
}
```
Your balance and trades will now be used from your vault - and no longer from your main account.
### Historic Hyperliquid data ### Historic Hyperliquid data
+2 -2
View File
@@ -2,7 +2,7 @@
## Supported Markets ## Supported Markets
Freqtrade supports spot trading, as well as futures trading for some selected exchanges. Please refer to the [documentation start page](index.md#supported-futures-exchanges) for an up-to-date list of supported exchanges. Freqtrade supports spot trading, as well as futures trading for some selected exchanges. Please refer to the [documentation start page](index.md#supported-futures-exchanges-experimental) for an up-to-date list of supported exchanges.
### Can my bot open short positions? ### Can my bot open short positions?
@@ -14,7 +14,7 @@ In spot markets, you can in some cases use leveraged spot tokens, which reflect
### Can my bot trade options or futures? ### Can my bot trade options or futures?
Futures trading is supported for selected exchanges. Please refer to the [documentation start page](index.md#supported-futures-exchanges) for an up-to-date list of supported exchanges. Futures trading is supported for selected exchanges. Please refer to the [documentation start page](index.md#supported-futures-exchanges-experimental) for an up-to-date list of supported exchanges.
## Beginner Tips & Tricks ## Beginner Tips & Tricks
-17
View File
@@ -46,23 +46,6 @@ On this page, you can also interact with the bot by starting and stopping it and
![FreqUI - trade view](assets/freqUI-trade-pane-dark.png#only-dark) ![FreqUI - trade view](assets/freqUI-trade-pane-dark.png#only-dark)
![FreqUI - trade view](assets/freqUI-trade-pane-light.png#only-light) ![FreqUI - trade view](assets/freqUI-trade-pane-light.png#only-light)
### Dashboard
The dashboard view provides an overview of the bot's performance and status.
If multiple bots are connected, the dashboard will show an overview of all connected bots, allowing you to easily switch between them or show just a subset of available bots.
#### Wallet Balance
New in freqtrade 2026.4: This shows the balance of the bot over time.
Compared to the "cumulative Profit" chart, this chart will show the actual balance of the bot over time, including unrealized profit and losses, as well as deposits and withdrawals.
Historic data has re-populated based on available exchange data - however is assumed to be best-effort and may not be 100% accurate.
More specifically, it won't cover deposits and withdrawals, and will assume a starting balance of current balance - profit/losses.
For clarity - a "Capture start" marker line is shown on the chart, which indicates the point at which the migration to the new wallet balance tracking system happened.
Only beyond this point, the wallet balance is expected to be accurate.
### Plot Configurator ### Plot Configurator
FreqUI Plots can be configured either via a `plot_config` configuration object in the strategy (which can be loaded via "from strategy" button) or via the UI. FreqUI Plots can be configured either via a `plot_config` configuration object in the strategy (which can be loaded via "from strategy" button) or via the UI.
+1 -1
View File
@@ -166,7 +166,7 @@ Below are the values you can expect to include/use inside a typical strategy dat
| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -2 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, FreqAI will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. As with the SVM, if `use_DBSCAN_to_remove_outliers` is active, DBSCAN (see details [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan)) may also detect outliers and subtract 1 from `do_predict`. Hence, if both the SVM and DBSCAN are active and identify a datapoint that was above the DI threshold as an outlier, the result will be `do_predict==-2`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`. <br> **Datatype:** Integer between -2 and 2. | `df['do_predict']` | Indication of an outlier data point. The return value is integer between -2 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, FreqAI will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. As with the SVM, if `use_DBSCAN_to_remove_outliers` is active, DBSCAN (see details [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan)) may also detect outliers and subtract 1 from `do_predict`. Hence, if both the SVM and DBSCAN are active and identify a datapoint that was above the DI threshold as an outlier, the result will be `do_predict==-2`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`. <br> **Datatype:** Integer between -2 and 2.
| `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence FreqAI has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di). <br> **Datatype:** Float. | `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence FreqAI has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di). <br> **Datatype:** Float.
| `df['%*']` | Any dataframe column prepended with `%` in `feature_engineering_*()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md). <br> **Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features are easily engineered using the multiplictative functionality of, e.g., `include_shifted_candles` and `include_timeframes` as described in the [parameter table](freqai-parameter-table.md)), these features are removed from the dataframe that is returned from FreqAI to the strategy. To keep a particular type of feature for plotting purposes, you would prepend it with `%%` (see details below). <br> **Datatype:** Depends on the feature created by the user. | `df['%*']` | Any dataframe column prepended with `%` in `feature_engineering_*()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md). <br> **Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features are easily engineered using the multiplictative functionality of, e.g., `include_shifted_candles` and `include_timeframes` as described in the [parameter table](freqai-parameter-table.md)), these features are removed from the dataframe that is returned from FreqAI to the strategy. To keep a particular type of feature for plotting purposes, you would prepend it with `%%` (see details below). <br> **Datatype:** Depends on the feature created by the user.
| `df['%%*']` | Any dataframe column prepended with `%%` in `feature_engineering_*()` is treated as a training feature, just the same as the above `%` prepend. However, in this case, the features are returned back to the strategy for FreqUI/plot-dataframe plotting and monitoring in Dry/Live/Backtesting <br> **Datatype:** Depends on the feature created by the user. <br>*Please note* that features created in `feature_engineering_expand()` will have automatic FreqAI naming schemas depending on the expansions that you configured (i.e. `include_timeframes`, `include_corr_pairlist`, `indicators_periods_candles`, `include_shifted_candles`). So if you want to plot `%%-rsi` from `feature_engineering_expand_all()`, the final naming scheme for your plotting config would be: `%%-rsi-period_10_ETH/USDT:USDT_1h` for the `rsi` feature with `period=10`, `timeframe=1h`, and `pair=ETH/USDT:USDT` (the `:USDT` is added if you are using futures pairs). It is useful to simply add `print(dataframe.columns)` in your `populate_indicators()` after `self.freqai.start()` to see the full list of available features that are returned to the strategy for plotting purposes. | `df['%%*']` | Any dataframe column prepended with `%%` in `feature_engineering_*()` is treated as a training feature, just the same as the above `%` prepend. However, in this case, the features are returned back to the strategy for FreqUI/plot-dataframe plotting and monitoring in Dry/Live/Backtesting <br> **Datatype:** Depends on the feature created by the user. Please note that features created in `feature_engineering_expand()` will have automatic FreqAI naming schemas depending on the expansions that you configured (i.e. `include_timeframes`, `include_corr_pairlist`, `indicators_periods_candles`, `include_shifted_candles`). So if you want to plot `%%-rsi` from `feature_engineering_expand_all()`, the final naming scheme for your plotting config would be: `%%-rsi-period_10_ETH/USDT:USDT_1h` for the `rsi` feature with `period=10`, `timeframe=1h`, and `pair=ETH/USDT:USDT` (the `:USDT` is added if you are using futures pairs). It is useful to simply add `print(dataframe.columns)` in your `populate_indicators()` after `self.freqai.start()` to see the full list of available features that are returned to the strategy for plotting purposes.
## Setting the `startup_candle_count` ## Setting the `startup_candle_count`
+4 -4
View File
@@ -111,10 +111,10 @@ It also allows multiple subplots to display both MACD and RSI at the same time.
Plot type can be configured using `type` key. Possible types are: Plot type can be configured using `type` key. Possible types are:
* `scatter` corresponding a scatter plot. * `scatter` corresponding to `plotly.graph_objects.Scatter` class (default).
* `bar` corresponding to a bar plot. * `bar` corresponding to `plotly.graph_objects.Bar` class.
Extra parameters to `plotly.graph_objects.*` constructor can be specified in `plotly` dict - these are only supported when using plotly as plotting library and will be ignored when using freq-ui. Extra parameters to `plotly.graph_objects.*` constructor can be specified in `plotly` dict.
Sample configuration with inline comments explaining the process: Sample configuration with inline comments explaining the process:
@@ -163,7 +163,7 @@ def plot_config(self):
``` ```
??? Note "As attribute (former method)" ??? Note "As attribute (former method)"
Assigning `plot_config` is also possible as Attribute (this used to be the default way). Assigning plot_config is also possible as Attribute (this used to be the default way).
This has the disadvantage that strategy parameters are not available, preventing certain configurations from working. This has the disadvantage that strategy parameters are not available, preventing certain configurations from working.
``` python ``` python
+3 -3
View File
@@ -1,7 +1,7 @@
markdown==3.10.2 markdown==3.10.2
mkdocs==1.6.1 mkdocs==1.6.1
mkdocs-material==9.7.6 mkdocs-material==9.7.5
mdx_truly_sane_lists==1.3 mdx_truly_sane_lists==1.3
pymdown-extensions==10.21.3 pymdown-extensions==10.21
jinja2==3.1.6 jinja2==3.1.6
mike==2.2.0 mike==2.1.4
+9 -9
View File
@@ -202,20 +202,20 @@ All endpoints in the below table need to be prefixed with the base URL of the AP
| `/blacklist` | GET | Show the current blacklist. | `/blacklist` | GET | Show the current blacklist.
| `/blacklist` | POST | Adds the specified pair to the blacklist.<br/>*Params:*<br/>- `blacklist` (`str`) | `/blacklist` | POST | Adds the specified pair to the blacklist.<br/>*Params:*<br/>- `blacklist` (`str`)
| `/blacklist` | DELETE | Deletes the specified list of pairs from the blacklist.<br/>*Params:*<br/>- `[pair,pair]` (`list[str]`) | `/blacklist` | DELETE | Deletes the specified list of pairs from the blacklist.<br/>*Params:*<br/>- `[pair,pair]` (`list[str]`)
| `/pair_candles` | GET | Returns dataframe for a pair / timeframe combination while the bot is running. | `/pair_candles` | GET | Returns dataframe for a pair / timeframe combination while the bot is running. **Alpha**
| `/pair_candles` | POST | Returns dataframe for a pair / timeframe combination while the bot is running, filtered by a provided list of columns to return.<br/>*Params:*<br/>- `<column_list>` (`list[str]`) | `/pair_candles` | POST | Returns dataframe for a pair / timeframe combination while the bot is running, filtered by a provided list of columns to return. **Alpha**<br/>*Params:*<br/>- `<column_list>` (`list[str]`)
| `/pair_history` | GET | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy. | `/pair_history` | GET | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy. **Alpha**
| `/pair_history` | POST | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy, filtered by a provided list of columns to return.<br/>*Params:*<br/>- `<column_list>` (`list[str]`) | `/pair_history` | POST | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy, filtered by a provided list of columns to return. **Alpha**<br/>*Params:*<br/>- `<column_list>` (`list[str]`)
| `/plot_config` | GET | Get plot config from the strategy (or nothing if not configured). | `/plot_config` | GET | Get plot config from the strategy (or nothing if not configured). **Alpha**
| `/strategies` | GET | List strategies in strategy directory. | `/strategies` | GET | List strategies in strategy directory. **Alpha**
| `/strategy/<strategy>` | GET | Get specific Strategy content by strategy class name.<br/>*Params:*<br/>- `<strategy>` (`str`) | `/strategy/<strategy>` | GET | Get specific Strategy content by strategy class name. **Alpha**<br/>*Params:*<br/>- `<strategy>` (`str`)
| `/available_pairs` | GET | List available backtest data. | `/available_pairs` | GET | List available backtest data. **Alpha**
| `/version` | GET | Show version. | `/version` | GET | Show version.
| `/sysinfo` | GET | Show information about the system load. | `/sysinfo` | GET | Show information about the system load.
| `/health` | GET | Show bot health (last bot loop). | `/health` | GET | Show bot health (last bot loop).
!!! Warning "Alpha status" !!! Warning "Alpha status"
Endpoints labeled with *Alpha status* or *Beta status* above may change at any time without notice. Endpoints labeled with *Alpha status* above may change at any time without notice.
### Message WebSocket ### Message WebSocket
-16
View File
@@ -39,22 +39,6 @@ The Order-type will be ignored if only one mode is available.
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. 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. 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.
### Which order type is used for stoploss on exchange?
The order type used for stoploss on exchange is determined by the `stoploss` value and the exchange capabilities.
If your selected exchange supports both stop-limit and stop-market orders, then the `stoploss` value will determine which order type is used for stoploss on exchange.
If your exchange only supports one of the two order types, you must configure your `stoploss` value accordingly, otherwise the bot will fail to start.
### Which order type should i use for stoploss on exchange?
If we translate the two stoploss order types into human words - they would be something like this:
* **stoploss-market** -> "when stop triggers, get me the hell out of here at whatever price".
* **stoploss-limit** -> "when stop triggers, place a limit order x% below the stoploss price. I accept a loss of "stoploss + 1%" at worst - but if price jumps further - i accept to wait for price to get back down to me, potentially resulting in a much bigger loss than "stoploss + 1%".
As a consequence, we recommend using stoploss-market orders whenever possible, as the main point of a stoploss is to get you out of a position when the market is crashing, and in such situations, you'll want to exit the position immediately at the best available price, rather than risking a limit order not getting filled and potentially incurring even greater losses.
The choice is ultimately up to you, but please be aware of the risk of using stoploss-limit orders, especially in volatile markets.
### stoploss_on_exchange and stoploss_on_exchange_limit_ratio ### stoploss_on_exchange and stoploss_on_exchange_limit_ratio
Enable or Disable stop loss on exchange. Enable or Disable stop loss on exchange.
-2
View File
@@ -910,8 +910,6 @@ if self.dp.runmode.value in ('live', 'dry_run'):
### *check_delisting(pair)* ### *check_delisting(pair)*
Return Datetime of the pair delisting schedule if any, otherwise return None
```python ```python
def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs): def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs):
if self.dp.runmode.value in ('live', 'dry_run'): if self.dp.runmode.value in ('live', 'dry_run'):
+1 -1
View File
@@ -1,6 +1,6 @@
"""Freqtrade bot""" """Freqtrade bot"""
__version__ = "2026.5-dev" __version__ = "2026.3"
if "dev" in __version__: if "dev" in __version__:
from pathlib import Path from pathlib import Path
+60 -3
View File
@@ -8,10 +8,15 @@ logger = logging.getLogger(__name__)
def start_convert_db(args: dict[str, Any]) -> None: def start_convert_db(args: dict[str, Any]) -> None:
from sqlalchemy import func, select
from sqlalchemy.orm import make_transient
from freqtrade.configuration.config_setup import setup_utils_configuration from freqtrade.configuration.config_setup import setup_utils_configuration
from freqtrade.persistence import Trade, init_db from freqtrade.persistence import Order, Trade, init_db
from freqtrade.persistence.db_migration import migrate_db from freqtrade.persistence.custom_data import _CustomData
from freqtrade.persistence.key_value_store import _KeyValueStoreModel
from freqtrade.persistence.migrations import set_sequence_ids
from freqtrade.persistence.pairlock import PairLock
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
@@ -19,4 +24,56 @@ def start_convert_db(args: dict[str, Any]) -> None:
session_target = Trade.session session_target = Trade.session
init_db(config["db_url_from"]) init_db(config["db_url_from"])
logger.info("Starting db migration.") logger.info("Starting db migration.")
migrate_db(session_target)
trade_count = 0
pairlock_count = 0
kv_count = 0
custom_data_count = 0
for trade in Trade.get_trades():
trade_count += 1
make_transient(trade)
for o in trade.orders:
make_transient(o)
session_target.add(trade)
session_target.commit()
for pairlock in PairLock.get_all_locks():
pairlock_count += 1
make_transient(pairlock)
session_target.add(pairlock)
session_target.commit()
for kv in _KeyValueStoreModel.session.scalars(select(_KeyValueStoreModel)):
kv_count += 1
make_transient(kv)
session_target.add(kv)
session_target.commit()
for cd in _CustomData.session.scalars(select(_CustomData)):
custom_data_count += 1
make_transient(cd)
session_target.add(cd)
session_target.commit()
# Update sequences
max_trade_id = session_target.scalar(select(func.max(Trade.id)))
max_order_id = session_target.scalar(select(func.max(Order.id)))
max_pairlock_id = session_target.scalar(select(func.max(PairLock.id)))
max_kv_id = session_target.scalar(select(func.max(_KeyValueStoreModel.id)))
max_custom_data_id = session_target.scalar(select(func.max(_CustomData.id)))
set_sequence_ids(
session_target.get_bind(),
trade_id=(max_trade_id or 0) + 1,
order_id=(max_order_id or 0) + 1,
pairlock_id=(max_pairlock_id or 0) + 1,
kv_id=(max_kv_id or 0) + 1,
custom_data_id=(max_custom_data_id or 0) + 1,
)
logger.info(
f"Migrated {trade_count} Trades, {pairlock_count} Pairlocks, "
f"{kv_count} Key-Value pairs, and {custom_data_count} Custom Data entries."
)
+1 -1
View File
@@ -393,7 +393,7 @@ def start_show_trades(args: dict[str, Any]) -> None:
tfilter = [] tfilter = []
if config.get("trade_ids"): if config.get("trade_ids"):
tfilter.append(Trade.id.in_(int(tid) for tid in config["trade_ids"])) tfilter.append(Trade.id.in_(config["trade_ids"]))
trades = Trade.get_trades(tfilter).all() trades = Trade.get_trades(tfilter).all()
logger.info(f"Printing {len(trades)} Trades: ") logger.info(f"Printing {len(trades)} Trades: ")
-5
View File
@@ -236,10 +236,6 @@ CONF_SCHEMA = {
"type": "string", "type": "string",
"enum": BACKTEST_CACHE_AGE, "enum": BACKTEST_CACHE_AGE,
}, },
"skip_wallet_history_migration": {
"description": "Disable wallet history migration.",
"type": "boolean",
},
# Hyperopt # Hyperopt
"hyperopt_path": { "hyperopt_path": {
"description": "Specify additional lookup path for Hyperopt Loss functions.", "description": "Specify additional lookup path for Hyperopt Loss functions.",
@@ -757,7 +753,6 @@ CONF_SCHEMA = {
"description": "Secret key for JWT authentication.", "description": "Secret key for JWT authentication.",
"type": "string", "type": "string",
"default": "somethingRandomSomethingRandom123", "default": "somethingRandomSomethingRandom123",
"minLength": 32,
}, },
"CORS_origins": { "CORS_origins": {
"description": "List of allowed CORS origins.", "description": "List of allowed CORS origins.",
@@ -92,7 +92,6 @@ def validate_config_consistency(conf: dict[str, Any], *, preliminary: bool = Fal
_validate_consumers(conf) _validate_consumers(conf)
validate_migrated_strategy_settings(conf) validate_migrated_strategy_settings(conf)
_validate_orderflow(conf) _validate_orderflow(conf)
_validate_demo_trading(conf)
# validate configuration before returning # validate configuration before returning
logger.info("Validating configuration ...") logger.info("Validating configuration ...")
@@ -414,11 +413,6 @@ def _validate_orderflow(conf: dict[str, Any]) -> None:
) )
def _validate_demo_trading(conf: dict[str, Any]) -> None:
if conf.get("exchange", {}).get("demo_trading", False) and conf.get("dry_run", False):
raise ConfigurationError("Demo trading cannot be used together with dry_run.")
def _strategy_settings(conf: dict[str, Any]) -> None: def _strategy_settings(conf: dict[str, Any]) -> None:
process_deprecated_setting(conf, None, "use_sell_signal", None, "use_exit_signal") process_deprecated_setting(conf, None, "use_sell_signal", None, "use_exit_signal")
process_deprecated_setting(conf, None, "sell_profit_only", None, "exit_profit_only") process_deprecated_setting(conf, None, "sell_profit_only", None, "exit_profit_only")
-1
View File
@@ -7,7 +7,6 @@ from .bt_fileutils import (
get_backtest_market_change, get_backtest_market_change,
get_backtest_result, get_backtest_result,
get_backtest_resultlist, get_backtest_resultlist,
get_backtest_wallet_change,
get_latest_backtest_filename, get_latest_backtest_filename,
get_latest_hyperopt_file, get_latest_hyperopt_file,
get_latest_hyperopt_filename, get_latest_hyperopt_filename,
+4 -27
View File
@@ -10,6 +10,7 @@ from io import BytesIO, StringIO
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
import numpy as np
import pandas as pd import pandas as pd
from freqtrade.constants import LAST_BT_RESULT_FN from freqtrade.constants import LAST_BT_RESULT_FN
@@ -307,31 +308,10 @@ def get_backtest_market_change(filename: Path, include_ts: bool = True) -> pd.Da
else: else:
df = pd.read_feather(filename) df = pd.read_feather(filename)
if include_ts: if include_ts:
df.loc[:, "__date_ts"] = df.loc[:, "date"].dt.as_unit("ms").astype("int64") df.loc[:, "__date_ts"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000
return df return df
def get_backtest_wallet_change(filename: Path, strategy_name: str) -> pd.DataFrame | None:
"""
Read backtest wallet change file.
:param filename: Path to the backtest result zip file
:param strategy_name: Name of the strategy to load
:return: DataFrame with wallet change data
"""
if filename.suffix != ".zip":
return None
try:
data = load_file_from_zip(filename, f"{filename.stem}_{strategy_name}_wallet.feather")
df = pd.read_feather(BytesIO(data))
df.loc[:, "__date_ts"] = df.loc[:, "date"].dt.as_unit("ms").astype("int64")
return df
except ValueError:
pass
return None
def find_existing_backtest_stats( def find_existing_backtest_stats(
dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -523,16 +503,13 @@ def load_backtest_analysis_data(
return None return None
def trade_list_to_dataframe( def trade_list_to_dataframe(trades: list[Trade] | list[LocalTrade]) -> pd.DataFrame:
trades: list[Trade] | list[LocalTrade], *, minified: bool = True
) -> pd.DataFrame:
""" """
Convert list of Trade objects to pandas Dataframe Convert list of Trade objects to pandas Dataframe
:param trades: List of trade objects :param trades: List of trade objects
:param minified: Whether to use minified version of trade JSON
:return: Dataframe with BT_DATA_COLUMNS :return: Dataframe with BT_DATA_COLUMNS
""" """
df = pd.DataFrame.from_records([t.to_json(minified) for t in trades], columns=BT_DATA_COLUMNS) df = pd.DataFrame.from_records([t.to_json(True) for t in trades], columns=BT_DATA_COLUMNS)
if len(df) > 0: if len(df) > 0:
df["close_date"] = pd.to_datetime(df["close_timestamp"], unit="ms", utc=True) df["close_date"] = pd.to_datetime(df["close_timestamp"], unit="ms", utc=True)
df["open_date"] = pd.to_datetime(df["open_timestamp"], unit="ms", utc=True) df["open_date"] = pd.to_datetime(df["open_timestamp"], unit="ms", utc=True)
@@ -1,15 +1,9 @@
import logging import logging
from datetime import datetime
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from freqtrade.constants import IntOrInf from freqtrade.constants import IntOrInf
from freqtrade.exchange import (
timeframe_to_prev_date,
timeframe_to_resample_freq,
)
from freqtrade.util import dt_from_ts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,95 +58,3 @@ def evaluate_result_multi(
""" """
df_final = analyze_trade_parallelism(trades, timeframe) df_final = analyze_trade_parallelism(trades, timeframe)
return df_final[df_final["open_trades"] > max_open_trades] return df_final[df_final["open_trades"] > max_open_trades]
def balance_distribution_over_time(
trades: pd.DataFrame,
min_date: datetime,
max_date: datetime,
timeframe: str,
stake_currency: str,
start_balance: float,
pairlist: list[str],
) -> pd.DataFrame:
"""
Return a dataframe with stake_currency and the pairlist as columns
Each column will contain the amount of the currency at the given time
Columns added are:
- stake_currency: amount of stake currency
- <pair>: amount of base currency in the pair
- <pair>_leverage: leverage used for the pair at the time (NaN if no open trade)
- <pair>_is_short: 1 if the open trade is short, 0 if long (NaN if no open trade)
- <pair>_collateral: amount of stake currency used as collateral for open trades
:param trades: Trades Dataframe - can be loaded from backtest, or created
via trade_list_to_dataframe
:param timeframe: Frequency to use for the resulting dataframe
:param min_date: start date
:param max_date: End date (will be rounded down to timeframe)
:param stake_currency: The stake currency
:param start_balance: Starting balance in stake currency
:param pairlist: List of trading pairs to include in the dataframe
Can be obtained via trade_df["pair"].unique()
For pairs without trades, the column will be all zeros
:return: Dataframe with balance distribution over time
"""
min_date_res = timeframe_to_prev_date(timeframe, min_date)
max_date_res = timeframe_to_prev_date(timeframe, max_date)
index = pd.date_range(min_date_res, max_date_res, freq=timeframe_to_resample_freq(timeframe))
pairs_lev = [f"{pair}_leverage" for pair in pairlist]
pairs_is_short = [f"{pair}_is_short" for pair in pairlist]
pairs_collateral = [f"{pair}_collateral" for pair in pairlist]
pairs_lev += pairs_is_short
df = pd.DataFrame(
index=index, columns=[stake_currency] + pairlist + pairs_lev + pairs_collateral, dtype=float
)
# Initialize variables to starting values
df[stake_currency] = float(start_balance)
df[pairlist + pairs_collateral] = 0.0
df[pairs_lev] = np.nan
for trade in trades.sort_values(by=["open_date"]).itertuples():
pair = trade.pair
end_date = trade.close_date if trade.close_date is not pd.NaT else None
# Exclude open orders - these won't have order_filled_timestamp set.
df.loc[trade.open_date : end_date, f"{pair}_leverage"] = trade.leverage
df.loc[trade.open_date : end_date, f"{pair}_is_short"] = 1 if trade.is_short else 0
orders = [o for o in trade.orders if o["order_filled_timestamp"]]
current_position = 0
current_collateral = 0
for order in sorted(orders, key=lambda x: x["order_filled_timestamp"]):
filled_at = pd.Timestamp(dt_from_ts(order["order_filled_timestamp"]))
real_amount = order.get("filled", order["amount"])
stake = order["safe_price"] * real_amount
stake_no_lev = stake / trade.leverage
if order["ft_is_entry"]:
# Entry order: lock collateral and pay fee
# For both long and short: balance decreases by collateral + fee
fee_open = stake * trade.fee_open
current_position += real_amount
current_collateral += stake_no_lev
df.loc[filled_at:end_date, pair] += real_amount
df.loc[filled_at:end_date, f"{pair}_collateral"] += stake_no_lev
df.loc[filled_at:, stake_currency] -= stake_no_lev + fee_open
else:
# Exit order: release collateral and realize profit/loss
fee_close = stake * trade.fee_close
if trade.is_short:
# For SHORT
df.loc[filled_at:, stake_currency] += (
current_collateral * (1 + trade.leverage) - stake
) - fee_close
else:
# For LONG
df.loc[filled_at:, stake_currency] += (
stake - current_collateral * (trade.leverage - 1) - fee_close
)
df.loc[filled_at:end_date, pair] -= real_amount
df.loc[filled_at:end_date, f"{pair}_collateral"] -= stake_no_lev
current_position -= real_amount
current_collateral -= stake_no_lev
# Round to avoid floating point issues
df = df.round(14)
return df
@@ -1,6 +1,6 @@
import logging import logging
from pandas import DataFrame, read_feather from pandas import DataFrame, read_feather, to_datetime
from pyarrow import dataset from pyarrow import dataset
from freqtrade.configuration import TimeRange from freqtrade.configuration import TimeRange
@@ -71,7 +71,7 @@ class FeatherDataHandler(IDataHandler):
"volume": "float", "volume": "float",
} }
) )
pairdata["date"] = pairdata["date"].dt.as_unit("ms") pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True)
return pairdata return pairdata
except Exception as e: except Exception as e:
logger.exception( logger.exception(
@@ -1,5 +1,6 @@
import logging import logging
import numpy as np
from pandas import DataFrame, read_json, to_datetime from pandas import DataFrame, read_json, to_datetime
from freqtrade import misc from freqtrade import misc
@@ -34,8 +35,8 @@ class JsonDataHandler(IDataHandler):
filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type) filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type)
self.create_dir_if_needed(filename) self.create_dir_if_needed(filename)
_data = data.copy() _data = data.copy()
# Convert date to int (milliseconds) # Convert date to int
_data["date"] = _data["date"].dt.as_unit("ms").astype("int64") _data["date"] = _data["date"].astype(np.int64) // 1000 // 1000
# Reset index, select only appropriate columns and save as json # Reset index, select only appropriate columns and save as json
_data.reset_index(drop=True).loc[:, self._columns].to_json( _data.reset_index(drop=True).loc[:, self._columns].to_json(
@@ -80,7 +81,7 @@ class JsonDataHandler(IDataHandler):
"volume": "float", "volume": "float",
} }
) )
pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True).dt.as_unit("ms") pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True)
return pairdata return pairdata
def ohlcv_append( def ohlcv_append(
@@ -104,9 +105,6 @@ class JsonDataHandler(IDataHandler):
:param trading_mode: Trading mode to use (used to determine the filename) :param trading_mode: Trading mode to use (used to determine the filename)
""" """
filename = self._pair_trades_filename(self._datadir, pair, trading_mode) filename = self._pair_trades_filename(self._datadir, pair, trading_mode)
# Convert StringDtype columns to object to avoid NaN serialization issues
for col in data.select_dtypes(include="string").columns:
data[col] = data[col].astype(object).where(data[col].notna(), other=None)
trades = data.values.tolist() trades = data.values.tolist()
misc.file_dump_json(filename, trades, is_zip=self._use_zip) misc.file_dump_json(filename, trades, is_zip=self._use_zip)
@@ -1,6 +1,6 @@
import logging import logging
from pandas import DataFrame, read_parquet from pandas import DataFrame, read_parquet, to_datetime
from freqtrade.configuration import TimeRange from freqtrade.configuration import TimeRange
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS
@@ -68,7 +68,7 @@ class ParquetDataHandler(IDataHandler):
"volume": "float", "volume": "float",
} }
) )
pairdata["date"] = pairdata["date"].dt.as_unit("ms") pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True)
return pairdata return pairdata
except Exception as e: except Exception as e:
logger.exception( logger.exception(
+25 -196
View File
@@ -140,7 +140,7 @@ def _calc_drawdown_series(
max_drawdown_df["drawdown_relative"] = (max_balance - cumulative_balance) / max_balance max_drawdown_df["drawdown_relative"] = (max_balance - cumulative_balance) / max_balance
else: else:
# NOTE: This is not completely accurate, # NOTE: This is not completely accurate,
# but will be good enough if starting_balance is not available # but might good enough if starting_balance is not available
max_drawdown_df["drawdown_relative"] = ( max_drawdown_df["drawdown_relative"] = (
max_drawdown_df["high_value"] - max_drawdown_df["cumulative"] max_drawdown_df["high_value"] - max_drawdown_df["cumulative"]
) / max_drawdown_df["high_value"] ) / max_drawdown_df["high_value"]
@@ -333,72 +333,6 @@ def calculate_expectancy(trades: pd.DataFrame) -> tuple[float, float]:
return expectancy, expectancy_ratio return expectancy, expectancy_ratio
def _calculate_annualized_ratio(
expected_returns_mean: float,
denominator: float,
annualization_factor: int = 365,
) -> float:
"""
Helper function to calculate annualized ratios like Sharpe and Sortino.
:param expected_returns_mean: Mean of the returns (expected returns)
:param denominator: Denominator of the ratio (e.g. standard deviation for Sharpe)
:param annualization_factor: Factor to annualize the ratio (default is 365 for daily returns)
:return: Annualized ratio, or -100.0 if denominator is zero or NaN to indicate this is
not optimal.
"""
if denominator != 0 and not np.isnan(denominator):
return float(expected_returns_mean / denominator * np.sqrt(annualization_factor))
# Define high (negative) ratio to be clear that this is NOT optimal.
return -100.0
def _calculate_daily_returns_from_balance(
balance_history: pd.DataFrame,
date_col: str,
balance_col: str,
) -> pd.Series:
wallet = _prepare_balance_history(balance_history, date_col, balance_col)
if len(wallet) == 0:
return pd.DataFrame(columns=[date_col, balance_col])
# Sample balance to daily end-of-day values to normalize variable snapshot frequency.
daily_balance = (
wallet.set_index(date_col)[balance_col].resample("1D").last().dropna().rename(balance_col)
)
daily_balance = daily_balance.reset_index()
if len(daily_balance) < 2:
return pd.Series(dtype=float)
return daily_balance[balance_col].pct_change().dropna()
def _prepare_balance_history(
balance_history: pd.DataFrame,
date_col: str,
balance_col: str,
) -> pd.DataFrame:
"""
Prepare balance history for calculations by filtering out rows with
missing date or balance values.
"""
if (
len(balance_history) == 0
or date_col not in balance_history
or balance_col not in balance_history
):
return pd.DataFrame(columns=[date_col, balance_col])
wallet = balance_history.loc[:, [date_col, balance_col]].copy()
wallet = wallet.dropna(subset=[date_col, balance_col]).sort_values(date_col)
if len(wallet) == 0:
return pd.DataFrame(columns=[date_col, balance_col])
return wallet
def calculate_sortino( def calculate_sortino(
trades: pd.DataFrame, trades: pd.DataFrame,
min_date: datetime | None, min_date: datetime | None,
@@ -420,31 +354,14 @@ def calculate_sortino(
down_stdev = np.std(trades.loc[trades["profit_abs"] < 0, "profit_abs"] / starting_balance) down_stdev = np.std(trades.loc[trades["profit_abs"] < 0, "profit_abs"] / starting_balance)
return _calculate_annualized_ratio(expected_returns_mean, down_stdev) if down_stdev != 0 and not np.isnan(down_stdev):
sortino_ratio = expected_returns_mean / down_stdev * np.sqrt(365)
else:
# Define high (negative) sortino ratio to be clear that this is NOT optimal.
sortino_ratio = -100
# print(expected_returns_mean, down_stdev, sortino_ratio)
def calculate_sortino_from_balance( return sortino_ratio
balance_history: pd.DataFrame,
date_col: str = "date",
balance_col: str = "total_quote",
) -> float:
"""
Calculate sortino ratio from historical balance snapshots.
:param balance_history: DataFrame containing at least date and balance columns
:param date_col: Column containing timestamps
:param balance_col: Column containing historical balance values
:return: sortino
"""
daily_returns = _calculate_daily_returns_from_balance(balance_history, date_col, balance_col)
if len(daily_returns) == 0:
return 0.0
expected_returns_mean = daily_returns.mean()
downside_returns = daily_returns[daily_returns < 0]
down_stdev = downside_returns.std(ddof=0)
return _calculate_annualized_ratio(expected_returns_mean, down_stdev)
def calculate_sharpe( def calculate_sharpe(
@@ -467,67 +384,14 @@ def calculate_sharpe(
expected_returns_mean = total_profit.sum() / days_period expected_returns_mean = total_profit.sum() / days_period
up_stdev = np.std(total_profit) up_stdev = np.std(total_profit)
return _calculate_annualized_ratio(expected_returns_mean, up_stdev) if up_stdev != 0:
sharp_ratio = expected_returns_mean / up_stdev * np.sqrt(365)
else:
# Define high (negative) sharpe ratio to be clear that this is NOT optimal.
sharp_ratio = -100
# print(expected_returns_mean, up_stdev, sharp_ratio)
def calculate_sharpe_from_balance( return sharp_ratio
balance_history: pd.DataFrame,
date_col: str = "date",
balance_col: str = "total_quote",
) -> float:
"""
Calculate sharpe ratio from historical balance snapshots.
:param balance_history: DataFrame containing at least date and balance columns
:param date_col: Column containing timestamps
:param balance_col: Column containing historical balance values
:return: sharpe
"""
daily_returns = _calculate_daily_returns_from_balance(balance_history, date_col, balance_col)
if len(daily_returns) == 0:
return 0.0
expected_returns_mean = daily_returns.mean()
up_stdev = daily_returns.std(ddof=0)
return _calculate_annualized_ratio(expected_returns_mean, up_stdev)
def calculate_max_drawdown_from_balance(
balance_history: pd.DataFrame,
date_col: str = "date",
balance_col: str = "total_quote",
relative: bool = False,
) -> DrawDownResult:
"""
Calculate max drawdown from historical balance snapshots.
:param balance_history: DataFrame containing at least date and balance columns
:param date_col: Column containing timestamps
:param balance_col: Column containing historical balance values
:param relative: If True, use relative drawdown for max calculation instead of absolute
:return: DrawDownResult object
:raise: ValueError if balance-history dataframe was found empty.
"""
wallet = _prepare_balance_history(
balance_history=balance_history,
date_col=date_col,
balance_col=balance_col,
)
if len(wallet) < 2:
raise ValueError("Balance-history dataframe empty.")
starting_balance = float(wallet[balance_col].iloc[0])
wallet.loc[:, "total_balance"] = wallet[balance_col].diff().fillna(0.0)
return calculate_max_drawdown(
wallet,
date_col=date_col,
value_col="total_balance",
starting_balance=starting_balance,
relative=relative,
)
def calculate_calmar( def calculate_calmar(
@@ -537,12 +401,12 @@ def calculate_calmar(
starting_balance: float, starting_balance: float,
) -> float: ) -> float:
""" """
Calculate calmar from trades data. Calculate calmar
:param trades: DataFrame containing trades (requires columns close_date and profit_abs) :param trades: DataFrame containing trades (requires columns close_date and profit_abs)
:return: calmar :return: calmar
""" """
if (len(trades) == 0) or (min_date is None) or (max_date is None) or (min_date == max_date): if (len(trades) == 0) or (min_date is None) or (max_date is None) or (min_date == max_date):
return 0.0 return 0
total_profit = trades["profit_abs"].sum() / starting_balance total_profit = trades["profit_abs"].sum() / starting_balance
days_period = max(1, (max_date - min_date).days) days_period = max(1, (max_date - min_date).days)
@@ -558,51 +422,16 @@ def calculate_calmar(
) )
max_drawdown = drawdown.relative_account_drawdown max_drawdown = drawdown.relative_account_drawdown
except ValueError: except ValueError:
return 0.0 max_drawdown = 0
return _calculate_annualized_ratio(expected_returns_mean, max_drawdown) if max_drawdown != 0:
calmar_ratio = expected_returns_mean / max_drawdown * math.sqrt(365)
else:
# Define high (negative) calmar ratio to be clear that this is NOT optimal.
calmar_ratio = -100
# print(expected_returns_mean, max_drawdown, calmar_ratio)
def calculate_calmar_from_balance( return calmar_ratio
balance_history: pd.DataFrame,
date_col: str = "date",
balance_col: str = "total_quote",
) -> float:
"""
Calculate calmar ratio from historical balance snapshots.
:param balance_history: DataFrame containing at least date and balance columns
:param date_col: Column containing timestamps
:param balance_col: Column containing historical balance values
:return: calmar
"""
wallet = _prepare_balance_history(
balance_history=balance_history,
date_col=date_col,
balance_col=balance_col,
)
if len(wallet) < 2:
return 0.0
starting_balance = float(wallet[balance_col].iloc[0])
final_balance = float(wallet[balance_col].iloc[-1])
days_period = max(1, (wallet[date_col].iloc[-1] - wallet[date_col].iloc[0]).days)
total_profit = (final_balance - starting_balance) / starting_balance
expected_returns_mean = total_profit / days_period * 100
try:
drawdown = calculate_max_drawdown_from_balance(
wallet,
date_col=date_col,
balance_col=balance_col,
)
max_drawdown = drawdown.relative_account_drawdown
except ValueError:
return 0.0
return _calculate_annualized_ratio(expected_returns_mean, max_drawdown)
def calculate_sqn(trades: pd.DataFrame, starting_balance: float) -> float: def calculate_sqn(trades: pd.DataFrame, starting_balance: float) -> float:
+1 -5
View File
@@ -46,10 +46,6 @@ class Binance(Exchange):
"l2_limit_range": [5, 10, 20, 50, 100, 500, 1000], "l2_limit_range": [5, 10, 20, 50, 100, 500, 1000],
"ws_enabled": True, "ws_enabled": True,
"has_delisting": True, "has_delisting": True,
# Demo trading
# https://www.binance.com/en/support/faq/detail/9be58f73e5e14338809e3b705b9687dd
# Intentionally Disabled as it's a separate market - not a simulated live market.
"supports_demo_trading": False,
} }
_ft_has_futures: FtHas = { _ft_has_futures: FtHas = {
"ohlcv_candle_limit": 499, "ohlcv_candle_limit": 499,
@@ -559,7 +555,7 @@ class Binance(Exchange):
class Binanceusdm(Binance): class Binanceusdm(Binance):
"""Binance USDM Exchange """Binacne USDM Exchange
Same as Binance - only futures trading is supported (via ccxt). Same as Binance - only futures trading is supported (via ccxt).
Not actually necessary, binance should be preferred. Not actually necessary, binance should be preferred.
File diff suppressed because it is too large Load Diff
+34 -50
View File
@@ -7,7 +7,6 @@ from freqtrade.constants import BuySell
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, PriceType, TradingMode from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, PriceType, TradingMode
from freqtrade.exceptions import ( from freqtrade.exceptions import (
DDosProtection, DDosProtection,
InvalidOrderException,
OperationalException, OperationalException,
RetryableOrderError, RetryableOrderError,
TemporaryError, TemporaryError,
@@ -35,18 +34,16 @@ class Bitget(Exchange):
"stoploss_query_requires_stop_flag": True, "stoploss_query_requires_stop_flag": True,
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones. "ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"], "order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
}
_ft_has_futures: FtHas = {
"funding_fee_candle_limit": 100,
"has_delisting": True,
"stop_price_param": "stopLossPrice",
"stop_price_prop": "stopLossPrice",
"stop_price_type_field": "triggerType", "stop_price_type_field": "triggerType",
"stop_price_type_value_mapping": { "stop_price_type_value_mapping": {
PriceType.LAST: "fill_price", PriceType.LAST: "fill_price",
PriceType.MARK: "mark_price", PriceType.MARK: "mark_price",
}, },
} }
_ft_has_futures: FtHas = {
"funding_fee_candle_limit": 100,
"has_delisting": True,
}
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
(TradingMode.SPOT, MarginMode.NONE), (TradingMode.SPOT, MarginMode.NONE),
@@ -102,36 +99,30 @@ class Bitget(Exchange):
return order return order
def _fetch_stop_order_fallback(self, order_id: str, pair: str) -> CcxtOrder: def _fetch_stop_order_fallback(self, order_id: str, pair: str) -> CcxtOrder:
# old stoploss orders params2 = {
paramsold = {"stop": True} "stop": True,
# new stoploss orders with stopLossPrice (used in futures starting 2026.4) }
paramsnew = {"planType": "profit_loss"} for method in (
params_to_try = ( self._api.fetch_open_orders,
(paramsnew, paramsold) if self.trading_mode == TradingMode.FUTURES else (paramsold,) self._api.fetch_canceled_and_closed_orders,
) ):
try:
for params2 in params_to_try: orders = method(pair, params=params2)
for method in ( orders_f = [order for order in orders if order["id"] == order_id]
self._api.fetch_open_orders, if orders_f:
self._api.fetch_canceled_and_closed_orders, order = orders_f[0]
): self._log_exchange_response("get_stop_order_fallback", order)
try: return self._convert_stop_order(pair, order_id, order)
orders = method(pair, params=params2) except (ccxt.OrderNotFound, ccxt.InvalidOrder):
orders_f = [order for order in orders if order["id"] == order_id] pass
if orders_f: except ccxt.DDoSProtection as e:
order = orders_f[0] raise DDosProtection(e) from e
self._log_exchange_response("get_stop_order_fallback", order) except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
return self._convert_stop_order(pair, order_id, order) raise TemporaryError(
except (ccxt.OrderNotFound, ccxt.InvalidOrder): f"Could not get order due to {e.__class__.__name__}. Message: {e}"
pass ) from e
except ccxt.DDoSProtection as e: except ccxt.BaseError as e:
raise DDosProtection(e) from e raise OperationalException(e) from e
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
raise TemporaryError(
f"Could not get order due to {e.__class__.__name__}. Message: {e}"
) from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
raise RetryableOrderError(f"StoplossOrder not found (pair: {pair} id: {order_id}).") raise RetryableOrderError(f"StoplossOrder not found (pair: {pair} id: {order_id}).")
@retrier(retries=API_RETRY_COUNT) @retrier(retries=API_RETRY_COUNT)
@@ -143,19 +134,6 @@ class Bitget(Exchange):
return self._fetch_stop_order_fallback(order_id, pair) return self._fetch_stop_order_fallback(order_id, pair)
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
cancel_params = params.copy() if params else {}
cancel_params["stop"] = True
if self.trading_mode != TradingMode.FUTURES:
return self.cancel_order(order_id, pair, cancel_params)
try:
return self.cancel_order(order_id, pair, {**cancel_params, "planType": "pos_loss"})
except (InvalidOrderException, IndexError):
# Keep compatibility with stoploss orders created by older versions.
return self.cancel_order(order_id, pair, cancel_params)
@retrier @retrier
def additional_exchange_init(self) -> None: def additional_exchange_init(self) -> None:
""" """
@@ -177,6 +155,12 @@ class Bitget(Exchange):
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
def _lev_prep(self, pair: str, leverage: float, side: BuySell, accept_fail: bool = False):
if self.trading_mode != TradingMode.SPOT:
# Explicitly setting margin_mode is not necessary as marginMode can be set per order.
# self.set_margin_mode(pair, self.margin_mode, accept_fail)
self._set_leverage(leverage, pair, accept_fail)
def _get_params( def _get_params(
self, self,
side: BuySell, side: BuySell,
-3
View File
@@ -35,9 +35,6 @@ class Bybit(Exchange):
# TODO: Can be removed once bybit fully forces all accounts to unified mode. # TODO: Can be removed once bybit fully forces all accounts to unified mode.
"fetchOrder": False, "fetchOrder": False,
}, },
# Demo trading
# https://learn.bybit.com/en/bybit-guide/how-to-use-bybit-demo-trading
"supports_demo_trading": True,
} }
_ft_has_futures: FtHas = { _ft_has_futures: FtHas = {
"ohlcv_has_history": True, "ohlcv_has_history": True,
+4 -2
View File
@@ -51,10 +51,12 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool:
if not valid: if not valid:
if check_for_bad: if check_for_bad:
raise OperationalException( raise OperationalException(
f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}.' f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}.'
) )
else: else:
logger.warning(f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}.') logger.warning(
f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}.'
)
if MAP_EXCHANGE_CHILDCLASS.get(exchange, exchange) in SUPPORTED_EXCHANGES: if MAP_EXCHANGE_CHILDCLASS.get(exchange, exchange) in SUPPORTED_EXCHANGES:
logger.info( logger.info(
+8 -23
View File
@@ -13,7 +13,6 @@ from datetime import UTC, datetime, timedelta
from math import floor, isnan from math import floor, isnan
from threading import Lock from threading import Lock
from typing import Any, Literal, TypeGuard, TypeVar from typing import Any, Literal, TypeGuard, TypeVar
from uuid import uuid4
import ccxt import ccxt
import ccxt.pro as ccxt_pro import ccxt.pro as ccxt_pro
@@ -250,7 +249,7 @@ class Exchange:
# Holds all open sell orders for dry_run # Holds all open sell orders for dry_run
self._dry_run_open_orders: dict[str, Any] = {} self._dry_run_open_orders: dict[str, Any] = {}
self._is_demo_trading = exchange_conf.get("demo_trading", False)
if self._config["dry_run"]: if self._config["dry_run"]:
logger.info("Instance is running with dry_run enabled") logger.info("Instance is running with dry_run enabled")
logger.info(f"Using CCXT {ccxt.__version__}") logger.info(f"Using CCXT {ccxt.__version__}")
@@ -366,7 +365,6 @@ class Exchange:
self.validate_pricing(config["exit_pricing"]) self.validate_pricing(config["exit_pricing"])
self.validate_pricing(config["entry_pricing"]) self.validate_pricing(config["entry_pricing"])
self.validate_orderflow(config["exchange"]) self.validate_orderflow(config["exchange"])
self.validate_demo_trading(config["exchange"])
self.validate_freqai(config) self.validate_freqai(config)
self._set_startup_candle_count(config) self._set_startup_candle_count(config)
@@ -420,9 +418,6 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(f"Initialization of ccxt failed. Reason: {e}") from e raise OperationalException(f"Initialization of ccxt failed. Reason: {e}") from e
if self.get_option("supports_demo_trading") and exchange_config.get("demo_trading", False):
api.enable_demo_trading(True)
return api return api
@property @property
@@ -438,12 +433,12 @@ class Exchange:
@property @property
def name(self) -> str: def name(self) -> str:
"""exchange Name (from ccxt)""" """exchange Name (from ccxt)"""
return self._api.name if not self._is_demo_trading else f"{self._api.name} (Demo)" return self._api.name
@property @property
def id(self) -> str: def id(self) -> str:
"""exchange ccxt id""" """exchange ccxt id"""
return self._api.id if not self._is_demo_trading else f"{self._api.id}_demo" return self._api.id
@property @property
def timeframes(self) -> list[str]: def timeframes(self) -> list[str]:
@@ -875,16 +870,6 @@ class Exchange:
"fetching historic OHLCV data, otherwise freqAI will not work." "fetching historic OHLCV data, otherwise freqAI will not work."
) )
def validate_demo_trading(self, exchange_conf: dict) -> None:
"""Validate demo trading configuration
Prevents accidental configuration with wrong expectations.
"""
if exchange_conf.get("demo_trading", False):
if not self.get_option("supports_demo_trading"):
raise ConfigurationError(f"Demo trading is not supported for {self.name}.")
else:
logger.info(f"Demo trading enabled for {self.name}")
def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> int: def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> int:
""" """
Checks if required startup_candles is more than ohlcv_candle_limit(). Checks if required startup_candles is more than ohlcv_candle_limit().
@@ -1153,7 +1138,7 @@ class Exchange:
stop_price: float | None = None, stop_price: float | None = None,
) -> CcxtOrder: ) -> CcxtOrder:
now = dt_now() now = dt_now()
order_id = f"dry_run_{side}_{pair}_{uuid4()}" order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
# Rounding here must respect to contract sizes # Rounding here must respect to contract sizes
_amount = self._contracts_to_amount( _amount = self._contracts_to_amount(
pair, self.amount_to_precision(pair, self._amount_to_contracts(pair, amount)) pair, self.amount_to_precision(pair, self._amount_to_contracts(pair, amount))
@@ -2670,11 +2655,11 @@ class Exchange:
if self._can_use_websocket(self._exchange_ws, pair, timeframe, candle_type): if self._can_use_websocket(self._exchange_ws, pair, timeframe, candle_type):
candle_ts = dt_ts(timeframe_to_prev_date(timeframe)) candle_ts = dt_ts(timeframe_to_prev_date(timeframe))
prev_candle_ts = dt_ts(date_minus_candles(timeframe, 1)) prev_candle_ts = dt_ts(date_minus_candles(timeframe, 1))
candles, last_refresh_time = self._exchange_ws.get_ohlcv_with_refresh( candles = self._exchange_ws.ohlcvs(pair, timeframe)
pair, timeframe, candle_type
)
last_refresh_time = int(last_refresh_time)
half_candle = int(candle_ts - (candle_ts - prev_candle_ts) * 0.5) half_candle = int(candle_ts - (candle_ts - prev_candle_ts) * 0.5)
last_refresh_time = int(
self._exchange_ws.klines_last_refresh.get((pair, timeframe, candle_type), 0)
)
if ( if (
candles candles
-2
View File
@@ -67,8 +67,6 @@ class FtHas(TypedDict, total=False):
# Delisting check # Delisting check
has_delisting: bool has_delisting: bool
# Demo mode - this is not sandbox but an exchange-provided demo mode.
supports_demo_trading: bool
class Ticker(TypedDict): class Ticker(TypedDict):
+77 -148
View File
@@ -1,8 +1,9 @@
import asyncio import asyncio
import logging import logging
import time
from copy import deepcopy from copy import deepcopy
from functools import partial from functools import partial
from threading import Event, RLock, Thread from threading import Thread
import ccxt import ccxt
@@ -23,71 +24,49 @@ class ExchangeWS:
self.config = config self.config = config
self._ccxt_object = ccxt_object self._ccxt_object = ccxt_object
self._background_tasks: set[asyncio.Task] = set() self._background_tasks: set[asyncio.Task] = set()
self._state_lock = RLock()
self._loop_ready = Event()
self._klines_watching: set[PairWithTimeframe] = set() self._klines_watching: set[PairWithTimeframe] = set()
self._klines_scheduled: set[PairWithTimeframe] = set() self._klines_scheduled: set[PairWithTimeframe] = set()
self._klines_last_refresh: dict[PairWithTimeframe, float] = {} self.klines_last_refresh: dict[PairWithTimeframe, float] = {}
self._klines_last_request: dict[PairWithTimeframe, float] = {} self.klines_last_request: dict[PairWithTimeframe, float] = {}
self._thread = Thread(name="ccxt_ws", target=self._start_forever) self._thread = Thread(name="ccxt_ws", target=self._start_forever)
self._thread.start() self._thread.start()
self.__cleanup_called = False
def _start_forever(self) -> None: def _start_forever(self) -> None:
self._loop = asyncio.new_event_loop() self._loop = asyncio.new_event_loop()
self._loop_ready.set()
try: try:
self._loop.run_forever() self._loop.run_forever()
finally: finally:
if not self._loop.is_closed(): if self._loop.is_running():
# Cancel remaining tasks and close the loop in the owning thread. self._loop.stop()
pending = asyncio.all_tasks(self._loop)
for task in pending:
task.cancel()
if pending:
self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
self._loop.close()
self._loop_ready.clear()
def _wait_for_loop(self, timeout: float = 1.0) -> bool:
"""
Wait for the event loop to be ready
Returns True once the loop is ready.
Will probably only return false during startup/shutdown.
"""
if hasattr(self, "_loop"):
return True
return self._loop_ready.wait(timeout=timeout) and hasattr(self, "_loop")
def cleanup(self) -> None: def cleanup(self) -> None:
logger.debug("Cleanup called - stopping") logger.debug("Cleanup called - stopping")
with self._state_lock: self._klines_watching.clear()
self._klines_watching.clear() for task in self._background_tasks:
tasks = list(self._background_tasks)
for task in tasks:
task.cancel() task.cancel()
if self._wait_for_loop(timeout=0.2) and not self._loop.is_closed(): if hasattr(self, "_loop") and not self._loop.is_closed():
self.reset_connections(cleanup=True) self.reset_connections()
self._loop.call_soon_threadsafe(self._loop.stop) self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=5) time.sleep(0.1)
if self._thread.is_alive(): if not self._loop.is_closed():
logger.warning("Websocket loop thread did not stop within timeout.") self._loop.close()
self._thread.join()
logger.debug("Stopped") logger.debug("Stopped")
def reset_connections(self, cleanup: bool = False) -> None: def reset_connections(self) -> None:
""" """
Reset all connections - avoids "connection-reset" errors that happen after ~9 days Reset all connections - avoids "connection-reset" errors that happen after ~9 days
""" """
if self._wait_for_loop() and not self._loop.is_closed(): if hasattr(self, "_loop") and not self._loop.is_closed():
logger.info(f"{'Cleaning up' if cleanup else 'Resetting'} exchange WS connections.") logger.info("Resetting WS connections.")
try: asyncio.run_coroutine_threadsafe(self._cleanup_async(), loop=self._loop)
fut = asyncio.run_coroutine_threadsafe(self._cleanup_async(), loop=self._loop) while not self.__cleanup_called:
fut.result(timeout=10) time.sleep(0.1)
except TimeoutError: self.__cleanup_called = False
logger.warning("Timed out while resetting websocket connections.")
except Exception:
logger.exception("Exception while resetting websocket connections")
async def _cleanup_async(self) -> None: async def _cleanup_async(self) -> None:
try: try:
@@ -97,14 +76,15 @@ class ExchangeWS:
self._ccxt_object.ohlcvs.clear() self._ccxt_object.ohlcvs.clear()
except Exception: except Exception:
logger.exception("Exception in _cleanup_async") logger.exception("Exception in _cleanup_async")
finally:
self.__cleanup_called = True
def _pop_history(self, paircomb: PairWithTimeframe) -> None: def _pop_history(self, paircomb: PairWithTimeframe) -> None:
""" """
Remove history for a pair/timeframe combination from ccxt cache Remove history for a pair/timeframe combination from ccxt cache
""" """
with self._state_lock: self._ccxt_object.ohlcvs.get(paircomb[0], {}).pop(paircomb[1], None)
self._ccxt_object.ohlcvs.get(paircomb[0], {}).pop(paircomb[1], None) self.klines_last_refresh.pop(paircomb, None)
self._klines_last_refresh.pop(paircomb, None)
@retrier(retries=3) @retrier(retries=3)
def ohlcvs(self, pair: str, timeframe: str) -> list[list]: def ohlcvs(self, pair: str, timeframe: str) -> list[list]:
@@ -120,129 +100,81 @@ class ExchangeWS:
# TemporaryError does not cause backoff - so we're essentially retrying immediately # TemporaryError does not cause backoff - so we're essentially retrying immediately
raise TemporaryError(f"Error deepcopying: {e}") from e raise TemporaryError(f"Error deepcopying: {e}") from e
def get_ohlcv_with_refresh(
self, pair: str, timeframe: str, candle_type: CandleType
) -> tuple[list[list], float]:
"""
Get deepcopied klines and update the last refresh time
"""
ohlcvs = self.ohlcvs(pair, timeframe)
with self._state_lock:
last_refresh = self._klines_last_refresh.get((pair, timeframe, candle_type), 0)
return ohlcvs, last_refresh
def cleanup_expired(self) -> None: def cleanup_expired(self) -> None:
""" """
Remove pairs from watchlist if they've not been requested within Remove pairs from watchlist if they've not been requested within
the last timeframe (+ offset) the last timeframe (+ offset)
""" """
changed = False changed = False
with self._state_lock: for p in list(self._klines_watching):
for p in list(self._klines_watching): _, timeframe, _ = p
_, timeframe, _ = p timeframe_s = timeframe_to_seconds(timeframe)
timeframe_s = timeframe_to_seconds(timeframe) last_refresh = self.klines_last_request.get(p, 0)
last_refresh = self._klines_last_request.get(p, 0) if last_refresh > 0 and (dt_ts() - last_refresh) > ((timeframe_s + 20) * 1000):
if last_refresh > 0 and (dt_ts() - last_refresh) > ((timeframe_s + 20) * 1000): logger.info(f"Removing {p} from websocket watchlist.")
logger.info(f"Removing {p} from websocket watchlist.") self._klines_watching.discard(p)
self._klines_watching.discard(p) # Pop history to avoid getting stale data
# Pop history to avoid getting stale data self._pop_history(p)
self._pop_history(p) changed = True
changed = True
if changed: if changed:
logger.info(f"Removal done: new watch list ({len(self._klines_watching)})") logger.info(f"Removal done: new watch list ({len(self._klines_watching)})")
async def _schedule_while_true(self) -> None: async def _schedule_while_true(self) -> None:
# For the ones we should be watching # For the ones we should be watching
with self._state_lock: for p in self._klines_watching:
pairs_to_check = list(self._klines_watching)
for p in pairs_to_check:
# Check if they're already scheduled # Check if they're already scheduled
with self._state_lock: if p not in self._klines_scheduled:
if p in self._klines_scheduled:
continue
self._klines_scheduled.add(p) self._klines_scheduled.add(p)
pair, timeframe, candle_type = p pair, timeframe, candle_type = p
task = asyncio.create_task( task = asyncio.create_task(
self._continuously_async_watch_ohlcv(pair, timeframe, candle_type) self._continuously_async_watch_ohlcv(pair, timeframe, candle_type)
) )
with self._state_lock: self._background_tasks.add(task)
self._background_tasks.add(task) task.add_done_callback(
task.add_done_callback( partial(
partial( self._continuous_stopped,
self._continuous_stopped, pair=pair,
pair=pair, timeframe=timeframe,
timeframe=timeframe, candle_type=candle_type,
candle_type=candle_type, )
) )
)
def exchange_has(self, endpoint: str) -> bool:
"""
Checks if exchange implements a specific API endpoint.
Wrapper around ccxt 'has' attribute
:param endpoint: Name of endpoint (e.g. 'fetchOHLCV', 'fetchTickers')
:return: bool
"""
return endpoint in self._ccxt_object.has and self._ccxt_object.has[endpoint]
async def _unwatch_ohlcv(self, pair: str, timeframe: str, candle_type: CandleType) -> None: async def _unwatch_ohlcv(self, pair: str, timeframe: str, candle_type: CandleType) -> None:
try: try:
if self.exchange_has("unWatchOHLCVForSymbols"): await self._ccxt_object.un_watch_ohlcv_for_symbols([[pair, timeframe]])
await self._ccxt_object.un_watch_ohlcv_for_symbols([[pair, timeframe]])
elif self.exchange_has("unWatchOHLCV"):
await self._ccxt_object.un_watch_ohlcv(pair, timeframe)
else:
logger.debug("un_watch_ohlcv not supported for %s, %s", pair, timeframe)
except ccxt.NotSupported as e: except ccxt.NotSupported as e:
logger.debug("un_watch_ohlcv_for_symbols not supported: %s", e) logger.debug("un_watch_ohlcv_for_symbols not supported: %s", e)
pass pass
except ccxt.NetworkError as e:
# Network errors are common on shutdown so we can ignore them.
# It's a network error - which most likely means that the connection is already closed.
logger.debug("Network error during unwatch for %s, %s: %s", pair, timeframe, e)
except Exception: except Exception:
logger.exception(f"Exception in _unwatch_ohlcv for {pair}, {timeframe},") logger.exception("Exception in _unwatch_ohlcv")
def _continuous_stopped( def _continuous_stopped(
self, task: asyncio.Task, pair: str, timeframe: str, candle_type: CandleType self, task: asyncio.Task, pair: str, timeframe: str, candle_type: CandleType
) -> None: ):
with self._state_lock: self._background_tasks.discard(task)
self._background_tasks.discard(task)
result = "done" result = "done"
try: if task.cancelled():
if task.cancelled(): result = "cancelled"
result = "cancelled" else:
else: if (result1 := task.result()) is not None:
if (result1 := task.result()) is not None: result = str(result1)
result = str(result1)
except Exception:
result = "error"
logger.exception(f"Unhandled exception in watch task callback for {pair}, {timeframe}")
finally:
logger.info(f"{pair}, {timeframe}, {candle_type} - Task finished - {result}")
if hasattr(self, "_loop") and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(
self._unwatch_ohlcv(pair, timeframe, candle_type), loop=self._loop
)
with self._state_lock: logger.info(f"{pair}, {timeframe}, {candle_type} - Task finished - {result}")
self._klines_scheduled.discard((pair, timeframe, candle_type)) asyncio.run_coroutine_threadsafe(
self._pop_history((pair, timeframe, candle_type)) self._unwatch_ohlcv(pair, timeframe, candle_type), loop=self._loop
)
self._klines_scheduled.discard((pair, timeframe, candle_type))
self._pop_history((pair, timeframe, candle_type))
async def _continuously_async_watch_ohlcv( async def _continuously_async_watch_ohlcv(
self, pair: str, timeframe: str, candle_type: CandleType self, pair: str, timeframe: str, candle_type: CandleType
) -> None: ) -> None:
try: try:
while True: while (pair, timeframe, candle_type) in self._klines_watching:
with self._state_lock:
if (pair, timeframe, candle_type) not in self._klines_watching:
break
start = dt_ts() start = dt_ts()
data = await self._ccxt_object.watch_ohlcv(pair, timeframe) data = await self._ccxt_object.watch_ohlcv(pair, timeframe)
with self._state_lock: self.klines_last_refresh[(pair, timeframe, candle_type)] = dt_ts()
self._klines_last_refresh[(pair, timeframe, candle_type)] = dt_ts()
logger.debug( logger.debug(
f"watch done {pair}, {timeframe}, data {len(data)} " f"watch done {pair}, {timeframe}, data {len(data)} "
f"in {(dt_ts() - start) / 1000:.3f}s" f"in {(dt_ts() - start) / 1000:.3f}s"
@@ -252,19 +184,14 @@ class ExchangeWS:
except ccxt.BaseError: except ccxt.BaseError:
logger.exception(f"Exception in continuously_async_watch_ohlcv for {pair}, {timeframe}") logger.exception(f"Exception in continuously_async_watch_ohlcv for {pair}, {timeframe}")
finally: finally:
with self._state_lock: self._klines_watching.discard((pair, timeframe, candle_type))
self._klines_watching.discard((pair, timeframe, candle_type))
def schedule_ohlcv(self, pair: str, timeframe: str, candle_type: CandleType) -> None: def schedule_ohlcv(self, pair: str, timeframe: str, candle_type: CandleType) -> None:
""" """
Schedule a pair/timeframe combination to be watched Schedule a pair/timeframe combination to be watched
""" """
if not self._wait_for_loop(): self._klines_watching.add((pair, timeframe, candle_type))
logger.warning(f"Websocket loop not ready. Could not schedule {pair}, {timeframe}.") self.klines_last_request[(pair, timeframe, candle_type)] = dt_ts()
return
with self._state_lock:
self._klines_watching.add((pair, timeframe, candle_type))
self._klines_last_request[(pair, timeframe, candle_type)] = dt_ts()
# asyncio.run_coroutine_threadsafe(self.schedule_schedule(), loop=self._loop) # asyncio.run_coroutine_threadsafe(self.schedule_schedule(), loop=self._loop)
asyncio.run_coroutine_threadsafe(self._schedule_while_true(), loop=self._loop) asyncio.run_coroutine_threadsafe(self._schedule_while_true(), loop=self._loop)
self.cleanup_expired() self.cleanup_expired()
@@ -280,10 +207,12 @@ class ExchangeWS:
Returns cached klines from ccxt's "watch" cache. Returns cached klines from ccxt's "watch" cache.
:param candle_ts: timestamp of the end-time of the candle we expect. :param candle_ts: timestamp of the end-time of the candle we expect.
""" """
candles, refresh_date = self.get_ohlcv_with_refresh(pair, timeframe, candle_type) # Deepcopy the response - as it might be modified in the background as new messages arrive
candles = self.ohlcvs(pair, timeframe)
refresh_date = self.klines_last_refresh[(pair, timeframe, candle_type)]
received_ts = candles[-1][0] if candles else 0 received_ts = candles[-1][0] if candles else 0
drop_hint = received_ts >= candle_ts drop_hint = received_ts >= candle_ts
if refresh_date and received_ts > refresh_date: if received_ts > refresh_date:
logger.warning( logger.warning(
f"{pair}, {timeframe} - Candle date > last refresh " f"{pair}, {timeframe} - Candle date > last refresh "
f"({format_ms_time(received_ts)} > {format_ms_time_det(refresh_date)}). " f"({format_ms_time(received_ts)} > {format_ms_time_det(refresh_date)}). "
+1 -1
View File
@@ -361,7 +361,7 @@ class FreqaiDataDrawer:
label_loc = df.columns.get_loc(label) label_loc = df.columns.get_loc(label)
pred_label_loc = predictions.columns.get_loc(label) pred_label_loc = predictions.columns.get_loc(label)
df.iloc[-1, label_loc] = predictions.iloc[-1, pred_label_loc] df.iloc[-1, label_loc] = predictions.iloc[-1, pred_label_loc]
if pd.api.types.is_string_dtype(df[label].dtype): if df[label].dtype == object:
continue continue
label_mean_loc = df.columns.get_loc(f"{label}_mean") label_mean_loc = df.columns.get_loc(f"{label}_mean")
label_std_loc = df.columns.get_loc(f"{label}_std") label_std_loc = df.columns.get_loc(f"{label}_std")
+10 -6
View File
@@ -24,6 +24,8 @@ from freqtrade.strategy import merge_informative_pair
from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.interface import IStrategy
pd.set_option("future.no_silent_downcasting", True)
SECONDS_IN_DAY = 86400 SECONDS_IN_DAY = 86400
SECONDS_IN_HOUR = 3600 SECONDS_IN_HOUR = 3600
@@ -237,14 +239,16 @@ class FreqaiDataKitchen:
filtered_df = filtered_df.replace([np.inf, -np.inf], np.nan) filtered_df = filtered_df.replace([np.inf, -np.inf], np.nan)
drop_index = pd.isnull(filtered_df).any(axis=1) # get the rows that have NaNs, drop_index = pd.isnull(filtered_df).any(axis=1) # get the rows that have NaNs,
drop_index = drop_index.replace(True, 1).replace(False, 0).infer_objects() drop_index = drop_index.replace(True, 1).replace(False, 0).infer_objects(copy=False)
if training_filter: if training_filter:
# we don't care about total row number (total no. datapoints) in training, we only care # we don't care about total row number (total no. datapoints) in training, we only care
# about removing any row with NaNs # about removing any row with NaNs
# if labels has multiple columns (user wants to train multiple modelEs), we detect here # if labels has multiple columns (user wants to train multiple modelEs), we detect here
labels = unfiltered_df.filter(label_list or [], axis=1) labels = unfiltered_df.filter(label_list or [], axis=1)
drop_index_labels = pd.isnull(labels).any(axis=1) drop_index_labels = pd.isnull(labels).any(axis=1)
drop_index_labels = drop_index_labels.replace(True, 1).replace(False, 0).infer_objects() drop_index_labels = (
drop_index_labels.replace(True, 1).replace(False, 0).infer_objects(copy=False)
)
dates = unfiltered_df["date"] dates = unfiltered_df["date"]
filtered_df = filtered_df[ filtered_df = filtered_df[
(drop_index == 0) & (drop_index_labels == 0) (drop_index == 0) & (drop_index_labels == 0)
@@ -431,7 +435,7 @@ class FreqaiDataKitchen:
for label in predictions.columns: for label in predictions.columns:
append_dict[label] = predictions[label] append_dict[label] = predictions[label]
if pd.api.types.is_string_dtype(predictions[label].dtype): if predictions[label].dtype == object:
continue continue
if "labels_mean" in self.data and label in self.data["labels_mean"]: if "labels_mean" in self.data and label in self.data["labels_mean"]:
append_dict[f"{label}_mean"] = self.data["labels_mean"][label] append_dict[f"{label}_mean"] = self.data["labels_mean"][label]
@@ -875,7 +879,7 @@ class FreqaiDataKitchen:
self.data["labels_mean"], self.data["labels_std"] = {}, {} self.data["labels_mean"], self.data["labels_std"] = {}, {}
for label in self.data_dictionary["train_labels"].columns: for label in self.data_dictionary["train_labels"].columns:
if pd.api.types.is_string_dtype(self.data_dictionary["train_labels"][label].dtype): if self.data_dictionary["train_labels"][label].dtype == object:
continue continue
f = spy.stats.norm.fit(self.data_dictionary["train_labels"][label]) f = spy.stats.norm.fit(self.data_dictionary["train_labels"][label])
self.data["labels_mean"][label], self.data["labels_std"][label] = f[0], f[1] self.data["labels_mean"][label], self.data["labels_std"][label] = f[0], f[1]
@@ -901,7 +905,7 @@ class FreqaiDataKitchen:
self.find_labels(dataframe) self.find_labels(dataframe)
for key in self.label_list: for key in self.label_list:
if pd.api.types.is_string_dtype(dataframe[key].dtype): if dataframe[key].dtype == object:
self.unique_classes[key] = dataframe[key].dropna().unique() self.unique_classes[key] = dataframe[key].dropna().unique()
if self.unique_classes: if self.unique_classes:
@@ -986,7 +990,7 @@ class FreqaiDataKitchen:
are populated. are populated.
The main example use is when predicting maxima and minima, the argrelextrema The main example use is when predicting maxima and minima, the argrelextrema
function cannot know the maxima/minima at the edges of the timerange. To improve function cannot know the maxima/minima at the edges of the timerange. To improve
model accuracy, it is best to compute argrelextrema on the full timerange model accuracy, it is best to compute argrelextrema on the full timerange
and then use this function to cut off the edges (buffer) by the kernel. and then use this function to cut off the edges (buffer) by the kernel.
+3 -3
View File
@@ -676,7 +676,7 @@ class IFreqaiModel(ABC):
self.set_start_dry_live_date(strat_df) self.set_start_dry_live_date(strat_df)
for label in hist_preds_df.columns: for label in hist_preds_df.columns:
if pd.api.types.is_string_dtype(hist_preds_df[label].dtype): if hist_preds_df[label].dtype == object:
continue continue
hist_preds_df[f"{label}_mean"] = 0 hist_preds_df[f"{label}_mean"] = 0
hist_preds_df[f"{label}_std"] = 0 hist_preds_df[f"{label}_std"] = 0
@@ -706,7 +706,7 @@ class IFreqaiModel(ABC):
num_candles = self.freqai_info.get("fit_live_predictions_candles", 100) num_candles = self.freqai_info.get("fit_live_predictions_candles", 100)
dk.data["labels_mean"], dk.data["labels_std"] = {}, {} dk.data["labels_mean"], dk.data["labels_std"] = {}, {}
for label in full_labels: for label in full_labels:
if pd.api.types.is_string_dtype(self.dd.historic_predictions[dk.pair][label].dtype): if self.dd.historic_predictions[dk.pair][label].dtype == object:
continue continue
f = spy.stats.norm.fit(self.dd.historic_predictions[dk.pair][label].tail(num_candles)) f = spy.stats.norm.fit(self.dd.historic_predictions[dk.pair][label].tail(num_candles))
dk.data["labels_mean"][label], dk.data["labels_std"][label] = f[0], f[1] dk.data["labels_mean"][label], dk.data["labels_std"][label] = f[0], f[1]
@@ -896,7 +896,7 @@ class IFreqaiModel(ABC):
] ]
self.fit_live_predictions(self.dk, self.dk.pair) self.fit_live_predictions(self.dk, self.dk.pair)
for label in label_columns: for label in label_columns:
if pd.api.types.is_string_dtype(dk.full_df[label].dtype): if dk.full_df[label].dtype == object:
continue continue
if "labels_mean" in self.dk.data: if "labels_mean" in self.dk.data:
dk.full_df.at[index, f"{label}_mean"] = self.dk.data["labels_mean"][ dk.full_df.at[index, f"{label}_mean"] = self.dk.data["labels_mean"][
@@ -1,12 +1,10 @@
import logging import logging
from collections.abc import Callable
from typing import Any from typing import Any
from lightgbm import LGBMClassifier from lightgbm import LGBMClassifier
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.tensorboard import LightGBMCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,10 +46,6 @@ class LightGBMClassifier(BaseClassifierModel):
init_model = self.get_init_model(dk.pair) init_model = self.get_init_model(dk.pair)
model = LGBMClassifier(**self.model_training_parameters) model = LGBMClassifier(**self.model_training_parameters)
activate_tensorboard = self.freqai_info.get("activate_tensorboard", True)
callbacks: list[Callable[..., Any]] = []
if LightGBMCallback is not None:
callbacks = [LightGBMCallback(dk.data_path, activate_tensorboard)]
model.fit( model.fit(
X=X, X=X,
y=y, y=y,
@@ -59,7 +53,6 @@ class LightGBMClassifier(BaseClassifierModel):
sample_weight=train_weights, sample_weight=train_weights,
eval_sample_weight=[test_weights], eval_sample_weight=[test_weights],
init_model=init_model, init_model=init_model,
callbacks=callbacks,
) )
return model return model
@@ -6,7 +6,6 @@ from lightgbm import LGBMClassifier
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
from freqtrade.freqai.base_models.FreqaiMultiOutputClassifier import FreqaiMultiOutputClassifier from freqtrade.freqai.base_models.FreqaiMultiOutputClassifier import FreqaiMultiOutputClassifier
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.tensorboard import LightGBMCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,11 +53,6 @@ class LightGBMClassifierMultiTarget(BaseClassifierModel):
else: else:
init_models = [None] * y.shape[1] init_models = [None] * y.shape[1]
activate_tensorboard = self.freqai_info.get("activate_tensorboard", True)
callbacks = []
if LightGBMCallback is not None:
callbacks = [LightGBMCallback(dk.data_path, activate_tensorboard)]
fit_params = [] fit_params = []
for i in range(len(eval_sets)): for i in range(len(eval_sets)):
fit_params.append( fit_params.append(
@@ -66,7 +60,6 @@ class LightGBMClassifierMultiTarget(BaseClassifierModel):
"eval_set": eval_sets[i], "eval_set": eval_sets[i],
"eval_sample_weight": eval_weights, "eval_sample_weight": eval_weights,
"init_model": init_models[i], "init_model": init_models[i],
"callbacks": callbacks,
} }
) )
@@ -1,12 +1,10 @@
import logging import logging
from collections.abc import Callable
from typing import Any from typing import Any
from lightgbm import LGBMRegressor from lightgbm import LGBMRegressor
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.tensorboard import LightGBMCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,11 +42,6 @@ class LightGBMRegressor(BaseRegressionModel):
model = LGBMRegressor(**self.model_training_parameters) model = LGBMRegressor(**self.model_training_parameters)
activate_tensorboard = self.freqai_info.get("activate_tensorboard", True)
callbacks: list[Callable[..., Any]] = []
if LightGBMCallback is not None:
callbacks = [LightGBMCallback(dk.data_path, activate_tensorboard)]
model.fit( model.fit(
X=X, X=X,
y=y, y=y,
@@ -56,7 +49,6 @@ class LightGBMRegressor(BaseRegressionModel):
sample_weight=train_weights, sample_weight=train_weights,
eval_sample_weight=[eval_weights], eval_sample_weight=[eval_weights],
init_model=init_model, init_model=init_model,
callbacks=callbacks,
) )
return model return model
@@ -6,7 +6,6 @@ from lightgbm import LGBMRegressor
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
from freqtrade.freqai.base_models.FreqaiMultiOutputRegressor import FreqaiMultiOutputRegressor from freqtrade.freqai.base_models.FreqaiMultiOutputRegressor import FreqaiMultiOutputRegressor
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.tensorboard import LightGBMCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -56,11 +55,6 @@ class LightGBMRegressorMultiTarget(BaseRegressionModel):
else: else:
init_models = [None] * y.shape[1] init_models = [None] * y.shape[1]
activate_tensorboard = self.freqai_info.get("activate_tensorboard", True)
callbacks = []
if LightGBMCallback is not None:
callbacks = [LightGBMCallback(dk.data_path, activate_tensorboard)]
fit_params = [] fit_params = []
for i in range(len(eval_sets)): for i in range(len(eval_sets)):
fit_params.append( fit_params.append(
@@ -68,7 +62,6 @@ class LightGBMRegressorMultiTarget(BaseRegressionModel):
"eval_set": eval_sets[i], "eval_set": eval_sets[i],
"eval_sample_weight": eval_weights, "eval_sample_weight": eval_weights,
"init_model": init_models[i], "init_model": init_models[i],
"callbacks": callbacks,
} }
) )
@@ -63,7 +63,7 @@ class SKLearnRandomForestClassifier(BaseClassifierModel):
) -> tuple[DataFrame, npt.NDArray[np.int_]]: ) -> tuple[DataFrame, npt.NDArray[np.int_]]:
""" """
Filter the prediction features data and predict with it. Filter the prediction features data and predict with it.
:param unfiltered_df: Full dataframe for the current backtest period. :param unfiltered_df: Full dataframe for the current backtest period.
:return: :return:
:pred_df: dataframe containing the predictions :pred_df: dataframe containing the predictions
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove
@@ -67,7 +67,7 @@ class XGBoostRFClassifier(BaseClassifierModel):
) -> tuple[DataFrame, npt.NDArray[np.int_]]: ) -> tuple[DataFrame, npt.NDArray[np.int_]]:
""" """
Filter the prediction features data and predict with it. Filter the prediction features data and predict with it.
:param unfiltered_df: Full dataframe for the current backtest period. :param unfiltered_df: Full dataframe for the current backtest period.
:return: :return:
:pred_df: dataframe containing the predictions :pred_df: dataframe containing the predictions
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove
+1 -4
View File
@@ -1,11 +1,9 @@
# ensure users can still use a non-torch freqai version # ensure users can still use a non-torch freqai version
try: try:
from freqtrade.freqai.tensorboard.lightgbm_callback import LightGBMTensorboardCallback
from freqtrade.freqai.tensorboard.tensorboard import TensorBoardCallback, TensorboardLogger from freqtrade.freqai.tensorboard.tensorboard import TensorBoardCallback, TensorboardLogger
TBLogger = TensorboardLogger TBLogger = TensorboardLogger
TBCallback = TensorBoardCallback TBCallback = TensorBoardCallback
LightGBMCallback = LightGBMTensorboardCallback
except ModuleNotFoundError: except ModuleNotFoundError:
from freqtrade.freqai.tensorboard.base_tensorboard import ( from freqtrade.freqai.tensorboard.base_tensorboard import (
BaseTensorBoardCallback, BaseTensorBoardCallback,
@@ -14,6 +12,5 @@ except ModuleNotFoundError:
TBLogger = BaseTensorboardLogger # type: ignore TBLogger = BaseTensorboardLogger # type: ignore
TBCallback = BaseTensorBoardCallback # type: ignore TBCallback = BaseTensorBoardCallback # type: ignore
LightGBMCallback = None # type: ignore
__all__ = ("TBLogger", "TBCallback", "LightGBMCallback") __all__ = ("TBLogger", "TBCallback")
@@ -1,24 +0,0 @@
from __future__ import annotations
from freqtrade.freqai.tensorboard.tensorboard import TensorboardLogger
class LightGBMTensorboardCallback:
def __init__(self, logdir, activate: bool) -> None:
self.activate = activate
self.logger = TensorboardLogger(logdir, activate)
def __call__(self, env) -> None:
if not self.activate:
return
evals = getattr(env, "evaluation_result_list", None)
if not evals:
return
for data_name, metric_name, value, _ in evals:
self.logger.log_scalar(f"{data_name}-{metric_name}", value, env.iteration)
end_iteration = getattr(env, "end_iteration", None)
if end_iteration is not None and env.iteration + 1 >= end_iteration:
self.logger.close()
+98 -108
View File
@@ -92,106 +92,100 @@ class FreqtradeBot(LoggingMixin):
exchange_config: ExchangeConfig = deepcopy(config["exchange"]) exchange_config: ExchangeConfig = deepcopy(config["exchange"])
# Remove credentials from original exchange config to avoid accidental credential exposure # Remove credentials from original exchange config to avoid accidental credential exposure
remove_exchange_credentials(config["exchange"], True) remove_exchange_credentials(config["exchange"], True)
try:
self.exchange = ExchangeResolver.load_exchange( self.exchange = ExchangeResolver.load_exchange(
self.config, exchange_config=exchange_config, load_leverage_tiers=True self.config, exchange_config=exchange_config, load_leverage_tiers=True
)
self.strategy: IStrategy = StrategyResolver.load_strategy(self.config)
# Check config consistency here since strategies can set certain options
validate_config_consistency(config)
# Re-validate exchange compatibility
self.exchange.validate_config(self.config)
init_db(self.config["db_url"])
self.wallets = Wallets(self.config, self.exchange)
PairLocks.timeframe = self.config["timeframe"]
self.trading_mode: TradingMode = self.config.get("trading_mode", TradingMode.SPOT)
self.margin_mode: MarginMode = self.config.get("margin_mode", MarginMode.NONE)
self.last_process: datetime | None = None
# RPC runs in separate threads, can start handling external commands just after
# initialization, even before Freqtradebot has a chance to start its throttling,
# so anything in the Freqtradebot instance should be ready (initialized), including
# the initial state of the bot.
# Keep this at the end of this initialization method.
self.rpc: RPCManager = RPCManager(self)
self.dataprovider = DataProvider(self.config, self.exchange, rpc=self.rpc)
self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
self.dataprovider.add_pairlisthandler(self.pairlists)
# Attach Dataprovider to strategy instance
self.strategy.dp = self.dataprovider
# Attach Wallets to strategy instance
self.strategy.wallets = self.wallets
# Init ExternalMessageConsumer if enabled
self.emc = (
ExternalMessageConsumer(self.config, self.dataprovider)
if self.config.get("external_message_consumer", {}).get("enabled", False)
else None
)
logger.info("Starting initial pairlist refresh")
with MeasureTime(
lambda duration, _: logger.info(f"Initial Pairlist refresh took {duration:.2f}s"), 0
):
self.active_pair_whitelist = self._refresh_active_whitelist()
# Set initial bot state from config
initial_state = self.config.get("initial_state")
self.state = State[initial_state.upper()] if initial_state else State.STOPPED
# Protect exit-logic from forcesell and vice versa
self._exit_lock = Lock()
timeframe_secs = timeframe_to_seconds(self.strategy.timeframe)
self._exit_reason_cache = PeriodicCache(100, ttl=timeframe_secs)
LoggingMixin.__init__(self, logger, timeframe_secs)
self._schedule = Scheduler()
if self.trading_mode == TradingMode.FUTURES:
def update():
self.update_funding_fees()
self.update_all_liquidation_prices()
self.wallets.update()
# This would be more efficient if scheduled in utc time, and performed at each
# funding interval, specified by funding_fee_times on the exchange classes
# However, this reduces the precision - and might therefore lead to problems.
for time_slot in range(0, 24):
for minutes in [1, 31]:
t = str(time(time_slot, minutes, 2))
self._schedule.every().day.at(t).do(update)
self._schedule.every().day.at("00:02").do(self.exchange.ws_connection_reset)
self.strategy.ft_bot_start()
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
self.protections = ProtectionManager(self.config, self.strategy.protections)
def log_took_too_long(duration: float, time_limit: float):
logger.warning(
f"Strategy analysis took {duration:.2f}s, more than 25% of the timeframe "
f"({time_limit:.2f}s). This can lead to delayed orders and missed signals."
"Consider either reducing the amount of work your strategy performs "
"or reduce the amount of pairs in the Pairlist."
) )
self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) self._measure_execution = MeasureTime(log_took_too_long, timeframe_secs * 0.25)
# Check config consistency here since strategies can set certain options
validate_config_consistency(config)
# Re-validate exchange compatibility
self.exchange.validate_config(self.config)
init_db(self.config["db_url"])
self.wallets = Wallets(self.config, self.exchange)
PairLocks.timeframe = self.config["timeframe"]
self.trading_mode: TradingMode = self.config.get("trading_mode", TradingMode.SPOT)
self.margin_mode: MarginMode = self.config.get("margin_mode", MarginMode.NONE)
self.last_process: datetime | None = None
# RPC runs in separate threads, can start handling external commands just after
# initialization, even before Freqtradebot has a chance to start its throttling,
# so anything in the Freqtradebot instance should be ready (initialized), including
# the initial state of the bot.
# Keep this at the end of this initialization method.
self.rpc: RPCManager = RPCManager(self)
self.dataprovider = DataProvider(self.config, self.exchange, rpc=self.rpc)
self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
self.dataprovider.add_pairlisthandler(self.pairlists)
# Attach Dataprovider to strategy instance
self.strategy.dp = self.dataprovider
# Attach Wallets to strategy instance
self.strategy.wallets = self.wallets
# Init ExternalMessageConsumer if enabled
self.emc: ExternalMessageConsumer | None = (
ExternalMessageConsumer(self.config, self.dataprovider)
if self.config.get("external_message_consumer", {}).get("enabled", False)
else None
)
logger.info("Starting initial pairlist refresh")
with MeasureTime(
lambda duration, _: logger.info(f"Initial Pairlist refresh took {duration:.2f}s"), 0
):
self.active_pair_whitelist = self._refresh_active_whitelist()
# Set initial bot state from config
initial_state = self.config.get("initial_state")
self.state = State[initial_state.upper()] if initial_state else State.STOPPED
# Protect exit-logic from forcesell and vice versa
self._exit_lock = Lock()
timeframe_secs = timeframe_to_seconds(self.strategy.timeframe)
self._exit_reason_cache = PeriodicCache(100, ttl=timeframe_secs)
LoggingMixin.__init__(self, logger, timeframe_secs)
self._schedule = Scheduler()
if self.trading_mode == TradingMode.FUTURES:
def update():
self.update_funding_fees()
self.update_all_liquidation_prices()
self.wallets.update()
# This would be more efficient if scheduled in utc time, and performed at each
# funding interval, specified by funding_fee_times on the exchange classes
# However, this reduces the precision - and might therefore lead to problems.
for time_slot in range(0, 24):
for minutes in [1, 31]:
t = str(time(time_slot, minutes, 2))
self._schedule.every().day.at(t).do(update)
self._schedule.every().day.at("00:02").do(self.exchange.ws_connection_reset)
self._schedule.every().day.at("00:07").do(self.wallets.record_wallet_state)
self.strategy.ft_bot_start()
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
self.protections = ProtectionManager(self.config, self.strategy.protections)
def log_took_too_long(duration: float, time_limit: float):
logger.warning(
f"Strategy analysis took {duration:.2f}s, more than 25% of the timeframe "
f"({time_limit:.2f}s). This can lead to delayed orders and missed signals."
"Consider either reducing the amount of work your strategy performs "
"or reduce the amount of pairs in the Pairlist."
)
self._measure_execution = MeasureTime(log_took_too_long, timeframe_secs * 0.25)
except Exception as e:
# Graceful shutdown in case of failed initialization.
self.cleanup()
raise e from e
def notify_status(self, msg: str, msg_type=RPCMessageType.STATUS) -> None: def notify_status(self, msg: str, msg_type=RPCMessageType.STATUS) -> None:
""" """
@@ -217,18 +211,14 @@ class FreqtradeBot(LoggingMixin):
logger.warning(f"Exception during cleanup: {e.__class__.__name__} {e}") logger.warning(f"Exception during cleanup: {e.__class__.__name__} {e}")
finally: finally:
if getattr(self, "strategy", None): self.strategy.ft_bot_cleanup()
self.strategy.ft_bot_cleanup()
if getattr(self, "rpc", None): self.rpc.cleanup()
self.rpc.cleanup() if self.emc:
if hasattr(self, "emc") and self.emc:
self.emc.shutdown() self.emc.shutdown()
if getattr(self, "exchange", None): self.exchange.close()
self.exchange.close()
try: try:
if hasattr(Trade, "session"): Trade.commit()
Trade.commit()
except Exception: except Exception:
# Exceptions here will be happening if the db disappeared. # Exceptions here will be happening if the db disappeared.
# At which point we can no longer commit anyway. # At which point we can no longer commit anyway.
@@ -239,7 +229,7 @@ class FreqtradeBot(LoggingMixin):
Called on startup and after reloading the bot - triggers notifications and Called on startup and after reloading the bot - triggers notifications and
performs startup tasks performs startup tasks
""" """
migrate_live_content(self.config, self.exchange, self.wallets.get_starting_balance()) migrate_live_content(self.config, self.exchange)
set_startup_time() set_startup_time()
self.rpc.startup_messages(self.config, self.pairlists, self.protections) self.rpc.startup_messages(self.config, self.pairlists, self.protections)
@@ -55,7 +55,6 @@ class BacktestContentTypeIcomplete(TypedDict, total=False):
backtest_start_time: int backtest_start_time: int
backtest_end_time: int backtest_end_time: int
run_id: str run_id: str
wallet_summary: DataFrame
class BacktestContentType(BacktestContentTypeIcomplete, total=True): class BacktestContentType(BacktestContentTypeIcomplete, total=True):
-6
View File
@@ -214,12 +214,6 @@ def dataframe_to_json(dataframe: pd.DataFrame) -> str:
:param dataframe: A pandas DataFrame :param dataframe: A pandas DataFrame
:returns: A JSON string of the pandas DataFrame :returns: A JSON string of the pandas DataFrame
""" """
date_columns = dataframe.select_dtypes(include=["datetime", "datetime64", "datetimetz"])
# Explicit conversion to ms
# This used to be part of to_json, but was deprecated in pandas 3
for date_column in date_columns:
dataframe[date_column] = date_columns[date_column].dt.as_unit("ms").astype("int64")
return dataframe.to_json(orient="split") return dataframe.to_json(orient="split")
@@ -126,14 +126,14 @@ class LookaheadAnalysisSubFunctions:
csv_df = add_or_update_row(csv_df, new_row_data) csv_df = add_or_update_row(csv_df, new_row_data)
# Fill NaN values with a default value (e.g., 0) # Fill NaN values with a default value (e.g., 0)
csv_df["total_signals"] = csv_df["total_signals"].astype("int64").fillna(0) csv_df["total_signals"] = csv_df["total_signals"].astype(int).fillna(0)
csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype("int64").fillna(0) csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype(int).fillna(0)
csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype("int64").fillna(0) csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype(int).fillna(0)
# Convert columns to integers # Convert columns to integers
csv_df["total_signals"] = csv_df["total_signals"].astype("int64") csv_df["total_signals"] = csv_df["total_signals"].astype(int)
csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype("int64") csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype(int)
csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype("int64") csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype(int)
logger.info(f"saving {config['lookahead_analysis_exportfilename']}") logger.info(f"saving {config['lookahead_analysis_exportfilename']}")
csv_df.to_csv(config["lookahead_analysis_exportfilename"], index=False) csv_df.to_csv(config["lookahead_analysis_exportfilename"], index=False)
+1 -21
View File
@@ -51,7 +51,6 @@ from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.backtest_caching import get_strategy_run_id from freqtrade.optimize.backtest_caching import get_strategy_run_id
from freqtrade.optimize.bt_progress import BTProgress from freqtrade.optimize.bt_progress import BTProgress
from freqtrade.optimize.optimize_reports import ( from freqtrade.optimize.optimize_reports import (
convert_bt_wallet_collection,
generate_backtest_stats, generate_backtest_stats,
generate_rejected_signals, generate_rejected_signals,
generate_trade_signal_candles, generate_trade_signal_candles,
@@ -138,7 +137,6 @@ class Backtesting:
} }
self.rejected_dict: dict[str, list] = {} self.rejected_dict: dict[str, list] = {}
self.starting_balance: float = 0.0 self.starting_balance: float = 0.0
self.wallet_captures: list = []
self._exchange_name = self.config["exchange"]["name"] self._exchange_name = self.config["exchange"]["name"]
self.__initial_backtest = exchange is None self.__initial_backtest = exchange is None
@@ -453,7 +451,6 @@ class Backtesting:
self.replaced_entry_orders = 0 self.replaced_entry_orders = 0
self.canceled_exit_orders = 0 self.canceled_exit_orders = 0
self.replaced_exit_orders = 0 self.replaced_exit_orders = 0
self.wallet_captures = []
self.dataprovider.clear_cache() self.dataprovider.clear_cache()
if enable_protections: if enable_protections:
self._load_protections(self.strategy) self._load_protections(self.strategy)
@@ -757,7 +754,7 @@ class Backtesting:
) -> bool: ) -> bool:
""" """
Check if an order is open and if it should've filled. Check if an order is open and if it should've filled.
:return: True if the order filled. :return: True if the order filled.
""" """
if order and self._get_order_filled(order.ft_price, row): if order and self._get_order_filled(order.ft_price, row):
order.close_bt_order(current_date, trade) order.close_bt_order(current_date, trade)
@@ -1606,7 +1603,6 @@ class Backtesting:
pair_detail_cache: dict[str, list[tuple]] = {} pair_detail_cache: dict[str, list[tuple]] = {}
pair_tradedir_cache: dict[str, LongShort | None] = {} pair_tradedir_cache: dict[str, LongShort | None] = {}
pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open] pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open]
self._capture_wallet(current_time, self.strategy.config["stake_currency"], 1)
for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det( for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det(
current_time, pairs current_time, pairs
@@ -1631,7 +1627,6 @@ class Backtesting:
) )
trade_dir = self.check_for_trade_entry(row) trade_dir = self.check_for_trade_entry(row)
pair_tradedir_cache[pair] = trade_dir pair_tradedir_cache[pair] = trade_dir
self._capture_wallet(current_time, pair.split("/")[0], row[OPEN_IDX])
else: else:
# Detail candle - from cache. # Detail candle - from cache.
@@ -1685,15 +1680,6 @@ class Backtesting:
yield current_time_det, pair, row, is_last_row, trade_dir yield current_time_det, pair, row, is_last_row, trade_dir
self.progress.increment() self.progress.increment()
def _capture_wallet(self, current_time: datetime, currency: str, price: float) -> None:
"""
Capture the current wallet state.
"""
if self.dataprovider.runmode != RunMode.BACKTEST:
return
if total := self.wallets.get_total(currency):
self.wallet_captures.append((current_time, currency, price, total))
def backtest( def backtest(
self, processed: dict, start_date: datetime, end_date: datetime self, processed: dict, start_date: datetime, end_date: datetime
) -> BacktestContentTypeIcomplete: ) -> BacktestContentTypeIcomplete:
@@ -1753,7 +1739,6 @@ class Backtesting:
"canceled_entry_orders": self.canceled_entry_orders, "canceled_entry_orders": self.canceled_entry_orders,
"replaced_entry_orders": self.replaced_entry_orders, "replaced_entry_orders": self.replaced_entry_orders,
"final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]), "final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
"wallet_summary": convert_bt_wallet_collection(self.wallet_captures),
} }
def backtest_one_strategy( def backtest_one_strategy(
@@ -1882,11 +1867,6 @@ class Backtesting:
dt_appendix, dt_appendix,
market_change_data=combined_res, market_change_data=combined_res,
analysis_results=self.analysis_results, analysis_results=self.analysis_results,
wallet_summary={
s: x["wallet_summary"]
for s, x in self.all_bt_content.items()
if "wallet_summary" in x
},
strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist}, strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
) )
@@ -12,7 +12,6 @@ from freqtrade.optimize.optimize_reports.bt_output import (
) )
from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results
from freqtrade.optimize.optimize_reports.optimize_reports import ( from freqtrade.optimize.optimize_reports.optimize_reports import (
convert_bt_wallet_collection,
generate_all_periodic_breakdown_stats, generate_all_periodic_breakdown_stats,
generate_backtest_stats, generate_backtest_stats,
generate_daily_stats, generate_daily_stats,
@@ -1,8 +1,6 @@
import logging import logging
from typing import Any, Literal from typing import Any, Literal
from rich.text import Text
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config
from freqtrade.ft_types import BacktestResultType from freqtrade.ft_types import BacktestResultType
from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats
@@ -11,8 +9,6 @@ from freqtrade.util import decimals_per_coin, fmt_coin, print_rich_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__EMPTY_LINE = ("", "")
def _get_line_floatfmt(stake_currency: str) -> list[str]: def _get_line_floatfmt(stake_currency: str) -> list[str]:
""" """
@@ -205,7 +201,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
short_metrics = ( short_metrics = (
[ [
__EMPTY_LINE, # Empty line to improve readability ("", ""), # Empty line to improve readability
( (
"Long / Short trades", "Long / Short trades",
f"{strat_results.get('trade_count_long', 'total_trades')} / " f"{strat_results.get('trade_count_long', 'total_trades')} / "
@@ -226,7 +222,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
else [] else []
) )
drawdown_metrics: list[tuple[str | Text, str | Text]] = [] drawdown_metrics = []
if "max_relative_drawdown" in strat_results: if "max_relative_drawdown" in strat_results:
# Compatibility to show old hyperopt results # Compatibility to show old hyperopt results
drawdown_metrics.append( drawdown_metrics.append(
@@ -291,79 +287,6 @@ def text_table_add_metrics(strat_results: dict) -> None:
if "trading_mode" in strat_results if "trading_mode" in strat_results
else [] else []
) )
wallet_metrics: list[tuple[str, str]] = [
(
"Min/Max balance (closed trades)",
f"{fmt_coin(strat_results['csum_min'], stake)} / "
f"{fmt_coin(strat_results['csum_max'], stake)}",
),
]
wallet_stats = strat_results.get("wallet_stats", {})
if wallet_stats:
drawdown_metrics.extend(
[
__EMPTY_LINE, # Empty line to improve readability
(Text("Wallet based Metrics", style="bold"), ""),
(
"Min/Max balance (wallet balance)",
f"{fmt_coin(wallet_stats['low_balance'], stake)} / "
f"{fmt_coin(wallet_stats['high_balance'], stake)}",
),
(
"Min/Max balance dates (wallet balance)",
f"{wallet_stats['low_date']} / {wallet_stats['high_date']}",
),
]
)
if "max_drawdown_abs" in wallet_stats:
# Assume that if sharpe is there, all others are there as well.
drawdown_metrics.extend(
[
(
"Max % of account underwater (balance)",
f"{wallet_stats['max_relative_drawdown']:.2%}",
),
(
"Absolute drawdown (wallet balance)",
f"{fmt_coin(wallet_stats['max_drawdown_abs'], stake)} "
f"({wallet_stats['max_drawdown_account']:.2%})",
),
(
"Drawdown duration",
wallet_stats["drawdown_duration"]
if "drawdown_duration" in wallet_stats
else "N/A",
),
(
"Profit at drawdown start",
fmt_coin(wallet_stats["max_drawdown_high"], stake),
),
(
"Profit at drawdown end",
fmt_coin(wallet_stats["max_drawdown_low"], stake),
),
("Drawdown start", wallet_stats["drawdown_start"]),
("Drawdown end", wallet_stats["drawdown_end"]),
(
"Sharpe (daily wallet balance)",
f"{wallet_stats['sharpe']:.2f}"
if wallet_stats and "sharpe" in wallet_stats
else "N/A",
),
(
"Sortino (daily wallet balance)",
f"{wallet_stats['sortino']:.2f}"
if wallet_stats and "sortino" in wallet_stats
else "N/A",
),
(
"Calmar (daily wallet balance)",
f"{wallet_stats['calmar']:.2f}"
if wallet_stats and "calmar" in wallet_stats
else "N/A",
),
]
)
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
# command stores these results and newer version of freqtrade must be able to handle old # command stores these results and newer version of freqtrade must be able to handle old
@@ -373,7 +296,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
("Backtesting to", strat_results["backtest_end"]), ("Backtesting to", strat_results["backtest_end"]),
*trading_mode, *trading_mode,
("Max open trades", strat_results["max_open_trades"]), ("Max open trades", strat_results["max_open_trades"]),
__EMPTY_LINE, # Empty line to improve readability ("", ""), # Empty line to improve readability
( (
"Total/Daily Avg Trades", "Total/Daily Avg Trades",
f"{strat_results['total_trades']} / {strat_results['trades_per_day']}", f"{strat_results['total_trades']} / {strat_results['trades_per_day']}",
@@ -392,18 +315,9 @@ def text_table_add_metrics(strat_results: dict) -> None:
), ),
("Total profit %", f"{strat_results['profit_total']:.2%}"), ("Total profit %", f"{strat_results['profit_total']:.2%}"),
("CAGR %", f"{strat_results['cagr']:.2%}" if "cagr" in strat_results else "N/A"), ("CAGR %", f"{strat_results['cagr']:.2%}" if "cagr" in strat_results else "N/A"),
( ("Sortino", f"{strat_results['sortino']:.2f}" if "sortino" in strat_results else "N/A"),
"Sharpe (closed trades)", ("Sharpe", f"{strat_results['sharpe']:.2f}" if "sharpe" in strat_results else "N/A"),
f"{strat_results['sharpe']:.2f}" if "sharpe" in strat_results else "N/A", ("Calmar", f"{strat_results['calmar']:.2f}" if "calmar" in strat_results else "N/A"),
),
(
"Sortino (closed trades)",
f"{strat_results['sortino']:.2f}" if "sortino" in strat_results else "N/A",
),
(
"Calmar (closed trades)",
f"{strat_results['calmar']:.2f}" if "calmar" in strat_results else "N/A",
),
("SQN", f"{strat_results['sqn']:.2f}" if "sqn" in strat_results else "N/A"), ("SQN", f"{strat_results['sqn']:.2f}" if "sqn" in strat_results else "N/A"),
( (
"Profit factor", "Profit factor",
@@ -432,13 +346,12 @@ def text_table_add_metrics(strat_results: dict) -> None:
"Avg. stake amount", "Avg. stake amount",
fmt_coin(strat_results["avg_stake_amount"], stake), fmt_coin(strat_results["avg_stake_amount"], stake),
), ),
("Market change", f"{strat_results['market_change']:.2%}"),
( (
"Total trade volume", "Total trade volume",
fmt_coin(strat_results["total_volume"], stake), fmt_coin(strat_results["total_volume"], stake),
), ),
*short_metrics, *short_metrics,
__EMPTY_LINE, # Empty line to improve readability ("", ""), # Empty line to improve readability
( (
"Best Pair", "Best Pair",
f"{strat_results['best_pair']['key']} " f"{strat_results['best_pair']['key']} "
@@ -494,9 +407,11 @@ def text_table_add_metrics(strat_results: dict) -> None:
f"{strat_results.get('timedout_exit_orders', 'N/A')}", f"{strat_results.get('timedout_exit_orders', 'N/A')}",
), ),
*entry_adjustment_metrics, *entry_adjustment_metrics,
__EMPTY_LINE, # Empty line to improve readability ("", ""), # Empty line to improve readability
*wallet_metrics, ("Min balance", fmt_coin(strat_results["csum_min"], stake)),
("Max balance", fmt_coin(strat_results["csum_max"], stake)),
*drawdown_metrics, *drawdown_metrics,
("Market change", f"{strat_results['market_change']:.2%}"),
] ]
print_rich_table(metrics, ["Metric", "Value"], summary="SUMMARY METRICS", justify="left") print_rich_table(metrics, ["Metric", "Value"], summary="SUMMARY METRICS", justify="left")
@@ -52,7 +52,6 @@ def store_backtest_results(
dtappendix: str, dtappendix: str,
*, *,
market_change_data: DataFrame | None = None, market_change_data: DataFrame | None = None,
wallet_summary: dict[str, DataFrame] | None = None,
analysis_results: dict[str, dict[str, DataFrame]] | None = None, analysis_results: dict[str, dict[str, DataFrame]] | None = None,
strategy_files: dict[str, str] | None = None, strategy_files: dict[str, str] | None = None,
) -> Path: ) -> Path:
@@ -124,15 +123,6 @@ def store_backtest_results(
market_change_buf.seek(0) market_change_buf.seek(0)
zipf.writestr(market_change_name, market_change_buf.getvalue()) zipf.writestr(market_change_name, market_change_buf.getvalue())
# Add wallet summary if present
if wallet_summary is not None:
for strategy, df in wallet_summary.items():
wallet_name = f"{base_filename.stem}_{strategy}_wallet.feather"
wallet_buf = BytesIO()
df.reset_index().to_feather(wallet_buf, compression_level=9, compression="lz4")
wallet_buf.seek(0)
zipf.writestr(wallet_name, wallet_buf.getvalue())
# Add analysis results if present and running in backtest mode # Add analysis results if present and running in backtest mode
if ( if (
config.get("export", "none") == "signals" config.get("export", "none") == "signals"
@@ -10,16 +10,12 @@ from freqtrade.constants import BACKTEST_BREAKDOWNS, DATETIME_PRINT_FORMAT
from freqtrade.data.metrics import ( from freqtrade.data.metrics import (
calculate_cagr, calculate_cagr,
calculate_calmar, calculate_calmar,
calculate_calmar_from_balance,
calculate_csum, calculate_csum,
calculate_expectancy, calculate_expectancy,
calculate_market_change, calculate_market_change,
calculate_max_drawdown, calculate_max_drawdown,
calculate_max_drawdown_from_balance,
calculate_sharpe, calculate_sharpe,
calculate_sharpe_from_balance,
calculate_sortino, calculate_sortino,
calculate_sortino_from_balance,
calculate_sqn, calculate_sqn,
) )
from freqtrade.ft_types import ( from freqtrade.ft_types import (
@@ -33,94 +29,6 @@ from freqtrade.util import decimals_per_coin, fmt_coin, format_duration, get_dry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def convert_bt_wallet_collection(wallet_captures: list[tuple]) -> DataFrame:
"""
Convert the wallet capture list to a DataFrame.
Assumes the wallet_captures list contains tuples with the following structure:
(date, currency, price, balance).
"""
if len(wallet_captures) == 0:
return DataFrame()
return DataFrame(
wallet_captures,
columns=["date", "currency", "rate", "balance"],
)
def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str, Any]:
"""Generate wallet statistics from the wallet DataFrame."""
if wallet_df is None or wallet_df.empty:
return {}
wallet_df.loc[:, "total_quote"] = wallet_df["rate"] * wallet_df["balance"]
# Group by date to get total wallet value at each timestamp
wallet = wallet_df.groupby("date")["total_quote"].sum().reset_index()
total_quote = wallet["total_quote"]
low_idx = total_quote.idxmin()
high_idx = total_quote.idxmax()
start_balance = wallet.iloc[0]["total_quote"]
end_balance = wallet.iloc[-1]["total_quote"]
high_balance = total_quote.loc[high_idx]
low_balance = total_quote.loc[low_idx]
low_date = wallet.loc[low_idx, "date"]
high_date = wallet.loc[high_idx, "date"]
sharpe = calculate_sharpe_from_balance(wallet)
sortino = calculate_sortino_from_balance(wallet)
calmar = calculate_calmar_from_balance(wallet)
try:
drawdown = calculate_max_drawdown_from_balance(wallet)
# max_relative_drawdown = Underwater
drawdown_duration = drawdown.low_date - drawdown.high_date
except ValueError:
drawdown = None
drawdown_duration = timedelta()
try:
underwater = calculate_max_drawdown_from_balance(wallet, relative=True)
except ValueError:
underwater = None
return {
"start_balance": start_balance,
"end_balance": end_balance,
"high_balance": high_balance,
"low_balance": low_balance,
"sharpe": sharpe,
"sortino": sortino,
"calmar": calmar,
"low_date": low_date.strftime(DATETIME_PRINT_FORMAT),
"low_ts": int(low_date.timestamp() * 1000),
"high_date": high_date.strftime(DATETIME_PRINT_FORMAT),
"high_ts": int(high_date.timestamp() * 1000),
# Drawdown metrics
"max_drawdown_account": drawdown.relative_account_drawdown if drawdown else 0.0,
"max_relative_drawdown": underwater.relative_account_drawdown if underwater else 0.0,
"max_drawdown_abs": drawdown.drawdown_abs if drawdown else 0.0,
"drawdown_start": (
drawdown.high_date.strftime(DATETIME_PRINT_FORMAT)
if drawdown and drawdown.high_date is not None
else None
),
"drawdown_start_ts": (
int(drawdown.high_date.timestamp() * 1000)
if drawdown and drawdown.high_date is not None
else None
),
"drawdown_end": (
drawdown.low_date.strftime(DATETIME_PRINT_FORMAT)
if drawdown and drawdown.low_date is not None
else None
),
"drawdown_end_ts": (
int(drawdown.low_date.timestamp() * 1000)
if drawdown and drawdown.low_date is not None
else None
),
"drawdown_duration": drawdown_duration,
"drawdown_duration_s": drawdown_duration.total_seconds(),
"max_drawdown_low": drawdown.low_value if drawdown else 0.0,
"max_drawdown_high": drawdown.high_value if drawdown else 0.0,
}
def generate_trade_signal_candles( def generate_trade_signal_candles(
preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str
) -> dict[str, DataFrame]: ) -> dict[str, DataFrame]:
@@ -247,7 +155,7 @@ def generate_pair_metrics( #
skip_nan: bool = False, skip_nan: bool = False,
) -> list[dict]: ) -> list[dict]:
""" """
Generates and returns a list for the given backtest data and the results dataframe Generates and returns a list for the given backtest data and the results dataframe
:param pairlist: Pairlist used :param pairlist: Pairlist used
:param stake_currency: stake-currency - used to correctly name headers :param stake_currency: stake-currency - used to correctly name headers
:param starting_balance: Starting balance :param starting_balance: Starting balance
@@ -340,7 +248,7 @@ def generate_strategy_comparison(bt_stats: dict) -> list[dict]:
def _get_resample_from_period(period: str) -> str: def _get_resample_from_period(period: str) -> str:
if period == "day": if period == "day":
return "1D" return "1d"
if period == "week": if period == "week":
# Weekly defaulting to Monday. # Weekly defaulting to Monday.
return "1W-MON" return "1W-MON"
@@ -530,8 +438,8 @@ def generate_daily_stats(results: DataFrame) -> dict[str, Any]:
"losing_days": 0, "losing_days": 0,
"daily_profit_list": [], "daily_profit_list": [],
} }
daily_profit_rel = results.resample("1D", on="close_date")["profit_ratio"].sum() daily_profit_rel = results.resample("1d", on="close_date")["profit_ratio"].sum()
daily_profit = results.resample("1D", on="close_date")["profit_abs"].sum().round(10) daily_profit = results.resample("1d", on="close_date")["profit_abs"].sum().round(10)
worst_rel = min(daily_profit_rel) worst_rel = min(daily_profit_rel)
best_rel = max(daily_profit_rel) best_rel = max(daily_profit_rel)
worst = min(daily_profit) worst = min(daily_profit)
@@ -684,7 +592,6 @@ def generate_strategy_stats(
"sharpe": calculate_sharpe(results, min_date, max_date, start_balance), "sharpe": calculate_sharpe(results, min_date, max_date, start_balance),
"calmar": calculate_calmar(results, min_date, max_date, start_balance), "calmar": calculate_calmar(results, min_date, max_date, start_balance),
"sqn": calculate_sqn(results, start_balance), "sqn": calculate_sqn(results, start_balance),
"wallet_stats": generate_wallet_stats(content.get("wallet_summary"), stake_currency),
"profit_factor": profit_factor, "profit_factor": profit_factor,
"backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT), "backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT),
"backtest_start_ts": int(min_date.timestamp() * 1000), "backtest_start_ts": int(min_date.timestamp() * 1000),
+2 -2
View File
@@ -9,7 +9,7 @@ class SKDecimal(FloatDistribution):
*, *,
step: float | None = None, step: float | None = None,
decimals: int | None = None, decimals: int | None = None,
name: str | None = None, name=None,
): ):
""" """
FloatDistribution with a fixed step size. FloatDistribution with a fixed step size.
@@ -26,7 +26,7 @@ class SKDecimal(FloatDistribution):
raise ValueError("You must set one of decimals or step") raise ValueError("You must set one of decimals or step")
# Convert decimals to step # Convert decimals to step
self.step = step or (1 / 10**decimals if decimals else 1) self.step = step or (1 / 10**decimals if decimals else 1)
self.name = name or "" self.name = name
super().__init__( super().__init__(
low=round(low, decimals) if decimals else low, low=round(low, decimals) if decimals else low,
-1
View File
@@ -10,4 +10,3 @@ from freqtrade.persistence.usedb_context import (
disable_database_use, disable_database_use,
enable_database_use, enable_database_use,
) )
from freqtrade.persistence.wallet_history import WalletHistory
-81
View File
@@ -1,81 +0,0 @@
import logging
from sqlalchemy import func, select
from sqlalchemy.orm import make_transient
from freqtrade.persistence.base import SessionType
from freqtrade.persistence.custom_data import _CustomData
from freqtrade.persistence.key_value_store import _KeyValueStoreModel
from freqtrade.persistence.migrations import set_sequence_ids
from freqtrade.persistence.pairlock import PairLock
from freqtrade.persistence.trade_model import Order, Trade
from freqtrade.persistence.wallet_history import WalletHistory
logger = logging.getLogger(__name__)
def migrate_db(session_target: SessionType):
trade_count = 0
pairlock_count = 0
kv_count = 0
custom_data_count = 0
wallet_history_count = 0
for trade in Trade.get_trades():
trade_count += 1
make_transient(trade)
for o in trade.orders:
make_transient(o)
session_target.add(trade)
session_target.commit()
for pairlock in PairLock.get_all_locks():
pairlock_count += 1
make_transient(pairlock)
session_target.add(pairlock)
session_target.commit()
for kv in _KeyValueStoreModel.session.scalars(select(_KeyValueStoreModel)):
kv_count += 1
make_transient(kv)
session_target.add(kv)
session_target.commit()
for cd in _CustomData.session.scalars(select(_CustomData)):
custom_data_count += 1
make_transient(cd)
session_target.add(cd)
session_target.commit()
for wh in WalletHistory.session.scalars(select(WalletHistory)):
wallet_history_count += 1
make_transient(wh)
session_target.add(wh)
session_target.commit()
# Update sequences
max_trade_id = session_target.scalar(select(func.max(Trade.id)))
max_order_id = session_target.scalar(select(func.max(Order.id)))
max_pairlock_id = session_target.scalar(select(func.max(PairLock.id)))
max_kv_id = session_target.scalar(select(func.max(_KeyValueStoreModel.id)))
max_custom_data_id = session_target.scalar(select(func.max(_CustomData.id)))
max_wallet_history_id = session_target.scalar(select(func.max(WalletHistory.id)))
set_sequence_ids(
session_target.get_bind(),
trade_id=(max_trade_id or 0) + 1,
order_id=(max_order_id or 0) + 1,
pairlock_id=(max_pairlock_id or 0) + 1,
kv_id=(max_kv_id or 0) + 1,
custom_data_id=(max_custom_data_id or 0) + 1,
wallet_history_id=(max_wallet_history_id or 0) + 1,
)
logger.info(
f"Migrated {trade_count} Trades, {pairlock_count} Pairlocks, "
f"{kv_count} Key-Value pairs, {custom_data_count} Custom Data entries, "
f"and {wallet_history_count} Wallet History entries."
)
+1 -4
View File
@@ -18,13 +18,10 @@ class ValueTypesEnum(StrEnum):
INT = "int" INT = "int"
# must be < 50 characters to fit the database column
KeyStoreKeys = Literal[ KeyStoreKeys = Literal[
"bot_start_time", "bot_start_time",
"startup_time", "startup_time",
"binance_migration", "binance_migration",
"wallet_history_migration",
"wallet_history_migration_date",
] ]
@@ -38,7 +35,7 @@ class _KeyValueStoreModel(ModelBase):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[KeyStoreKeys] = mapped_column(String(50), nullable=False, index=True) key: Mapped[KeyStoreKeys] = mapped_column(String(25), nullable=False, index=True)
value_type: Mapped[ValueTypesEnum] = mapped_column(String(20), nullable=False) value_type: Mapped[ValueTypesEnum] = mapped_column(String(20), nullable=False)
+4 -50
View File
@@ -35,12 +35,10 @@ def get_last_sequence_ids(engine, sequence_name: str, table_back_name: str) -> i
if engine.name == "postgresql": if engine.name == "postgresql":
with engine.begin() as connection: with engine.begin() as connection:
last_id = connection.execute( last_id = connection.execute(text(f"select nextval('{sequence_name}')")).fetchone()[0]
text(f"""select nextval('"{sequence_name}"')""")
).fetchone()[0]
with engine.begin() as connection: with engine.begin() as connection:
connection.execute( connection.execute(
text(f'ALTER SEQUENCE "{sequence_name}" rename to "{table_back_name}_id_seq_bak"') text(f"ALTER SEQUENCE {sequence_name} rename to {table_back_name}_id_seq_bak")
) )
return last_id return last_id
@@ -53,7 +51,6 @@ def set_sequence_ids(
pairlock_id: int | None = None, pairlock_id: int | None = None,
kv_id: int | None = None, kv_id: int | None = None,
custom_data_id: int | None = None, custom_data_id: int | None = None,
wallet_history_id: int | None = None,
): ):
""" """
Set sequence ids to the given values. Set sequence ids to the given values.
@@ -65,7 +62,6 @@ def set_sequence_ids(
:param pairlock_id: value to set for pairlocks_id_seq (optional) :param pairlock_id: value to set for pairlocks_id_seq (optional)
:param kv_id: value to set for KeyValueStore_id_seq (optional) :param kv_id: value to set for KeyValueStore_id_seq (optional)
:param custom_data_id: value to set for trade_custom_data_id_seq (optional) :param custom_data_id: value to set for trade_custom_data_id_seq (optional)
:param wallet_history_id: value to set for wallet_history_id_seq (optional)
""" """
if engine.name == "postgresql": if engine.name == "postgresql":
with engine.begin() as connection: with engine.begin() as connection:
@@ -85,10 +81,6 @@ def set_sequence_ids(
connection.execute( connection.execute(
text(f"ALTER SEQUENCE trade_custom_data_id_seq RESTART WITH {custom_data_id}") text(f"ALTER SEQUENCE trade_custom_data_id_seq RESTART WITH {custom_data_id}")
) )
if wallet_history_id:
connection.execute(
text(f"ALTER SEQUENCE wallet_history_id_seq RESTART WITH {wallet_history_id}")
)
def drop_index_on_table(engine, inspector, table_bak_name): def drop_index_on_table(engine, inspector, table_bak_name):
@@ -96,9 +88,9 @@ def drop_index_on_table(engine, inspector, table_bak_name):
# drop indexes on backup table in new session # drop indexes on backup table in new session
for index in inspector.get_indexes(table_bak_name): for index in inspector.get_indexes(table_bak_name):
if engine.name == "mysql": if engine.name == "mysql":
connection.execute(text(f'drop index "{index["name"]}" on {table_bak_name}')) connection.execute(text(f"drop index {index['name']} on {table_bak_name}"))
else: else:
connection.execute(text(f'drop index "{index["name"]}"')) connection.execute(text(f"drop index {index['name']}"))
def migrate_trades_and_orders_table( def migrate_trades_and_orders_table(
@@ -323,31 +315,6 @@ def migrate_pairlocks_table(decl_base, inspector, engine, pairlock_back_name: st
set_sequence_ids(engine, pairlock_id=pairlock_id) set_sequence_ids(engine, pairlock_id=pairlock_id)
def migrate_kv_store_table(decl_base, inspector, engine, kv_store_back_name: str, cols: list):
# Schema migration necessary
with engine.begin() as connection:
connection.execute(text(f'alter table "KeyValueStore" rename to "{kv_store_back_name}"'))
drop_index_on_table(engine, inspector, kv_store_back_name)
kv_store_id = get_last_sequence_ids(engine, "KeyValueStore_id_seq", kv_store_back_name)
# let SQLAlchemy create the schema as required
decl_base.metadata.create_all(engine)
# Copy data back - following the correct schema
with engine.begin() as connection:
connection.execute(
text(
f"""insert into "KeyValueStore"
(id, key, value_type, string_value, datetime_value, float_value, int_value)
select id, key, value_type, string_value, datetime_value, float_value, int_value
from "{kv_store_back_name}"
"""
)
)
set_sequence_ids(engine, kv_id=kv_store_id)
def set_sqlite_to_wal(engine): def set_sqlite_to_wal(engine):
if engine.name == "sqlite" and str(engine.url) != "sqlite://": if engine.name == "sqlite" and str(engine.url) != "sqlite://":
# Set Mode to # Set Mode to
@@ -418,15 +385,12 @@ def check_migrate(engine: Engine, decl_base, previous_tables: list[str]) -> None
cols_trades = inspector.get_columns("trades") cols_trades = inspector.get_columns("trades")
cols_orders = inspector.get_columns("orders") cols_orders = inspector.get_columns("orders")
cols_pairlocks = inspector.get_columns("pairlocks") cols_pairlocks = inspector.get_columns("pairlocks")
cols_kv_store = inspector.get_columns("KeyValueStore")
tabs = get_table_names_for_table(inspector, "trades") tabs = get_table_names_for_table(inspector, "trades")
table_back_name = get_backup_name(tabs, "trades_bak") table_back_name = get_backup_name(tabs, "trades_bak")
order_tabs = get_table_names_for_table(inspector, "orders") order_tabs = get_table_names_for_table(inspector, "orders")
order_table_bak_name = get_backup_name(order_tabs, "orders_bak") order_table_bak_name = get_backup_name(order_tabs, "orders_bak")
pairlock_tabs = get_table_names_for_table(inspector, "pairlocks") pairlock_tabs = get_table_names_for_table(inspector, "pairlocks")
pairlock_table_bak_name = get_backup_name(pairlock_tabs, "pairlocks_bak") pairlock_table_bak_name = get_backup_name(pairlock_tabs, "pairlocks_bak")
kv_store_tabs = get_table_names_for_table(inspector, "KeyValueStore")
kv_store_back_name = get_backup_name(kv_store_tabs, "KeyValueStore_bak")
# Check if migration necessary # Check if migration necessary
# Migrates both trades and orders table! # Migrates both trades and orders table!
@@ -457,16 +421,6 @@ def check_migrate(engine: Engine, decl_base, previous_tables: list[str]) -> None
migrate_pairlocks_table( migrate_pairlocks_table(
decl_base, inspector, engine, pairlock_table_bak_name, cols_pairlocks decl_base, inspector, engine, pairlock_table_bak_name, cols_pairlocks
) )
if "KeyValueStore" in previous_tables:
key_column = next(filter(lambda x: x["name"] == "key", cols_kv_store), None)
# length of key column < 50, recreate table with correct length and migrate data
if key_column and getattr(key_column["type"], "length", -1) < 50:
migrating = True
logger.info(
f"Running database migration for KeyValueStore - backup: {kv_store_back_name}"
)
migrate_kv_store_table(decl_base, inspector, engine, kv_store_back_name, cols_kv_store)
if "orders" not in previous_tables and "trades" in previous_tables: if "orders" not in previous_tables and "trades" in previous_tables:
raise OperationalException( raise OperationalException(
"Your database seems to be very old. " "Your database seems to be very old. "
-2
View File
@@ -20,7 +20,6 @@ from freqtrade.persistence.key_value_store import _KeyValueStoreModel
from freqtrade.persistence.migrations import check_migrate from freqtrade.persistence.migrations import check_migrate
from freqtrade.persistence.pairlock import PairLock from freqtrade.persistence.pairlock import PairLock
from freqtrade.persistence.trade_model import Order, Trade from freqtrade.persistence.trade_model import Order, Trade
from freqtrade.persistence.wallet_history import WalletHistory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,7 +91,6 @@ def init_db(db_url: str) -> None:
_CustomData.session = scoped_session( _CustomData.session = scoped_session(
sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id
) )
WalletHistory.session = Trade.session
previous_tables = inspect(engine).get_table_names() previous_tables = inspect(engine).get_table_names()
ModelBase.metadata.create_all(engine) ModelBase.metadata.create_all(engine)
-5
View File
@@ -29,11 +29,6 @@ class PairLock(ModelBase):
active: Mapped[bool] = mapped_column(nullable=False, default=True, index=True) active: Mapped[bool] = mapped_column(nullable=False, default=True, index=True)
@property
def lock_end_time_utc(self) -> datetime:
"""Lock end time with UTC timezoneinfo"""
return self.lock_end_time.replace(tzinfo=UTC)
def __repr__(self) -> str: def __repr__(self) -> str:
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT) lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT) lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT)
+1 -11
View File
@@ -42,7 +42,6 @@ class PairLocks:
) -> PairLock: ) -> PairLock:
""" """
Create PairLock from now to "until". Create PairLock from now to "until".
Doesn't create a new lock if there is already a lock with the same Reason, side and endtime.
Uses database by default, unless PairLocks.use_db is set to False, Uses database by default, unless PairLocks.use_db is set to False,
in which case a list is maintained. in which case a list is maintained.
:param pair: pair to lock. use '*' to lock all pairs :param pair: pair to lock. use '*' to lock all pairs
@@ -51,19 +50,10 @@ class PairLocks:
:param now: Current timestamp. Used to determine lock start time. :param now: Current timestamp. Used to determine lock start time.
:param side: Side to lock pair, can be 'long', 'short' or '*' :param side: Side to lock pair, can be 'long', 'short' or '*'
""" """
lock_end_time = timeframe_to_next_date(PairLocks.timeframe, until)
existing_locks = PairLocks.get_pair_locks(pair, now, side=side)
for lock in existing_locks:
if (
lock.reason == reason
and lock.lock_end_time_utc == lock_end_time
and lock.side == side
):
return lock
lock = PairLock( lock = PairLock(
pair=pair, pair=pair,
lock_time=now or datetime.now(UTC), lock_time=now or datetime.now(UTC),
lock_end_time=lock_end_time, lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
reason=reason, reason=reason,
side=side, side=side,
active=True, active=True,
+14 -17
View File
@@ -189,8 +189,8 @@ class Order(ModelBase):
def __repr__(self): def __repr__(self):
return ( return (
f"Order(id={self.id}, trade={self.ft_trade_id}, order_id={self.order_id}, " f"Order(id={self.id}, trade={self.ft_trade_id}, order_id={self.order_id}, "
f"side={self.side or self.ft_order_side}, filled={self.safe_filled}, " f"side={self.side}, filled={self.safe_filled}, price={self.safe_price}, "
f"price={self.safe_price}, amount={self.amount}, " f"amount={self.amount}, "
f"status={self.status}, date={self.order_date_utc:{DATETIME_PRINT_FORMAT}})" f"status={self.status}, date={self.order_date_utc:{DATETIME_PRINT_FORMAT}})"
) )
@@ -858,9 +858,9 @@ class LocalTrade:
higher_stop = stop_loss_norm > self.stop_loss higher_stop = stop_loss_norm > self.stop_loss
lower_stop = stop_loss_norm < self.stop_loss lower_stop = stop_loss_norm < self.stop_loss
# stop losses only walk up, never down! # stop losses only walk up, never down!,
# but adding more to a leveraged trade would create a lower liquidation price, # ? But adding more to a leveraged trade would create a lower liquidation price,
# decreasing the minimum stoploss # ? decreasing the minimum stoploss
if ( if (
allow_refresh allow_refresh
or (higher_stop and not self.is_short) or (higher_stop and not self.is_short)
@@ -1248,16 +1248,12 @@ class LocalTrade:
close_profit_abs = 0.0 close_profit_abs = 0.0
# Reset funding fees # Reset funding fees
self.funding_fees = 0.0 self.funding_fees = 0.0
# Total funding fees - cumulated over all orders funding_fees = 0.0
total_funding_fees = 0.0 ordercount = len(self.orders) - 1
# current funding fees - resetting on every exit to be aligned with profit calculation,
# as funding fees are part of the profit
current_funding_fee = 0.0
for i, o in enumerate(self.orders): for i, o in enumerate(self.orders):
if o.ft_is_open or not o.filled: if o.ft_is_open or not o.filled:
continue continue
current_funding_fee += o.funding_fee or 0.0 funding_fees += o.funding_fee or 0.0
total_funding_fees += o.funding_fee or 0.0
tmp_amount = FtPrecise(o.safe_amount_after_fee) tmp_amount = FtPrecise(o.safe_amount_after_fee)
tmp_price = FtPrecise(o.safe_price) tmp_price = FtPrecise(o.safe_price)
@@ -1272,8 +1268,11 @@ class LocalTrade:
avg_price = current_stake / current_amount avg_price = current_stake / current_amount
if is_exit: if is_exit:
# Intermediate funding fees for profit calculation # Process exits
self.funding_fees = current_funding_fee if i == ordercount and is_closing:
# Apply funding fees only to the last closing order
self.funding_fees = funding_fees
exit_rate = o.safe_price exit_rate = o.safe_price
exit_amount = o.safe_amount_after_fee exit_amount = o.safe_amount_after_fee
prof = self.calculate_profit(exit_rate, exit_amount, float(avg_price)) prof = self.calculate_profit(exit_rate, exit_amount, float(avg_price))
@@ -1282,12 +1281,10 @@ class LocalTrade:
# This needs to be calculated based on the last occurring exit to be aligned # This needs to be calculated based on the last occurring exit to be aligned
# with realized_profit. # with realized_profit.
close_profit = (close_profit_abs / total_stake) * self.leverage close_profit = (close_profit_abs / total_stake) * self.leverage
current_funding_fee = 0.0
else: else:
total_stake += self._calc_open_trade_value(tmp_amount, price) total_stake += self._calc_open_trade_value(tmp_amount, price)
max_stake_amount += tmp_amount * price max_stake_amount += tmp_amount * price
# Assign cumulated funding fees after all orders have been processed self.funding_fees = funding_fees
self.funding_fees = total_funding_fees
self.max_stake_amount = float(max_stake_amount) / (self.leverage or 1.0) self.max_stake_amount = float(max_stake_amount) / (self.leverage or 1.0)
if close_profit: if close_profit:
-50
View File
@@ -1,50 +0,0 @@
from datetime import datetime
from typing import ClassVar
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from freqtrade.persistence.base import ModelBase, SessionType
class WalletHistory(ModelBase):
"""
Daily wallet state tracking with minimal fields
"""
__tablename__ = "wallet_history"
session: ClassVar[SessionType]
id: Mapped[int] = mapped_column(Integer, primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
currency: Mapped[str] = mapped_column(String(25), nullable=False)
# Rate: price of 1 unit of `currency` quoted in `quote_currency`.
# e.g., USDT/ETH -> USDT per ETH
rate: Mapped[float] = mapped_column(Float, nullable=True)
# Quote currency for rate/total fields (e.g., 'USDT')
quote_currency: Mapped[str] = mapped_column(String(25), nullable=False)
# Balance in `currency` units
balance: Mapped[float] = mapped_column(Float, nullable=False)
# Canonical total wallet equity/value denominated in `quote_currency` (if available)
# For futures positions, collateral + PnL is used to compute this value.
total_quote: Mapped[float] = mapped_column(Float, nullable=True)
# Total position value in `quote_currency` - including leverage
total_position_value: Mapped[float] = mapped_column(Float, nullable=True)
collateral: Mapped[float] = mapped_column(Float, nullable=True)
leverage: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
bot_managed: Mapped[bool] = mapped_column(nullable=False, default=True)
__table_args__ = (
# Ensure one record per currency per day
UniqueConstraint("timestamp", "currency", name="unique_wallet_daily"),
)
def __repr__(self) -> str:
return (
f"WalletHistory(timestamp={self.timestamp}, currency={self.currency}, "
f"rate={self.rate}, total_quote={self.total_quote}, "
f"balance={self.balance}, leverage={self.leverage})"
)
+3 -3
View File
@@ -263,7 +263,7 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
trades["desc"] = trades.apply( trades["desc"] = trades.apply(
lambda row: ( lambda row: (
f"{row['profit_ratio']:.2%}, " f"{row['profit_ratio']:.2%}, "
+ (f"{row['enter_tag']}, " if pd.notna(row["enter_tag"]) else "") + (f"{row['enter_tag']}, " if row["enter_tag"] is not None else "")
+ f"{row['exit_reason']}, " + f"{row['exit_reason']}, "
+ f"{row['trade_duration']} min" + f"{row['trade_duration']} min"
), ),
@@ -356,7 +356,7 @@ def plot_area(
:param indicator_b: indicator name as populated in strategy :param indicator_b: indicator name as populated in strategy
:param label: label for the filled area :param label: label for the filled area
:param fill_color: color to be used for the filled area :param fill_color: color to be used for the filled area
:return: fig with added filled_traces plot :return: fig with added filled_traces plot
""" """
if indicator_a in data and indicator_b in data: if indicator_a in data and indicator_b in data:
# make lines invisible to get the area plotted, only. # make lines invisible to get the area plotted, only.
@@ -383,7 +383,7 @@ def add_areas(fig, row: int, data: pd.DataFrame, indicators) -> make_subplots:
:param data: candlestick DataFrame :param data: candlestick DataFrame
:param indicators: dict with indicators. ie.: plot_config['main_plot'] or :param indicators: dict with indicators. ie.: plot_config['main_plot'] or
plot_config['subplots'][subplot_label] plot_config['subplots'][subplot_label]
:return: fig with added filled_traces plot :return: fig with added filled_traces plot
""" """
for indicator, ind_conf in indicators.items(): for indicator, ind_conf in indicators.items():
if "fill_to" in ind_conf: if "fill_to" in ind_conf:
+2 -3
View File
@@ -23,10 +23,9 @@ class DelistFilter(IPairList):
self._max_days_from_now = self._pairlistconfig.get("max_days_from_now", 0) self._max_days_from_now = self._pairlistconfig.get("max_days_from_now", 0)
if self._max_days_from_now < 0: if self._max_days_from_now < 0:
raise ConfigurationError("DelistFilter requires max_days_from_now to be >= 0") raise ConfigurationError("DelistFilter requires max_days_from_now to be >= 0")
if not self._exchange.get_option("has_delisting"): if not self._exchange._ft_has["has_delisting"]:
raise ConfigurationError( raise ConfigurationError(
f"DelistFilter doesn't support {self._exchange.name} in " "DelistFilter doesn't support this exchange and trading mode combination.",
f"{self._exchange.trading_mode} mode."
) )
def short_desc(self) -> str: def short_desc(self) -> str:
@@ -38,7 +38,7 @@ class MarketCapPairList(IPairList):
self._max_rank = self._pairlistconfig.get("max_rank", 30) self._max_rank = self._pairlistconfig.get("max_rank", 30)
self._refresh_period = self._pairlistconfig.get("refresh_period", 86400) self._refresh_period = self._pairlistconfig.get("refresh_period", 86400)
self._categories = self._pairlistconfig.get("categories", []) self._categories = self._pairlistconfig.get("categories", [])
self._marketcap_cache: FtTTLCache = FtTTLCache(maxsize=2, ttl=self._refresh_period) self._marketcap_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
_coingecko_config = self._config.get("coingecko", {}) _coingecko_config = self._config.get("coingecko", {})
+1 -1
View File
@@ -51,7 +51,7 @@ class StrategyResolver(IResolver):
strategy: IStrategy = StrategyResolver._load_strategy( strategy: IStrategy = StrategyResolver._load_strategy(
strategy_name, config=config, extra_dir=config.get("strategy_path") strategy_name, config=config, extra_dir=config.get("strategy_path")
) )
strategy.ft_set_special_params_from_file() strategy.ft_load_params_from_file()
# Set attributes # Set attributes
# Check if we need to override configuration # Check if we need to override configuration
# (Attribute name, default, subkey) # (Attribute name, default, subkey)
+1
View File
@@ -15,6 +15,7 @@ from freqtrade.rpc.api_server.deps import get_api_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ALGORITHM = "HS256" ALGORITHM = "HS256"
__DEFAULT_JWT = "somethingRandomSomethingRandom123"
router_login = APIRouter() router_login = APIRouter()
+1 -35
View File
@@ -16,11 +16,10 @@ from freqtrade.data.btanalysis import (
get_backtest_market_change, get_backtest_market_change,
get_backtest_result, get_backtest_result,
get_backtest_resultlist, get_backtest_resultlist,
get_backtest_wallet_change,
load_and_merge_backtest_result, load_and_merge_backtest_result,
update_backtest_metadata, update_backtest_metadata,
) )
from freqtrade.enums import BacktestState, RunMode from freqtrade.enums import BacktestState
from freqtrade.exceptions import ConfigurationError, DependencyException, OperationalException from freqtrade.exceptions import ConfigurationError, DependencyException, OperationalException
from freqtrade.ft_types import get_BacktestResultType_default from freqtrade.ft_types import get_BacktestResultType_default
from freqtrade.misc import deep_merge_dicts, is_file_in_dir from freqtrade.misc import deep_merge_dicts, is_file_in_dir
@@ -30,7 +29,6 @@ from freqtrade.rpc.api_server.api_schemas import (
BacktestMetadataUpdate, BacktestMetadataUpdate,
BacktestRequest, BacktestRequest,
BacktestResponse, BacktestResponse,
WalletHistoryResponse,
) )
from freqtrade.rpc.api_server.deps import get_config, verify_strategy from freqtrade.rpc.api_server.deps import get_config, verify_strategy
from freqtrade.rpc.api_server.webserver_bgwork import ApiBG from freqtrade.rpc.api_server.webserver_bgwork import ApiBG
@@ -108,11 +106,6 @@ def __run_backtest_bg(btconfig: Config):
ApiBG.bt["bt"].results, ApiBG.bt["bt"].results,
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
market_change_data=combined_res, market_change_data=combined_res,
wallet_summary={
s: x["wallet_summary"]
for s, x in ApiBG.bt["bt"].all_bt_content.items()
if "wallet_summary" in x
},
strategy_files={ strategy_files={
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
}, },
@@ -144,7 +137,6 @@ async def api_start_backtest(
verify_strategy(bt_settings.strategy) verify_strategy(bt_settings.strategy)
btconfig = deepcopy(config) btconfig = deepcopy(config)
btconfig["runmode"] = RunMode.BACKTEST
remove_exchange_credentials(btconfig["exchange"], True) remove_exchange_credentials(btconfig["exchange"], True)
settings = dict(bt_settings) settings = dict(bt_settings)
if settings.get("freqai", None) is not None: if settings.get("freqai", None) is not None:
@@ -362,29 +354,3 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)):
"data": df.values.tolist(), "data": df.values.tolist(),
"length": len(df), "length": len(df),
} }
@router.get(
"/backtest/history/{file}/{strategy}/wallet",
response_model=WalletHistoryResponse,
tags=["webserver", "backtest"],
)
def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config)):
bt_results_base: Path = config["user_data_dir"] / "backtest_results"
file_abs = (bt_results_base / file).with_suffix(".zip")
# Ensure file is in backtest_results directory
if not is_file_in_dir(file_abs, bt_results_base):
raise HTTPException(status_code=400, detail="Unable to retrieve wallet history.")
results = get_backtest_wallet_change(file_abs, strategy)
if results is None:
raise HTTPException(status_code=404, detail="Unable to retrieve wallet history.")
# Consolidate the wallet to the base currency
results.loc[:, "total_quote"] = results["rate"] * results["balance"]
results = results.groupby(["date", "__date_ts"]).agg({"total_quote": "sum"}).reset_index()
return {
"columns": results.columns.tolist(),
"data": results.values.tolist(),
"length": len(results),
}
-10
View File
@@ -255,7 +255,6 @@ class ShowConfig(BaseModel):
timeframe_ms: int timeframe_ms: int
timeframe_min: int timeframe_min: int
exchange: str exchange: str
demo_trading: bool
strategy: str | None = None strategy: str | None = None
force_entry_enable: bool force_entry_enable: bool
exit_pricing: dict[str, Any] exit_pricing: dict[str, Any]
@@ -680,15 +679,6 @@ class BacktestMarketChange(BaseModel):
data: list[list[Any]] data: list[list[Any]]
class WalletHistoryResponse(BaseModel):
columns: list[str]
length: int
data: list[list[Any]]
# start date of the effectively captured data
# Before this date, it's based on a reconstructed wallet history
capture_start_ts: int | None = None
class MarketRequest(ExchangeModePayloadMixin, BaseModel): class MarketRequest(ExchangeModePayloadMixin, BaseModel):
base: str | None = None base: str | None = None
quote: str | None = None quote: str | None = None
-17
View File
@@ -31,7 +31,6 @@ from freqtrade.rpc.api_server.api_schemas import (
ResultMsg, ResultMsg,
Stats, Stats,
StatusMsg, StatusMsg,
WalletHistoryResponse,
WhitelistResponse, WhitelistResponse,
) )
from freqtrade.rpc.api_server.deps import get_config, get_rpc from freqtrade.rpc.api_server.deps import get_config, get_rpc
@@ -105,22 +104,6 @@ def stats(rpc: RPC = Depends(get_rpc)):
return rpc._rpc_stats() return rpc._rpc_stats()
@router.get(
"/historic_balance",
response_model=WalletHistoryResponse,
tags=["info"],
)
def api_get_wallet_history(rpc: RPC = Depends(get_rpc)):
results, capture_date_ts = rpc._rpc_get_historic_balance()
return {
"columns": results.columns.tolist(),
"data": results.values.tolist(),
"length": len(results),
"capture_start_ts": capture_date_ts,
}
@router.get("/daily", response_model=DailyWeeklyMonthly, tags=["Trading-info"]) @router.get("/daily", response_model=DailyWeeklyMonthly, tags=["Trading-info"])
def daily( def daily(
timescale: int = Query(7, ge=1, description="Number of days to fetch data for"), timescale: int = Query(7, ge=1, description="Number of days to fetch data for"),
+1 -2
View File
@@ -69,8 +69,7 @@ logger = logging.getLogger(__name__)
# 2.45: Add price to forceexit endpoint # 2.45: Add price to forceexit endpoint
# 2.46: Add prepend_data to download-data endpoint # 2.46: Add prepend_data to download-data endpoint
# 2.47: Add Strategy parameters # 2.47: Add Strategy parameters
# 2.48: add /backtest/history/wallets endpoint API_VERSION = 2.47
API_VERSION = 2.48
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()
+8 -33
View File
@@ -11,8 +11,8 @@ from typing import TYPE_CHECKING, Any
import psutil import psutil
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from dateutil.tz import tzlocal from dateutil.tz import tzlocal
from numpy import inf, isnan, mean, nan from numpy import inf, int64, isnan, mean, nan
from pandas import DataFrame, NaT, read_sql from pandas import DataFrame, NaT
from sqlalchemy import func, select from sqlalchemy import func, select
from freqtrade import __version__ from freqtrade import __version__
@@ -176,7 +176,6 @@ class RPC:
timeframe_to_minutes(config["timeframe"]) if "timeframe" in config else 0 timeframe_to_minutes(config["timeframe"]) if "timeframe" in config else 0
), ),
"exchange": config["exchange"]["name"], "exchange": config["exchange"]["name"],
"demo_trading": config["exchange"].get("demo_trading", False),
"strategy": config["strategy"], "strategy": config["strategy"],
"force_entry_enable": config.get("force_entry_enable", False), "force_entry_enable": config.get("force_entry_enable", False),
"exit_pricing": config.get("exit_pricing", {}), "exit_pricing": config.get("exit_pricing", {}),
@@ -786,26 +785,6 @@ class RPC:
"bot_start_date": format_date(bot_start), "bot_start_date": format_date(bot_start),
} }
def _rpc_get_historic_balance(self) -> tuple[DataFrame, int]:
"""
Returns the historic balance of the bot
:return: DataFrame with the balance history and the timestamp of the migration
"""
results = read_sql("wallet_history", con=Trade.session.bind, parse_dates=["timestamp"])
results = results.rename({"timestamp": "date"}, axis=1)
results.loc[:, "__date_ts"] = results.loc[:, "date"].dt.as_unit("ms").astype("int64")
# Exclude non-bot managed for now
results_filtered = results.loc[results["bot_managed"].astype(bool)]
results_final = (
results_filtered.groupby(["date", "__date_ts"])
.agg({"total_quote": "sum"})
.reset_index()
)
hist = KeyValueStore.get_datetime_value("wallet_history_migration_date")
return results_final, dt_ts_def(hist, 0)
def __balance_get_est_stake( def __balance_get_est_stake(
self, coin: str, stake_currency: str, amount: float, balance: Wallet self, coin: str, stake_currency: str, amount: float, balance: Wallet
) -> tuple[float, float]: ) -> tuple[float, float]:
@@ -896,7 +875,7 @@ class RPC:
for symbol, pos in self._freqtrade.wallets.get_all_positions().items(): for symbol, pos in self._freqtrade.wallets.get_all_positions().items():
est_stake = pos.collateral est_stake = pos.collateral
pos_base = self._freqtrade.exchange.get_pair_base_currency(symbol) pos_base = self._freqtrade.exchange.get_pair_base_currency(symbol)
if pos.leverage and pos.position: if pos.leverage:
try: try:
rate = self._freqtrade.exchange.get_conversion_rate(pos_base, stake_currency) rate = self._freqtrade.exchange.get_conversion_rate(pos_base, stake_currency)
if rate: if rate:
@@ -1407,7 +1386,7 @@ class RPC:
} }
def _rpc_locks(self) -> dict[str, Any]: def _rpc_locks(self) -> dict[str, Any]:
"""Returns the current locks""" """Returns the current locks"""
locks = PairLocks.get_pair_locks(None) locks = PairLocks.get_pair_locks(None)
return {"lock_count": len(locks), "locks": [lock.to_json() for lock in locks]} return {"lock_count": len(locks), "locks": [lock.to_json() for lock in locks]}
@@ -1536,9 +1515,7 @@ class RPC:
df_cols = [col for col in dataframe_columns if col in cols_set] df_cols = [col for col in dataframe_columns if col in cols_set]
dataframe = dataframe.loc[:, df_cols] dataframe = dataframe.loc[:, df_cols]
dataframe.loc[:, "__date_ts"] = ( dataframe.loc[:, "__date_ts"] = dataframe.loc[:, "date"].astype(int64) // 1000 // 1000
dataframe.loc[:, "date"].dt.as_unit("ms").astype("int64")
)
# Move signal close to separate column when signal for easy plotting # Move signal close to separate column when signal for easy plotting
for sig_type in signals.keys(): for sig_type in signals.keys():
if sig_type in dataframe.columns: if sig_type in dataframe.columns:
@@ -1548,7 +1525,8 @@ class RPC:
# band-aid until this is fixed: # band-aid until this is fixed:
# https://github.com/pandas-dev/pandas/issues/45836 # https://github.com/pandas-dev/pandas/issues/45836
date_columns = dataframe.select_dtypes(include=["datetime", "datetime64", "datetimetz"]) datetime_types = ["datetime", "datetime64", "datetime64[ns, UTC]"]
date_columns = dataframe.select_dtypes(include=datetime_types)
for date_column in date_columns: for date_column in date_columns:
# replace NaT with `None` # replace NaT with `None`
dataframe[date_column] = dataframe[date_column].astype(object).replace({NaT: None}) dataframe[date_column] = dataframe[date_column].astype(object).replace({NaT: None})
@@ -1694,11 +1672,8 @@ class RPC:
else dt_ts(dt_now() - timedelta(days=30)), else dt_ts(dt_now() - timedelta(days=30)),
is_new_pair=True, # history is never available - so always treat as new pair is_new_pair=True, # history is never available - so always treat as new pair
candle_type=config.get("candle_type_def", CandleType.SPOT), candle_type=config.get("candle_type_def", CandleType.SPOT),
until_ms=timerange_parsed.stopts * 1000 if timerange_parsed.stopts else None, until_ms=timerange_parsed.stopts,
) )
if timerange_parsed.stopts and len(data) > 1:
# trim last candle if it is newer than the stop time
data = data.loc[data["date"] <= timerange_parsed.stopdt]
else: else:
_data = load_data( _data = load_data(
datadir=config["datadir"], datadir=config["datadir"],
+4 -6
View File
@@ -59,7 +59,7 @@ class RPCManager:
logger.info("Cleaning up rpc modules ...") logger.info("Cleaning up rpc modules ...")
while self.registered_modules: while self.registered_modules:
mod = self.registered_modules.pop() mod = self.registered_modules.pop()
logger.info(f"Cleaning up rpc.{mod.name} ...") logger.info("Cleaning up rpc.%s ...", mod.name)
mod.cleanup() mod.cleanup()
del mod del mod
@@ -73,7 +73,7 @@ class RPCManager:
} }
""" """
if msg.get("type") not in NO_ECHO_MESSAGES: if msg.get("type") not in NO_ECHO_MESSAGES:
logger.info(f"Sending rpc message: {msg}") logger.info("Sending rpc message: %s", msg)
for mod in self.registered_modules: for mod in self.registered_modules:
logger.debug("Forwarding message to rpc.%s", mod.name) logger.debug("Forwarding message to rpc.%s", mod.name)
try: try:
@@ -81,7 +81,7 @@ class RPCManager:
except NotImplementedError: except NotImplementedError:
logger.error(f"Message type '{msg['type']}' not implemented by handler {mod.name}.") logger.error(f"Message type '{msg['type']}' not implemented by handler {mod.name}.")
except Exception: except Exception:
logger.exception(f"Exception occurred within RPC module {mod.name}") logger.exception("Exception occurred within RPC module %s", mod.name)
def process_msg_queue(self, queue: deque) -> None: def process_msg_queue(self, queue: deque) -> None:
""" """
@@ -89,7 +89,7 @@ class RPCManager:
""" """
while queue: while queue:
msg = queue.popleft() msg = queue.popleft()
logger.info(f"Sending rpc strategy_msg: {msg}") logger.info("Sending rpc strategy_msg: %s", msg)
for mod in self.registered_modules: for mod in self.registered_modules:
if mod._config.get(mod.name, {}).get("allow_custom_messages", False): if mod._config.get(mod.name, {}).get("allow_custom_messages", False):
mod.send_msg( mod.send_msg(
@@ -114,8 +114,6 @@ class RPCManager:
trailing_stop = config["trailing_stop"] trailing_stop = config["trailing_stop"]
timeframe = config["timeframe"] timeframe = config["timeframe"]
exchange_name = config["exchange"]["name"] exchange_name = config["exchange"]["name"]
if config["exchange"].get("demo_trading"):
exchange_name += " (demo trading)"
strategy_name = config.get("strategy", "") strategy_name = config.get("strategy", "")
pos_adjust_enabled = "On" if config["position_adjustment_enable"] else "Off" pos_adjust_enabled = "On" if config["position_adjustment_enable"] else "Off"
self.send_msg( self.send_msg(
+4 -4
View File
@@ -483,7 +483,7 @@ class Telegram(RPCHandler):
profit_prefix = "Sub " profit_prefix = "Sub "
cp_extra = ( cp_extra = (
f"*Final Profit:* `{format_pct(msg['final_profit_ratio'])} " f"*Final Profit:* `{format_pct(msg['final_profit_ratio'])} "
f"({fmt_coin(msg['cumulative_profit'], msg['stake_currency'])}{cp_fiat})`\n" f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n"
) )
else: else:
exit_wording = f"Partially {exit_wording.lower()}" exit_wording = f"Partially {exit_wording.lower()}"
@@ -832,7 +832,7 @@ class Telegram(RPCHandler):
): ):
# Adding initial stoploss only if it is different from stoploss # Adding initial stoploss only if it is different from stoploss
lines.append( lines.append(
f"*Initial Stoploss:* `{round_value(r['initial_stop_loss_abs'], 8)}` " f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
f"`({format_pct(r['initial_stop_loss_ratio'])})`" f"`({format_pct(r['initial_stop_loss_ratio'])})`"
) )
@@ -2049,7 +2049,7 @@ class Telegram(RPCHandler):
await self._send_msg( await self._send_msg(
f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n" f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n"
f"*Exchange:* `{val['exchange']}{' (Demo)' if val['demo_trading'] else ''}`\n" f"*Exchange:* `{val['exchange']}`\n"
f"*Market: * `{val['trading_mode']}`\n" f"*Market: * `{val['trading_mode']}`\n"
f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n" f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n"
f"*Max open Trades:* `{val['max_open_trades']}`\n" f"*Max open Trades:* `{val['max_open_trades']}`\n"
@@ -2243,7 +2243,7 @@ class Telegram(RPCHandler):
else: else:
raise RPCException( raise RPCException(
"Invalid usage of command /marketdir. \n" "Invalid usage of command /marketdir. \n"
"Usage: */marketdir [short | long | even | none]*" "Usage: */marketdir [short | long | even | none]*"
) )
async def _tg_info(self, update: Update, context: CallbackContext) -> None: async def _tg_info(self, update: Update, context: CallbackContext) -> None:
+4 -8
View File
@@ -51,10 +51,9 @@ class HyperStrategyMixin:
for par in self._ft_hyper_params[space].values(): for par in self._ft_hyper_params[space].values():
yield par.name, par yield par.name, par
def ft_set_special_params_from_file(self) -> None: def ft_load_params_from_file(self) -> None:
""" """
Sets special parameters (stoploss, roi, trailing, max_open_trades) from the Load Parameters from parameter file
previously loaded file.
Should/must run before config values are loaded in strategy_resolver. Should/must run before config values are loaded in strategy_resolver.
""" """
if self._ft_params_from_file: if self._ft_params_from_file:
@@ -97,7 +96,7 @@ class HyperStrategyMixin:
params_values = deep_merge_dicts( params_values = deep_merge_dicts(
self._ft_params_from_file.get(space, {}), getattr(self, f"{space}_params", {}) self._ft_params_from_file.get(space, {}), getattr(self, f"{space}_params", {})
) )
self._ft_set_param(self._ft_hyper_params[space], params_values, space, hyperopt) self._ft_load_params(self._ft_hyper_params[space], params_values, space, hyperopt)
def load_params_from_file(self) -> dict: def load_params_from_file(self) -> dict:
filename_str = getattr(self, "__file__", "") filename_str = getattr(self, "__file__", "")
@@ -119,15 +118,12 @@ class HyperStrategyMixin:
return {} return {}
def _ft_set_param( def _ft_load_params(
self, params: SpaceParams, param_values: dict, space: str, hyperopt: bool = False self, params: SpaceParams, param_values: dict, space: str, hyperopt: bool = False
) -> None: ) -> None:
""" """
Set optimizable parameter values. Set optimizable parameter values.
:param params: Dictionary with new parameter values. :param params: Dictionary with new parameter values.
:param param_values: Dictionary with values to set.
:param space: The space to which the parameters belong.
:param hyperopt: Flag indicating if we are in hyperopt mode.
""" """
if not param_values: if not param_values:
logger.info(f"No params for {space} found, using default values.") logger.info(f"No params for {space} found, using default values.")
+1 -2
View File
@@ -222,8 +222,7 @@ class IStrategy(ABC, HyperStrategyMixin):
""" """
Clean up FreqAI and child threads Clean up FreqAI and child threads
""" """
if getattr(self, "freqai", None): self.freqai.shutdown()
self.freqai.shutdown()
@abstractmethod @abstractmethod
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
-2
View File
@@ -3,7 +3,6 @@ from freqtrade.util.datetime_helpers import (
dt_from_ts, dt_from_ts,
dt_humanize_delta, dt_humanize_delta,
dt_now, dt_now,
dt_now_no_micro,
dt_ts, dt_ts,
dt_ts_def, dt_ts_def,
dt_ts_none, dt_ts_none,
@@ -40,7 +39,6 @@ __all__ = [
"dt_from_ts", "dt_from_ts",
"dt_humanize_delta", "dt_humanize_delta",
"dt_now", "dt_now",
"dt_now_no_micro",
"dt_ts", "dt_ts",
"dt_ts_def", "dt_ts_def",
"dt_ts_none", "dt_ts_none",
-7
View File
@@ -12,13 +12,6 @@ def dt_now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
def dt_now_no_micro() -> datetime:
"""Return the current datetime in UTC without microseconds.
Should not be used outside of tests.
"""
return dt_now().replace(microsecond=0)
def dt_utc( def dt_utc(
year: int, year: int,
month: int, month: int,
+4 -5
View File
@@ -1,10 +1,8 @@
from freqtrade.constants import Config
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.util.migrations.funding_rate_mig import migrate_funding_fee_timeframe from freqtrade.util.migrations.funding_rate_mig import migrate_funding_fee_timeframe
from freqtrade.util.migrations.migrate_wallet_history import migrate_wallet_history
def migrate_data(config: Config, exchange: Exchange | None = None) -> None: def migrate_data(config, exchange: Exchange | None = None) -> None:
""" """
Migrate persisted data from old formats to new formats Migrate persisted data from old formats to new formats
""" """
@@ -12,9 +10,10 @@ def migrate_data(config: Config, exchange: Exchange | None = None) -> None:
migrate_funding_fee_timeframe(config, exchange) migrate_funding_fee_timeframe(config, exchange)
def migrate_live_content(config: Config, exchange: Exchange, starting_balance: float) -> None: def migrate_live_content(config, exchange: Exchange | None = None) -> None:
""" """
Migrate database content from old formats to new formats Migrate database content from old formats to new formats
Used for dry/live mode. Used for dry/live mode.
""" """
migrate_wallet_history(config, exchange, starting_balance) # Currently not used
pass
@@ -1,214 +0,0 @@
import logging
import numpy as np
import pandas as pd
from freqtrade.constants import Config
from freqtrade.data.btanalysis.bt_fileutils import trade_list_to_dataframe
from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time
from freqtrade.exchange import Exchange
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_prev_date
from freqtrade.persistence import KeyValueStore, Trade, WalletHistory
from freqtrade.util import dt_now, dt_ts
logger = logging.getLogger(__name__)
def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float):
if config.get("skip_wallet_history_migration") or not exchange.get_option(
"ohlcv_has_history", True
):
# we can't fill up wallet history without ohlcv history
return
if KeyValueStore.get_int_value("wallet_history_migration"):
logger.debug("Wallet history migration already completed.")
return
logger.info("Starting wallet history migration...")
_migrate_wallet_history(config, exchange, starting_balance)
logger.info("Wallet history migration completed.")
KeyValueStore.store_value("wallet_history_migration", 1)
KeyValueStore.store_value("wallet_history_migration_date", dt_now())
def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float):
# Prepare balance distribution data with OHLCV rates
balance_dist, pairlist_valid = _prepare_balance_distribution(config, exchange, starting_balance)
if not balance_dist.empty and pairlist_valid:
_create_wallet_history_entries(
config, exchange, balance_dist, pairlist_valid, config["stake_currency"]
)
def _prepare_balance_distribution(
config: Config, exchange: Exchange, starting_balance: float
) -> tuple[pd.DataFrame, list[str]]:
trade_df = trade_list_to_dataframe(Trade.get_trades_proxy(), minified=False)
if trade_df.empty:
# no trades, nothing to do
return pd.DataFrame(), []
pairlist = list(trade_df["pair"].unique())
timeframe = "1d"
stake_currency = config["stake_currency"]
min_date = timeframe_to_prev_date(timeframe, KeyValueStore.get_datetime_value("bot_start_time"))
balance_dist = balance_distribution_over_time(
trade_df,
min_date=min_date,
max_date=dt_now(),
start_balance=starting_balance,
stake_currency=stake_currency,
timeframe=timeframe,
pairlist=pairlist,
)
pairlist_valid = [p for p in pairlist if p in exchange.markets]
pairlist_invalid = set(pairlist) - set(pairlist_valid)
if pairlist_invalid:
logger.warning(
f"The following trading pairs from the trade history are not available on the exchange "
f"and will be skipped during wallet history migration: {', '.join(pairlist_invalid)}"
)
logger.info("Wallet History migration: Fetching OHLCV data ...")
data = exchange.refresh_latest_ohlcv(
[(p, timeframe, config["candle_type_def"]) for p in pairlist_valid],
since_ms=dt_ts(min_date),
cache=False,
drop_incomplete=False,
)
logger.info(
"Wallet History migration: Done fetching OHLCV data for wallet history migration..."
)
dfs = []
# Combine all dataframes into one using the open rate
for p, x in data.items():
x = x.set_index("date", drop=True)
col = f"{p[0]}_open"
x[col] = x["open"]
dfs.append(x[[col]])
if not dfs:
logger.warning(
"No OHLCV data available for the trading pairs; skipping wallet history migration."
)
return pd.DataFrame(), []
merged = pd.concat(dfs, axis=1)
balance_dist = balance_dist.join(merged, how="left")
df_value = pd.DataFrame(
index=balance_dist.index, columns=[f"{p}_value" for p in pairlist_valid], dtype=float
)
for p in pairlist_valid:
# df_value[f"{p}_value"] = balance_dist[f"{p}_open"] * balance_dist[p]
# Identical calculation to rpc and wallets.py
df_value[f"{p}_value"] = np.where(
balance_dist[f"{p}_is_short"] == 0,
(balance_dist[f"{p}_open"] * balance_dist[p])
- balance_dist[f"{p}_collateral"] * (balance_dist[f"{p}_leverage"] - 1),
(
balance_dist[f"{p}_collateral"] * (1 + balance_dist[f"{p}_leverage"])
- balance_dist[f"{p}_open"] * balance_dist[p]
),
)
balance_dist = pd.concat([balance_dist, df_value], axis=1)
# Aggregate total value at each point in time
balance_dist["total_value"] = balance_dist[
[f"{p}_value" for p in pairlist_valid] + [stake_currency]
].sum(axis=1)
return balance_dist, pairlist_valid
def _create_wallet_history_entries(
config: Config,
exchange: Exchange,
balance_dist: pd.DataFrame,
pairlist_valid: list[str],
stake_currency: str,
):
is_futures = config["trading_mode"] == "futures"
# Precompute column indices for faster tuple-based iteration
# Assume the first column is the index (date)
stake_idx = balance_dist.columns.get_loc(stake_currency)
pair_balance_idx = {pair: balance_dist.columns.get_loc(pair) + 1 for pair in pairlist_valid}
pair_leverage_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_leverage") + 1 for pair in pairlist_valid
}
pair_collateral_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_collateral") + 1 for pair in pairlist_valid
}
pair_is_short_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_is_short") + 1 for pair in pairlist_valid
}
pair_rate_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_open") + 1 for pair in pairlist_valid
}
# Convert balance_dist to WalletHistory entries
wallet_entries = []
for row in balance_dist.itertuples(index=True, name=None):
date = row[0]
# Add stake currency entry
stake_balance = row[stake_idx + 1]
if not pd.isna(stake_balance):
wallet_entries.append(
WalletHistory(
timestamp=date,
currency=stake_currency,
rate=1.0, # Stake currency price is always 1.0
balance=stake_balance,
total_quote=stake_balance,
quote_currency=stake_currency,
leverage=1.0,
bot_managed=True,
)
)
# Add entries for each trading pair
for pair in pairlist_valid:
base_currency = exchange.get_pair_base_currency(pair)
balance = row[pair_balance_idx[pair]]
leverage = row[pair_leverage_idx[pair]]
# Only add entry if balance is not empty/NaN
if not pd.isna(balance) and balance > 0:
rate_value = row[pair_rate_idx[pair]]
rate = rate_value if not pd.isna(rate_value) else None
total_quote = balance * rate if rate else None
collateral: float | None = None
if is_futures:
collateral = row[pair_collateral_idx[pair]]
is_short = row[pair_is_short_idx[pair]]
if collateral is not None and not pd.isna(collateral) and rate is not None:
# Same formula than in rpc's _rpc_balance
total_quote = (
(rate * balance - collateral * (leverage - 1))
if is_short == 0
else (collateral * (1 + leverage) - rate * balance)
)
wallet_entries.append(
WalletHistory(
timestamp=date,
currency=base_currency,
quote_currency=stake_currency,
rate=rate,
balance=balance,
total_quote=total_quote,
leverage=leverage if not pd.isna(leverage) else 1.0,
bot_managed=True,
total_position_value=balance * rate if is_futures and rate else None,
# collateral=collateral,
)
)
# Save entries to database
if wallet_entries:
try:
# Use bulk_save_objects for better performance
WalletHistory.session.bulk_save_objects(wallet_entries)
WalletHistory.session.commit()
logger.info(f"Successfully created {len(wallet_entries)} wallet balance records")
except Exception as e:
WalletHistory.session.rollback()
logger.error(f"Error saving wallet balance records: {e}")
+2 -70
View File
@@ -10,8 +10,8 @@ from freqtrade.enums import RunMode, TradingMode
from freqtrade.exceptions import DependencyException from freqtrade.exceptions import DependencyException
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.misc import safe_value_fallback from freqtrade.misc import safe_value_fallback
from freqtrade.persistence import LocalTrade, Trade, WalletHistory from freqtrade.persistence import LocalTrade, Trade
from freqtrade.util import dt_floor_day, dt_now from freqtrade.util.datetime_helpers import dt_now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -445,71 +445,3 @@ class Wallets:
logger.debug(msg) logger.debug(msg)
else: else:
logger.info(msg) logger.info(msg)
def record_wallet_state(self) -> None:
"""Record daily wallet totals to database"""
if self._is_backtest:
# only record in live mode.
return
timestamp = dt_floor_day(dt_now())
# Record total balances for all currencies
wallet_records = []
position_collaterals = 0.0
open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in Trade.get_open_trades()}
for pos in self.get_all_positions().values():
base = self._exchange.get_pair_base_currency(pos.symbol)
rate = self._exchange.get_conversion_rate(base, self._stake_currency)
total_quote = None
leverage = pos.leverage or 1.0
if rate:
# Same formula than in rpc's _rpc_balance
total_quote = (
rate * pos.position - pos.collateral * (leverage - 1)
if pos.side == "long"
else pos.collateral * (1 + leverage) - rate * pos.position
)
position_record = WalletHistory(
timestamp=timestamp,
currency=pos.symbol,
quote_currency=self._stake_currency,
rate=rate,
balance=pos.position,
total_quote=total_quote,
total_position_value=rate * pos.position if rate else None,
collateral=pos.collateral,
leverage=leverage,
bot_managed=base in open_assets,
)
position_collaterals += pos.collateral
wallet_records.append(position_record)
for wallet in self.get_all_balances().values():
# TODO: (needs decision) exclude minimal balances?
rate = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency)
is_bot_managed = (
self._stake_currency == wallet.currency or wallet.currency in open_assets
)
balance = wallet.total - (
position_collaterals if wallet.currency == self._stake_currency else 0
)
total_quote = rate * balance if rate else None
wallet_record = WalletHistory(
timestamp=timestamp,
currency=wallet.currency,
quote_currency=self._stake_currency,
rate=rate,
balance=balance,
leverage=1.0,
total_quote=total_quote,
bot_managed=is_bot_managed,
)
wallet_records.append(wallet_record)
try:
WalletHistory.session.bulk_save_objects(wallet_records)
WalletHistory.session.commit()
except Exception as e:
WalletHistory.session.rollback()
logger.error(f"Error saving wallet balance records: {e}")
+1 -1
View File
@@ -1,7 +1,7 @@
from freqtrade_client.ft_rest_client import FtRestClient from freqtrade_client.ft_rest_client import FtRestClient
__version__ = "2026.5-dev" __version__ = "2026.3"
if "dev" in __version__: if "dev" in __version__:
from pathlib import Path from pathlib import Path
+1 -1
View File
@@ -1,3 +1,3 @@
# Requirements for freqtrade client library # Requirements for freqtrade client library
requests==2.34.2 requests==2.33.0
python-rapidjson==1.23 python-rapidjson==1.23
+1 -7
View File
@@ -40,7 +40,7 @@ dependencies = [
"urllib3", "urllib3",
"jsonschema", "jsonschema",
"numpy>2.0,<3.0", "numpy>2.0,<3.0",
"pandas>=2.2.0,<4.0", "pandas>=2.2.0,<3.0",
"TA-Lib<0.7", "TA-Lib<0.7",
"ft-pandas-ta", "ft-pandas-ta",
"technical", "technical",
@@ -217,12 +217,6 @@ reportRedeclaration = false # 1
reportReturnType = false # 28 reportReturnType = false # 28
reportTypedDictNotRequiredAccess = false # 27 reportTypedDictNotRequiredAccess = false # 27
[tool.uv]
exclude-newer = "1 week"
[tool.uv.exclude-newer-package]
ccxt = false
pymdown-extensions = "6 days"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
+12 -12
View File
@@ -6,12 +6,12 @@
-r requirements-freqai-rl.txt -r requirements-freqai-rl.txt
-r docs/requirements-docs.txt -r docs/requirements-docs.txt
ruff==0.15.12 ruff==0.15.6
mypy==2.1.0 mypy==1.19.1
pre-commit==4.6.0 pre-commit==4.5.1
pytest==9.0.3 pytest==9.0.2
pytest-asyncio==1.3.0 pytest-asyncio==1.3.0
pytest-cov==7.1.0 pytest-cov==7.0.0
pytest-mock==3.15.1 pytest-mock==3.15.1
pytest-random-order==1.2.0 pytest-random-order==1.2.0
pytest-timeout==2.4.0 pytest-timeout==2.4.0
@@ -20,17 +20,17 @@ pytest-xdist==3.8.0
time-machine==3.2.0 time-machine==3.2.0
# Convert jupyter notebooks to markdown documents # Convert jupyter notebooks to markdown documents
nbconvert==7.17.1 nbconvert==7.17.0
# mypy types # mypy types
scipy-stubs==1.17.1.4 # keep in sync with `scipy` in `requirements-hyperopt.txt` scipy-stubs==1.17.1.2 # keep in sync with `scipy` in `requirements-hyperopt.txt`
types-cachetools==7.0.0.20260518 types-cachetools==6.2.0.20251022
types-filelock==3.2.7 types-filelock==3.2.7
types-requests==2.33.0.20260518 types-requests==2.32.4.20260107
types-tabulate==0.10.0.20260508 types-tabulate==0.10.0.20260308
types-python-dateutil==2.9.0.20260518 types-python-dateutil==2.9.0.20260305
pip-audit==2.10.0 pip-audit==2.10.0
# For build step in CI # For build step in CI
build==1.5.0 build==1.4.2
# For pre-commit-update check # For pre-commit-update check
pyyaml==6.0.3 pyyaml==6.0.3
+3 -3
View File
@@ -2,10 +2,10 @@
-r requirements-freqai.txt -r requirements-freqai.txt
# Required for freqai-rl # Required for freqai-rl
torch==2.11.0; sys_platform != 'darwin' or platform_machine != 'x86_64' torch==2.10.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
gymnasium==1.2.3 gymnasium==1.2.3
# SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos # SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos
stable-baselines3==2.8.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.8.0; sys_platform != 'darwin' or platform_machine != 'x86_64' sb3_contrib>=2.2.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
# Progress bar for stable-baselines3 and sb3-contrib # Progress bar for stable-baselines3 and sb3-contrib
tqdm==4.67.3 tqdm==4.67.3

Some files were not shown because too many files have changed in this diff Show More