Merge pull request #13106 from freqtrade/new_release
New release 2026.4
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
cooldown:
|
||||
default-days: 7
|
||||
- package-ecosystem: docker # zizmor: ignore[dependabot-cooldown] Docker does not support cooldowns at the moment.
|
||||
# Docker does not support cooldowns at the moment.
|
||||
# https://github.com/dependabot/dependabot-core/issues/14044
|
||||
# cooldown:
|
||||
# default-days: 7
|
||||
directories:
|
||||
- "/"
|
||||
- "/docker"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Binance Leverage tiers update
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "25 3 * * 4"
|
||||
- cron: "25 2 * * 4"
|
||||
# on demand
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,12 +24,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: false
|
||||
@@ -46,7 +42,7 @@ jobs:
|
||||
run: python build_helpers/binance_update_lev_tiers.py
|
||||
|
||||
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.REPO_SCOPED_TOKEN }}
|
||||
add-paths: freqtrade/exchange/binance_leverage_tiers.json
|
||||
|
||||
+17
-41
@@ -32,13 +32,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -73,7 +68,7 @@ jobs:
|
||||
run: |
|
||||
pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc
|
||||
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
- uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
@@ -177,13 +172,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 🐍
|
||||
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
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "3.13"
|
||||
@@ -201,7 +191,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
@@ -219,13 +210,8 @@ jobs:
|
||||
run: |
|
||||
./tests/test_docs.sh
|
||||
|
||||
- name: Set up Python 🐍
|
||||
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
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "3.13"
|
||||
@@ -256,13 +242,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
enable-cache: true
|
||||
@@ -328,13 +309,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
@@ -345,7 +321,7 @@ jobs:
|
||||
python -m build --sdist --wheel
|
||||
|
||||
- name: Upload artifacts 📦
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: freqtrade-build
|
||||
path: |
|
||||
@@ -357,7 +333,7 @@ jobs:
|
||||
python -m build --sdist --wheel ft_client
|
||||
|
||||
- name: Upload artifacts 📦
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: freqtrade-client-build
|
||||
path: |
|
||||
@@ -388,7 +364,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish to PyPI (Test)
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
|
||||
@@ -417,7 +393,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
|
||||
|
||||
docker-build:
|
||||
|
||||
@@ -26,15 +26,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
activate-environment: true
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r docs/requirements-docs.txt
|
||||
uv pip install -r docs/requirements-docs.txt
|
||||
|
||||
- name: Fetch gh-pages branch
|
||||
run: |
|
||||
|
||||
@@ -31,13 +31,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Pre-build dev container image
|
||||
uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.19
|
||||
uses: devcontainers/ci@b63b30de439b47a52267f241112c5b453b673db5 # v0.3.1900000449
|
||||
with:
|
||||
subFolder: .github
|
||||
imageName: ghcr.io/${{ github.repository }}-devcontainer
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-tags
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -183,13 +183,13 @@ jobs:
|
||||
uses: ./.github/actions/docker-tags
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to github
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -25,12 +25,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
- name: Install uv and Python 🐍
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
python-version: "3.13"
|
||||
@@ -41,7 +37,7 @@ jobs:
|
||||
- name: Run auto-update
|
||||
run: pre-commit autoupdate
|
||||
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.REPO_SCOPED_TOKEN }}
|
||||
add-paths: .pre-commit-config.yaml
|
||||
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
|
||||
@@ -15,23 +15,23 @@ repos:
|
||||
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: "v1.19.1"
|
||||
rev: "v1.20.2"
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: build_helpers
|
||||
additional_dependencies:
|
||||
- types-cachetools==6.2.0.20251022
|
||||
- types-cachetools==6.2.0.20260408
|
||||
- types-filelock==3.2.7
|
||||
- types-requests==2.32.4.20260107
|
||||
- types-tabulate==0.10.0.20260308
|
||||
- types-python-dateutil==2.9.0.20260305
|
||||
- scipy-stubs==1.17.1.2
|
||||
- SQLAlchemy==2.0.48
|
||||
- types-requests==2.33.0.20260408
|
||||
- types-tabulate==0.10.0.20260408
|
||||
- types-python-dateutil==2.9.0.20260408
|
||||
- scipy-stubs==1.17.1.4
|
||||
- SQLAlchemy==2.0.49
|
||||
# stages: [push]
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: 'v0.15.7'
|
||||
rev: 'v0.15.12'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
@@ -70,6 +70,6 @@ repos:
|
||||
|
||||
# Ensure github actions remain safe
|
||||
- repo: https://github.com/woodruffw/zizmor-pre-commit
|
||||
rev: v1.23.1
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13.12-slim-trixie AS base
|
||||
FROM python:3.14.3-slim-trixie AS base
|
||||
|
||||
# Setup env
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
@@ -87,7 +87,7 @@ def extract_command_partials():
|
||||
help_output = _get_help_output(subparser)
|
||||
_write_partial_file(f"docs/commands/{command}.md", help_output)
|
||||
else:
|
||||
print(f" Warning: subcommand '{command}' not found in parser")
|
||||
print(f" Warning: subcommand '{command}' not found in parser")
|
||||
|
||||
# freqtrade-client still uses subprocess as requested
|
||||
print("Running for freqtrade-client")
|
||||
|
||||
@@ -283,6 +283,10 @@
|
||||
"month"
|
||||
]
|
||||
},
|
||||
"skip_wallet_history_migration": {
|
||||
"description": "Disable wallet history migration.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hyperopt_path": {
|
||||
"description": "Specify additional lookup path for Hyperopt Loss functions.",
|
||||
"type": "string"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11.14-slim-bookworm AS base
|
||||
FROM python:3.11.15-slim-bookworm AS base
|
||||
|
||||
# Setup env
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
+203
-162
@@ -160,117 +160,131 @@ The most important in the backtesting is to understand the result.
|
||||
A backtesting result will look like that:
|
||||
|
||||
```
|
||||
BACKTESTING REPORT
|
||||
┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ LTC/USDT:USDT │ 16 │ 1.0 │ 56.176 │ 5.62 │ 16:16:00 │ 16 0 0 100 │
|
||||
│ ETC/USDT:USDT │ 12 │ 0.72 │ 30.936 │ 3.09 │ 9:55:00 │ 11 0 1 91.7 │
|
||||
│ 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.31 │ 11.054 │ 1.11 │ 12:08:00 │ 9 0 1 90.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.14 │ -7.261 │ -0.73 │ 21:18:00 │ 8 0 1 88.9 │
|
||||
│ DOT/USDT:USDT │ 6 │ -0.4 │ -9.187 │ -0.92 │ 5:35:00 │ 4 0 2 66.7 │
|
||||
│ ADA/USDT:USDT │ 8 │ -1.76 │ -52.098 │ -5.21 │ 11:38:00 │ 6 0 2 75.0 │
|
||||
│ TOTAL │ 77 │ 0.22 │ 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└───────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||
LEFT OPEN TRADES REPORT
|
||||
┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ 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 │
|
||||
│ ETC/USDT:USDT │ 1 │ -4.24 │ -15.365 │ -1.54 │ 10:40: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.420 │ -4.44 │ 6 days, 2:03:00 │ 0 0 3 0 │
|
||||
└───────────────┴────────┴──────────────┴─────────────────┴──────────────┴──────────────────┴────────────────────────┘
|
||||
ENTER TAG STATS
|
||||
┏━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Enter Tag ┃ Entries ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ OTHER │ 77 │ 0.22 │ 54.774 │ 5.48 │ 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 ┃ Exits ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ roi │ 67 │ 1.05 │ 242.179 │ 24.22 │ 15:49:00 │ 67 0 0 100 │
|
||||
│ exit_signal │ 4 │ -2.23 │ -31.217 │ -3.12 │ 1 day, 8:38:00 │ 0 0 4 0 │
|
||||
│ force_exit │ 3 │ -4.56 │ -44.420 │ -4.44 │ 6 days, 2:03: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.22 │ 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└─────────────┴───────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||
MIXED TAG STATS
|
||||
┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Enter Tag ┃ Exit Reason ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ │ roi │ 67 │ 1.05 │ 242.179 │ 24.22 │ 15:49:00 │ 67 0 0 100 │
|
||||
│ │ exit_signal │ 4 │ -2.23 │ -31.217 │ -3.12 │ 1 day, 8:38:00 │ 0 0 4 0 │
|
||||
│ │ force_exit │ 3 │ -4.56 │ -44.420 │ -4.44 │ 6 days, 2:03: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.22 │ 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└───────────┴─────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||
SUMMARY METRICS
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Metric ┃ Value ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ Backtesting from │ 2025-07-01 00:00:00 │
|
||||
│ Backtesting to │ 2025-08-01 00:00:00 │
|
||||
│ Trading Mode │ Isolated Futures │
|
||||
│ Max open trades │ 3 │
|
||||
│ │ │
|
||||
│ Total/Daily Avg Trades │ 77 / 2.48 │
|
||||
│ Starting balance │ 1000 USDT │
|
||||
│ Final balance │ 1054.774 USDT │
|
||||
│ Absolute profit │ 54.774 USDT │
|
||||
│ Total profit % │ 5.48% │
|
||||
│ CAGR % │ 87.36% │
|
||||
│ Sortino │ 2.48 │
|
||||
│ Sharpe │ 3.75 │
|
||||
│ Calmar │ 40.99 │
|
||||
│ SQN │ 0.69 │
|
||||
│ Profit factor │ 1.29 │
|
||||
│ Expectancy (Ratio) │ 0.71 (0.04) │
|
||||
│ Avg. daily profit │ 1.767 USDT │
|
||||
│ Avg. stake amount │ 345.016 USDT │
|
||||
│ Total trade volume │ 53316.954 USDT │
|
||||
│ │ │
|
||||
│ Long / Short trades │ 67 / 10 │
|
||||
│ Long / Short profit % │ 8.94% / -3.47% │
|
||||
│ Long / Short profit USDT │ 89.425 / -34.651 │
|
||||
│ │ │
|
||||
│ Best Pair │ LTC/USDT:USDT 5.62% │
|
||||
│ Worst Pair │ ADA/USDT:USDT -5.21% │
|
||||
│ Best trade │ ETC/USDT:USDT 2.00% │
|
||||
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
||||
│ Best day │ 26.91 USDT │
|
||||
│ Worst day │ -47.741 USDT │
|
||||
│ 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 Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │
|
||||
│ Max Consecutive Wins / Loss │ 36 / 3 │
|
||||
│ Rejected Entry signals │ 258 │
|
||||
│ Entry/Exit Timeouts │ 0 / 0 │
|
||||
│ │ │
|
||||
│ Min balance │ 1003.168 USDT │
|
||||
│ Max balance │ 1149.421 USDT │
|
||||
│ Max % of account underwater │ 8.23% │
|
||||
│ Absolute drawdown │ 94.647 USDT (8.23%) │
|
||||
│ Drawdown duration │ 9 days 08:50:00 │
|
||||
│ Profit at drawdown start │ 149.421 USDT │
|
||||
│ Profit at drawdown end │ 54.774 USDT │
|
||||
│ Drawdown start │ 2025-07-22 15:10:00 │
|
||||
│ Drawdown end │ 2025-08-01 00:00:00 │
|
||||
│ Market change │ 30.51% │
|
||||
└───────────────────────────────┴─────────────────────────────────┘
|
||||
BACKTESTING REPORT
|
||||
┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ 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 │
|
||||
│ ETC/USDT:USDT │ 12 │ 0.73 │ 31.513 │ 3.15 │ 9:55:00 │ 11 0 1 91.7 │
|
||||
│ ETH/USDT:USDT │ 8 │ 0.69 │ 18.659 │ 1.87 │ 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 │
|
||||
│ BTC/USDT:USDT │ 8 │ 0.22 │ 7.502 │ 0.75 │ 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 │
|
||||
│ DOT/USDT:USDT │ 6 │ -0.39 │ -9.169 │ -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 │
|
||||
│ TOTAL │ 77 │ 0.23 │ 57.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└───────────────┴────────┴──────────────┴─────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||
LEFT OPEN TRADES REPORT
|
||||
┏━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Pair ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ 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 │
|
||||
│ 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 │
|
||||
│ TOTAL │ 3 │ -4.56 │ -44.461 │ -4.45 │ 6 days, 2:03:00 │ 0 0 3 0 │
|
||||
└───────────────┴────────┴──────────────┴─────────────┴──────────────┴──────────────────┴────────────────────────┘
|
||||
ENTER TAG STATS
|
||||
┏━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Enter Tag ┃ Entries ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ OTHER │ 77 │ 0.23 │ 57.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
│ TOTAL │ 77 │ 0.23 │ 57.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└───────────┴─────────┴──────────────┴─────────────┴──────────────┴──────────────┴────────────────────────┘
|
||||
EXIT REASON STATS
|
||||
┏━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Exit Reason ┃ Exits ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ roi │ 67 │ 1.06 │ 245.117 │ 24.51 │ 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 │
|
||||
│ force_exit │ 3 │ -4.56 │ -44.461 │ -4.45 │ 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 │
|
||||
│ TOTAL │ 77 │ 0.23 │ 57.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└─────────────┴───────┴──────────────┴─────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||
MIXED TAG STATS
|
||||
┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Enter Tag ┃ Exit Reason ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃
|
||||
┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ │ roi │ 67 │ 1.06 │ 245.117 │ 24.51 │ 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 │
|
||||
│ │ force_exit │ 3 │ -4.56 │ -44.461 │ -4.45 │ 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 │
|
||||
│ TOTAL │ │ 77 │ 0.23 │ 57.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │
|
||||
└───────────┴─────────────┴────────┴──────────────┴─────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||
SUMMARY METRICS
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Metric ┃ Value ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ Backtesting from │ 2025-07-01 00:00:00 │
|
||||
│ Backtesting to │ 2025-08-01 00:00:00 │
|
||||
│ Trading Mode │ Isolated Futures │
|
||||
│ Max open trades │ 3 │
|
||||
│ │ │
|
||||
│ Total/Daily Avg Trades │ 77 / 2.48 │
|
||||
│ Starting balance │ 1000 USDT │
|
||||
│ Final balance │ 1057.157 USDT │
|
||||
│ Absolute profit │ 57.157 USDT │
|
||||
│ Total profit % │ 5.72% │
|
||||
│ CAGR % │ 92.41% │
|
||||
│ Sharpe (closed trades) │ 3.89 │
|
||||
│ Sortino (closed trades) │ 2.57 │
|
||||
│ Calmar (closed trades) │ 43.03 │
|
||||
│ SQN │ 0.71 │
|
||||
│ Profit factor │ 1.30 │
|
||||
│ Expectancy (Ratio) │ 0.74 (0.04) │
|
||||
│ Avg. daily profit │ 1.844 USDT │
|
||||
│ Avg. stake amount │ 345.478 USDT │
|
||||
│ Market change │ 30.51% │
|
||||
│ Total trade volume │ 53390.788 USDT │
|
||||
│ │ │
|
||||
│ Long / Short trades │ 67 / 10 │
|
||||
│ Long / Short profit % │ 9.19% / -3.48% │
|
||||
│ Long / Short profit USDT │ 91.940 / -34.783 │
|
||||
│ │ │
|
||||
│ Best Pair │ LTC/USDT:USDT 5.69% │
|
||||
│ Worst Pair │ ADA/USDT:USDT -5.21% │
|
||||
│ Best trade │ XRP/USDT:USDT 2.00% │
|
||||
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
||||
│ Best day │ 27.031 USDT │
|
||||
│ Worst day │ -47.826 USDT │
|
||||
│ 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 Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │
|
||||
│ Max Consecutive Wins / Loss │ 36 / 3 │
|
||||
│ Rejected Entry signals │ 258 │
|
||||
│ Entry/Exit Timeouts │ 0 / 0 │
|
||||
│ │ │
|
||||
│ Min/Max balance (closed trades) │ 1003.205 USDT / 1151.425 USDT │
|
||||
│ Max % of account underwater │ 8.19% │
|
||||
│ Absolute drawdown │ 94.268 USDT (8.19%) │
|
||||
│ Drawdown duration │ 9 days 08:50:00 │
|
||||
│ 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 │
|
||||
└────────────────────────────────────────┴───────────────────────────────────────────┘
|
||||
|
||||
Backtested 2025-07-01 00:00:00 -> 2025-08-01 00:00:00 | Max open trades : 3
|
||||
STRATEGY SUMMARY
|
||||
┏━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Strategy ┃ Trades ┃ Avg Profit % ┃ Tot Profit USDT ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ Drawdown ┃
|
||||
┡━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ SampleStrategy │ 77 │ 0.22 │ 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │ 94.647 USDT 8.23% │
|
||||
└────────────────┴────────┴──────────────┴─────────────────┴──────────────┴──────────────┴────────────────────────┴────────────────────┘
|
||||
STRATEGY SUMMARY
|
||||
┏━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
|
||||
┃ Strategy ┃ Trades ┃ Avg Profit % ┃ Tot Profit ┃ Tot Profit % ┃ Avg Duration ┃ Win Draw Loss Win% ┃ Drawdown ┃
|
||||
┡━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩
|
||||
│ SampleStrategy │ 77 │ 0.23 │ 57.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ 94.268 8.19% │
|
||||
└────────────────┴────────┴──────────────┴─────────────┴──────────────┴──────────────┴────────────────────────┴────────────────┘
|
||||
|
||||
```
|
||||
|
||||
### Backtesting report table
|
||||
@@ -329,54 +343,72 @@ 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.
|
||||
|
||||
```
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Metric ┃ Value ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ Backtesting from │ 2025-07-01 00:00:00 │
|
||||
│ Backtesting to │ 2025-08-01 00:00:00 │
|
||||
│ Trading Mode │ Isolated Futures │
|
||||
│ Max open trades │ 3 │
|
||||
│ │ │
|
||||
│ Total/Daily Avg Trades │ 72 / 2.32 │
|
||||
│ Starting balance │ 1000 USDT │
|
||||
│ Final balance │ 1106.734 USDT │
|
||||
│ Absolute profit │ 106.734 USDT │
|
||||
│ Total profit % │ 10.67% │
|
||||
│ CAGR % │ 230.04% │
|
||||
│ Sortino │ 4.99 │
|
||||
│ Sharpe │ 8.00 │
|
||||
│ Calmar │ 77.76 │
|
||||
│ SQN │ 1.52 │
|
||||
│ Profit factor │ 1.79 │
|
||||
│ Expectancy (Ratio) │ 1.48 (0.07) │
|
||||
│ Avg. daily profit │ 3.443 USDT │
|
||||
│ Avg. stake amount │ 363.133 USDT │
|
||||
│ Total trade volume │ 52466.174 USDT │
|
||||
│ │ │
|
||||
│ Best Pair │ LTC/USDT:USDT 4.48% │
|
||||
│ Worst Pair │ ADA/USDT:USDT -1.78% │
|
||||
│ Best trade │ ETC/USDT:USDT 2.00% │
|
||||
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
||||
│ Best day │ 23.535 USDT │
|
||||
│ Worst day │ -49.813 USDT │
|
||||
│ Days win/draw/lose │ 21 / 6 / 4 │
|
||||
│ Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:30 │
|
||||
│ Min/Max/Avg. Duration Losers │ 0d 12:00 / 17d 08:00 / 3d 23:28 │
|
||||
│ Max Consecutive Wins / Loss │ 58 / 4 │
|
||||
│ Rejected Entry signals │ 254 │
|
||||
│ Entry/Exit Timeouts │ 0 / 0 │
|
||||
│ │ │
|
||||
│ Min balance │ 1003.168 USDT │
|
||||
│ Max balance │ 1209 USDT │
|
||||
│ Max % of account underwater │ 8.46% │
|
||||
│ Absolute drawdown │ 102.266 USDT (8.46%) │
|
||||
│ Drawdown duration │ 9 days 08:50:00 │
|
||||
│ Profit at drawdown start │ 209 USDT │
|
||||
│ Profit at drawdown end │ 106.734 USDT │
|
||||
│ Drawdown start │ 2025-07-22 15:10:00 │
|
||||
│ Drawdown end │ 2025-08-01 00:00:00 │
|
||||
│ Market change │ 30.51% │
|
||||
└───────────────────────────────┴─────────────────────────────────┘
|
||||
SUMMARY METRICS
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Metric ┃ Value ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ Backtesting from │ 2025-07-01 00:00:00 │
|
||||
│ Backtesting to │ 2025-08-01 00:00:00 │
|
||||
│ Trading Mode │ Isolated Futures │
|
||||
│ Max open trades │ 3 │
|
||||
│ │ │
|
||||
│ Total/Daily Avg Trades │ 77 / 2.48 │
|
||||
│ Starting balance │ 1000 USDT │
|
||||
│ Final balance │ 1057.157 USDT │
|
||||
│ Absolute profit │ 57.157 USDT │
|
||||
│ Total profit % │ 5.72% │
|
||||
│ CAGR % │ 92.41% │
|
||||
│ Sharpe (closed trades) │ 3.89 │
|
||||
│ Sortino (closed trades) │ 2.57 │
|
||||
│ Calmar (closed trades) │ 43.03 │
|
||||
│ SQN │ 0.71 │
|
||||
│ Profit factor │ 1.30 │
|
||||
│ Expectancy (Ratio) │ 0.74 (0.04) │
|
||||
│ Avg. daily profit │ 1.844 USDT │
|
||||
│ Avg. stake amount │ 345.478 USDT │
|
||||
│ Market change │ 30.51% │
|
||||
│ Total trade volume │ 53390.788 USDT │
|
||||
│ │ │
|
||||
│ Long / Short trades │ 67 / 10 │
|
||||
│ Long / Short profit % │ 9.19% / -3.48% │
|
||||
│ Long / Short profit USDT │ 91.940 / -34.783 │
|
||||
│ │ │
|
||||
│ Best Pair │ LTC/USDT:USDT 5.69% │
|
||||
│ Worst Pair │ ADA/USDT:USDT -5.21% │
|
||||
│ Best trade │ XRP/USDT:USDT 2.00% │
|
||||
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
||||
│ Best day │ 27.031 USDT │
|
||||
│ Worst day │ -47.826 USDT │
|
||||
│ 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 Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │
|
||||
│ Max Consecutive Wins / Loss │ 36 / 3 │
|
||||
│ Rejected Entry signals │ 258 │
|
||||
│ Entry/Exit Timeouts │ 0 / 0 │
|
||||
│ │ │
|
||||
│ Min/Max balance (closed trades) │ 1003.205 USDT / 1151.425 USDT │
|
||||
│ Max % of account underwater │ 8.19% │
|
||||
│ Absolute drawdown │ 94.268 USDT (8.19%) │
|
||||
│ Drawdown duration │ 9 days 08:50:00 │
|
||||
│ 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).
|
||||
@@ -388,14 +420,15 @@ It contains key metrics about the performance of your strategy on backtesting da
|
||||
- `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`.
|
||||
- `CAGR %`: Compound annual growth rate.
|
||||
- `Sortino`: Annualized Sortino ratio.
|
||||
- `Sharpe`: Annualized Sharpe ratio.
|
||||
- `Calmar`: Annualized Calmar ratio.
|
||||
- `Sharpe (closed trades)`: Annualized Sharpe ratio including only closed trades (ignoring open trades with profits or losses).
|
||||
- `Sortino (closed trades)`: Annualized Sortino ratio including only closed trades (ignoring open trades with profits or losses).
|
||||
- `Calmar (closed trades)`: Annualized Calmar ratio including only closed trades (ignoring open trades with profits or losses).
|
||||
- `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.
|
||||
- `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. 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.
|
||||
- `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).
|
||||
@@ -409,13 +442,21 @@ It contains key metrics about the performance of your strategy on backtesting da
|
||||
- `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.
|
||||
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
|
||||
- `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
|
||||
- `Min/Max balance (closed trades)`: Lowest and Highest Wallet balance during the backtest period based on closed trades trades.
|
||||
- `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 (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.
|
||||
- `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).
|
||||
- `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 (wallet balance)`: Lowest and Highest Wallet balance during the backtest period - including capital tied in open trades.
|
||||
- `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
|
||||
|
||||
|
||||
+37
-9
@@ -345,6 +345,15 @@ 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.
|
||||
|
||||
### 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 requires the API key Memo (the name you give the API key) to go along with the exchange key and secret.
|
||||
@@ -429,31 +438,50 @@ 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.
|
||||
* 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
|
||||
|
||||
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:
|
||||
!!! Warning "Vaults and Subaccounts"
|
||||
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 address and not the vault/subaccount address).
|
||||
"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": {
|
||||
"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
|
||||
"subAccountAddress": "your_subaccount_address" // Required 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.
|
||||
Your balance and trades will now be used from your 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.
|
||||
### 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
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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-experimental) 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) for an up-to-date list of supported exchanges.
|
||||
|
||||
### 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?
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Beginner Tips & Tricks
|
||||
|
||||
|
||||
@@ -46,6 +46,23 @@ On this page, you can also interact with the bot by starting and stopping it and
|
||||

|
||||

|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
+4
-4
@@ -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:
|
||||
|
||||
* `scatter` corresponding to `plotly.graph_objects.Scatter` class (default).
|
||||
* `bar` corresponding to `plotly.graph_objects.Bar` class.
|
||||
* `scatter` corresponding a scatter plot.
|
||||
* `bar` corresponding to a bar plot.
|
||||
|
||||
Extra parameters to `plotly.graph_objects.*` constructor can be specified in `plotly` dict.
|
||||
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.
|
||||
|
||||
Sample configuration with inline comments explaining the process:
|
||||
|
||||
@@ -163,7 +163,7 @@ def plot_config(self):
|
||||
```
|
||||
|
||||
??? 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.
|
||||
|
||||
``` python
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
markdown==3.10.2
|
||||
mkdocs==1.6.1
|
||||
mkdocs-material==9.7.5
|
||||
mkdocs-material==9.7.6
|
||||
mdx_truly_sane_lists==1.3
|
||||
pymdown-extensions==10.21
|
||||
pymdown-extensions==10.21.2
|
||||
jinja2==3.1.6
|
||||
mike==2.1.4
|
||||
mike==2.2.0
|
||||
|
||||
@@ -39,6 +39,22 @@ 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.
|
||||
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
|
||||
|
||||
Enable or Disable stop loss on exchange.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Freqtrade bot"""
|
||||
|
||||
__version__ = "2026.3"
|
||||
__version__ = "2026.4"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,15 +8,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.persistence import Order, Trade, init_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
|
||||
from freqtrade.persistence import Trade, init_db
|
||||
from freqtrade.persistence.db_migration import migrate_db
|
||||
|
||||
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
|
||||
|
||||
@@ -24,56 +19,4 @@ def start_convert_db(args: dict[str, Any]) -> None:
|
||||
session_target = Trade.session
|
||||
init_db(config["db_url_from"])
|
||||
logger.info("Starting db migration.")
|
||||
|
||||
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."
|
||||
)
|
||||
migrate_db(session_target)
|
||||
|
||||
@@ -236,6 +236,10 @@ CONF_SCHEMA = {
|
||||
"type": "string",
|
||||
"enum": BACKTEST_CACHE_AGE,
|
||||
},
|
||||
"skip_wallet_history_migration": {
|
||||
"description": "Disable wallet history migration.",
|
||||
"type": "boolean",
|
||||
},
|
||||
# Hyperopt
|
||||
"hyperopt_path": {
|
||||
"description": "Specify additional lookup path for Hyperopt Loss functions.",
|
||||
|
||||
@@ -92,6 +92,7 @@ def validate_config_consistency(conf: dict[str, Any], *, preliminary: bool = Fal
|
||||
_validate_consumers(conf)
|
||||
validate_migrated_strategy_settings(conf)
|
||||
_validate_orderflow(conf)
|
||||
_validate_demo_trading(conf)
|
||||
|
||||
# validate configuration before returning
|
||||
logger.info("Validating configuration ...")
|
||||
@@ -413,6 +414,11 @@ 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:
|
||||
process_deprecated_setting(conf, None, "use_sell_signal", None, "use_exit_signal")
|
||||
process_deprecated_setting(conf, None, "sell_profit_only", None, "exit_profit_only")
|
||||
|
||||
@@ -7,6 +7,7 @@ from .bt_fileutils import (
|
||||
get_backtest_market_change,
|
||||
get_backtest_result,
|
||||
get_backtest_resultlist,
|
||||
get_backtest_wallet_change,
|
||||
get_latest_backtest_filename,
|
||||
get_latest_hyperopt_file,
|
||||
get_latest_hyperopt_filename,
|
||||
|
||||
@@ -10,7 +10,6 @@ from io import BytesIO, StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from freqtrade.constants import LAST_BT_RESULT_FN
|
||||
@@ -308,10 +307,31 @@ def get_backtest_market_change(filename: Path, include_ts: bool = True) -> pd.Da
|
||||
else:
|
||||
df = pd.read_feather(filename)
|
||||
if include_ts:
|
||||
df.loc[:, "__date_ts"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000
|
||||
df.loc[:, "__date_ts"] = df.loc[:, "date"].dt.as_unit("ms").astype("int64")
|
||||
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(
|
||||
dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
@@ -503,13 +523,16 @@ def load_backtest_analysis_data(
|
||||
return None
|
||||
|
||||
|
||||
def trade_list_to_dataframe(trades: list[Trade] | list[LocalTrade]) -> pd.DataFrame:
|
||||
def trade_list_to_dataframe(
|
||||
trades: list[Trade] | list[LocalTrade], *, minified: bool = True
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Convert list of Trade objects to pandas Dataframe
|
||||
:param trades: List of trade objects
|
||||
:param minified: Whether to use minified version of trade JSON
|
||||
:return: Dataframe with BT_DATA_COLUMNS
|
||||
"""
|
||||
df = pd.DataFrame.from_records([t.to_json(True) for t in trades], columns=BT_DATA_COLUMNS)
|
||||
df = pd.DataFrame.from_records([t.to_json(minified) for t in trades], columns=BT_DATA_COLUMNS)
|
||||
if len(df) > 0:
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
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__)
|
||||
@@ -58,3 +64,95 @@ def evaluate_result_multi(
|
||||
"""
|
||||
df_final = analyze_trade_parallelism(trades, timeframe)
|
||||
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
|
||||
|
||||
from pandas import DataFrame, read_feather, to_datetime
|
||||
from pandas import DataFrame, read_feather
|
||||
from pyarrow import dataset
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
@@ -71,7 +71,7 @@ class FeatherDataHandler(IDataHandler):
|
||||
"volume": "float",
|
||||
}
|
||||
)
|
||||
pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True)
|
||||
pairdata["date"] = pairdata["date"].dt.as_unit("ms")
|
||||
return pairdata
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from pandas import DataFrame, read_json, to_datetime
|
||||
|
||||
from freqtrade import misc
|
||||
@@ -35,8 +34,8 @@ class JsonDataHandler(IDataHandler):
|
||||
filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type)
|
||||
self.create_dir_if_needed(filename)
|
||||
_data = data.copy()
|
||||
# Convert date to int
|
||||
_data["date"] = _data["date"].astype(np.int64) // 1000 // 1000
|
||||
# Convert date to int (milliseconds)
|
||||
_data["date"] = _data["date"].dt.as_unit("ms").astype("int64")
|
||||
|
||||
# Reset index, select only appropriate columns and save as json
|
||||
_data.reset_index(drop=True).loc[:, self._columns].to_json(
|
||||
@@ -81,7 +80,7 @@ class JsonDataHandler(IDataHandler):
|
||||
"volume": "float",
|
||||
}
|
||||
)
|
||||
pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True)
|
||||
pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True).dt.as_unit("ms")
|
||||
return pairdata
|
||||
|
||||
def ohlcv_append(
|
||||
@@ -105,6 +104,9 @@ class JsonDataHandler(IDataHandler):
|
||||
:param trading_mode: Trading mode to use (used to determine the filename)
|
||||
"""
|
||||
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()
|
||||
misc.file_dump_json(filename, trades, is_zip=self._use_zip)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from pandas import DataFrame, read_parquet, to_datetime
|
||||
from pandas import DataFrame, read_parquet
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS
|
||||
@@ -68,7 +68,7 @@ class ParquetDataHandler(IDataHandler):
|
||||
"volume": "float",
|
||||
}
|
||||
)
|
||||
pairdata["date"] = to_datetime(pairdata["date"], unit="ms", utc=True)
|
||||
pairdata["date"] = pairdata["date"].dt.as_unit("ms")
|
||||
return pairdata
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
+196
-25
@@ -140,7 +140,7 @@ def _calc_drawdown_series(
|
||||
max_drawdown_df["drawdown_relative"] = (max_balance - cumulative_balance) / max_balance
|
||||
else:
|
||||
# NOTE: This is not completely accurate,
|
||||
# but might good enough if starting_balance is not available
|
||||
# but will be good enough if starting_balance is not available
|
||||
max_drawdown_df["drawdown_relative"] = (
|
||||
max_drawdown_df["high_value"] - max_drawdown_df["cumulative"]
|
||||
) / max_drawdown_df["high_value"]
|
||||
@@ -333,6 +333,72 @@ def calculate_expectancy(trades: pd.DataFrame) -> tuple[float, float]:
|
||||
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(
|
||||
trades: pd.DataFrame,
|
||||
min_date: datetime | None,
|
||||
@@ -354,14 +420,31 @@ def calculate_sortino(
|
||||
|
||||
down_stdev = np.std(trades.loc[trades["profit_abs"] < 0, "profit_abs"] / starting_balance)
|
||||
|
||||
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
|
||||
return _calculate_annualized_ratio(expected_returns_mean, down_stdev)
|
||||
|
||||
# print(expected_returns_mean, down_stdev, sortino_ratio)
|
||||
return sortino_ratio
|
||||
|
||||
def calculate_sortino_from_balance(
|
||||
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(
|
||||
@@ -384,14 +467,67 @@ def calculate_sharpe(
|
||||
expected_returns_mean = total_profit.sum() / days_period
|
||||
up_stdev = np.std(total_profit)
|
||||
|
||||
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
|
||||
return _calculate_annualized_ratio(expected_returns_mean, up_stdev)
|
||||
|
||||
# print(expected_returns_mean, up_stdev, sharp_ratio)
|
||||
return sharp_ratio
|
||||
|
||||
def calculate_sharpe_from_balance(
|
||||
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(
|
||||
@@ -401,12 +537,12 @@ def calculate_calmar(
|
||||
starting_balance: float,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate calmar
|
||||
Calculate calmar from trades data.
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_abs)
|
||||
:return: calmar
|
||||
"""
|
||||
if (len(trades) == 0) or (min_date is None) or (max_date is None) or (min_date == max_date):
|
||||
return 0
|
||||
return 0.0
|
||||
|
||||
total_profit = trades["profit_abs"].sum() / starting_balance
|
||||
days_period = max(1, (max_date - min_date).days)
|
||||
@@ -422,16 +558,51 @@ def calculate_calmar(
|
||||
)
|
||||
max_drawdown = drawdown.relative_account_drawdown
|
||||
except ValueError:
|
||||
max_drawdown = 0
|
||||
return 0.0
|
||||
|
||||
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
|
||||
return _calculate_annualized_ratio(expected_returns_mean, max_drawdown)
|
||||
|
||||
# print(expected_returns_mean, max_drawdown, calmar_ratio)
|
||||
return calmar_ratio
|
||||
|
||||
def calculate_calmar_from_balance(
|
||||
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:
|
||||
|
||||
@@ -46,6 +46,10 @@ class Binance(Exchange):
|
||||
"l2_limit_range": [5, 10, 20, 50, 100, 500, 1000],
|
||||
"ws_enabled": 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 = {
|
||||
"ohlcv_candle_limit": 499,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, PriceType, TradingMode
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
InvalidOrderException,
|
||||
OperationalException,
|
||||
RetryableOrderError,
|
||||
TemporaryError,
|
||||
@@ -34,16 +35,18 @@ class Bitget(Exchange):
|
||||
"stoploss_query_requires_stop_flag": True,
|
||||
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
|
||||
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
|
||||
}
|
||||
_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_value_mapping": {
|
||||
PriceType.LAST: "fill_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]] = [
|
||||
(TradingMode.SPOT, MarginMode.NONE),
|
||||
@@ -99,30 +102,36 @@ class Bitget(Exchange):
|
||||
return order
|
||||
|
||||
def _fetch_stop_order_fallback(self, order_id: str, pair: str) -> CcxtOrder:
|
||||
params2 = {
|
||||
"stop": True,
|
||||
}
|
||||
for method in (
|
||||
self._api.fetch_open_orders,
|
||||
self._api.fetch_canceled_and_closed_orders,
|
||||
):
|
||||
try:
|
||||
orders = method(pair, params=params2)
|
||||
orders_f = [order for order in orders if order["id"] == order_id]
|
||||
if orders_f:
|
||||
order = orders_f[0]
|
||||
self._log_exchange_response("get_stop_order_fallback", order)
|
||||
return self._convert_stop_order(pair, order_id, order)
|
||||
except (ccxt.OrderNotFound, ccxt.InvalidOrder):
|
||||
pass
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f"Could not get order due to {e.__class__.__name__}. Message: {e}"
|
||||
) from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
# old stoploss orders
|
||||
paramsold = {"stop": True}
|
||||
# new stoploss orders with stopLossPrice (used in futures starting 2026.4)
|
||||
paramsnew = {"planType": "profit_loss"}
|
||||
params_to_try = (
|
||||
(paramsnew, paramsold) if self.trading_mode == TradingMode.FUTURES else (paramsold,)
|
||||
)
|
||||
|
||||
for params2 in params_to_try:
|
||||
for method in (
|
||||
self._api.fetch_open_orders,
|
||||
self._api.fetch_canceled_and_closed_orders,
|
||||
):
|
||||
try:
|
||||
orders = method(pair, params=params2)
|
||||
orders_f = [order for order in orders if order["id"] == order_id]
|
||||
if orders_f:
|
||||
order = orders_f[0]
|
||||
self._log_exchange_response("get_stop_order_fallback", order)
|
||||
return self._convert_stop_order(pair, order_id, order)
|
||||
except (ccxt.OrderNotFound, ccxt.InvalidOrder):
|
||||
pass
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f"Could not get order due to {e.__class__.__name__}. Message: {e}"
|
||||
) from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
raise RetryableOrderError(f"StoplossOrder not found (pair: {pair} id: {order_id}).")
|
||||
|
||||
@retrier(retries=API_RETRY_COUNT)
|
||||
@@ -134,6 +143,19 @@ class Bitget(Exchange):
|
||||
|
||||
return self._fetch_stop_order_fallback(order_id, pair)
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
|
||||
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
|
||||
def additional_exchange_init(self) -> None:
|
||||
"""
|
||||
@@ -155,12 +177,6 @@ class Bitget(Exchange):
|
||||
except ccxt.BaseError as 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(
|
||||
self,
|
||||
side: BuySell,
|
||||
|
||||
@@ -35,6 +35,9 @@ class Bybit(Exchange):
|
||||
# TODO: Can be removed once bybit fully forces all accounts to unified mode.
|
||||
"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 = {
|
||||
"ohlcv_has_history": True,
|
||||
|
||||
@@ -51,12 +51,10 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool:
|
||||
if not valid:
|
||||
if check_for_bad:
|
||||
raise OperationalException(
|
||||
f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}.'
|
||||
f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}.'
|
||||
)
|
||||
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:
|
||||
logger.info(
|
||||
|
||||
@@ -13,6 +13,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from math import floor, isnan
|
||||
from threading import Lock
|
||||
from typing import Any, Literal, TypeGuard, TypeVar
|
||||
from uuid import uuid4
|
||||
|
||||
import ccxt
|
||||
import ccxt.pro as ccxt_pro
|
||||
@@ -249,7 +250,7 @@ class Exchange:
|
||||
|
||||
# Holds all open sell orders for dry_run
|
||||
self._dry_run_open_orders: dict[str, Any] = {}
|
||||
|
||||
self._is_demo_trading = exchange_conf.get("demo_trading", False)
|
||||
if self._config["dry_run"]:
|
||||
logger.info("Instance is running with dry_run enabled")
|
||||
logger.info(f"Using CCXT {ccxt.__version__}")
|
||||
@@ -365,6 +366,7 @@ class Exchange:
|
||||
self.validate_pricing(config["exit_pricing"])
|
||||
self.validate_pricing(config["entry_pricing"])
|
||||
self.validate_orderflow(config["exchange"])
|
||||
self.validate_demo_trading(config["exchange"])
|
||||
self.validate_freqai(config)
|
||||
|
||||
self._set_startup_candle_count(config)
|
||||
@@ -418,6 +420,9 @@ class Exchange:
|
||||
except ccxt.BaseError as 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
|
||||
|
||||
@property
|
||||
@@ -433,12 +438,12 @@ class Exchange:
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""exchange Name (from ccxt)"""
|
||||
return self._api.name
|
||||
return self._api.name if not self._is_demo_trading else f"{self._api.name} (Demo)"
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""exchange ccxt id"""
|
||||
return self._api.id
|
||||
return self._api.id if not self._is_demo_trading else f"{self._api.id}_demo"
|
||||
|
||||
@property
|
||||
def timeframes(self) -> list[str]:
|
||||
@@ -870,6 +875,16 @@ class Exchange:
|
||||
"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:
|
||||
"""
|
||||
Checks if required startup_candles is more than ohlcv_candle_limit().
|
||||
@@ -1138,7 +1153,7 @@ class Exchange:
|
||||
stop_price: float | None = None,
|
||||
) -> CcxtOrder:
|
||||
now = dt_now()
|
||||
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
|
||||
order_id = f"dry_run_{side}_{pair}_{uuid4()}"
|
||||
# Rounding here must respect to contract sizes
|
||||
_amount = self._contracts_to_amount(
|
||||
pair, self.amount_to_precision(pair, self._amount_to_contracts(pair, amount))
|
||||
@@ -2655,11 +2670,11 @@ class Exchange:
|
||||
if self._can_use_websocket(self._exchange_ws, pair, timeframe, candle_type):
|
||||
candle_ts = dt_ts(timeframe_to_prev_date(timeframe))
|
||||
prev_candle_ts = dt_ts(date_minus_candles(timeframe, 1))
|
||||
candles = self._exchange_ws.ohlcvs(pair, timeframe)
|
||||
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)
|
||||
candles, last_refresh_time = self._exchange_ws.get_ohlcv_with_refresh(
|
||||
pair, timeframe, candle_type
|
||||
)
|
||||
last_refresh_time = int(last_refresh_time)
|
||||
half_candle = int(candle_ts - (candle_ts - prev_candle_ts) * 0.5)
|
||||
|
||||
if (
|
||||
candles
|
||||
|
||||
@@ -67,6 +67,8 @@ class FtHas(TypedDict, total=False):
|
||||
|
||||
# Delisting check
|
||||
has_delisting: bool
|
||||
# Demo mode - this is not sandbox but an exchange-provided demo mode.
|
||||
supports_demo_trading: bool
|
||||
|
||||
|
||||
class Ticker(TypedDict):
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from functools import partial
|
||||
from threading import Thread
|
||||
from threading import Event, RLock, Thread
|
||||
|
||||
import ccxt
|
||||
|
||||
@@ -24,49 +23,71 @@ class ExchangeWS:
|
||||
self.config = config
|
||||
self._ccxt_object = ccxt_object
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self._state_lock = RLock()
|
||||
self._loop_ready = Event()
|
||||
|
||||
self._klines_watching: set[PairWithTimeframe] = set()
|
||||
self._klines_scheduled: set[PairWithTimeframe] = set()
|
||||
self.klines_last_refresh: dict[PairWithTimeframe, float] = {}
|
||||
self.klines_last_request: dict[PairWithTimeframe, float] = {}
|
||||
self._klines_last_refresh: dict[PairWithTimeframe, float] = {}
|
||||
self._klines_last_request: dict[PairWithTimeframe, float] = {}
|
||||
self._thread = Thread(name="ccxt_ws", target=self._start_forever)
|
||||
self._thread.start()
|
||||
self.__cleanup_called = False
|
||||
|
||||
def _start_forever(self) -> None:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._loop_ready.set()
|
||||
try:
|
||||
self._loop.run_forever()
|
||||
finally:
|
||||
if self._loop.is_running():
|
||||
self._loop.stop()
|
||||
if not self._loop.is_closed():
|
||||
# Cancel remaining tasks and close the loop in the owning thread.
|
||||
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:
|
||||
logger.debug("Cleanup called - stopping")
|
||||
self._klines_watching.clear()
|
||||
for task in self._background_tasks:
|
||||
with self._state_lock:
|
||||
self._klines_watching.clear()
|
||||
tasks = list(self._background_tasks)
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if hasattr(self, "_loop") and not self._loop.is_closed():
|
||||
self.reset_connections()
|
||||
|
||||
if self._wait_for_loop(timeout=0.2) and not self._loop.is_closed():
|
||||
self.reset_connections(cleanup=True)
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
time.sleep(0.1)
|
||||
if not self._loop.is_closed():
|
||||
self._loop.close()
|
||||
|
||||
self._thread.join()
|
||||
self._thread.join(timeout=5)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("Websocket loop thread did not stop within timeout.")
|
||||
logger.debug("Stopped")
|
||||
|
||||
def reset_connections(self) -> None:
|
||||
def reset_connections(self, cleanup: bool = False) -> None:
|
||||
"""
|
||||
Reset all connections - avoids "connection-reset" errors that happen after ~9 days
|
||||
"""
|
||||
if hasattr(self, "_loop") and not self._loop.is_closed():
|
||||
logger.info("Resetting WS connections.")
|
||||
asyncio.run_coroutine_threadsafe(self._cleanup_async(), loop=self._loop)
|
||||
while not self.__cleanup_called:
|
||||
time.sleep(0.1)
|
||||
self.__cleanup_called = False
|
||||
if self._wait_for_loop() and not self._loop.is_closed():
|
||||
logger.info(f"{'Cleaning up' if cleanup else 'Resetting'} exchange WS connections.")
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(self._cleanup_async(), loop=self._loop)
|
||||
fut.result(timeout=10)
|
||||
except TimeoutError:
|
||||
logger.warning("Timed out while resetting websocket connections.")
|
||||
except Exception:
|
||||
logger.exception("Exception while resetting websocket connections")
|
||||
|
||||
async def _cleanup_async(self) -> None:
|
||||
try:
|
||||
@@ -76,15 +97,14 @@ class ExchangeWS:
|
||||
self._ccxt_object.ohlcvs.clear()
|
||||
except Exception:
|
||||
logger.exception("Exception in _cleanup_async")
|
||||
finally:
|
||||
self.__cleanup_called = True
|
||||
|
||||
def _pop_history(self, paircomb: PairWithTimeframe) -> None:
|
||||
"""
|
||||
Remove history for a pair/timeframe combination from ccxt cache
|
||||
"""
|
||||
self._ccxt_object.ohlcvs.get(paircomb[0], {}).pop(paircomb[1], None)
|
||||
self.klines_last_refresh.pop(paircomb, None)
|
||||
with self._state_lock:
|
||||
self._ccxt_object.ohlcvs.get(paircomb[0], {}).pop(paircomb[1], None)
|
||||
self._klines_last_refresh.pop(paircomb, None)
|
||||
|
||||
@retrier(retries=3)
|
||||
def ohlcvs(self, pair: str, timeframe: str) -> list[list]:
|
||||
@@ -100,81 +120,129 @@ class ExchangeWS:
|
||||
# TemporaryError does not cause backoff - so we're essentially retrying immediately
|
||||
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:
|
||||
"""
|
||||
Remove pairs from watchlist if they've not been requested within
|
||||
the last timeframe (+ offset)
|
||||
"""
|
||||
changed = False
|
||||
for p in list(self._klines_watching):
|
||||
_, timeframe, _ = p
|
||||
timeframe_s = timeframe_to_seconds(timeframe)
|
||||
last_refresh = self.klines_last_request.get(p, 0)
|
||||
if last_refresh > 0 and (dt_ts() - last_refresh) > ((timeframe_s + 20) * 1000):
|
||||
logger.info(f"Removing {p} from websocket watchlist.")
|
||||
self._klines_watching.discard(p)
|
||||
# Pop history to avoid getting stale data
|
||||
self._pop_history(p)
|
||||
changed = True
|
||||
with self._state_lock:
|
||||
for p in list(self._klines_watching):
|
||||
_, timeframe, _ = p
|
||||
timeframe_s = timeframe_to_seconds(timeframe)
|
||||
last_refresh = self._klines_last_request.get(p, 0)
|
||||
if last_refresh > 0 and (dt_ts() - last_refresh) > ((timeframe_s + 20) * 1000):
|
||||
logger.info(f"Removing {p} from websocket watchlist.")
|
||||
self._klines_watching.discard(p)
|
||||
# Pop history to avoid getting stale data
|
||||
self._pop_history(p)
|
||||
changed = True
|
||||
if changed:
|
||||
logger.info(f"Removal done: new watch list ({len(self._klines_watching)})")
|
||||
|
||||
async def _schedule_while_true(self) -> None:
|
||||
# For the ones we should be watching
|
||||
for p in self._klines_watching:
|
||||
with self._state_lock:
|
||||
pairs_to_check = list(self._klines_watching)
|
||||
|
||||
for p in pairs_to_check:
|
||||
# Check if they're already scheduled
|
||||
if p not in self._klines_scheduled:
|
||||
with self._state_lock:
|
||||
if p in self._klines_scheduled:
|
||||
continue
|
||||
self._klines_scheduled.add(p)
|
||||
pair, timeframe, candle_type = p
|
||||
task = asyncio.create_task(
|
||||
self._continuously_async_watch_ohlcv(pair, timeframe, candle_type)
|
||||
)
|
||||
pair, timeframe, candle_type = p
|
||||
task = asyncio.create_task(
|
||||
self._continuously_async_watch_ohlcv(pair, timeframe, candle_type)
|
||||
)
|
||||
with self._state_lock:
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(
|
||||
partial(
|
||||
self._continuous_stopped,
|
||||
pair=pair,
|
||||
timeframe=timeframe,
|
||||
candle_type=candle_type,
|
||||
)
|
||||
task.add_done_callback(
|
||||
partial(
|
||||
self._continuous_stopped,
|
||||
pair=pair,
|
||||
timeframe=timeframe,
|
||||
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:
|
||||
try:
|
||||
await self._ccxt_object.un_watch_ohlcv_for_symbols([[pair, timeframe]])
|
||||
if self.exchange_has("unWatchOHLCVForSymbols"):
|
||||
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:
|
||||
logger.debug("un_watch_ohlcv_for_symbols not supported: %s", e)
|
||||
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:
|
||||
logger.exception("Exception in _unwatch_ohlcv")
|
||||
logger.exception(f"Exception in _unwatch_ohlcv for {pair}, {timeframe},")
|
||||
|
||||
def _continuous_stopped(
|
||||
self, task: asyncio.Task, pair: str, timeframe: str, candle_type: CandleType
|
||||
):
|
||||
self._background_tasks.discard(task)
|
||||
) -> None:
|
||||
with self._state_lock:
|
||||
self._background_tasks.discard(task)
|
||||
result = "done"
|
||||
if task.cancelled():
|
||||
result = "cancelled"
|
||||
else:
|
||||
if (result1 := task.result()) is not None:
|
||||
result = str(result1)
|
||||
try:
|
||||
if task.cancelled():
|
||||
result = "cancelled"
|
||||
else:
|
||||
if (result1 := task.result()) is not None:
|
||||
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
|
||||
)
|
||||
|
||||
logger.info(f"{pair}, {timeframe}, {candle_type} - Task finished - {result}")
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
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))
|
||||
with self._state_lock:
|
||||
self._klines_scheduled.discard((pair, timeframe, candle_type))
|
||||
self._pop_history((pair, timeframe, candle_type))
|
||||
|
||||
async def _continuously_async_watch_ohlcv(
|
||||
self, pair: str, timeframe: str, candle_type: CandleType
|
||||
) -> None:
|
||||
try:
|
||||
while (pair, timeframe, candle_type) in self._klines_watching:
|
||||
while True:
|
||||
with self._state_lock:
|
||||
if (pair, timeframe, candle_type) not in self._klines_watching:
|
||||
break
|
||||
start = dt_ts()
|
||||
data = await self._ccxt_object.watch_ohlcv(pair, timeframe)
|
||||
self.klines_last_refresh[(pair, timeframe, candle_type)] = dt_ts()
|
||||
with self._state_lock:
|
||||
self._klines_last_refresh[(pair, timeframe, candle_type)] = dt_ts()
|
||||
logger.debug(
|
||||
f"watch done {pair}, {timeframe}, data {len(data)} "
|
||||
f"in {(dt_ts() - start) / 1000:.3f}s"
|
||||
@@ -184,14 +252,19 @@ class ExchangeWS:
|
||||
except ccxt.BaseError:
|
||||
logger.exception(f"Exception in continuously_async_watch_ohlcv for {pair}, {timeframe}")
|
||||
finally:
|
||||
self._klines_watching.discard((pair, timeframe, candle_type))
|
||||
with self._state_lock:
|
||||
self._klines_watching.discard((pair, timeframe, candle_type))
|
||||
|
||||
def schedule_ohlcv(self, pair: str, timeframe: str, candle_type: CandleType) -> None:
|
||||
"""
|
||||
Schedule a pair/timeframe combination to be watched
|
||||
"""
|
||||
self._klines_watching.add((pair, timeframe, candle_type))
|
||||
self.klines_last_request[(pair, timeframe, candle_type)] = dt_ts()
|
||||
if not self._wait_for_loop():
|
||||
logger.warning(f"Websocket loop not ready. Could not schedule {pair}, {timeframe}.")
|
||||
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_while_true(), loop=self._loop)
|
||||
self.cleanup_expired()
|
||||
@@ -207,12 +280,10 @@ class ExchangeWS:
|
||||
Returns cached klines from ccxt's "watch" cache.
|
||||
:param candle_ts: timestamp of the end-time of the candle we expect.
|
||||
"""
|
||||
# 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)]
|
||||
candles, refresh_date = self.get_ohlcv_with_refresh(pair, timeframe, candle_type)
|
||||
received_ts = candles[-1][0] if candles else 0
|
||||
drop_hint = received_ts >= candle_ts
|
||||
if received_ts > refresh_date:
|
||||
if refresh_date and received_ts > refresh_date:
|
||||
logger.warning(
|
||||
f"{pair}, {timeframe} - Candle date > last refresh "
|
||||
f"({format_ms_time(received_ts)} > {format_ms_time_det(refresh_date)}). "
|
||||
|
||||
@@ -361,7 +361,7 @@ class FreqaiDataDrawer:
|
||||
label_loc = df.columns.get_loc(label)
|
||||
pred_label_loc = predictions.columns.get_loc(label)
|
||||
df.iloc[-1, label_loc] = predictions.iloc[-1, pred_label_loc]
|
||||
if df[label].dtype == object:
|
||||
if pd.api.types.is_string_dtype(df[label].dtype):
|
||||
continue
|
||||
label_mean_loc = df.columns.get_loc(f"{label}_mean")
|
||||
label_std_loc = df.columns.get_loc(f"{label}_std")
|
||||
|
||||
@@ -24,8 +24,6 @@ from freqtrade.strategy import merge_informative_pair
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
|
||||
|
||||
pd.set_option("future.no_silent_downcasting", True)
|
||||
|
||||
SECONDS_IN_DAY = 86400
|
||||
SECONDS_IN_HOUR = 3600
|
||||
|
||||
@@ -239,16 +237,14 @@ class FreqaiDataKitchen:
|
||||
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 = drop_index.replace(True, 1).replace(False, 0).infer_objects(copy=False)
|
||||
drop_index = drop_index.replace(True, 1).replace(False, 0).infer_objects()
|
||||
if training_filter:
|
||||
# we don't care about total row number (total no. datapoints) in training, we only care
|
||||
# about removing any row with NaNs
|
||||
# if labels has multiple columns (user wants to train multiple modelEs), we detect here
|
||||
labels = unfiltered_df.filter(label_list or [], 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(copy=False)
|
||||
)
|
||||
drop_index_labels = drop_index_labels.replace(True, 1).replace(False, 0).infer_objects()
|
||||
dates = unfiltered_df["date"]
|
||||
filtered_df = filtered_df[
|
||||
(drop_index == 0) & (drop_index_labels == 0)
|
||||
@@ -435,7 +431,7 @@ class FreqaiDataKitchen:
|
||||
|
||||
for label in predictions.columns:
|
||||
append_dict[label] = predictions[label]
|
||||
if predictions[label].dtype == object:
|
||||
if pd.api.types.is_string_dtype(predictions[label].dtype):
|
||||
continue
|
||||
if "labels_mean" in self.data and label in self.data["labels_mean"]:
|
||||
append_dict[f"{label}_mean"] = self.data["labels_mean"][label]
|
||||
@@ -879,7 +875,7 @@ class FreqaiDataKitchen:
|
||||
|
||||
self.data["labels_mean"], self.data["labels_std"] = {}, {}
|
||||
for label in self.data_dictionary["train_labels"].columns:
|
||||
if self.data_dictionary["train_labels"][label].dtype == object:
|
||||
if pd.api.types.is_string_dtype(self.data_dictionary["train_labels"][label].dtype):
|
||||
continue
|
||||
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]
|
||||
@@ -905,7 +901,7 @@ class FreqaiDataKitchen:
|
||||
self.find_labels(dataframe)
|
||||
|
||||
for key in self.label_list:
|
||||
if dataframe[key].dtype == object:
|
||||
if pd.api.types.is_string_dtype(dataframe[key].dtype):
|
||||
self.unique_classes[key] = dataframe[key].dropna().unique()
|
||||
|
||||
if self.unique_classes:
|
||||
@@ -990,7 +986,7 @@ class FreqaiDataKitchen:
|
||||
are populated.
|
||||
|
||||
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
|
||||
and then use this function to cut off the edges (buffer) by the kernel.
|
||||
|
||||
|
||||
@@ -676,7 +676,7 @@ class IFreqaiModel(ABC):
|
||||
self.set_start_dry_live_date(strat_df)
|
||||
|
||||
for label in hist_preds_df.columns:
|
||||
if hist_preds_df[label].dtype == object:
|
||||
if pd.api.types.is_string_dtype(hist_preds_df[label].dtype):
|
||||
continue
|
||||
hist_preds_df[f"{label}_mean"] = 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)
|
||||
dk.data["labels_mean"], dk.data["labels_std"] = {}, {}
|
||||
for label in full_labels:
|
||||
if self.dd.historic_predictions[dk.pair][label].dtype == object:
|
||||
if pd.api.types.is_string_dtype(self.dd.historic_predictions[dk.pair][label].dtype):
|
||||
continue
|
||||
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]
|
||||
@@ -896,7 +896,7 @@ class IFreqaiModel(ABC):
|
||||
]
|
||||
self.fit_live_predictions(self.dk, self.dk.pair)
|
||||
for label in label_columns:
|
||||
if dk.full_df[label].dtype == object:
|
||||
if pd.api.types.is_string_dtype(dk.full_df[label].dtype):
|
||||
continue
|
||||
if "labels_mean" in self.dk.data:
|
||||
dk.full_df.at[index, f"{label}_mean"] = self.dk.data["labels_mean"][
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from lightgbm import LGBMClassifier
|
||||
|
||||
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.tensorboard import LightGBMCallback
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +48,10 @@ class LightGBMClassifier(BaseClassifierModel):
|
||||
init_model = self.get_init_model(dk.pair)
|
||||
|
||||
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(
|
||||
X=X,
|
||||
y=y,
|
||||
@@ -53,6 +59,7 @@ class LightGBMClassifier(BaseClassifierModel):
|
||||
sample_weight=train_weights,
|
||||
eval_sample_weight=[test_weights],
|
||||
init_model=init_model,
|
||||
callbacks=callbacks,
|
||||
)
|
||||
|
||||
return model
|
||||
|
||||
@@ -6,6 +6,7 @@ from lightgbm import LGBMClassifier
|
||||
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
|
||||
from freqtrade.freqai.base_models.FreqaiMultiOutputClassifier import FreqaiMultiOutputClassifier
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.tensorboard import LightGBMCallback
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,6 +54,11 @@ class LightGBMClassifierMultiTarget(BaseClassifierModel):
|
||||
else:
|
||||
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 = []
|
||||
for i in range(len(eval_sets)):
|
||||
fit_params.append(
|
||||
@@ -60,6 +66,7 @@ class LightGBMClassifierMultiTarget(BaseClassifierModel):
|
||||
"eval_set": eval_sets[i],
|
||||
"eval_sample_weight": eval_weights,
|
||||
"init_model": init_models[i],
|
||||
"callbacks": callbacks,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from lightgbm import LGBMRegressor
|
||||
|
||||
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.tensorboard import LightGBMCallback
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,6 +44,11 @@ class LightGBMRegressor(BaseRegressionModel):
|
||||
|
||||
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(
|
||||
X=X,
|
||||
y=y,
|
||||
@@ -49,6 +56,7 @@ class LightGBMRegressor(BaseRegressionModel):
|
||||
sample_weight=train_weights,
|
||||
eval_sample_weight=[eval_weights],
|
||||
init_model=init_model,
|
||||
callbacks=callbacks,
|
||||
)
|
||||
|
||||
return model
|
||||
|
||||
@@ -6,6 +6,7 @@ from lightgbm import LGBMRegressor
|
||||
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
|
||||
from freqtrade.freqai.base_models.FreqaiMultiOutputRegressor import FreqaiMultiOutputRegressor
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.tensorboard import LightGBMCallback
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -55,6 +56,11 @@ class LightGBMRegressorMultiTarget(BaseRegressionModel):
|
||||
else:
|
||||
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 = []
|
||||
for i in range(len(eval_sets)):
|
||||
fit_params.append(
|
||||
@@ -62,6 +68,7 @@ class LightGBMRegressorMultiTarget(BaseRegressionModel):
|
||||
"eval_set": eval_sets[i],
|
||||
"eval_sample_weight": eval_weights,
|
||||
"init_model": init_models[i],
|
||||
"callbacks": callbacks,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class SKLearnRandomForestClassifier(BaseClassifierModel):
|
||||
) -> tuple[DataFrame, npt.NDArray[np.int_]]:
|
||||
"""
|
||||
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:
|
||||
:pred_df: dataframe containing the predictions
|
||||
: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_]]:
|
||||
"""
|
||||
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:
|
||||
:pred_df: dataframe containing the predictions
|
||||
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# ensure users can still use a non-torch freqai version
|
||||
try:
|
||||
from freqtrade.freqai.tensorboard.lightgbm_callback import LightGBMTensorboardCallback
|
||||
from freqtrade.freqai.tensorboard.tensorboard import TensorBoardCallback, TensorboardLogger
|
||||
|
||||
TBLogger = TensorboardLogger
|
||||
TBCallback = TensorBoardCallback
|
||||
LightGBMCallback = LightGBMTensorboardCallback
|
||||
except ModuleNotFoundError:
|
||||
from freqtrade.freqai.tensorboard.base_tensorboard import (
|
||||
BaseTensorBoardCallback,
|
||||
@@ -12,5 +14,6 @@ except ModuleNotFoundError:
|
||||
|
||||
TBLogger = BaseTensorboardLogger # type: ignore
|
||||
TBCallback = BaseTensorBoardCallback # type: ignore
|
||||
LightGBMCallback = None # type: ignore
|
||||
|
||||
__all__ = ("TBLogger", "TBCallback")
|
||||
__all__ = ("TBLogger", "TBCallback", "LightGBMCallback")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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()
|
||||
+108
-98
@@ -92,100 +92,106 @@ class FreqtradeBot(LoggingMixin):
|
||||
exchange_config: ExchangeConfig = deepcopy(config["exchange"])
|
||||
# Remove credentials from original exchange config to avoid accidental credential exposure
|
||||
remove_exchange_credentials(config["exchange"], True)
|
||||
|
||||
self.exchange = ExchangeResolver.load_exchange(
|
||||
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."
|
||||
try:
|
||||
self.exchange = ExchangeResolver.load_exchange(
|
||||
self.config, exchange_config=exchange_config, load_leverage_tiers=True
|
||||
)
|
||||
|
||||
self._measure_execution = MeasureTime(log_took_too_long, timeframe_secs * 0.25)
|
||||
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 | 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:
|
||||
"""
|
||||
@@ -211,14 +217,18 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.warning(f"Exception during cleanup: {e.__class__.__name__} {e}")
|
||||
|
||||
finally:
|
||||
self.strategy.ft_bot_cleanup()
|
||||
if getattr(self, "strategy", None):
|
||||
self.strategy.ft_bot_cleanup()
|
||||
|
||||
self.rpc.cleanup()
|
||||
if self.emc:
|
||||
if getattr(self, "rpc", None):
|
||||
self.rpc.cleanup()
|
||||
if hasattr(self, "emc") and self.emc:
|
||||
self.emc.shutdown()
|
||||
self.exchange.close()
|
||||
if getattr(self, "exchange", None):
|
||||
self.exchange.close()
|
||||
try:
|
||||
Trade.commit()
|
||||
if hasattr(Trade, "session"):
|
||||
Trade.commit()
|
||||
except Exception:
|
||||
# Exceptions here will be happening if the db disappeared.
|
||||
# At which point we can no longer commit anyway.
|
||||
@@ -229,7 +239,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
Called on startup and after reloading the bot - triggers notifications and
|
||||
performs startup tasks
|
||||
"""
|
||||
migrate_live_content(self.config, self.exchange)
|
||||
migrate_live_content(self.config, self.exchange, self.wallets.get_starting_balance())
|
||||
set_startup_time()
|
||||
|
||||
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
|
||||
|
||||
@@ -55,6 +55,7 @@ class BacktestContentTypeIcomplete(TypedDict, total=False):
|
||||
backtest_start_time: int
|
||||
backtest_end_time: int
|
||||
run_id: str
|
||||
wallet_summary: DataFrame
|
||||
|
||||
|
||||
class BacktestContentType(BacktestContentTypeIcomplete, total=True):
|
||||
|
||||
@@ -126,14 +126,14 @@ class LookaheadAnalysisSubFunctions:
|
||||
csv_df = add_or_update_row(csv_df, new_row_data)
|
||||
|
||||
# Fill NaN values with a default value (e.g., 0)
|
||||
csv_df["total_signals"] = csv_df["total_signals"].astype(int).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(int).fillna(0)
|
||||
csv_df["total_signals"] = csv_df["total_signals"].astype("int64").fillna(0)
|
||||
csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype("int64").fillna(0)
|
||||
csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype("int64").fillna(0)
|
||||
|
||||
# Convert columns to integers
|
||||
csv_df["total_signals"] = csv_df["total_signals"].astype(int)
|
||||
csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype(int)
|
||||
csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype(int)
|
||||
csv_df["total_signals"] = csv_df["total_signals"].astype("int64")
|
||||
csv_df["biased_entry_signals"] = csv_df["biased_entry_signals"].astype("int64")
|
||||
csv_df["biased_exit_signals"] = csv_df["biased_exit_signals"].astype("int64")
|
||||
|
||||
logger.info(f"saving {config['lookahead_analysis_exportfilename']}")
|
||||
csv_df.to_csv(config["lookahead_analysis_exportfilename"], index=False)
|
||||
|
||||
@@ -51,6 +51,7 @@ from freqtrade.mixins import LoggingMixin
|
||||
from freqtrade.optimize.backtest_caching import get_strategy_run_id
|
||||
from freqtrade.optimize.bt_progress import BTProgress
|
||||
from freqtrade.optimize.optimize_reports import (
|
||||
convert_bt_wallet_collection,
|
||||
generate_backtest_stats,
|
||||
generate_rejected_signals,
|
||||
generate_trade_signal_candles,
|
||||
@@ -137,6 +138,7 @@ class Backtesting:
|
||||
}
|
||||
self.rejected_dict: dict[str, list] = {}
|
||||
self.starting_balance: float = 0.0
|
||||
self.wallet_captures: list = []
|
||||
|
||||
self._exchange_name = self.config["exchange"]["name"]
|
||||
self.__initial_backtest = exchange is None
|
||||
@@ -451,6 +453,7 @@ class Backtesting:
|
||||
self.replaced_entry_orders = 0
|
||||
self.canceled_exit_orders = 0
|
||||
self.replaced_exit_orders = 0
|
||||
self.wallet_captures = []
|
||||
self.dataprovider.clear_cache()
|
||||
if enable_protections:
|
||||
self._load_protections(self.strategy)
|
||||
@@ -754,7 +757,7 @@ class Backtesting:
|
||||
) -> bool:
|
||||
"""
|
||||
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):
|
||||
order.close_bt_order(current_date, trade)
|
||||
@@ -1603,6 +1606,7 @@ class Backtesting:
|
||||
pair_detail_cache: dict[str, list[tuple]] = {}
|
||||
pair_tradedir_cache: dict[str, LongShort | None] = {}
|
||||
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(
|
||||
current_time, pairs
|
||||
@@ -1627,6 +1631,7 @@ class Backtesting:
|
||||
)
|
||||
trade_dir = self.check_for_trade_entry(row)
|
||||
pair_tradedir_cache[pair] = trade_dir
|
||||
self._capture_wallet(current_time, pair.split("/")[0], row[OPEN_IDX])
|
||||
|
||||
else:
|
||||
# Detail candle - from cache.
|
||||
@@ -1680,6 +1685,15 @@ class Backtesting:
|
||||
yield current_time_det, pair, row, is_last_row, trade_dir
|
||||
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(
|
||||
self, processed: dict, start_date: datetime, end_date: datetime
|
||||
) -> BacktestContentTypeIcomplete:
|
||||
@@ -1739,6 +1753,7 @@ class Backtesting:
|
||||
"canceled_entry_orders": self.canceled_entry_orders,
|
||||
"replaced_entry_orders": self.replaced_entry_orders,
|
||||
"final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
|
||||
"wallet_summary": convert_bt_wallet_collection(self.wallet_captures),
|
||||
}
|
||||
|
||||
def backtest_one_strategy(
|
||||
@@ -1867,6 +1882,11 @@ class Backtesting:
|
||||
dt_appendix,
|
||||
market_change_data=combined_res,
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from freqtrade.optimize.optimize_reports.bt_output import (
|
||||
)
|
||||
from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results
|
||||
from freqtrade.optimize.optimize_reports.optimize_reports import (
|
||||
convert_bt_wallet_collection,
|
||||
generate_all_periodic_breakdown_stats,
|
||||
generate_backtest_stats,
|
||||
generate_daily_stats,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config
|
||||
from freqtrade.ft_types import BacktestResultType
|
||||
from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats
|
||||
@@ -9,6 +11,8 @@ from freqtrade.util import decimals_per_coin, fmt_coin, print_rich_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__EMPTY_LINE = ("", "")
|
||||
|
||||
|
||||
def _get_line_floatfmt(stake_currency: str) -> list[str]:
|
||||
"""
|
||||
@@ -201,7 +205,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
|
||||
short_metrics = (
|
||||
[
|
||||
("", ""), # Empty line to improve readability
|
||||
__EMPTY_LINE, # Empty line to improve readability
|
||||
(
|
||||
"Long / Short trades",
|
||||
f"{strat_results.get('trade_count_long', 'total_trades')} / "
|
||||
@@ -222,7 +226,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
else []
|
||||
)
|
||||
|
||||
drawdown_metrics = []
|
||||
drawdown_metrics: list[tuple[str | Text, str | Text]] = []
|
||||
if "max_relative_drawdown" in strat_results:
|
||||
# Compatibility to show old hyperopt results
|
||||
drawdown_metrics.append(
|
||||
@@ -287,6 +291,79 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
if "trading_mode" in strat_results
|
||||
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
|
||||
# command stores these results and newer version of freqtrade must be able to handle old
|
||||
@@ -296,7 +373,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
("Backtesting to", strat_results["backtest_end"]),
|
||||
*trading_mode,
|
||||
("Max open trades", strat_results["max_open_trades"]),
|
||||
("", ""), # Empty line to improve readability
|
||||
__EMPTY_LINE, # Empty line to improve readability
|
||||
(
|
||||
"Total/Daily Avg Trades",
|
||||
f"{strat_results['total_trades']} / {strat_results['trades_per_day']}",
|
||||
@@ -315,9 +392,18 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
),
|
||||
("Total profit %", f"{strat_results['profit_total']:.2%}"),
|
||||
("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", 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"),
|
||||
(
|
||||
"Sharpe (closed trades)",
|
||||
f"{strat_results['sharpe']:.2f}" if "sharpe" 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"),
|
||||
(
|
||||
"Profit factor",
|
||||
@@ -346,12 +432,13 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
"Avg. stake amount",
|
||||
fmt_coin(strat_results["avg_stake_amount"], stake),
|
||||
),
|
||||
("Market change", f"{strat_results['market_change']:.2%}"),
|
||||
(
|
||||
"Total trade volume",
|
||||
fmt_coin(strat_results["total_volume"], stake),
|
||||
),
|
||||
*short_metrics,
|
||||
("", ""), # Empty line to improve readability
|
||||
__EMPTY_LINE, # Empty line to improve readability
|
||||
(
|
||||
"Best Pair",
|
||||
f"{strat_results['best_pair']['key']} "
|
||||
@@ -407,11 +494,9 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
||||
f"{strat_results.get('timedout_exit_orders', 'N/A')}",
|
||||
),
|
||||
*entry_adjustment_metrics,
|
||||
("", ""), # Empty line to improve readability
|
||||
("Min balance", fmt_coin(strat_results["csum_min"], stake)),
|
||||
("Max balance", fmt_coin(strat_results["csum_max"], stake)),
|
||||
__EMPTY_LINE, # Empty line to improve readability
|
||||
*wallet_metrics,
|
||||
*drawdown_metrics,
|
||||
("Market change", f"{strat_results['market_change']:.2%}"),
|
||||
]
|
||||
print_rich_table(metrics, ["Metric", "Value"], summary="SUMMARY METRICS", justify="left")
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ def store_backtest_results(
|
||||
dtappendix: str,
|
||||
*,
|
||||
market_change_data: DataFrame | None = None,
|
||||
wallet_summary: dict[str, DataFrame] | None = None,
|
||||
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
|
||||
strategy_files: dict[str, str] | None = None,
|
||||
) -> Path:
|
||||
@@ -123,6 +124,15 @@ def store_backtest_results(
|
||||
market_change_buf.seek(0)
|
||||
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
|
||||
if (
|
||||
config.get("export", "none") == "signals"
|
||||
|
||||
@@ -10,12 +10,16 @@ from freqtrade.constants import BACKTEST_BREAKDOWNS, DATETIME_PRINT_FORMAT
|
||||
from freqtrade.data.metrics import (
|
||||
calculate_cagr,
|
||||
calculate_calmar,
|
||||
calculate_calmar_from_balance,
|
||||
calculate_csum,
|
||||
calculate_expectancy,
|
||||
calculate_market_change,
|
||||
calculate_max_drawdown,
|
||||
calculate_max_drawdown_from_balance,
|
||||
calculate_sharpe,
|
||||
calculate_sharpe_from_balance,
|
||||
calculate_sortino,
|
||||
calculate_sortino_from_balance,
|
||||
calculate_sqn,
|
||||
)
|
||||
from freqtrade.ft_types import (
|
||||
@@ -29,6 +33,94 @@ from freqtrade.util import decimals_per_coin, fmt_coin, format_duration, get_dry
|
||||
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(
|
||||
preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str
|
||||
) -> dict[str, DataFrame]:
|
||||
@@ -155,7 +247,7 @@ def generate_pair_metrics( #
|
||||
skip_nan: bool = False,
|
||||
) -> 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 stake_currency: stake-currency - used to correctly name headers
|
||||
:param starting_balance: Starting balance
|
||||
@@ -248,7 +340,7 @@ def generate_strategy_comparison(bt_stats: dict) -> list[dict]:
|
||||
|
||||
def _get_resample_from_period(period: str) -> str:
|
||||
if period == "day":
|
||||
return "1d"
|
||||
return "1D"
|
||||
if period == "week":
|
||||
# Weekly defaulting to Monday.
|
||||
return "1W-MON"
|
||||
@@ -438,8 +530,8 @@ def generate_daily_stats(results: DataFrame) -> dict[str, Any]:
|
||||
"losing_days": 0,
|
||||
"daily_profit_list": [],
|
||||
}
|
||||
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_rel = results.resample("1D", on="close_date")["profit_ratio"].sum()
|
||||
daily_profit = results.resample("1D", on="close_date")["profit_abs"].sum().round(10)
|
||||
worst_rel = min(daily_profit_rel)
|
||||
best_rel = max(daily_profit_rel)
|
||||
worst = min(daily_profit)
|
||||
@@ -592,6 +684,7 @@ def generate_strategy_stats(
|
||||
"sharpe": calculate_sharpe(results, min_date, max_date, start_balance),
|
||||
"calmar": calculate_calmar(results, min_date, max_date, start_balance),
|
||||
"sqn": calculate_sqn(results, start_balance),
|
||||
"wallet_stats": generate_wallet_stats(content.get("wallet_summary"), stake_currency),
|
||||
"profit_factor": profit_factor,
|
||||
"backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
"backtest_start_ts": int(min_date.timestamp() * 1000),
|
||||
|
||||
@@ -9,7 +9,7 @@ class SKDecimal(FloatDistribution):
|
||||
*,
|
||||
step: float | None = None,
|
||||
decimals: int | None = None,
|
||||
name=None,
|
||||
name: str | None = None,
|
||||
):
|
||||
"""
|
||||
FloatDistribution with a fixed step size.
|
||||
@@ -26,7 +26,7 @@ class SKDecimal(FloatDistribution):
|
||||
raise ValueError("You must set one of decimals or step")
|
||||
# Convert decimals to step
|
||||
self.step = step or (1 / 10**decimals if decimals else 1)
|
||||
self.name = name
|
||||
self.name = name or ""
|
||||
|
||||
super().__init__(
|
||||
low=round(low, decimals) if decimals else low,
|
||||
|
||||
@@ -10,3 +10,4 @@ from freqtrade.persistence.usedb_context import (
|
||||
disable_database_use,
|
||||
enable_database_use,
|
||||
)
|
||||
from freqtrade.persistence.wallet_history import WalletHistory
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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."
|
||||
)
|
||||
@@ -18,10 +18,13 @@ class ValueTypesEnum(StrEnum):
|
||||
INT = "int"
|
||||
|
||||
|
||||
# must be < 50 characters to fit the database column
|
||||
KeyStoreKeys = Literal[
|
||||
"bot_start_time",
|
||||
"startup_time",
|
||||
"binance_migration",
|
||||
"wallet_history_migration",
|
||||
"wallet_history_migration_date",
|
||||
]
|
||||
|
||||
|
||||
@@ -35,7 +38,7 @@ class _KeyValueStoreModel(ModelBase):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
key: Mapped[KeyStoreKeys] = mapped_column(String(25), nullable=False, index=True)
|
||||
key: Mapped[KeyStoreKeys] = mapped_column(String(50), nullable=False, index=True)
|
||||
|
||||
value_type: Mapped[ValueTypesEnum] = mapped_column(String(20), nullable=False)
|
||||
|
||||
|
||||
@@ -35,10 +35,12 @@ def get_last_sequence_ids(engine, sequence_name: str, table_back_name: str) -> i
|
||||
|
||||
if engine.name == "postgresql":
|
||||
with engine.begin() as connection:
|
||||
last_id = connection.execute(text(f"select nextval('{sequence_name}')")).fetchone()[0]
|
||||
last_id = connection.execute(
|
||||
text(f"""select nextval('"{sequence_name}"')""")
|
||||
).fetchone()[0]
|
||||
with engine.begin() as connection:
|
||||
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
|
||||
@@ -51,6 +53,7 @@ def set_sequence_ids(
|
||||
pairlock_id: int | None = None,
|
||||
kv_id: int | None = None,
|
||||
custom_data_id: int | None = None,
|
||||
wallet_history_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
Set sequence ids to the given values.
|
||||
@@ -62,6 +65,7 @@ def set_sequence_ids(
|
||||
:param pairlock_id: value to set for pairlocks_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 wallet_history_id: value to set for wallet_history_id_seq (optional)
|
||||
"""
|
||||
if engine.name == "postgresql":
|
||||
with engine.begin() as connection:
|
||||
@@ -81,6 +85,10 @@ def set_sequence_ids(
|
||||
connection.execute(
|
||||
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):
|
||||
@@ -88,9 +96,9 @@ def drop_index_on_table(engine, inspector, table_bak_name):
|
||||
# drop indexes on backup table in new session
|
||||
for index in inspector.get_indexes(table_bak_name):
|
||||
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:
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
connection.execute(text(f'drop index "{index["name"]}"'))
|
||||
|
||||
|
||||
def migrate_trades_and_orders_table(
|
||||
@@ -315,6 +323,31 @@ def migrate_pairlocks_table(decl_base, inspector, engine, pairlock_back_name: st
|
||||
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):
|
||||
if engine.name == "sqlite" and str(engine.url) != "sqlite://":
|
||||
# Set Mode to
|
||||
@@ -385,12 +418,15 @@ def check_migrate(engine: Engine, decl_base, previous_tables: list[str]) -> None
|
||||
cols_trades = inspector.get_columns("trades")
|
||||
cols_orders = inspector.get_columns("orders")
|
||||
cols_pairlocks = inspector.get_columns("pairlocks")
|
||||
cols_kv_store = inspector.get_columns("KeyValueStore")
|
||||
tabs = get_table_names_for_table(inspector, "trades")
|
||||
table_back_name = get_backup_name(tabs, "trades_bak")
|
||||
order_tabs = get_table_names_for_table(inspector, "orders")
|
||||
order_table_bak_name = get_backup_name(order_tabs, "orders_bak")
|
||||
pairlock_tabs = get_table_names_for_table(inspector, "pairlocks")
|
||||
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
|
||||
# Migrates both trades and orders table!
|
||||
@@ -421,6 +457,16 @@ def check_migrate(engine: Engine, decl_base, previous_tables: list[str]) -> None
|
||||
migrate_pairlocks_table(
|
||||
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:
|
||||
raise OperationalException(
|
||||
"Your database seems to be very old. "
|
||||
|
||||
@@ -20,6 +20,7 @@ from freqtrade.persistence.key_value_store import _KeyValueStoreModel
|
||||
from freqtrade.persistence.migrations import check_migrate
|
||||
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__)
|
||||
@@ -91,6 +92,7 @@ def init_db(db_url: str) -> None:
|
||||
_CustomData.session = scoped_session(
|
||||
sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id
|
||||
)
|
||||
WalletHistory.session = Trade.session
|
||||
|
||||
previous_tables = inspect(engine).get_table_names()
|
||||
ModelBase.metadata.create_all(engine)
|
||||
|
||||
@@ -29,6 +29,11 @@ class PairLock(ModelBase):
|
||||
|
||||
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:
|
||||
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
|
||||
lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT)
|
||||
|
||||
@@ -42,6 +42,7 @@ class PairLocks:
|
||||
) -> PairLock:
|
||||
"""
|
||||
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,
|
||||
in which case a list is maintained.
|
||||
:param pair: pair to lock. use '*' to lock all pairs
|
||||
@@ -50,10 +51,19 @@ class PairLocks:
|
||||
:param now: Current timestamp. Used to determine lock start time.
|
||||
: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(
|
||||
pair=pair,
|
||||
lock_time=now or datetime.now(UTC),
|
||||
lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
|
||||
lock_end_time=lock_end_time,
|
||||
reason=reason,
|
||||
side=side,
|
||||
active=True,
|
||||
|
||||
@@ -858,9 +858,9 @@ class LocalTrade:
|
||||
higher_stop = stop_loss_norm > self.stop_loss
|
||||
lower_stop = stop_loss_norm < self.stop_loss
|
||||
|
||||
# stop losses only walk up, never down!,
|
||||
# ? But adding more to a leveraged trade would create a lower liquidation price,
|
||||
# ? decreasing the minimum stoploss
|
||||
# stop losses only walk up, never down!
|
||||
# but adding more to a leveraged trade would create a lower liquidation price,
|
||||
# decreasing the minimum stoploss
|
||||
if (
|
||||
allow_refresh
|
||||
or (higher_stop and not self.is_short)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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})"
|
||||
)
|
||||
@@ -263,7 +263,7 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
|
||||
trades["desc"] = trades.apply(
|
||||
lambda row: (
|
||||
f"{row['profit_ratio']:.2%}, "
|
||||
+ (f"{row['enter_tag']}, " if row["enter_tag"] is not None else "")
|
||||
+ (f"{row['enter_tag']}, " if pd.notna(row["enter_tag"]) else "")
|
||||
+ f"{row['exit_reason']}, "
|
||||
+ f"{row['trade_duration']} min"
|
||||
),
|
||||
@@ -356,7 +356,7 @@ def plot_area(
|
||||
:param indicator_b: indicator name as populated in strategy
|
||||
:param label: label 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:
|
||||
# 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 indicators: dict with indicators. ie.: plot_config['main_plot'] or
|
||||
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():
|
||||
if "fill_to" in ind_conf:
|
||||
|
||||
@@ -23,9 +23,10 @@ class DelistFilter(IPairList):
|
||||
self._max_days_from_now = self._pairlistconfig.get("max_days_from_now", 0)
|
||||
if self._max_days_from_now < 0:
|
||||
raise ConfigurationError("DelistFilter requires max_days_from_now to be >= 0")
|
||||
if not self._exchange._ft_has["has_delisting"]:
|
||||
if not self._exchange.get_option("has_delisting"):
|
||||
raise ConfigurationError(
|
||||
"DelistFilter doesn't support this exchange and trading mode combination.",
|
||||
f"DelistFilter doesn't support {self._exchange.name} in "
|
||||
f"{self._exchange.trading_mode} mode."
|
||||
)
|
||||
|
||||
def short_desc(self) -> str:
|
||||
|
||||
@@ -16,10 +16,11 @@ from freqtrade.data.btanalysis import (
|
||||
get_backtest_market_change,
|
||||
get_backtest_result,
|
||||
get_backtest_resultlist,
|
||||
get_backtest_wallet_change,
|
||||
load_and_merge_backtest_result,
|
||||
update_backtest_metadata,
|
||||
)
|
||||
from freqtrade.enums import BacktestState
|
||||
from freqtrade.enums import BacktestState, RunMode
|
||||
from freqtrade.exceptions import ConfigurationError, DependencyException, OperationalException
|
||||
from freqtrade.ft_types import get_BacktestResultType_default
|
||||
from freqtrade.misc import deep_merge_dicts, is_file_in_dir
|
||||
@@ -29,6 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import (
|
||||
BacktestMetadataUpdate,
|
||||
BacktestRequest,
|
||||
BacktestResponse,
|
||||
WalletHistoryResponse,
|
||||
)
|
||||
from freqtrade.rpc.api_server.deps import get_config, verify_strategy
|
||||
from freqtrade.rpc.api_server.webserver_bgwork import ApiBG
|
||||
@@ -106,6 +108,11 @@ def __run_backtest_bg(btconfig: Config):
|
||||
ApiBG.bt["bt"].results,
|
||||
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
|
||||
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={
|
||||
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
|
||||
},
|
||||
@@ -137,6 +144,7 @@ async def api_start_backtest(
|
||||
verify_strategy(bt_settings.strategy)
|
||||
|
||||
btconfig = deepcopy(config)
|
||||
btconfig["runmode"] = RunMode.BACKTEST
|
||||
remove_exchange_credentials(btconfig["exchange"], True)
|
||||
settings = dict(bt_settings)
|
||||
if settings.get("freqai", None) is not None:
|
||||
@@ -354,3 +362,29 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)):
|
||||
"data": df.values.tolist(),
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ class ShowConfig(BaseModel):
|
||||
timeframe_ms: int
|
||||
timeframe_min: int
|
||||
exchange: str
|
||||
demo_trading: bool
|
||||
strategy: str | None = None
|
||||
force_entry_enable: bool
|
||||
exit_pricing: dict[str, Any]
|
||||
@@ -679,6 +680,15 @@ class BacktestMarketChange(BaseModel):
|
||||
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):
|
||||
base: str | None = None
|
||||
quote: str | None = None
|
||||
|
||||
@@ -31,6 +31,7 @@ from freqtrade.rpc.api_server.api_schemas import (
|
||||
ResultMsg,
|
||||
Stats,
|
||||
StatusMsg,
|
||||
WalletHistoryResponse,
|
||||
WhitelistResponse,
|
||||
)
|
||||
from freqtrade.rpc.api_server.deps import get_config, get_rpc
|
||||
@@ -104,6 +105,22 @@ def stats(rpc: RPC = Depends(get_rpc)):
|
||||
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"])
|
||||
def daily(
|
||||
timescale: int = Query(7, ge=1, description="Number of days to fetch data for"),
|
||||
|
||||
@@ -69,7 +69,8 @@ logger = logging.getLogger(__name__)
|
||||
# 2.45: Add price to forceexit endpoint
|
||||
# 2.46: Add prepend_data to download-data endpoint
|
||||
# 2.47: Add Strategy parameters
|
||||
API_VERSION = 2.47
|
||||
# 2.48: add /backtest/history/wallets endpoint
|
||||
API_VERSION = 2.48
|
||||
|
||||
# Public API, requires no auth.
|
||||
router_public = APIRouter()
|
||||
|
||||
+29
-6
@@ -11,8 +11,8 @@ from typing import TYPE_CHECKING, Any
|
||||
import psutil
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.tz import tzlocal
|
||||
from numpy import inf, int64, isnan, mean, nan
|
||||
from pandas import DataFrame, NaT
|
||||
from numpy import inf, isnan, mean, nan
|
||||
from pandas import DataFrame, NaT, read_sql
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from freqtrade import __version__
|
||||
@@ -176,6 +176,7 @@ class RPC:
|
||||
timeframe_to_minutes(config["timeframe"]) if "timeframe" in config else 0
|
||||
),
|
||||
"exchange": config["exchange"]["name"],
|
||||
"demo_trading": config["exchange"].get("demo_trading", False),
|
||||
"strategy": config["strategy"],
|
||||
"force_entry_enable": config.get("force_entry_enable", False),
|
||||
"exit_pricing": config.get("exit_pricing", {}),
|
||||
@@ -785,6 +786,26 @@ class RPC:
|
||||
"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"]]
|
||||
|
||||
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(
|
||||
self, coin: str, stake_currency: str, amount: float, balance: Wallet
|
||||
) -> tuple[float, float]:
|
||||
@@ -875,7 +896,7 @@ class RPC:
|
||||
for symbol, pos in self._freqtrade.wallets.get_all_positions().items():
|
||||
est_stake = pos.collateral
|
||||
pos_base = self._freqtrade.exchange.get_pair_base_currency(symbol)
|
||||
if pos.leverage:
|
||||
if pos.leverage and pos.position:
|
||||
try:
|
||||
rate = self._freqtrade.exchange.get_conversion_rate(pos_base, stake_currency)
|
||||
if rate:
|
||||
@@ -1386,7 +1407,7 @@ class RPC:
|
||||
}
|
||||
|
||||
def _rpc_locks(self) -> dict[str, Any]:
|
||||
"""Returns the current locks"""
|
||||
"""Returns the current locks"""
|
||||
|
||||
locks = PairLocks.get_pair_locks(None)
|
||||
return {"lock_count": len(locks), "locks": [lock.to_json() for lock in locks]}
|
||||
@@ -1515,7 +1536,9 @@ class RPC:
|
||||
df_cols = [col for col in dataframe_columns if col in cols_set]
|
||||
dataframe = dataframe.loc[:, df_cols]
|
||||
|
||||
dataframe.loc[:, "__date_ts"] = dataframe.loc[:, "date"].astype(int64) // 1000 // 1000
|
||||
dataframe.loc[:, "__date_ts"] = (
|
||||
dataframe.loc[:, "date"].dt.as_unit("ms").astype("int64")
|
||||
)
|
||||
# Move signal close to separate column when signal for easy plotting
|
||||
for sig_type in signals.keys():
|
||||
if sig_type in dataframe.columns:
|
||||
@@ -1525,7 +1548,7 @@ class RPC:
|
||||
|
||||
# band-aid until this is fixed:
|
||||
# https://github.com/pandas-dev/pandas/issues/45836
|
||||
datetime_types = ["datetime", "datetime64", "datetime64[ns, UTC]"]
|
||||
datetime_types = ["datetime", "datetime64", "datetimetz"]
|
||||
date_columns = dataframe.select_dtypes(include=datetime_types)
|
||||
for date_column in date_columns:
|
||||
# replace NaT with `None`
|
||||
|
||||
@@ -59,7 +59,7 @@ class RPCManager:
|
||||
logger.info("Cleaning up rpc modules ...")
|
||||
while self.registered_modules:
|
||||
mod = self.registered_modules.pop()
|
||||
logger.info("Cleaning up rpc.%s ...", mod.name)
|
||||
logger.info(f"Cleaning up rpc.{mod.name} ...")
|
||||
mod.cleanup()
|
||||
del mod
|
||||
|
||||
@@ -73,7 +73,7 @@ class RPCManager:
|
||||
}
|
||||
"""
|
||||
if msg.get("type") not in NO_ECHO_MESSAGES:
|
||||
logger.info("Sending rpc message: %s", msg)
|
||||
logger.info(f"Sending rpc message: {msg}")
|
||||
for mod in self.registered_modules:
|
||||
logger.debug("Forwarding message to rpc.%s", mod.name)
|
||||
try:
|
||||
@@ -81,7 +81,7 @@ class RPCManager:
|
||||
except NotImplementedError:
|
||||
logger.error(f"Message type '{msg['type']}' not implemented by handler {mod.name}.")
|
||||
except Exception:
|
||||
logger.exception("Exception occurred within RPC module %s", mod.name)
|
||||
logger.exception(f"Exception occurred within RPC module {mod.name}")
|
||||
|
||||
def process_msg_queue(self, queue: deque) -> None:
|
||||
"""
|
||||
@@ -89,7 +89,7 @@ class RPCManager:
|
||||
"""
|
||||
while queue:
|
||||
msg = queue.popleft()
|
||||
logger.info("Sending rpc strategy_msg: %s", msg)
|
||||
logger.info(f"Sending rpc strategy_msg: {msg}")
|
||||
for mod in self.registered_modules:
|
||||
if mod._config.get(mod.name, {}).get("allow_custom_messages", False):
|
||||
mod.send_msg(
|
||||
@@ -114,6 +114,8 @@ class RPCManager:
|
||||
trailing_stop = config["trailing_stop"]
|
||||
timeframe = config["timeframe"]
|
||||
exchange_name = config["exchange"]["name"]
|
||||
if config["exchange"].get("demo_trading"):
|
||||
exchange_name += " (demo trading)"
|
||||
strategy_name = config.get("strategy", "")
|
||||
pos_adjust_enabled = "On" if config["position_adjustment_enable"] else "Off"
|
||||
self.send_msg(
|
||||
|
||||
@@ -483,7 +483,7 @@ class Telegram(RPCHandler):
|
||||
profit_prefix = "Sub "
|
||||
cp_extra = (
|
||||
f"*Final Profit:* `{format_pct(msg['final_profit_ratio'])} "
|
||||
f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n"
|
||||
f"({fmt_coin(msg['cumulative_profit'], msg['stake_currency'])}{cp_fiat})`\n"
|
||||
)
|
||||
else:
|
||||
exit_wording = f"Partially {exit_wording.lower()}"
|
||||
@@ -832,7 +832,7 @@ class Telegram(RPCHandler):
|
||||
):
|
||||
# Adding initial stoploss only if it is different from stoploss
|
||||
lines.append(
|
||||
f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
|
||||
f"*Initial Stoploss:* `{round_value(r['initial_stop_loss_abs'], 8)}` "
|
||||
f"`({format_pct(r['initial_stop_loss_ratio'])})`"
|
||||
)
|
||||
|
||||
@@ -2049,7 +2049,7 @@ class Telegram(RPCHandler):
|
||||
|
||||
await self._send_msg(
|
||||
f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n"
|
||||
f"*Exchange:* `{val['exchange']}`\n"
|
||||
f"*Exchange:* `{val['exchange']}{' (Demo)' if val['demo_trading'] else ''}`\n"
|
||||
f"*Market: * `{val['trading_mode']}`\n"
|
||||
f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n"
|
||||
f"*Max open Trades:* `{val['max_open_trades']}`\n"
|
||||
@@ -2243,7 +2243,7 @@ class Telegram(RPCHandler):
|
||||
else:
|
||||
raise RPCException(
|
||||
"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:
|
||||
|
||||
@@ -222,7 +222,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
Clean up FreqAI and child threads
|
||||
"""
|
||||
self.freqai.shutdown()
|
||||
if getattr(self, "freqai", None):
|
||||
self.freqai.shutdown()
|
||||
|
||||
@abstractmethod
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
|
||||
@@ -3,6 +3,7 @@ from freqtrade.util.datetime_helpers import (
|
||||
dt_from_ts,
|
||||
dt_humanize_delta,
|
||||
dt_now,
|
||||
dt_now_no_micro,
|
||||
dt_ts,
|
||||
dt_ts_def,
|
||||
dt_ts_none,
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
"dt_from_ts",
|
||||
"dt_humanize_delta",
|
||||
"dt_now",
|
||||
"dt_now_no_micro",
|
||||
"dt_ts",
|
||||
"dt_ts_def",
|
||||
"dt_ts_none",
|
||||
|
||||
@@ -12,6 +12,13 @@ def dt_now() -> datetime:
|
||||
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(
|
||||
year: int,
|
||||
month: int,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from freqtrade.constants import Config
|
||||
from freqtrade.exchange import Exchange
|
||||
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, exchange: Exchange | None = None) -> None:
|
||||
def migrate_data(config: Config, exchange: Exchange | None = None) -> None:
|
||||
"""
|
||||
Migrate persisted data from old formats to new formats
|
||||
"""
|
||||
@@ -10,10 +12,9 @@ def migrate_data(config, exchange: Exchange | None = None) -> None:
|
||||
migrate_funding_fee_timeframe(config, exchange)
|
||||
|
||||
|
||||
def migrate_live_content(config, exchange: Exchange | None = None) -> None:
|
||||
def migrate_live_content(config: Config, exchange: Exchange, starting_balance: float) -> None:
|
||||
"""
|
||||
Migrate database content from old formats to new formats
|
||||
Used for dry/live mode.
|
||||
"""
|
||||
# Currently not used
|
||||
pass
|
||||
migrate_wallet_history(config, exchange, starting_balance)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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}")
|
||||
+70
-2
@@ -10,8 +10,8 @@ from freqtrade.enums import RunMode, TradingMode
|
||||
from freqtrade.exceptions import DependencyException
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.misc import safe_value_fallback
|
||||
from freqtrade.persistence import LocalTrade, Trade
|
||||
from freqtrade.util.datetime_helpers import dt_now
|
||||
from freqtrade.persistence import LocalTrade, Trade, WalletHistory
|
||||
from freqtrade.util import dt_floor_day, dt_now
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -445,3 +445,71 @@ class Wallets:
|
||||
logger.debug(msg)
|
||||
else:
|
||||
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,7 +1,7 @@
|
||||
from freqtrade_client.ft_rest_client import FtRestClient
|
||||
|
||||
|
||||
__version__ = "2026.3"
|
||||
__version__ = "2026.4"
|
||||
|
||||
if "dev" in __version__:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Requirements for freqtrade client library
|
||||
requests==2.33.0
|
||||
requests==2.33.1
|
||||
python-rapidjson==1.23
|
||||
|
||||
+6
-1
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"urllib3",
|
||||
"jsonschema",
|
||||
"numpy>2.0,<3.0",
|
||||
"pandas>=2.2.0,<3.0",
|
||||
"pandas>=2.2.0,<4.0",
|
||||
"TA-Lib<0.7",
|
||||
"ft-pandas-ta",
|
||||
"technical",
|
||||
@@ -217,6 +217,11 @@ reportRedeclaration = false # 1
|
||||
reportReturnType = false # 28
|
||||
reportTypedDictNotRequiredAccess = false # 27
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "1 week"
|
||||
|
||||
[tool.uv.exclude-newer-package]
|
||||
ccxt = false
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
+11
-11
@@ -6,12 +6,12 @@
|
||||
-r requirements-freqai-rl.txt
|
||||
-r docs/requirements-docs.txt
|
||||
|
||||
ruff==0.15.6
|
||||
mypy==1.19.1
|
||||
ruff==0.15.11
|
||||
mypy==1.20.1
|
||||
pre-commit==4.5.1
|
||||
pytest==9.0.2
|
||||
pytest==9.0.3
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-mock==3.15.1
|
||||
pytest-random-order==1.2.0
|
||||
pytest-timeout==2.4.0
|
||||
@@ -20,17 +20,17 @@ pytest-xdist==3.8.0
|
||||
time-machine==3.2.0
|
||||
|
||||
# Convert jupyter notebooks to markdown documents
|
||||
nbconvert==7.17.0
|
||||
nbconvert==7.17.1
|
||||
|
||||
# mypy types
|
||||
scipy-stubs==1.17.1.2 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
types-cachetools==6.2.0.20251022
|
||||
scipy-stubs==1.17.1.4 # keep in sync with `scipy` in `requirements-hyperopt.txt`
|
||||
types-cachetools==6.2.0.20260408
|
||||
types-filelock==3.2.7
|
||||
types-requests==2.32.4.20260107
|
||||
types-tabulate==0.10.0.20260308
|
||||
types-python-dateutil==2.9.0.20260305
|
||||
types-requests==2.33.0.20260408
|
||||
types-tabulate==0.10.0.20260408
|
||||
types-python-dateutil==2.9.0.20260408
|
||||
pip-audit==2.10.0
|
||||
# For build step in CI
|
||||
build==1.4.2
|
||||
build==1.4.3
|
||||
# For pre-commit-update check
|
||||
pyyaml==6.0.3
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
-r requirements-freqai.txt
|
||||
|
||||
# Required for freqai-rl
|
||||
torch==2.10.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
torch==2.11.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
gymnasium==1.2.3
|
||||
# SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos
|
||||
stable_baselines3==2.7.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
sb3_contrib>=2.2.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
stable_baselines3==2.8.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
sb3_contrib==2.8.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
# Progress bar for stable-baselines3 and sb3-contrib
|
||||
tqdm==4.67.3
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
# Required for hyperopt
|
||||
scipy==1.17.1
|
||||
scikit-learn==1.8.0
|
||||
filelock==3.25.2
|
||||
optuna==4.7.0
|
||||
cmaes==0.12.0
|
||||
filelock==3.29.0
|
||||
optuna==4.8.0
|
||||
cmaes==0.13.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Include all requirements to run the bot.
|
||||
-r requirements.txt
|
||||
|
||||
plotly==6.6.0
|
||||
plotly==6.7.0
|
||||
|
||||
+15
-15
@@ -1,22 +1,22 @@
|
||||
numpy==2.4.3
|
||||
pandas==2.3.3
|
||||
numpy==2.4.4
|
||||
pandas==3.0.2
|
||||
bottleneck==1.6.0
|
||||
numexpr==2.14.1
|
||||
# Indicator libraries
|
||||
ft-pandas-ta==0.3.16
|
||||
ta-lib==0.6.8
|
||||
technical==1.5.4
|
||||
technical==1.6.0
|
||||
|
||||
ccxt==4.5.44
|
||||
cryptography==46.0.6
|
||||
aiohttp==3.13.3
|
||||
SQLAlchemy==2.0.48
|
||||
python-telegram-bot==22.6
|
||||
ccxt==4.5.50
|
||||
cryptography==46.0.7
|
||||
aiohttp==3.13.5
|
||||
SQLAlchemy==2.0.49
|
||||
python-telegram-bot==22.7
|
||||
# can't be hard-pinned due to telegram-bot pinning httpx with ~
|
||||
httpx>=0.24.1
|
||||
humanize==4.15.0
|
||||
cachetools==7.0.5
|
||||
requests==2.33.0
|
||||
requests==2.33.1
|
||||
urllib3==2.6.3
|
||||
certifi==2026.2.25
|
||||
jsonschema==4.26.0
|
||||
@@ -24,22 +24,22 @@ tabulate==0.10.0
|
||||
pycoingecko==3.2.0
|
||||
jinja2==3.1.6
|
||||
joblib==1.5.3
|
||||
rich==14.3.3
|
||||
rich==15.0.0
|
||||
pyarrow==23.0.1; platform_machine != 'armv7l'
|
||||
|
||||
|
||||
# Load ticker files 30% faster
|
||||
python-rapidjson==1.23
|
||||
# Properly format api responses
|
||||
orjson==3.11.7
|
||||
orjson==3.11.8
|
||||
|
||||
# Notify systemd
|
||||
sdnotify==0.3.2
|
||||
|
||||
# API Server
|
||||
fastapi==0.135.1
|
||||
pydantic==2.12.5
|
||||
uvicorn==0.41.0
|
||||
fastapi==0.136.0
|
||||
pydantic==2.13.2
|
||||
uvicorn==0.44.0
|
||||
pyjwt==2.12.1
|
||||
aiofiles==25.1.0
|
||||
psutil==7.2.2
|
||||
@@ -59,4 +59,4 @@ websockets==16.0
|
||||
janus==2.0.0
|
||||
|
||||
ast-comments==1.3.0
|
||||
packaging==26.0
|
||||
packaging==26.1
|
||||
|
||||
@@ -9,8 +9,8 @@ $VenvName = ".venv"
|
||||
$VenvDir = Join-Path $PSScriptRoot $VenvName
|
||||
|
||||
# Supported Python minor versions (detection order: prefer newest first)
|
||||
$SupportedMinorVersions = @(13,12,11)
|
||||
# Build a human-readable supported versions string like "3.11, 3.12 and 3.13"
|
||||
$SupportedMinorVersions = @(14,13,12,11)
|
||||
# Build a human-readable supported versions string like "3.11, 3.12 3.13 and 3.14"
|
||||
$asc = $SupportedMinorVersions | Sort-Object
|
||||
if ($asc.Count -eq 1) {
|
||||
$SupportedPythonVersions = "3.$($asc[0])"
|
||||
|
||||
@@ -8,8 +8,8 @@ function echo_block() {
|
||||
}
|
||||
UV=false
|
||||
# Supported Python minor versions (order matters for detection)
|
||||
SUPPORTED_MINOR_VERS=(13 12 11)
|
||||
SUPPORTED_PY_VERSIONS="3.11, 3.12 and 3.13"
|
||||
SUPPORTED_MINOR_VERS=(14 13 12 11)
|
||||
SUPPORTED_PY_VERSIONS="3.11, 3.12, 3.13 and 3.14"
|
||||
|
||||
function check_installed_pip() {
|
||||
${PYTHON} -m pip > /dev/null
|
||||
@@ -254,7 +254,7 @@ function install() {
|
||||
install_redhat
|
||||
else
|
||||
echo "This script does not support your OS."
|
||||
echo "If you have Python version 3.11 - 3.13, pip, virtualenv installed you can continue."
|
||||
echo "If you have Python version 3.11 - 3.14, pip, virtualenv installed you can continue."
|
||||
echo "Wait 10 seconds to continue the next install steps or use ctrl+c to interrupt this shell."
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
+11
-9
@@ -169,25 +169,27 @@ def generate_trades_history(n_rows, start_date: datetime | None = None, days=5):
|
||||
return df
|
||||
|
||||
|
||||
def generate_test_data(timeframe: str, size: int, start: str = "2020-07-05", random_seed=42):
|
||||
def generate_test_data(
|
||||
timeframe: str, size: int, start: str = "2020-07-05", random_seed=42, base=20
|
||||
):
|
||||
np.random.seed(random_seed)
|
||||
|
||||
base = np.random.normal(20, 2, size=size)
|
||||
base = np.random.normal(base, 2, size=size)
|
||||
if timeframe == "1y":
|
||||
date = pd.date_range(start, periods=size, freq="1YS", tz="UTC")
|
||||
date = pd.date_range(start, periods=size, freq="1YS", tz="UTC", unit="ms")
|
||||
elif timeframe == "1M":
|
||||
date = pd.date_range(start, periods=size, freq="1MS", tz="UTC")
|
||||
date = pd.date_range(start, periods=size, freq="1MS", tz="UTC", unit="ms")
|
||||
elif timeframe == "3M":
|
||||
date = pd.date_range(start, periods=size, freq="3MS", tz="UTC")
|
||||
date = pd.date_range(start, periods=size, freq="3MS", tz="UTC", unit="ms")
|
||||
elif timeframe == "1w" or timeframe == "7d":
|
||||
date = pd.date_range(start, periods=size, freq="1W-MON", tz="UTC")
|
||||
date = pd.date_range(start, periods=size, freq="1W-MON", tz="UTC", unit="ms")
|
||||
else:
|
||||
tf_mins = timeframe_to_minutes(timeframe)
|
||||
if tf_mins >= 1:
|
||||
date = pd.date_range(start, periods=size, freq=f"{tf_mins}min", tz="UTC")
|
||||
date = pd.date_range(start, periods=size, freq=f"{tf_mins}min", tz="UTC", unit="ms")
|
||||
else:
|
||||
tf_secs = timeframe_to_seconds(timeframe)
|
||||
date = pd.date_range(start, periods=size, freq=f"{tf_secs}s", tz="UTC")
|
||||
date = pd.date_range(start, periods=size, freq=f"{tf_secs}s", tz="UTC", unit="ms")
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"date": date,
|
||||
@@ -205,7 +207,7 @@ def generate_test_data(timeframe: str, size: int, start: str = "2020-07-05", ran
|
||||
def generate_test_data_raw(timeframe: str, size: int, start: str = "2020-07-05", random_seed=42):
|
||||
"""Generates data in the ohlcv format used by ccxt"""
|
||||
df = generate_test_data(timeframe, size, start, random_seed)
|
||||
df["date"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000
|
||||
df["date"] = df.loc[:, "date"].dt.as_unit("ms").astype("int64")
|
||||
return list(list(x) for x in zip(*(df[x].values.tolist() for x in df.columns), strict=False))
|
||||
|
||||
|
||||
|
||||
+58
-399
@@ -1,17 +1,18 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from pandas import DataFrame, DateOffset, Timestamp, to_datetime
|
||||
from pandas import DataFrame, to_datetime
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import LAST_BT_RESULT_FN
|
||||
from freqtrade.data.btanalysis import (
|
||||
BT_DATA_COLUMNS,
|
||||
analyze_trade_parallelism,
|
||||
extract_trades_of_period,
|
||||
get_backtest_market_change,
|
||||
get_backtest_wallet_change,
|
||||
get_latest_backtest_filename,
|
||||
get_latest_hyperopt_file,
|
||||
load_backtest_data,
|
||||
@@ -20,22 +21,7 @@ from freqtrade.data.btanalysis import (
|
||||
load_trades,
|
||||
load_trades_from_db,
|
||||
)
|
||||
from freqtrade.data.history import load_data, load_pair_history
|
||||
from freqtrade.data.metrics import (
|
||||
calculate_cagr,
|
||||
calculate_calmar,
|
||||
calculate_csum,
|
||||
calculate_expectancy,
|
||||
calculate_market_change,
|
||||
calculate_max_drawdown,
|
||||
calculate_sharpe,
|
||||
calculate_sortino,
|
||||
calculate_sqn,
|
||||
calculate_underwater,
|
||||
combine_dataframes_with_mean,
|
||||
combined_dataframes_with_rel_mean,
|
||||
create_cum_profit,
|
||||
)
|
||||
from freqtrade.data.history import load_pair_history
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.util import dt_utc
|
||||
from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades
|
||||
@@ -209,17 +195,6 @@ def test_extract_trades_of_period(testdatadir):
|
||||
assert trades1.iloc[-1].close_date == datetime(2017, 11, 14, 15, 25, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_analyze_trade_parallelism(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
res = analyze_trade_parallelism(bt_data, "5m")
|
||||
assert isinstance(res, DataFrame)
|
||||
assert "open_trades" in res.columns
|
||||
assert res["open_trades"].max() == 3
|
||||
assert res["open_trades"].min() == 0
|
||||
|
||||
|
||||
def test_load_trades(default_conf, mocker):
|
||||
db_mock = mocker.patch(
|
||||
"freqtrade.data.btanalysis.bt_fileutils.load_trades_from_db", MagicMock()
|
||||
@@ -263,375 +238,6 @@ def test_load_trades(default_conf, mocker):
|
||||
assert bt_mock.call_count == 0
|
||||
|
||||
|
||||
def test_calculate_market_change(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
result = calculate_market_change(data)
|
||||
assert isinstance(result, float)
|
||||
assert pytest.approx(result) == 0.01100002
|
||||
|
||||
result = calculate_market_change(data, min_date=dt_utc(2018, 1, 20))
|
||||
assert isinstance(result, float)
|
||||
assert pytest.approx(result) == 0.0375149
|
||||
|
||||
# Move min-date after the last date
|
||||
result = calculate_market_change(data, min_date=dt_utc(2018, 2, 20))
|
||||
assert pytest.approx(result) == 0.0
|
||||
|
||||
|
||||
def test_combine_dataframes_with_mean(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
df = combine_dataframes_with_mean(data)
|
||||
assert isinstance(df, DataFrame)
|
||||
assert "ETH/BTC" in df.columns
|
||||
assert "ADA/BTC" in df.columns
|
||||
assert "mean" in df.columns
|
||||
|
||||
|
||||
def test_combined_dataframes_with_rel_mean(testdatadir):
|
||||
pairs = ["BTC/USDT", "XRP/USDT"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
df = combined_dataframes_with_rel_mean(
|
||||
data,
|
||||
fromdt=data["BTC/USDT"].at[0, "date"],
|
||||
todt=data["BTC/USDT"].at[data["BTC/USDT"].index[-1], "date"],
|
||||
)
|
||||
assert isinstance(df, DataFrame)
|
||||
assert "BTC/USDT" not in df.columns
|
||||
assert "XRP/USDT" not in df.columns
|
||||
assert "mean" in df.columns
|
||||
assert "rel_mean" in df.columns
|
||||
assert "count" in df.columns
|
||||
assert df.iloc[0]["count"] == 2
|
||||
assert df.iloc[-1]["count"] == 2
|
||||
assert len(df) < len(data["BTC/USDT"])
|
||||
assert df["rel_mean"].between(-0.5, 0.5).all()
|
||||
|
||||
|
||||
def test_combine_dataframes_with_mean_no_data(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="6m")
|
||||
with pytest.raises(ValueError, match=r"No data provided\."):
|
||||
combine_dataframes_with_mean(data)
|
||||
|
||||
|
||||
def test_create_cum_profit(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
timerange = TimeRange.parse_timerange("20180110-20180112")
|
||||
|
||||
df = load_pair_history(pair="TRX/BTC", timeframe="5m", datadir=testdatadir, timerange=timerange)
|
||||
|
||||
cum_profits = create_cum_profit(
|
||||
df.set_index("date"), bt_data[bt_data["pair"] == "TRX/BTC"], "cum_profits", timeframe="5m"
|
||||
)
|
||||
assert "cum_profits" in cum_profits.columns
|
||||
assert cum_profits.iloc[0]["cum_profits"] == 0
|
||||
assert pytest.approx(cum_profits.iloc[-1]["cum_profits"]) == 9.0225563e-05
|
||||
|
||||
|
||||
def test_create_cum_profit1(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
# Move close-time to "off" the candle, to make sure the logic still works
|
||||
bt_data["close_date"] = bt_data.loc[:, "close_date"] + DateOffset(seconds=20)
|
||||
timerange = TimeRange.parse_timerange("20180110-20180112")
|
||||
|
||||
df = load_pair_history(pair="TRX/BTC", timeframe="5m", datadir=testdatadir, timerange=timerange)
|
||||
|
||||
cum_profits = create_cum_profit(
|
||||
df.set_index("date"), bt_data[bt_data["pair"] == "TRX/BTC"], "cum_profits", timeframe="5m"
|
||||
)
|
||||
assert "cum_profits" in cum_profits.columns
|
||||
assert cum_profits.iloc[0]["cum_profits"] == 0
|
||||
assert pytest.approx(cum_profits.iloc[-1]["cum_profits"]) == 9.0225563e-05
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
create_cum_profit(
|
||||
df.set_index("date"),
|
||||
bt_data[bt_data["pair"] == "NOTAPAIR"],
|
||||
"cum_profits",
|
||||
timeframe="5m",
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_max_drawdown(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
drawdown = calculate_max_drawdown(bt_data, value_col="profit_abs")
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == 0.29753914
|
||||
assert isinstance(drawdown.high_date, Timestamp)
|
||||
assert isinstance(drawdown.low_date, Timestamp)
|
||||
assert isinstance(drawdown.high_value, float)
|
||||
assert isinstance(drawdown.low_value, float)
|
||||
assert drawdown.high_date == Timestamp("2018-01-16 19:30:00", tz="UTC")
|
||||
assert drawdown.low_date == Timestamp("2018-01-16 22:25:00", tz="UTC")
|
||||
|
||||
underwater = calculate_underwater(bt_data)
|
||||
assert isinstance(underwater, DataFrame)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
calculate_max_drawdown(DataFrame())
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
calculate_underwater(DataFrame())
|
||||
|
||||
|
||||
def test_calculate_csum(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
csum_min, csum_max = calculate_csum(bt_data)
|
||||
|
||||
assert isinstance(csum_min, float)
|
||||
assert isinstance(csum_max, float)
|
||||
assert csum_min < csum_max
|
||||
assert csum_min < 0.0001
|
||||
assert csum_max > 0.0002
|
||||
csum_min1, csum_max1 = calculate_csum(bt_data, 5)
|
||||
|
||||
assert csum_min1 == csum_min + 5
|
||||
assert csum_max1 == csum_max + 5
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
csum_min, csum_max = calculate_csum(DataFrame())
|
||||
|
||||
|
||||
def test_calculate_expectancy(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
expectancy, expectancy_ratio = calculate_expectancy(DataFrame())
|
||||
assert expectancy == 0.0
|
||||
assert expectancy_ratio == 100
|
||||
|
||||
expectancy, expectancy_ratio = calculate_expectancy(bt_data)
|
||||
assert isinstance(expectancy, float)
|
||||
assert isinstance(expectancy_ratio, float)
|
||||
assert pytest.approx(expectancy) == 5.820687070932315e-06
|
||||
assert pytest.approx(expectancy_ratio) == 0.07151374226574791
|
||||
|
||||
data = {"profit_abs": [100, 200, 50, -150, 300, -100, 80, -30]}
|
||||
df = DataFrame(data)
|
||||
expectancy, expectancy_ratio = calculate_expectancy(df)
|
||||
|
||||
assert pytest.approx(expectancy) == 56.25
|
||||
assert pytest.approx(expectancy_ratio) == 0.60267857
|
||||
|
||||
|
||||
def test_calculate_sortino(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
sortino = calculate_sortino(DataFrame(), None, None, 0)
|
||||
assert sortino == 0.0
|
||||
|
||||
sortino = calculate_sortino(
|
||||
bt_data,
|
||||
bt_data["open_date"].min(),
|
||||
bt_data["close_date"].max(),
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(sortino, float)
|
||||
assert pytest.approx(sortino) == 35.17722
|
||||
|
||||
|
||||
def test_calculate_sharpe(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
sharpe = calculate_sharpe(DataFrame(), None, None, 0)
|
||||
assert sharpe == 0.0
|
||||
|
||||
sharpe = calculate_sharpe(
|
||||
bt_data,
|
||||
bt_data["open_date"].min(),
|
||||
bt_data["close_date"].max(),
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(sharpe, float)
|
||||
assert pytest.approx(sharpe) == 44.5078669
|
||||
|
||||
|
||||
def test_calculate_calmar(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
calmar = calculate_calmar(DataFrame(), None, None, 0)
|
||||
assert calmar == 0.0
|
||||
|
||||
calmar = calculate_calmar(
|
||||
bt_data,
|
||||
bt_data["open_date"].min(),
|
||||
bt_data["close_date"].max(),
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(calmar, float)
|
||||
assert pytest.approx(calmar) == 559.040508
|
||||
|
||||
|
||||
def test_calculate_sqn(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
sqn = calculate_sqn(DataFrame(), 0)
|
||||
assert sqn == 0.0
|
||||
|
||||
sqn = calculate_sqn(
|
||||
bt_data,
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(sqn, float)
|
||||
assert pytest.approx(sqn) == 3.2991
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"profits,starting_balance,expected_sqn,description",
|
||||
[
|
||||
([1.0, -0.5, 2.0, -1.0, 0.5, 1.5, -0.5, 1.0], 100, 1.3229, "Mixed profits/losses"),
|
||||
([], 100, 0.0, "Empty dataframe"),
|
||||
([1.0, 0.5, 2.0, 1.5, 0.8], 100, 4.3657, "All winning trades"),
|
||||
([-1.0, -0.5, -2.0, -1.5, -0.8], 100, -4.3657, "All losing trades"),
|
||||
([1.0], 100, -100, "Single trade"),
|
||||
],
|
||||
)
|
||||
def test_calculate_sqn_cases(profits, starting_balance, expected_sqn, description):
|
||||
"""
|
||||
Test SQN calculation with various scenarios:
|
||||
"""
|
||||
trades = DataFrame({"profit_abs": profits})
|
||||
sqn = calculate_sqn(trades, starting_balance=starting_balance)
|
||||
|
||||
assert isinstance(sqn, float)
|
||||
assert pytest.approx(sqn, rel=1e-4) == expected_sqn
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start,end,days, expected",
|
||||
[
|
||||
(64900, 176000, 3 * 365, 0.3945),
|
||||
(64900, 176000, 365, 1.7119),
|
||||
(1000, 1000, 365, 0.0),
|
||||
(1000, 1500, 365, 0.5),
|
||||
(1000, 1500, 100, 3.3927), # sub year
|
||||
(0.01000000, 0.01762792, 120, 4.6087), # sub year BTC values
|
||||
(1000, 1010, 0, 0.0), # zero days
|
||||
(-100, 100, 365, 0.0), # negative starting balance
|
||||
],
|
||||
)
|
||||
def test_calculate_cagr(start, end, days, expected):
|
||||
assert round(calculate_cagr(days, start, end), 4) == expected
|
||||
|
||||
|
||||
def test_calculate_max_drawdown2():
|
||||
values = [
|
||||
0.011580,
|
||||
0.010048,
|
||||
0.011340,
|
||||
0.012161,
|
||||
0.010416,
|
||||
0.010009,
|
||||
0.020024,
|
||||
-0.024662,
|
||||
-0.022350,
|
||||
0.020496,
|
||||
-0.029859,
|
||||
-0.030511,
|
||||
0.010041,
|
||||
0.010872,
|
||||
-0.025782,
|
||||
0.010400,
|
||||
0.012374,
|
||||
0.012467,
|
||||
0.114741,
|
||||
0.010303,
|
||||
0.010088,
|
||||
-0.033961,
|
||||
0.010680,
|
||||
0.010886,
|
||||
-0.029274,
|
||||
0.011178,
|
||||
0.010693,
|
||||
0.010711,
|
||||
]
|
||||
|
||||
dates = [dt_utc(2020, 1, 1) + timedelta(days=i) for i in range(len(values))]
|
||||
df = DataFrame(zip(values, dates, strict=False), columns=["profit", "open_date"])
|
||||
# sort by profit and reset index
|
||||
df = df.sort_values("profit").reset_index(drop=True)
|
||||
df1 = df.copy()
|
||||
drawdown = calculate_max_drawdown(
|
||||
df, date_col="open_date", starting_balance=0.2, value_col="profit"
|
||||
)
|
||||
# Ensure df has not been altered.
|
||||
assert df.equals(df1)
|
||||
|
||||
assert isinstance(drawdown.drawdown_abs, float)
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
# High must be before low
|
||||
assert drawdown.high_date < drawdown.low_date
|
||||
# High value must be higher than low value
|
||||
assert drawdown.high_value > drawdown.low_value
|
||||
assert drawdown.drawdown_abs == 0.091755
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == 0.32129575
|
||||
|
||||
df = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
|
||||
# No losing trade ...
|
||||
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
|
||||
assert drawdown.drawdown_abs == 0.0
|
||||
assert drawdown.low_value == 0.0
|
||||
assert drawdown.current_high_value >= 0.0
|
||||
assert drawdown.current_drawdown_abs == 0.0
|
||||
|
||||
df1 = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
|
||||
df1.loc[:, "profit"] = df1["profit"] * -1
|
||||
# No winning trade ...
|
||||
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
|
||||
assert drawdown.drawdown_abs == 0.055545
|
||||
assert drawdown.high_value == 0.0
|
||||
assert drawdown.current_high_value == 0.0
|
||||
assert drawdown.current_drawdown_abs == 0.055545
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"profits,relative,highd,lowdays,result,result_rel",
|
||||
[
|
||||
([0.0, -500.0, 500.0, 10000.0, -1000.0], False, 3, 4, 1000.0, 0.090909),
|
||||
([0.0, -500.0, 500.0, 10000.0, -1000.0], True, 0, 1, 500.0, 0.5),
|
||||
],
|
||||
)
|
||||
def test_calculate_max_drawdown_abs(profits, relative, highd, lowdays, result, result_rel):
|
||||
"""
|
||||
Test case from issue https://github.com/freqtrade/freqtrade/issues/6655
|
||||
[1000, 500, 1000, 11000, 10000] # absolute results
|
||||
[1000, 50%, 0%, 0%, ~9%] # Relative drawdowns
|
||||
"""
|
||||
init_date = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
dates = [init_date + timedelta(days=i) for i in range(len(profits))]
|
||||
df = DataFrame(zip(profits, dates, strict=False), columns=["profit_abs", "open_date"])
|
||||
# sort by profit and reset index
|
||||
df = df.sort_values("profit_abs").reset_index(drop=True)
|
||||
df1 = df.copy()
|
||||
drawdown = calculate_max_drawdown(
|
||||
df, date_col="open_date", starting_balance=1000, relative=relative
|
||||
)
|
||||
# Ensure df has not been altered.
|
||||
assert df.equals(df1)
|
||||
|
||||
assert isinstance(drawdown.drawdown_abs, float)
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
assert drawdown.high_date == init_date + timedelta(days=highd)
|
||||
assert drawdown.low_date == init_date + timedelta(days=lowdays)
|
||||
|
||||
# High must be before low
|
||||
assert drawdown.high_date < drawdown.low_date
|
||||
# High value must be higher than low value
|
||||
assert drawdown.high_value > drawdown.low_value
|
||||
assert drawdown.drawdown_abs == result
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == result_rel
|
||||
|
||||
|
||||
def test_load_file_from_zip(tmp_path):
|
||||
with pytest.raises(ValueError, match=r"Zip file .* not found\."):
|
||||
load_file_from_zip(tmp_path / "test.zip", "testfile.txt")
|
||||
@@ -649,3 +255,56 @@ def test_load_file_from_zip(tmp_path):
|
||||
|
||||
with pytest.raises(ValueError, match=r"File .* not found in zip.*"):
|
||||
load_file_from_zip(zip_file, "testfile55.txt")
|
||||
|
||||
|
||||
def test_get_backtest_market_change(tmp_path):
|
||||
df = DataFrame(
|
||||
{
|
||||
"date": [dt_utc(2020, 1, 1), dt_utc(2020, 1, 2)],
|
||||
"price": [100.0, 110.0],
|
||||
}
|
||||
)
|
||||
feather_file = tmp_path / "backtest-result_market_change.feather"
|
||||
df.to_feather(feather_file)
|
||||
|
||||
direct_df = get_backtest_market_change(feather_file)
|
||||
assert isinstance(direct_df, DataFrame)
|
||||
assert "__date_ts" in direct_df.columns
|
||||
assert direct_df.loc[0, "__date_ts"] == int(df.loc[0, "date"].timestamp() * 1000)
|
||||
|
||||
no_ts_df = get_backtest_market_change(feather_file, include_ts=False)
|
||||
assert "__date_ts" not in no_ts_df.columns
|
||||
|
||||
zip_file = tmp_path / "backtest-result.zip"
|
||||
with ZipFile(zip_file, "w") as zipf:
|
||||
zipf.write(feather_file, arcname=f"{zip_file.stem}_market_change.feather")
|
||||
|
||||
zipped_df = get_backtest_market_change(zip_file)
|
||||
assert isinstance(zipped_df, DataFrame)
|
||||
assert zipped_df.loc[0, "__date_ts"] == int(df.loc[0, "date"].timestamp() * 1000)
|
||||
assert list(zipped_df["price"]) == [100.0, 110.0]
|
||||
|
||||
|
||||
def test_get_backtest_wallet_change(tmp_path):
|
||||
df = DataFrame(
|
||||
{
|
||||
"date": [dt_utc(2020, 1, 1), dt_utc(2020, 1, 2)],
|
||||
"balance": [1.0, 1.1],
|
||||
"rate": [1.0, 1.1],
|
||||
}
|
||||
)
|
||||
wallet_feather = tmp_path / "backtest-result_TestStrategy_wallet.feather"
|
||||
df.to_feather(wallet_feather)
|
||||
|
||||
zip_file = tmp_path / "backtest-result.zip"
|
||||
with ZipFile(zip_file, "w") as zipf:
|
||||
zipf.write(wallet_feather, arcname=wallet_feather.name)
|
||||
|
||||
wallet_df = get_backtest_wallet_change(zip_file, "TestStrategy")
|
||||
assert isinstance(wallet_df, DataFrame)
|
||||
assert "__date_ts" in wallet_df.columns
|
||||
assert wallet_df.loc[0, "__date_ts"] == int(df.loc[0, "date"].timestamp() * 1000)
|
||||
assert list(wallet_df["balance"]) == [1.0, 1.1]
|
||||
|
||||
assert get_backtest_wallet_change(tmp_path / "backtest-result.feather", "TestStrategy") is None
|
||||
assert get_backtest_wallet_change(zip_file, "UnknownStrategy") is None
|
||||
|
||||
@@ -207,10 +207,13 @@ def test_ohlcv_to_dataframe_multi(timeframe):
|
||||
|
||||
data1 = data.copy()
|
||||
if timeframe in ("1M", "3M", "1y"):
|
||||
data1.loc[:, "date"] = data1.loc[:, "date"] + pd.to_timedelta("1w")
|
||||
data1.loc[:, "date"] = data1.loc[:, "date"] + pd.to_timedelta("1W")
|
||||
else:
|
||||
# Shift by half a timeframe
|
||||
data1.loc[:, "date"] = data1.loc[:, "date"] + (pd.to_timedelta(timeframe) / 2)
|
||||
timeframe_f = (
|
||||
timeframe.upper() if timeframe.endswith("d") or timeframe.endswith("w") else timeframe
|
||||
)
|
||||
data1.loc[:, "date"] = data1.loc[:, "date"] + (pd.to_timedelta(timeframe_f) / 2)
|
||||
df2 = ohlcv_to_dataframe(data1, timeframe, "UNITTEST/USDT")
|
||||
|
||||
assert len(df2) == len(data) - 1
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pandas import DataFrame, DateOffset, Timestamp, to_datetime
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.data.btanalysis import (
|
||||
load_backtest_data,
|
||||
)
|
||||
from freqtrade.data.history import load_data, load_pair_history
|
||||
from freqtrade.data.metrics import (
|
||||
calculate_cagr,
|
||||
calculate_calmar,
|
||||
calculate_calmar_from_balance,
|
||||
calculate_csum,
|
||||
calculate_expectancy,
|
||||
calculate_market_change,
|
||||
calculate_max_drawdown,
|
||||
calculate_max_drawdown_from_balance,
|
||||
calculate_sharpe,
|
||||
calculate_sharpe_from_balance,
|
||||
calculate_sortino,
|
||||
calculate_sortino_from_balance,
|
||||
calculate_sqn,
|
||||
calculate_underwater,
|
||||
combine_dataframes_with_mean,
|
||||
combined_dataframes_with_rel_mean,
|
||||
create_cum_profit,
|
||||
)
|
||||
from freqtrade.util import dt_utc
|
||||
|
||||
|
||||
def test_calculate_market_change(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
result = calculate_market_change(data)
|
||||
assert isinstance(result, float)
|
||||
assert pytest.approx(result) == 0.01100002
|
||||
|
||||
result = calculate_market_change(data, min_date=dt_utc(2018, 1, 20))
|
||||
assert isinstance(result, float)
|
||||
assert pytest.approx(result) == 0.0375149
|
||||
|
||||
# Move min-date after the last date
|
||||
result = calculate_market_change(data, min_date=dt_utc(2018, 2, 20))
|
||||
assert pytest.approx(result) == 0.0
|
||||
|
||||
|
||||
def test_combine_dataframes_with_mean(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
df = combine_dataframes_with_mean(data)
|
||||
assert isinstance(df, DataFrame)
|
||||
assert "ETH/BTC" in df.columns
|
||||
assert "ADA/BTC" in df.columns
|
||||
assert "mean" in df.columns
|
||||
|
||||
|
||||
def test_combined_dataframes_with_rel_mean(testdatadir):
|
||||
pairs = ["BTC/USDT", "XRP/USDT"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="5m")
|
||||
df = combined_dataframes_with_rel_mean(
|
||||
data,
|
||||
fromdt=data["BTC/USDT"].at[0, "date"],
|
||||
todt=data["BTC/USDT"].at[data["BTC/USDT"].index[-1], "date"],
|
||||
)
|
||||
assert isinstance(df, DataFrame)
|
||||
assert "BTC/USDT" not in df.columns
|
||||
assert "XRP/USDT" not in df.columns
|
||||
assert "mean" in df.columns
|
||||
assert "rel_mean" in df.columns
|
||||
assert "count" in df.columns
|
||||
assert df.iloc[0]["count"] == 2
|
||||
assert df.iloc[-1]["count"] == 2
|
||||
assert len(df) < len(data["BTC/USDT"])
|
||||
assert df["rel_mean"].between(-0.5, 0.5).all()
|
||||
|
||||
|
||||
def test_combine_dataframes_with_mean_no_data(testdatadir):
|
||||
pairs = ["ETH/BTC", "ADA/BTC"]
|
||||
data = load_data(datadir=testdatadir, pairs=pairs, timeframe="6m")
|
||||
with pytest.raises(ValueError, match=r"No data provided\."):
|
||||
combine_dataframes_with_mean(data)
|
||||
|
||||
|
||||
def test_create_cum_profit(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
timerange = TimeRange.parse_timerange("20180110-20180112")
|
||||
|
||||
df = load_pair_history(pair="TRX/BTC", timeframe="5m", datadir=testdatadir, timerange=timerange)
|
||||
|
||||
cum_profits = create_cum_profit(
|
||||
df.set_index("date"), bt_data[bt_data["pair"] == "TRX/BTC"], "cum_profits", timeframe="5m"
|
||||
)
|
||||
assert "cum_profits" in cum_profits.columns
|
||||
assert cum_profits.iloc[0]["cum_profits"] == 0
|
||||
assert pytest.approx(cum_profits.iloc[-1]["cum_profits"]) == 9.0225563e-05
|
||||
|
||||
|
||||
def test_create_cum_profit1(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
# Move close-time to "off" the candle, to make sure the logic still works
|
||||
bt_data["close_date"] = bt_data.loc[:, "close_date"] + DateOffset(seconds=20)
|
||||
timerange = TimeRange.parse_timerange("20180110-20180112")
|
||||
|
||||
df = load_pair_history(pair="TRX/BTC", timeframe="5m", datadir=testdatadir, timerange=timerange)
|
||||
|
||||
cum_profits = create_cum_profit(
|
||||
df.set_index("date"), bt_data[bt_data["pair"] == "TRX/BTC"], "cum_profits", timeframe="5m"
|
||||
)
|
||||
assert "cum_profits" in cum_profits.columns
|
||||
assert cum_profits.iloc[0]["cum_profits"] == 0
|
||||
assert pytest.approx(cum_profits.iloc[-1]["cum_profits"]) == 9.0225563e-05
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
create_cum_profit(
|
||||
df.set_index("date"),
|
||||
bt_data[bt_data["pair"] == "NOTAPAIR"],
|
||||
"cum_profits",
|
||||
timeframe="5m",
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_max_drawdown(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
drawdown = calculate_max_drawdown(bt_data, value_col="profit_abs")
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == 0.29753914
|
||||
assert isinstance(drawdown.high_date, Timestamp)
|
||||
assert isinstance(drawdown.low_date, Timestamp)
|
||||
assert isinstance(drawdown.high_value, float)
|
||||
assert isinstance(drawdown.low_value, float)
|
||||
assert drawdown.high_date == Timestamp("2018-01-16 19:30:00", tz="UTC")
|
||||
assert drawdown.low_date == Timestamp("2018-01-16 22:25:00", tz="UTC")
|
||||
|
||||
underwater = calculate_underwater(bt_data)
|
||||
assert isinstance(underwater, DataFrame)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
calculate_max_drawdown(DataFrame())
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
calculate_underwater(DataFrame())
|
||||
|
||||
|
||||
def test_calculate_max_drawdown_from_balance():
|
||||
balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
[
|
||||
"2025-01-01 00:00:00+00:00",
|
||||
"2025-01-01 12:00:00+00:00",
|
||||
"2025-01-01 18:00:00+00:00",
|
||||
"2025-01-04 00:00:00+00:00",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 120.0, 80.0, 110.0],
|
||||
}
|
||||
)
|
||||
|
||||
drawdown = calculate_max_drawdown_from_balance(balance_history)
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == 1 / 3
|
||||
assert pytest.approx(drawdown.drawdown_abs) == 40
|
||||
assert pytest.approx(drawdown.current_high_value) == 20
|
||||
assert pytest.approx(drawdown.low_value) == -20
|
||||
assert pytest.approx(drawdown.high_value) == 20
|
||||
|
||||
assert drawdown.high_date == Timestamp("2025-01-01 12:00:00", tz="UTC")
|
||||
assert drawdown.low_date == Timestamp("2025-01-01 18:00:00", tz="UTC")
|
||||
|
||||
|
||||
def test_calculate_max_drawdown_from_balance_empty_or_short():
|
||||
with pytest.raises(ValueError, match=r"Balance-history dataframe empty\."):
|
||||
calculate_max_drawdown_from_balance(DataFrame())
|
||||
|
||||
one_point = DataFrame(
|
||||
{
|
||||
"date": to_datetime(["2025-01-01 00:00:00+00:00"], utc=True),
|
||||
"total_quote": [100.0],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"Balance-history dataframe empty\."):
|
||||
calculate_max_drawdown_from_balance(one_point)
|
||||
|
||||
|
||||
def test_calculate_csum(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
csum_min, csum_max = calculate_csum(bt_data)
|
||||
|
||||
assert isinstance(csum_min, float)
|
||||
assert isinstance(csum_max, float)
|
||||
assert csum_min < csum_max
|
||||
assert csum_min < 0.0001
|
||||
assert csum_max > 0.0002
|
||||
csum_min1, csum_max1 = calculate_csum(bt_data, 5)
|
||||
|
||||
assert csum_min1 == csum_min + 5
|
||||
assert csum_max1 == csum_max + 5
|
||||
|
||||
with pytest.raises(ValueError, match=r"Trade dataframe empty\."):
|
||||
csum_min, csum_max = calculate_csum(DataFrame())
|
||||
|
||||
|
||||
def test_calculate_expectancy(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
expectancy, expectancy_ratio = calculate_expectancy(DataFrame())
|
||||
assert expectancy == 0.0
|
||||
assert expectancy_ratio == 100
|
||||
|
||||
expectancy, expectancy_ratio = calculate_expectancy(bt_data)
|
||||
assert isinstance(expectancy, float)
|
||||
assert isinstance(expectancy_ratio, float)
|
||||
assert pytest.approx(expectancy) == 5.820687070932315e-06
|
||||
assert pytest.approx(expectancy_ratio) == 0.07151374226574791
|
||||
|
||||
data = {"profit_abs": [100, 200, 50, -150, 300, -100, 80, -30]}
|
||||
df = DataFrame(data)
|
||||
expectancy, expectancy_ratio = calculate_expectancy(df)
|
||||
|
||||
assert pytest.approx(expectancy) == 56.25
|
||||
assert pytest.approx(expectancy_ratio) == 0.60267857
|
||||
|
||||
|
||||
def test_calculate_sortino(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
sortino = calculate_sortino(DataFrame(), None, None, 0)
|
||||
assert sortino == 0.0
|
||||
|
||||
sortino = calculate_sortino(
|
||||
bt_data,
|
||||
bt_data["open_date"].min(),
|
||||
bt_data["close_date"].max(),
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(sortino, float)
|
||||
assert pytest.approx(sortino) == 35.17722
|
||||
|
||||
|
||||
def test_calculate_sortino_from_balance():
|
||||
balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
[
|
||||
"2025-01-01 00:00:00+00:00",
|
||||
"2025-01-02 00:00:00+00:00",
|
||||
"2025-01-03 00:00:00+00:00",
|
||||
"2025-01-04 00:00:00+00:00",
|
||||
"2025-01-05 00:00:00+00:00",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 110.0, 104.5, 125.4, 112.86],
|
||||
}
|
||||
)
|
||||
|
||||
sortino = calculate_sortino_from_balance(balance_history)
|
||||
expected_returns = np.array([0.1, -0.05, 0.2, -0.1])
|
||||
expected_sortino = expected_returns.mean() / np.std(expected_returns[expected_returns < 0])
|
||||
expected_sortino *= np.sqrt(365)
|
||||
|
||||
assert isinstance(sortino, float)
|
||||
assert pytest.approx(sortino) == expected_sortino
|
||||
# Explicit assert
|
||||
assert pytest.approx(sortino) == 28.6574597
|
||||
|
||||
|
||||
def test_calculate_sortino_from_balance_empty_or_no_downside():
|
||||
assert calculate_sortino_from_balance(DataFrame()) == 0.0
|
||||
|
||||
positive_balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
[
|
||||
"2025-01-01 00:00:00+00:00",
|
||||
"2025-01-02 00:00:00+00:00",
|
||||
"2025-01-03 00:00:00+00:00",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 110.0, 121.0],
|
||||
}
|
||||
)
|
||||
assert calculate_sortino_from_balance(positive_balance_history) == -100
|
||||
|
||||
|
||||
def test_calculate_sharpe(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
sharpe = calculate_sharpe(DataFrame(), None, None, 0)
|
||||
assert sharpe == 0.0
|
||||
|
||||
sharpe = calculate_sharpe(
|
||||
bt_data,
|
||||
bt_data["open_date"].min(),
|
||||
bt_data["close_date"].max(),
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(sharpe, float)
|
||||
assert pytest.approx(sharpe) == 44.5078669
|
||||
|
||||
|
||||
def test_calculate_sharpe_from_balance():
|
||||
balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
[
|
||||
"2025-01-01 00:00:00+00:00",
|
||||
"2025-01-02 00:00:00+00:00",
|
||||
"2025-01-03 00:00:00+00:00",
|
||||
"2025-01-04 00:00:00+00:00",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 110.0, 104.5, 125.4],
|
||||
}
|
||||
)
|
||||
|
||||
sharpe = calculate_sharpe_from_balance(balance_history)
|
||||
expected_returns = np.array([0.1, -0.05, 0.2])
|
||||
expected_sharpe = expected_returns.mean() / expected_returns.std() * np.sqrt(365)
|
||||
|
||||
assert isinstance(sharpe, float)
|
||||
assert pytest.approx(sharpe) == expected_sharpe
|
||||
|
||||
|
||||
def test_calculate_sharpe_from_balance_empty_or_flat():
|
||||
assert calculate_sharpe_from_balance(DataFrame()) == 0.0
|
||||
|
||||
flat_balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
["2025-01-01 00:00:00+00:00", "2025-01-02 00:00:00+00:00"],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 100.0],
|
||||
}
|
||||
)
|
||||
assert calculate_sharpe_from_balance(flat_balance_history) == -100
|
||||
|
||||
|
||||
def test_calculate_calmar(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
calmar = calculate_calmar(DataFrame(), None, None, 0)
|
||||
assert calmar == 0.0
|
||||
|
||||
calmar = calculate_calmar(
|
||||
bt_data,
|
||||
bt_data["open_date"].min(),
|
||||
bt_data["close_date"].max(),
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(calmar, float)
|
||||
assert pytest.approx(calmar) == 559.040508
|
||||
|
||||
|
||||
def test_calculate_calmar_from_balance():
|
||||
balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
[
|
||||
"2025-01-01 00:00:00+00:00",
|
||||
"2025-01-01 12:00:00+00:00",
|
||||
"2025-01-01 18:00:00+00:00",
|
||||
"2025-01-04 00:00:00+00:00",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 120.0, 80.0, 110.0],
|
||||
}
|
||||
)
|
||||
|
||||
calmar = calculate_calmar_from_balance(balance_history)
|
||||
expected_returns_mean = ((110.0 - 100.0) / 100.0) / 3 * 100
|
||||
expected_calmar = expected_returns_mean / (1 / 3) * np.sqrt(365)
|
||||
|
||||
assert isinstance(calmar, float)
|
||||
assert pytest.approx(calmar) == expected_calmar
|
||||
|
||||
|
||||
def test_calculate_calmar_from_balance_empty_or_flat():
|
||||
assert calculate_calmar_from_balance(DataFrame()) == 0.0
|
||||
|
||||
flat_balance_history = DataFrame(
|
||||
{
|
||||
"date": to_datetime(
|
||||
["2025-01-01 00:00:00+00:00", "2025-01-02 00:00:00+00:00"],
|
||||
utc=True,
|
||||
),
|
||||
"total_quote": [100.0, 100.0],
|
||||
}
|
||||
)
|
||||
assert calculate_calmar_from_balance(flat_balance_history) == -100
|
||||
|
||||
|
||||
def test_calculate_sqn(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
sqn = calculate_sqn(DataFrame(), 0)
|
||||
assert sqn == 0.0
|
||||
|
||||
sqn = calculate_sqn(
|
||||
bt_data,
|
||||
0.01,
|
||||
)
|
||||
assert isinstance(sqn, float)
|
||||
assert pytest.approx(sqn) == 3.2991
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"profits,starting_balance,expected_sqn,description",
|
||||
[
|
||||
([1.0, -0.5, 2.0, -1.0, 0.5, 1.5, -0.5, 1.0], 100, 1.3229, "Mixed profits/losses"),
|
||||
([], 100, 0.0, "Empty dataframe"),
|
||||
([1.0, 0.5, 2.0, 1.5, 0.8], 100, 4.3657, "All winning trades"),
|
||||
([-1.0, -0.5, -2.0, -1.5, -0.8], 100, -4.3657, "All losing trades"),
|
||||
([1.0], 100, -100, "Single trade"),
|
||||
],
|
||||
)
|
||||
def test_calculate_sqn_cases(profits, starting_balance, expected_sqn, description):
|
||||
"""
|
||||
Test SQN calculation with various scenarios:
|
||||
"""
|
||||
trades = DataFrame({"profit_abs": profits})
|
||||
sqn = calculate_sqn(trades, starting_balance=starting_balance)
|
||||
|
||||
assert isinstance(sqn, float)
|
||||
assert pytest.approx(sqn, rel=1e-4) == expected_sqn
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start,end,days, expected",
|
||||
[
|
||||
(64900, 176000, 3 * 365, 0.3945),
|
||||
(64900, 176000, 365, 1.7119),
|
||||
(1000, 1000, 365, 0.0),
|
||||
(1000, 1500, 365, 0.5),
|
||||
(1000, 1500, 100, 3.3927), # sub year
|
||||
(0.01000000, 0.01762792, 120, 4.6087), # sub year BTC values
|
||||
(1000, 1010, 0, 0.0), # zero days
|
||||
(-100, 100, 365, 0.0), # negative starting balance
|
||||
],
|
||||
)
|
||||
def test_calculate_cagr(start, end, days, expected):
|
||||
assert round(calculate_cagr(days, start, end), 4) == expected
|
||||
|
||||
|
||||
def test_calculate_max_drawdown2():
|
||||
values = [
|
||||
0.011580,
|
||||
0.010048,
|
||||
0.011340,
|
||||
0.012161,
|
||||
0.010416,
|
||||
0.010009,
|
||||
0.020024,
|
||||
-0.024662,
|
||||
-0.022350,
|
||||
0.020496,
|
||||
-0.029859,
|
||||
-0.030511,
|
||||
0.010041,
|
||||
0.010872,
|
||||
-0.025782,
|
||||
0.010400,
|
||||
0.012374,
|
||||
0.012467,
|
||||
0.114741,
|
||||
0.010303,
|
||||
0.010088,
|
||||
-0.033961,
|
||||
0.010680,
|
||||
0.010886,
|
||||
-0.029274,
|
||||
0.011178,
|
||||
0.010693,
|
||||
0.010711,
|
||||
]
|
||||
|
||||
dates = [dt_utc(2020, 1, 1) + timedelta(days=i) for i in range(len(values))]
|
||||
df = DataFrame(zip(values, dates, strict=False), columns=["profit", "open_date"])
|
||||
# sort by profit and reset index
|
||||
df = df.sort_values("profit").reset_index(drop=True)
|
||||
df1 = df.copy()
|
||||
drawdown = calculate_max_drawdown(
|
||||
df, date_col="open_date", starting_balance=0.2, value_col="profit"
|
||||
)
|
||||
# Ensure df has not been altered.
|
||||
assert df.equals(df1)
|
||||
|
||||
assert isinstance(drawdown.drawdown_abs, float)
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
# High must be before low
|
||||
assert drawdown.high_date < drawdown.low_date
|
||||
# High value must be higher than low value
|
||||
assert drawdown.high_value > drawdown.low_value
|
||||
assert drawdown.drawdown_abs == 0.091755
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == 0.32129575
|
||||
|
||||
df = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
|
||||
# No losing trade ...
|
||||
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
|
||||
assert drawdown.drawdown_abs == 0.0
|
||||
assert drawdown.low_value == 0.0
|
||||
assert drawdown.current_high_value >= 0.0
|
||||
assert drawdown.current_drawdown_abs == 0.0
|
||||
|
||||
df1 = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
|
||||
df1.loc[:, "profit"] = df1["profit"] * -1
|
||||
# No winning trade ...
|
||||
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
|
||||
assert drawdown.drawdown_abs == 0.055545
|
||||
assert drawdown.high_value == 0.0
|
||||
assert drawdown.current_high_value == 0.0
|
||||
assert drawdown.current_drawdown_abs == 0.055545
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"profits,relative,highd,lowdays,result,result_rel",
|
||||
[
|
||||
([0.0, -500.0, 500.0, 10000.0, -1000.0], False, 3, 4, 1000.0, 0.090909),
|
||||
([0.0, -500.0, 500.0, 10000.0, -1000.0], True, 0, 1, 500.0, 0.5),
|
||||
],
|
||||
)
|
||||
def test_calculate_max_drawdown_abs(profits, relative, highd, lowdays, result, result_rel):
|
||||
"""
|
||||
Test case from issue https://github.com/freqtrade/freqtrade/issues/6655
|
||||
[1000, 500, 1000, 11000, 10000] # absolute results
|
||||
[1000, 50%, 0%, 0%, ~9%] # Relative drawdowns
|
||||
"""
|
||||
init_date = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
dates = [init_date + timedelta(days=i) for i in range(len(profits))]
|
||||
df = DataFrame(zip(profits, dates, strict=False), columns=["profit_abs", "open_date"])
|
||||
# sort by profit and reset index
|
||||
df = df.sort_values("profit_abs").reset_index(drop=True)
|
||||
df1 = df.copy()
|
||||
drawdown = calculate_max_drawdown(
|
||||
df, date_col="open_date", starting_balance=1000, relative=relative
|
||||
)
|
||||
# Ensure df has not been altered.
|
||||
assert df.equals(df1)
|
||||
|
||||
assert isinstance(drawdown.drawdown_abs, float)
|
||||
assert isinstance(drawdown.relative_account_drawdown, float)
|
||||
assert drawdown.high_date == init_date + timedelta(days=highd)
|
||||
assert drawdown.low_date == init_date + timedelta(days=lowdays)
|
||||
|
||||
# High must be before low
|
||||
assert drawdown.high_date < drawdown.low_date
|
||||
# High value must be higher than low value
|
||||
assert drawdown.high_value > drawdown.low_value
|
||||
assert drawdown.drawdown_abs == result
|
||||
assert pytest.approx(drawdown.relative_account_drawdown) == result_rel
|
||||
@@ -0,0 +1,210 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from pandas import DataFrame, Timestamp
|
||||
|
||||
from freqtrade.data.btanalysis import (
|
||||
analyze_trade_parallelism,
|
||||
load_backtest_data,
|
||||
)
|
||||
from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time
|
||||
from freqtrade.util import dt_utc
|
||||
|
||||
|
||||
def test_analyze_trade_parallelism(testdatadir):
|
||||
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||
bt_data = load_backtest_data(filename)
|
||||
|
||||
res = analyze_trade_parallelism(bt_data, "5m")
|
||||
assert isinstance(res, DataFrame)
|
||||
assert "open_trades" in res.columns
|
||||
assert res["open_trades"].max() == 3
|
||||
assert res["open_trades"].min() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_short", [False, True])
|
||||
def test_balance_distribution_over_time(is_short):
|
||||
"""
|
||||
Test balance_distribution_over_time for both long and short trades.
|
||||
"""
|
||||
# Create a minimal trades DataFrame with 4 trades over time
|
||||
# Base dates for trades
|
||||
start_date = dt_utc(2023, 1, 1)
|
||||
base_date = start_date + timedelta(hours=15)
|
||||
stake_currency = "USDT"
|
||||
start_balance = 1000.0
|
||||
fee = 0.001 # 0.1% fee
|
||||
|
||||
# Create trades spanning different time periods
|
||||
trades_data = {
|
||||
"pair": ["BTC/USDT", "ETH/USDT", "XRP/USDT", "LTC/USDT"],
|
||||
"stake_amount": [100.0, 150.0, 80.0, 120.0],
|
||||
"open_date": [
|
||||
base_date,
|
||||
base_date + timedelta(hours=2),
|
||||
base_date + timedelta(hours=5),
|
||||
base_date + timedelta(hours=8),
|
||||
],
|
||||
"close_date": [
|
||||
base_date + timedelta(hours=3),
|
||||
base_date + timedelta(hours=6),
|
||||
base_date + timedelta(hours=9),
|
||||
base_date + timedelta(hours=12),
|
||||
],
|
||||
"open_rate": [40000.0, 2000.0, 0.5, 100.0],
|
||||
"close_rate": [41000.0, 2100.0, 0.52, 105.0],
|
||||
"fee_open": [fee, fee, fee, fee],
|
||||
"fee_close": [fee, fee, fee, fee],
|
||||
"is_short": [is_short, is_short, is_short, is_short],
|
||||
"leverage": [1.0, 1.0, 1.0, 1.0],
|
||||
"orders": [
|
||||
# Trade 1: BTC/USDT - entry at 40000, exit at 41000
|
||||
[
|
||||
{
|
||||
"amount": 0.0025, # 100 / 40000
|
||||
"filled": 0.0025,
|
||||
"safe_price": 40000.0,
|
||||
"ft_order_side": "sell" if is_short else "buy",
|
||||
"order_filled_timestamp": int(base_date.timestamp() * 1000),
|
||||
"ft_is_entry": True,
|
||||
},
|
||||
{
|
||||
"amount": 0.0025,
|
||||
"filled": 0.0025,
|
||||
"safe_price": 41000.0,
|
||||
"ft_order_side": "buy" if is_short else "sell",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=3)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": False,
|
||||
},
|
||||
],
|
||||
# Trade 2: ETH/USDT - entry at 2000, exit at 2100
|
||||
[
|
||||
{
|
||||
"amount": 0.075, # 150 / 2000
|
||||
"filled": 0.075,
|
||||
"safe_price": 2000.0,
|
||||
"ft_order_side": "sell" if is_short else "buy",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=2)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": True,
|
||||
},
|
||||
{
|
||||
"amount": 0.075,
|
||||
"filled": 0.075,
|
||||
"safe_price": 2100.0,
|
||||
"ft_order_side": "buy" if is_short else "sell",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=6)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": False,
|
||||
},
|
||||
],
|
||||
# Trade 3: XRP/USDT - entry at 0.5, exit at 0.52
|
||||
[
|
||||
{
|
||||
"amount": 160.0, # 80 / 0.5
|
||||
"filled": 160.0,
|
||||
"safe_price": 0.5,
|
||||
"ft_order_side": "sell" if is_short else "buy",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=5)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": True,
|
||||
},
|
||||
{
|
||||
"amount": 160.0,
|
||||
"filled": 160.0,
|
||||
"safe_price": 0.52,
|
||||
"ft_order_side": "buy" if is_short else "sell",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=9)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": False,
|
||||
},
|
||||
],
|
||||
# Trade 4: LTC/USDT - entry at 100, exit at 105
|
||||
[
|
||||
{
|
||||
"amount": 1.2, # 120 / 100
|
||||
"filled": 1.2,
|
||||
"safe_price": 100.0,
|
||||
"ft_order_side": "sell" if is_short else "buy",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=8)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": True,
|
||||
},
|
||||
{
|
||||
"amount": 1.2,
|
||||
"filled": 1.2,
|
||||
"safe_price": 105.0,
|
||||
"ft_order_side": "buy" if is_short else "sell",
|
||||
"order_filled_timestamp": int(
|
||||
(base_date + timedelta(hours=12)).timestamp() * 1000
|
||||
),
|
||||
"ft_is_entry": False,
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
trades_df = DataFrame(trades_data)
|
||||
pairlist = ["BTC/USDT", "ETH/USDT", "XRP/USDT", "LTC/USDT"]
|
||||
|
||||
min_date = start_date
|
||||
max_date = start_date + timedelta(hours=35)
|
||||
|
||||
result = balance_distribution_over_time(
|
||||
trades=trades_df,
|
||||
min_date=min_date,
|
||||
max_date=max_date,
|
||||
timeframe="1h",
|
||||
stake_currency=stake_currency,
|
||||
start_balance=start_balance,
|
||||
pairlist=pairlist,
|
||||
)
|
||||
|
||||
# Verify basic structure
|
||||
assert isinstance(result, DataFrame)
|
||||
assert stake_currency in result.columns
|
||||
for pair in pairlist:
|
||||
assert pair in result.columns
|
||||
assert f"{pair}_leverage" in result.columns
|
||||
assert f"{pair}_is_short" in result.columns
|
||||
assert f"{pair}_collateral" in result.columns
|
||||
|
||||
# Verify the index is a DatetimeIndex
|
||||
assert isinstance(result.index, Timestamp.__class__.__bases__[0])
|
||||
|
||||
# Verify we have entries over the full time period (36h)
|
||||
assert len(result) == 36
|
||||
|
||||
# First trade opens 15h after the start date
|
||||
assert result.iloc[0][stake_currency] == 1000
|
||||
expected_first_balance = start_balance - (100.0 + 100.0 * fee)
|
||||
assert result.iloc[15][stake_currency] == pytest.approx(expected_first_balance)
|
||||
|
||||
# Check that pair columns have non-zero values during trade periods
|
||||
# Trade 1 (BTC/USDT) is open from hour 15 to hour 18
|
||||
# At hour 16, BTC/USDT should have position
|
||||
btc_during_trade = result.loc[base_date + timedelta(hours=1), "BTC/USDT"]
|
||||
assert btc_during_trade > 0, "Trade should have positive position during open period"
|
||||
|
||||
# After Trade 1 closes at hour 3, BTC/USDT position should be 0
|
||||
btc_after_close = result.loc[base_date + timedelta(hours=4) :, "BTC/USDT"]
|
||||
assert all(btc_after_close == 0), "Position should be 0 after trade closes"
|
||||
|
||||
# Final stake currency should reflect all trades' cash flows minus fees
|
||||
final_balance = result.iloc[-1][stake_currency]
|
||||
|
||||
# Verify the balance changed (trades had effect)
|
||||
assert final_balance != start_balance, "Balance should change after trading"
|
||||
|
||||
# Since all exit prices > entry prices, exits return more cash than entries spent
|
||||
# This means final balance > start balance for long trades and < start balance for short trades
|
||||
assert (final_balance > start_balance) if not is_short else (final_balance < start_balance), (
|
||||
"Balance increases for long and decreases for short trades"
|
||||
)
|
||||
@@ -69,7 +69,7 @@ def make_response_from_url(start_date, end_date):
|
||||
"taker_buy_quote_volume,ignore"
|
||||
)
|
||||
df = pd.DataFrame(columns=cols.split(","), dtype=float)
|
||||
df["open_time"] = date_col.astype("int64") // 10**6
|
||||
df["open_time"] = date_col.as_unit("ms").astype("int64")
|
||||
df["open"] = df["high"] = df["low"] = df["close"] = df["volume"] = 1.0
|
||||
return df
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, PropertyMock
|
||||
import pytest
|
||||
|
||||
from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode
|
||||
from freqtrade.exceptions import OperationalException, RetryableOrderError
|
||||
from freqtrade.exceptions import InvalidOrderException, OperationalException, RetryableOrderError
|
||||
from freqtrade.exchange.common import API_RETRY_COUNT
|
||||
from freqtrade.util import dt_now, dt_ts, dt_utc
|
||||
from tests.conftest import EXMS, get_patched_exchange
|
||||
@@ -77,6 +77,41 @@ def test_fetch_stoploss_order_bitget_exceptions(default_conf_usdt, mocker):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_persistence")
|
||||
def test_cancel_stoploss_order_bitget(default_conf_usdt, mocker):
|
||||
default_conf_usdt["dry_run"] = False
|
||||
api_mock = MagicMock()
|
||||
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="bitget")
|
||||
|
||||
# Spot scenario
|
||||
exchange.cancel_order = MagicMock(return_value={"id": "1234"})
|
||||
assert exchange.cancel_stoploss_order("1234", "ETH/USDT", {}) == {"id": "1234"}
|
||||
assert exchange.cancel_order.call_count == 1
|
||||
exchange.cancel_order.assert_called_once_with("1234", "ETH/USDT", {"stop": True})
|
||||
|
||||
# Futures scenario
|
||||
default_conf_usdt["trading_mode"] = TradingMode.FUTURES
|
||||
default_conf_usdt["margin_mode"] = MarginMode.ISOLATED
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="bitget")
|
||||
exchange.cancel_order = MagicMock(return_value={"id": "1234"})
|
||||
assert exchange.cancel_stoploss_order("1234", "ETH/USDT:USDT", {}) == {"id": "1234"}
|
||||
assert exchange.cancel_order.call_count == 1
|
||||
exchange.cancel_order.assert_called_once_with(
|
||||
"1234", "ETH/USDT:USDT", {"stop": True, "planType": "pos_loss"}
|
||||
)
|
||||
|
||||
exchange.cancel_order = MagicMock(
|
||||
side_effect=[InvalidOrderException("API error"), {"id": "1234"}]
|
||||
)
|
||||
assert exchange.cancel_stoploss_order("1234", "ETH/USDT:USDT", {}) == {"id": "1234"}
|
||||
assert exchange.cancel_order.call_count == 2
|
||||
exchange.cancel_order.assert_any_call(
|
||||
"1234", "ETH/USDT:USDT", {"stop": True, "planType": "pos_loss"}
|
||||
)
|
||||
exchange.cancel_order.assert_any_call("1234", "ETH/USDT:USDT", {"stop": True})
|
||||
|
||||
|
||||
def test_bitget_ohlcv_candle_limit(mocker, default_conf_usdt):
|
||||
# This test is also a live test - so we're sure our limits are correct.
|
||||
api_mock = MagicMock()
|
||||
@@ -183,7 +218,7 @@ def test__lev_prep_bitget(default_conf, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange="bitget")
|
||||
exchange._lev_prep("BTC/USDC:USDC", 3.2, "buy")
|
||||
|
||||
assert api_mock.set_margin_mode.call_count == 0
|
||||
assert api_mock.set_margin_mode.call_count == 1
|
||||
assert api_mock.set_leverage.call_count == 1
|
||||
api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=3.2)
|
||||
|
||||
@@ -191,7 +226,7 @@ def test__lev_prep_bitget(default_conf, mocker):
|
||||
|
||||
exchange._lev_prep("BTC/USDC:USDC", 19.99, "sell")
|
||||
|
||||
assert api_mock.set_margin_mode.call_count == 0
|
||||
assert api_mock.set_margin_mode.call_count == 1
|
||||
assert api_mock.set_leverage.call_count == 1
|
||||
api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=19.99)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user