Merge branch 'fix-bitget-stoploss' of https://github.com/ABSllk/freqtrade into fix-bitget-stoploss

This commit is contained in:
ABS
2026-04-17 00:18:59 +08:00
34 changed files with 2408 additions and 2008 deletions
@@ -2,7 +2,7 @@ name: Binance Leverage tiers update
on:
schedule:
- cron: "25 3 * * 4"
- cron: "25 2 * * 4"
# on demand
workflow_dispatch:
@@ -24,11 +24,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.14"
- name: Install uv
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
+7 -31
View File
@@ -32,12 +32,7 @@ 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
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
@@ -177,12 +172,7 @@ 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
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
@@ -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,12 +210,7 @@ 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
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
@@ -256,12 +242,7 @@ 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
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
@@ -328,12 +309,7 @@ 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
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
+1 -6
View File
@@ -26,12 +26,7 @@ jobs:
with:
persist-credentials: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.13'
- name: Install uv
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
+1 -1
View File
@@ -31,7 +31,7 @@ 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 }}
+3 -3
View File
@@ -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 }}
+1 -5
View File
@@ -25,11 +25,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
- name: Install uv and Python 🐍
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
activate-environment: true
+4 -4
View File
@@ -15,7 +15,7 @@ repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.20.0"
rev: "v1.20.1"
hooks:
- id: mypy
exclude: build_helpers
@@ -26,12 +26,12 @@ repos:
- types-tabulate==0.10.0.20260308
- types-python-dateutil==2.9.0.20260402
- scipy-stubs==1.17.1.3
- SQLAlchemy==2.0.48
- SQLAlchemy==2.0.49
# stages: [push]
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.15.9'
rev: 'v0.15.10'
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
+203 -170
View File
@@ -160,118 +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.669 USDT │
│ Absolute profit │ 54.669 USDT │
│ Total profit % │ 5.47% │
│ CAGR % │ 87.14% │
│ Sortino 2.46
│ Sharpe 3.73
│ Calmar │ 40.81
│ SQN │ 0.69
│ Profit factor │ 1.29
│ Expectancy (Ratio) │ 0.71 (0.04) │
│ Avg. daily profit │ 1.764 USDT │
│ Avg. stake amount │ 345.251 USDT │
Total trade volume53352.96 USDT
Long / Short trades67 / 10
│ Long / Short profit % │ 8.93% / -3.46%
│ Long / Short profit USDT89.262 / -34.593
Best PairLTC/USDT:USDT 5.62%
Worst Pair │ ADA/USDT:USDT -5.21%
Best tradeETC/USDT:USDT 2.00%
Worst trade │ ADA/USDT:USDT -10.17%
Best day26.931 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 Timeouts0 / 0
Min/Max balance realized │ 1003.168 USDT / 1149.577 USDT
│ Min/Max balance unrealized │ 1000 USDT / 1149.577 USDT
│ Min/Max balance dates │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00
Max % of account underwater │ 8.26%
Absolute drawdown │ 94.908 USDT (8.26%)
Drawdown duration │ 9 days 08:50:00
│ Profit at drawdown start149.577 USDT │
Profit at drawdown end │ 54.669 USDT
│ Drawdown start │ 2025-07-22 15:10:00 │
Drawdown end │ 2025-08-01 00:00:00
Market change30.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.7331.513 │ 3.15 │ 9:55:00 │ 11 0 1 91.7 │
│ ETH/USDT:USDT │ 8 │ 0.6918.659 │ 1.87 │ 1 day, 13:55:00 │ 7 0 1 87.5 │
│ 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.2357.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.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │
│ TOTAL │ 77 │ 0.2357.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.06245.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.2357.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.06245.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.2357.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 volume53390.788 USDT
│ Long / Short trades │ 67 / 10
│ Long / Short profit % │ 9.19% / -3.48%
Long / Short profit USDT91.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 Timeouts0 / 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.2357.157 │ 5.72 │ 22:12:00 │ 67 0 10 87.0 │ 94.268 8.19% │
└────────────────┴────────┴──────────────┴─────────────┴──────────────┴──────────────┴────────────────────────┴────────────────┘
```
### Backtesting report table
@@ -330,59 +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 Trades77 / 2.48
Starting balance1000 USDT
Final balance │ 1054.669 USDT │
Absolute profit54.669 USDT
Total profit % │ 5.47%
CAGR % │ 87.14%
Sortino │ 2.46
│ Sharpe │ 3.73
Calmar 40.81
SQN 0.69
Profit factor1.29
Expectancy (Ratio)0.71 (0.04)
Avg. daily profit1.764 USDT
│ Avg. stake amount │ 345.251 USDT │
Total trade volume53352.96 USDT │
Long / Short trades │ 67 / 10
Long / Short profit % │ 8.93% / -3.46%
│ Long / Short profit USDT │ 89.262 / -34.593
Best Pair │ LTC/USDT:USDT 5.62%
Worst PairADA/USDT:USDT -5.21%
│ Best tradeETC/USDT:USDT 2.00% │
│ Worst trade │ ADA/USDT:USDT -10.17%
│ Best day26.931 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 Timeouts0 / 0
Min/Max balance realized │ 1003.168 USDT / 1149.577 USDT
Min/Max balance unrealized │ 1000 USDT / 1149.577 USDT
│ Min/Max balance dates 2025-07-01 00:05:00 / 2025-07-22 15:15:00
│ Max % of account underwater │ 8.26% │
│ Absolute drawdown │ 94.908 USDT (8.26%) │
│ Drawdown duration │ 9 days 08:50:00 │
│ Profit at drawdown start │ 149.577 USDT │
│ Profit at drawdown end │ 54.669 USDT │
│ Drawdown start │ 2025-07-22 15:10:00 │
│ Drawdown end │ 2025-08-01 00:00:00 │
Market change30.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 trades3
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 change30.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 signals258
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).
@@ -394,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).
@@ -415,15 +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/Max balance realized`: Lowest and Highest Wallet balance during the backtest period based on closed trades trades.
- `Min/Max balance unrealized`: Lowest and Highest Wallet balance during the backtest period - including capital tied in open trades.
- `Min/Max balance dates`: Dates when the minimum and maximum unrealized balance occurred.
- `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
+4 -4
View File
@@ -111,10 +111,10 @@ It also allows multiple subplots to display both MACD and RSI at the same time.
Plot type can be configured using `type` key. Possible types are:
* `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
+2 -3
View File
@@ -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,7 +307,7 @@ 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
@@ -326,7 +325,7 @@ def get_backtest_wallet_change(filename: Path, strategy_name: str) -> pd.DataFra
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"].astype(np.int64) // 1000 // 1000
df.loc[:, "__date_ts"] = df.loc[:, "date"].dt.as_unit("ms").astype("int64")
return df
except ValueError:
pass
@@ -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(
+195 -24
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+30 -24
View File
@@ -101,30 +101,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)
+1 -1
View File
@@ -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")
+3 -3
View File
@@ -435,7 +435,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 +879,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 +905,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:
+3 -3
View File
@@ -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"][
@@ -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)
@@ -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(
@@ -289,25 +293,77 @@ def text_table_add_metrics(strat_results: dict) -> None:
)
wallet_metrics: list[tuple[str, str]] = [
(
"Min/Max balance realized",
"Min/Max balance (closed trades)",
f"{fmt_coin(strat_results['csum_min'], stake)} / "
f"{fmt_coin(strat_results['csum_max'], stake)}",
),
]
if wallet_stats := strat_results.get("wallet_stats"):
wallet_metrics.extend(
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 unrealized",
"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",
"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
@@ -317,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']}",
@@ -336,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",
@@ -367,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']} "
@@ -428,10 +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
__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")
@@ -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 (
@@ -59,15 +63,61 @@ def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str
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,
}
@@ -290,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"
@@ -480,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)
+1 -1
View File
@@ -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"
),
+6 -4
View File
@@ -11,7 +11,7 @@ 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 numpy import inf, isnan, mean, nan
from pandas import DataFrame, NaT, read_sql
from sqlalchemy import func, select
@@ -794,7 +794,7 @@ class RPC:
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"].astype("int64") // 1000 // 1000
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"]]
@@ -1536,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:
@@ -1546,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`
+2
View File
@@ -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",
+7
View File
@@ -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
View File
@@ -222,7 +222,6 @@ exclude-newer = "1 week"
[tool.uv.exclude-newer-package]
ccxt = false
cryptography = "1 days"
[tool.ruff]
line-length = 100
+2 -2
View File
@@ -6,10 +6,10 @@
-r requirements-freqai-rl.txt
-r docs/requirements-docs.txt
ruff==0.15.8
ruff==0.15.9
mypy==1.20.0
pre-commit==4.5.1
pytest==9.0.2
pytest==9.0.3
pytest-asyncio==1.3.0
pytest-cov==7.1.0
pytest-mock==3.15.1
+4 -4
View File
@@ -5,12 +5,12 @@ 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.47
ccxt==4.5.48
cryptography==46.0.7
aiohttp==3.13.5
SQLAlchemy==2.0.48
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
@@ -39,7 +39,7 @@ sdnotify==0.3.2
# API Server
fastapi==0.135.3
pydantic==2.12.5
uvicorn==0.42.0
uvicorn==0.43.0
pyjwt==2.12.1
aiofiles==25.1.0
psutil==7.2.2
+1 -1
View File
@@ -207,7 +207,7 @@ def generate_test_data(
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))
+173 -1
View File
@@ -1,7 +1,8 @@
from datetime import UTC, datetime, timedelta
import numpy as np
import pytest
from pandas import DataFrame, DateOffset, Timestamp
from pandas import DataFrame, DateOffset, Timestamp, to_datetime
from freqtrade.configuration import TimeRange
from freqtrade.data.btanalysis import (
@@ -11,12 +12,16 @@ 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,
@@ -142,6 +147,48 @@ def test_calculate_max_drawdown(testdatadir):
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)
@@ -200,6 +247,53 @@ def test_calculate_sortino(testdatadir):
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)
@@ -217,6 +311,45 @@ def test_calculate_sharpe(testdatadir):
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)
@@ -234,6 +367,45 @@ def test_calculate_calmar(testdatadir):
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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -287,7 +287,7 @@ class TestCCXTExchange:
# Check if last-timeframe is within the last 2 intervals
now = datetime.now(UTC) - timedelta(minutes=(timeframe_to_minutes(timeframe) * 2))
assert exch.klines(pair_tf).iloc[-1]["date"] >= timeframe_to_prev_date(timeframe, now)
assert exch.klines(pair_tf)["date"].astype(int).iloc[0] // 1e6 == since_ms
assert exch.klines(pair_tf)["date"].dt.as_unit("ms").astype("int64").iloc[0] == since_ms
def _ccxt__async_get_candle_history(
self, exchange, pair: str, timeframe: str, candle_type: CandleType, factor: float = 0.9
+60 -1
View File
@@ -28,6 +28,7 @@ from freqtrade.optimize.optimize_reports import (
generate_trading_stats,
show_sorted_pairlist,
store_backtest_results,
text_table_add_metrics,
text_table_bt_results,
text_table_strategy,
)
@@ -36,6 +37,7 @@ from freqtrade.optimize.optimize_reports.optimize_reports import (
_get_resample_from_period,
calc_streak,
generate_tag_metrics,
generate_wallet_stats,
)
from freqtrade.resolvers.strategy_resolver import StrategyResolver
from freqtrade.util import dt_ts, format_duration
@@ -616,6 +618,63 @@ def test_text_table_strategy(testdatadir, capsys):
)
def test_generate_wallet_stats_extended_metrics():
wallet_df = pd.DataFrame(
{
"date": [
dt_utc(2025, 1, 1, 0, 0, 0),
dt_utc(2025, 1, 1, 12, 0, 0),
dt_utc(2025, 1, 1, 18, 0, 0),
dt_utc(2025, 1, 3, 0, 0, 0),
],
"currency": ["BTC", "BTC", "BTC", "BTC"],
"rate": [1.0, 1.0, 1.0, 1.0],
"balance": [100.0, 120.0, 80.0, 110.0],
}
)
stats = generate_wallet_stats(wallet_df, "BTC")
assert "sharpe" in stats
assert "sortino" in stats
assert "calmar" in stats
assert "max_drawdown_account" in stats
assert "max_drawdown_abs" in stats
assert pytest.approx(stats["max_drawdown_account"]) == 1 / 3
assert stats["drawdown_start"] == "2025-01-01 12:00:00"
assert stats["drawdown_end"] == "2025-01-01 18:00:00"
def test_text_table_add_metrics_shows_wallet_ratios(testdatadir, capsys):
filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_stats(filename)
strat_results = next(iter(bt_data["strategy"].values()))
strat_results["wallet_stats"] = {
"low_balance": 0.95,
"high_balance": 1.12,
"low_date": "2025-01-01 18:00:00",
"high_date": "2025-01-01 12:00:00",
"sharpe": 1.23,
"sortino": 2.34,
"calmar": 3.45,
"max_drawdown_account": 0.12,
"max_relative_drawdown": 0.15,
"max_drawdown_abs": 0.05,
"drawdown_start": "2025-01-01 12:00:00",
"drawdown_end": "2025-01-01 18:00:00",
"max_drawdown_high": 1.12,
"max_drawdown_low": 0.95,
}
text_table_add_metrics(strat_results)
text = capsys.readouterr().out
assert "Sharpe (daily wallet balance)" in text
assert "Sortino (daily wallet balance)" in text
assert "Calmar (daily wallet balance)" in text
assert "Max % of account underwater (balance)" in text
def test_generate_periodic_breakdown_stats(testdatadir):
filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_data(filename).to_dict(orient="records")
@@ -654,7 +713,7 @@ def test_generate_periodic_breakdown_stats(testdatadir):
def test__get_resample_from_period():
assert _get_resample_from_period("day") == "1d"
assert _get_resample_from_period("day") == "1D"
assert _get_resample_from_period("week") == "1W-MON"
assert _get_resample_from_period("month") == "1ME"
assert _get_resample_from_period("weekday") == "weekday"
+7 -4
View File
@@ -22,6 +22,7 @@ from freqtrade.strategy.parameters import (
)
from freqtrade.strategy.strategy_validation import StrategyResultValidator
from freqtrade.util import dt_now
from freqtrade.util.datetime_helpers import dt_now_no_micro
from tests.conftest import CURRENT_TEST_STRATEGY, TRADE_SIDES, log_has, log_has_re
from .strats.strategy_test_v3 import StrategyTestV3
@@ -33,7 +34,7 @@ _STRATEGY.dp = DataProvider({}, None, None)
def test_returns_latest_signal(ohlcv_history):
ohlcv_history.loc[1, "date"] = dt_now()
ohlcv_history.loc[1, "date"] = dt_now_no_micro()
# Take a copy to correctly modify the call
mocked_history = ohlcv_history.copy()
mocked_history["enter_long"] = 0
@@ -160,7 +161,7 @@ def test_get_signal_exception_valueerror(mocker, caplog, ohlcv_history):
def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
# default_conf defines a 5m interval. we check interval * 2 + 5m
# this is necessary as the last candle is removed (partial candles) by default
ohlcv_history.loc[1, "date"] = dt_now() - timedelta(minutes=16)
ohlcv_history.loc[1, "date"] = dt_now_no_micro() - timedelta(minutes=16)
# Take a copy to correctly modify the call
mocked_history = ohlcv_history.copy()
mocked_history["exit_long"] = 0
@@ -179,7 +180,7 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
def test_get_signal_no_sell_column(default_conf, mocker, caplog, ohlcv_history):
# default_conf defines a 5m interval. we check interval * 2 + 5m
# this is necessary as the last candle is removed (partial candles) by default
ohlcv_history.loc[1, "date"] = dt_now()
ohlcv_history.loc[1, "date"] = dt_now_no_micro()
# Take a copy to correctly modify the call
mocked_history = ohlcv_history.copy()
# Intentionally don't set sell column
@@ -223,7 +224,7 @@ def test_ignore_expired_candle(default_conf):
def test_assert_df_raise(mocker, caplog, ohlcv_history):
ohlcv_history.loc[1, "date"] = dt_now() - timedelta(minutes=16)
ohlcv_history.loc[1, "date"] = dt_now_no_micro() - timedelta(minutes=16)
# Take a copy to correctly modify the call
mocked_history = ohlcv_history.copy()
mocked_history["sell"] = 0
@@ -1040,6 +1041,7 @@ def test_auto_hyperopt_interface_loadparams(default_conf, mocker, caplog):
],
)
def test_pandas_warning_direct(ohlcv_history, function, raises, recwarn):
recwarn.clear()
df = _STRATEGY.populate_indicators(ohlcv_history, {"pair": "ETH/BTC"})
if raises:
assert len(recwarn) == 1
@@ -1053,6 +1055,7 @@ def test_pandas_warning_direct(ohlcv_history, function, raises, recwarn):
def test_pandas_warning_through_analyze_pair(ohlcv_history, mocker, recwarn):
recwarn.clear()
mocker.patch.object(_STRATEGY.dp, "ohlcv", return_value=ohlcv_history)
_STRATEGY.analyze_pair("ETH/BTC")
assert len(recwarn) == 0, f"warnings: {', '.join(str(w) for w in recwarn.list)}"
+6 -2
View File
@@ -6,7 +6,9 @@ import time_machine
from freqtrade.util import (
dt_floor_day,
dt_from_ts,
dt_humanize_delta,
dt_now,
dt_now_no_micro,
dt_ts,
dt_ts_def,
dt_ts_none,
@@ -16,15 +18,17 @@ from freqtrade.util import (
format_ms_time_det,
shorten_date,
)
from freqtrade.util.datetime_helpers import dt_humanize_delta
def test_dt_now():
with time_machine.travel("2021-09-01 05:01:00 +00:00", tick=False) as t:
with time_machine.travel("2021-09-01 05:01:00.123 +00:00", tick=False) as t:
now = datetime.now(UTC)
assert dt_now() == now
assert dt_ts() == int(now.timestamp() * 1000)
assert dt_ts(now) == int(now.timestamp() * 1000)
assert dt_now().microsecond != 0.0
assert dt_now_no_micro().microsecond == 0.0
assert dt_now_no_micro() == now.replace(microsecond=0)
t.shift(timedelta(hours=5))
assert dt_now() >= now