Merge pull request #6893 from freqtrade/new_release

New release 2022.5
This commit is contained in:
Matthias 2022-05-28 13:46:24 +02:00 committed by GitHub
commit eed0d67005
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 12725 additions and 10321 deletions

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [ ubuntu-18.04, ubuntu-20.04 ] os: [ ubuntu-18.04, ubuntu-20.04, ubuntu-22.04 ]
python-version: ["3.8", "3.9", "3.10"] python-version: ["3.8", "3.9", "3.10"]
steps: steps:
@ -70,7 +70,7 @@ jobs:
if: matrix.python-version == '3.9' if: matrix.python-version == '3.9'
- name: Coveralls - name: Coveralls
if: (runner.os == 'Linux' && matrix.python-version == '3.8') if: (runner.os == 'Linux' && matrix.python-version == '3.9')
env: env:
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories # Coveralls token. Not used as secret due to github not providing secrets to forked repositories
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
@ -78,11 +78,13 @@ jobs:
# Allow failure for coveralls # Allow failure for coveralls
coveralls || true coveralls || true
- name: Backtesting - name: Backtesting (multi)
run: | run: |
cp config_examples/config_bittrex.example.json config.json cp config_examples/config_bittrex.example.json config.json
freqtrade create-userdir --userdir user_data freqtrade create-userdir --userdir user_data
freqtrade backtesting --datadir tests/testdata --strategy SampleStrategy freqtrade new-strategy -s AwesomeStrategy
freqtrade new-strategy -s AwesomeStrategyMin --template minimal
freqtrade backtesting --datadir tests/testdata --strategy-list AwesomeStrategy AwesomeStrategyMin -i 5m
- name: Hyperopt - name: Hyperopt
run: | run: |
@ -157,29 +159,15 @@ jobs:
pip install -e . pip install -e .
- name: Tests - name: Tests
if: (runner.os != 'Linux' || matrix.python-version != '3.8')
run: | run: |
pytest --random-order pytest --random-order
- name: Tests (with cov)
if: (runner.os == 'Linux' && matrix.python-version == '3.8')
run: |
pytest --random-order --cov=freqtrade --cov-config=.coveragerc
- name: Coveralls
if: (runner.os == 'Linux' && matrix.python-version == '3.8')
env:
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
run: |
# Allow failure for coveralls
coveralls -v || true
- name: Backtesting - name: Backtesting
run: | run: |
cp config_examples/config_bittrex.example.json config.json cp config_examples/config_bittrex.example.json config.json
freqtrade create-userdir --userdir user_data freqtrade create-userdir --userdir user_data
freqtrade backtesting --datadir tests/testdata --strategy SampleStrategy freqtrade new-strategy -s AwesomeStrategyAdv --template advanced
freqtrade backtesting --datadir tests/testdata --strategy AwesomeStrategyAdv
- name: Hyperopt - name: Hyperopt
run: | run: |
@ -273,7 +261,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: 3.9 python-version: "3.10"
- name: pre-commit dependencies - name: pre-commit dependencies
run: | run: |
@ -292,7 +280,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: 3.9 python-version: "3.10"
- name: Documentation build - name: Documentation build
run: | run: |
@ -358,7 +346,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: 3.8 python-version: "3.9"
- name: Extract branch name - name: Extract branch name
shell: bash shell: bash
@ -419,7 +407,7 @@ jobs:
- name: Discord notification - name: Discord notification
uses: rjstone/discord-webhook-notify@v1 uses: rjstone/discord-webhook-notify@v1
if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) && (github.event_name != 'schedule')
with: with:
severity: info severity: info
details: Deploy Succeeded! details: Deploy Succeeded!

View File

@ -14,10 +14,10 @@ repos:
exclude: build_helpers exclude: build_helpers
additional_dependencies: additional_dependencies:
- types-cachetools==5.0.1 - types-cachetools==5.0.1
- types-filelock==3.2.5 - types-filelock==3.2.6
- types-requests==2.27.20 - types-requests==2.27.27
- types-tabulate==0.8.7 - types-tabulate==0.8.9
- types-python-dateutil==2.8.12 - types-python-dateutil==2.8.16
# stages: [push] # stages: [push]
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort

View File

@ -1,4 +1,4 @@
FROM python:3.9.9-slim-bullseye as base FROM python:3.10.4-slim-bullseye as base
# Setup env # Setup env
ENV LANG C.UTF-8 ENV LANG C.UTF-8

View File

@ -9,10 +9,6 @@ Freqtrade is a free and open source crypto trading bot written in Python. It is
![freqtrade](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/freqtrade-screenshot.png) ![freqtrade](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/freqtrade-screenshot.png)
## Sponsored promotion
[![tokenbot-promo](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/TokenBot-Freqtrade-banner.png)](https://tokenbot.com/?utm_source=github&utm_medium=freqtrade&utm_campaign=algodevs)
## Disclaimer ## Disclaimer
This software is for educational purposes only. Do not risk money which This software is for educational purposes only. Do not risk money which
@ -39,7 +35,7 @@ Please read the [exchange specific notes](docs/exchanges.md) to learn about even
- [X] [OKX](https://okx.com/) (Former OKEX) - [X] [OKX](https://okx.com/) (Former OKEX)
- [ ] [potentially many others](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_ - [ ] [potentially many others](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
### Experimentally, freqtrade also supports futures on the following exchanges ### Supported Futures Exchanges (experimental)
- [X] [Binance](https://www.binance.com/) - [X] [Binance](https://www.binance.com/)
- [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Gate.io](https://www.gate.io/ref/6266643)

View File

@ -1,4 +1,4 @@
FROM python:3.9.9-slim-bullseye as base FROM python:3.9.12-slim-bullseye as base
# Setup env # Setup env
ENV LANG C.UTF-8 ENV LANG C.UTF-8

View File

@ -287,45 +287,54 @@ A backtesting result will look like that:
| ADA/BTC | 1 | 0.89 | 0.89 | 0.00004434 | 0.44 | 6:00:00 | 1 0 0 100 | | ADA/BTC | 1 | 0.89 | 0.89 | 0.00004434 | 0.44 | 6:00:00 | 1 0 0 100 |
| LTC/BTC | 1 | 0.68 | 0.68 | 0.00003421 | 0.34 | 2:00:00 | 1 0 0 100 | | LTC/BTC | 1 | 0.68 | 0.68 | 0.00003421 | 0.34 | 2:00:00 | 1 0 0 100 |
| TOTAL | 2 | 0.78 | 1.57 | 0.00007855 | 0.78 | 4:00:00 | 2 0 0 100 | | TOTAL | 2 | 0.78 | 1.57 | 0.00007855 | 0.78 | 4:00:00 | 2 0 0 100 |
================ SUMMARY METRICS =============== ================== SUMMARY METRICS ==================
| Metric | Value | | Metric | Value |
|------------------------+---------------------| |-----------------------------+---------------------|
| Backtesting from | 2019-01-01 00:00:00 | | Backtesting from | 2019-01-01 00:00:00 |
| Backtesting to | 2019-05-01 00:00:00 | | Backtesting to | 2019-05-01 00:00:00 |
| Max open trades | 3 | | Max open trades | 3 |
| | | | | |
| Total/Daily Avg Trades | 429 / 3.575 | | Total/Daily Avg Trades | 429 / 3.575 |
| Starting balance | 0.01000000 BTC | | Starting balance | 0.01000000 BTC |
| Final balance | 0.01762792 BTC | | Final balance | 0.01762792 BTC |
| Absolute profit | 0.00762792 BTC | | Absolute profit | 0.00762792 BTC |
| Total profit % | 76.2% | | Total profit % | 76.2% |
| CAGR % | 460.87% | | CAGR % | 460.87% |
| Trades per day | 3.575 | | Avg. stake amount | 0.001 BTC |
| Avg. stake amount | 0.001 BTC | | Total trade volume | 0.429 BTC |
| Total trade volume | 0.429 BTC | | | |
| | | | Long / Short | 352 / 77 |
| Best Pair | LSK/BTC 26.26% | | Total profit Long % | 1250.58% |
| Worst Pair | ZEC/BTC -10.18% | | Total profit Short % | -15.02% |
| Best Trade | LSK/BTC 4.25% | | Absolute profit Long | 0.00838792 BTC |
| Worst Trade | ZEC/BTC -10.25% | | Absolute profit Short | -0.00076 BTC |
| Best day | 0.00076 BTC | | | |
| Worst day | -0.00036 BTC | | Best Pair | LSK/BTC 26.26% |
| Days win/draw/lose | 12 / 82 / 25 | | Worst Pair | ZEC/BTC -10.18% |
| Avg. Duration Winners | 4:23:00 | | Best Trade | LSK/BTC 4.25% |
| Avg. Duration Loser | 6:55:00 | | Worst Trade | ZEC/BTC -10.25% |
| Rejected Entry signals | 3089 | | Best day | 0.00076 BTC |
| Entry/Exit Timeouts | 0 / 0 | | Worst day | -0.00036 BTC |
| | | | Days win/draw/lose | 12 / 82 / 25 |
| Min balance | 0.00945123 BTC | | Avg. Duration Winners | 4:23:00 |
| Max balance | 0.01846651 BTC | | Avg. Duration Loser | 6:55:00 |
| Drawdown (Account) | 13.33% | | Rejected Entry signals | 3089 |
| Drawdown | 0.0015 BTC | | Entry/Exit Timeouts | 0 / 0 |
| Drawdown high | 0.0013 BTC | | Canceled Trade Entries | 34 |
| Drawdown low | -0.0002 BTC | | Canceled Entry Orders | 123 |
| Drawdown Start | 2019-02-15 14:10:00 | | Replaced Entry Orders | 89 |
| Drawdown End | 2019-04-11 18:15:00 | | | |
| Market change | -5.88% | | Min balance | 0.00945123 BTC |
=============================================== | Max balance | 0.01846651 BTC |
| Max % of account underwater | 25.19% |
| Absolute Drawdown (Account) | 13.33% |
| Drawdown | 0.0015 BTC |
| Drawdown high | 0.0013 BTC |
| Drawdown low | -0.0002 BTC |
| Drawdown Start | 2019-02-15 14:10:00 |
| Drawdown End | 2019-04-11 18:15:00 |
| Market change | -5.88% |
=====================================================
``` ```
### Backtesting report table ### Backtesting report table
@ -377,50 +386,54 @@ The last element of the backtest report is the summary metrics table.
It contains some useful key metrics about performance of your strategy on backtesting data. It contains some useful key metrics about performance of your strategy on backtesting data.
``` ```
================ SUMMARY METRICS =============== ================== SUMMARY METRICS ==================
| Metric | Value | | Metric | Value |
|------------------------+---------------------| |-----------------------------+---------------------|
| Backtesting from | 2019-01-01 00:00:00 | | Backtesting from | 2019-01-01 00:00:00 |
| Backtesting to | 2019-05-01 00:00:00 | | Backtesting to | 2019-05-01 00:00:00 |
| Max open trades | 3 | | Max open trades | 3 |
| | | | | |
| Total/Daily Avg Trades | 429 / 3.575 | | Total/Daily Avg Trades | 429 / 3.575 |
| Starting balance | 0.01000000 BTC | | Starting balance | 0.01000000 BTC |
| Final balance | 0.01762792 BTC | | Final balance | 0.01762792 BTC |
| Absolute profit | 0.00762792 BTC | | Absolute profit | 0.00762792 BTC |
| Total profit % | 76.2% | | Total profit % | 76.2% |
| CAGR % | 460.87% | | CAGR % | 460.87% |
| Avg. stake amount | 0.001 BTC | | Avg. stake amount | 0.001 BTC |
| Total trade volume | 0.429 BTC | | Total trade volume | 0.429 BTC |
| | | | | |
| Long / Short | 352 / 77 | | Long / Short | 352 / 77 |
| Total profit Long % | 1250.58% | | Total profit Long % | 1250.58% |
| Total profit Short % | -15.02% | | Total profit Short % | -15.02% |
| Absolute profit Long | 0.00838792 BTC | | Absolute profit Long | 0.00838792 BTC |
| Absolute profit Short | -0.00076 BTC | | Absolute profit Short | -0.00076 BTC |
| | | | | |
| Best Pair | LSK/BTC 26.26% | | Best Pair | LSK/BTC 26.26% |
| Worst Pair | ZEC/BTC -10.18% | | Worst Pair | ZEC/BTC -10.18% |
| Best Trade | LSK/BTC 4.25% | | Best Trade | LSK/BTC 4.25% |
| Worst Trade | ZEC/BTC -10.25% | | Worst Trade | ZEC/BTC -10.25% |
| Best day | 0.00076 BTC | | Best day | 0.00076 BTC |
| Worst day | -0.00036 BTC | | Worst day | -0.00036 BTC |
| Days win/draw/lose | 12 / 82 / 25 | | Days win/draw/lose | 12 / 82 / 25 |
| Avg. Duration Winners | 4:23:00 | | Avg. Duration Winners | 4:23:00 |
| Avg. Duration Loser | 6:55:00 | | Avg. Duration Loser | 6:55:00 |
| Rejected Entry signals | 3089 | | Rejected Entry signals | 3089 |
| Entry/Exit Timeouts | 0 / 0 | | Entry/Exit Timeouts | 0 / 0 |
| | | | Canceled Trade Entries | 34 |
| Min balance | 0.00945123 BTC | | Canceled Entry Orders | 123 |
| Max balance | 0.01846651 BTC | | Replaced Entry Orders | 89 |
| Drawdown (Account) | 13.33% | | | |
| Drawdown | 0.0015 BTC | | Min balance | 0.00945123 BTC |
| Drawdown high | 0.0013 BTC | | Max balance | 0.01846651 BTC |
| Drawdown low | -0.0002 BTC | | Max % of account underwater | 25.19% |
| Drawdown Start | 2019-02-15 14:10:00 | | Absolute Drawdown (Account) | 13.33% |
| Drawdown End | 2019-04-11 18:15:00 | | Drawdown | 0.0015 BTC |
| Market change | -5.88% | | Drawdown high | 0.0013 BTC |
================================================ | Drawdown low | -0.0002 BTC |
| Drawdown Start | 2019-02-15 14:10:00 |
| Drawdown End | 2019-04-11 18:15:00 |
| Market change | -5.88% |
=====================================================
``` ```
@ -440,8 +453,13 @@ It contains some useful key metrics about performance of your strategy on backte
- `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades.
- `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached. - `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached.
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
- `Canceled Trade Entries`: Number of trades that have been canceled by user request via `adjust_entry_price`.
- `Canceled Entry Orders`: Number of entry orders that have been canceled by user request via `adjust_entry_price`.
- `Replaced Entry Orders`: Number of entry orders that have been replaced by user request via `adjust_entry_price`.
- `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period. - `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
- `Drawdown (Account)`: Maximum Account Drawdown experienced. Calculated as $(Absolute Drawdown) / (DrawdownHigh + startingBalance)$. - `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 (Account)`: Maximum Account Drawdown experienced. Calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`.
- `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point. - `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point.
- `Drawdown high` / `Drawdown low`: Profit at the beginning and end of the largest drawdown period. A negative low value means initial capital lost. - `Drawdown high` / `Drawdown low`: Profit at the beginning and end of the largest drawdown period. A negative low value means initial capital lost.
- `Drawdown Start` / `Drawdown End`: Start and end datetime for this largest drawdown (can also be visualized via the `plot-dataframe` sub-command). - `Drawdown Start` / `Drawdown End`: Start and end datetime for this largest drawdown (can also be visualized via the `plot-dataframe` sub-command).
@ -457,7 +475,7 @@ You can get an overview over daily / weekly or monthly results by using the `--b
To visualize daily and weekly breakdowns, you can use the following: To visualize daily and weekly breakdowns, you can use the following:
``` bash ``` bash
freqtrade backtesting --strategy MyAwesomeStrategy --breakdown day month freqtrade backtesting --strategy MyAwesomeStrategy --breakdown day week
``` ```
``` output ``` output
@ -473,7 +491,7 @@ freqtrade backtesting --strategy MyAwesomeStrategy --breakdown day month
``` ```
The output will show a table containing the realized absolute Profit (in stake currency) for the given timeperiod, as well as wins, draws and losses that materialized (closed) on this day. The output will show a table containing the realized absolute Profit (in stake currency) for the given timeperiod, as well as wins, draws and losses that materialized (closed) on this day. Below that there will be a second table for the summarized values of weeks indicated by the date of the closing Sunday. The same would apply to a monthly breakdown indicated by the last day of the month.
### Backtest result caching ### Backtest result caching
@ -512,8 +530,9 @@ Since backtesting lacks some detailed information about what happens within a ca
- Exit-reason does not explain if a trade was positive or negative, just what triggered the exit (this can look odd if negative ROI values are used) - Exit-reason does not explain if a trade was positive or negative, just what triggered the exit (this can look odd if negative ROI values are used)
- Evaluation sequence (if multiple signals happen on the same candle) - Evaluation sequence (if multiple signals happen on the same candle)
- Exit-signal - Exit-signal
- ROI (if not stoploss)
- Stoploss - Stoploss
- ROI
- Trailing stoploss
Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode. Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode.
Also, keep in mind that past results don't guarantee future success. Also, keep in mind that past results don't guarantee future success.

View File

@ -34,6 +34,7 @@ By default, loop runs every few seconds (`internals.process_throttle_secs`) and
* Check timeouts for open orders. * Check timeouts for open orders.
* Calls `check_entry_timeout()` strategy callback for open entry orders. * Calls `check_entry_timeout()` strategy callback for open entry orders.
* Calls `check_exit_timeout()` strategy callback for open exit orders. * Calls `check_exit_timeout()` strategy callback for open exit orders.
* Calls `adjust_entry_price()` strategy callback for open entry orders.
* Verifies existing positions and eventually places exit orders. * Verifies existing positions and eventually places exit orders.
* Considers stoploss, ROI and exit-signal, `custom_exit()` and `custom_stoploss()`. * Considers stoploss, ROI and exit-signal, `custom_exit()` and `custom_stoploss()`.
* Determine exit-price based on `exit_pricing` configuration setting or by using the `custom_exit_price()` callback. * Determine exit-price based on `exit_pricing` configuration setting or by using the `custom_exit_price()` callback.
@ -58,6 +59,7 @@ This loop will be repeated again and again until the bot is stopped.
* Calculate entry / exit signals (calls `populate_entry_trend()` and `populate_exit_trend()` once per pair). * Calculate entry / exit signals (calls `populate_entry_trend()` and `populate_exit_trend()` once per pair).
* Loops per candle simulating entry and exit points. * Loops per candle simulating entry and exit points.
* Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_entry_timeout()` / `check_exit_timeout()` strategy callbacks. * Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_entry_timeout()` / `check_exit_timeout()` strategy callbacks.
* Calls `adjust_entry_price()` strategy callback for open entry orders.
* Check for trade entry signals (`enter_long` / `enter_short` columns). * Check for trade entry signals (`enter_long` / `enter_short` columns).
* Confirm trade entry / exits (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy). * Confirm trade entry / exits (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy).
* Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle). * Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle).

View File

@ -140,7 +140,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode. <br>*Defaults to `true`.* <br> **Datatype:** Boolean | `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in Dry Run mode.<br>*Defaults to `1000`.* <br> **Datatype:** Float | `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in Dry Run mode.<br>*Defaults to `1000`.* <br> **Datatype:** Float
| `cancel_open_orders_on_exit` | Cancel open orders when the `/stop` RPC command is issued, `Ctrl+C` is pressed or the bot dies unexpectedly. When set to `true`, this allows you to use `/stop` to cancel unfilled and partially filled orders in the event of a market crash. It does not impact open positions. <br>*Defaults to `false`.* <br> **Datatype:** Boolean | `cancel_open_orders_on_exit` | Cancel open orders when the `/stop` RPC command is issued, `Ctrl+C` is pressed or the bot dies unexpectedly. When set to `true`, this allows you to use `/stop` to cancel unfilled and partially filled orders in the event of a market crash. It does not impact open positions. <br>*Defaults to `false`.* <br> **Datatype:** Boolean
| `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.* <br> **Datatype:** Boolean | `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `minimal_roi` | **Required.** Set the threshold as ratio the bot will use to exit a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict | `minimal_roi` | **Required.** Set the threshold as ratio the bot will use to exit a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict
| `stoploss` | **Required.** Value as ratio of the stoploss used by the bot. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Float (as ratio) | `stoploss` | **Required.** Value as ratio of the stoploss used by the bot. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Float (as ratio)
| `trailing_stop` | Enables trailing stoploss (based on `stoploss` in either configuration or strategy file). More details in the [stoploss documentation](stoploss.md#trailing-stop-loss). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Boolean | `trailing_stop` | Enables trailing stoploss (based on `stoploss` in either configuration or strategy file). More details in the [stoploss documentation](stoploss.md#trailing-stop-loss). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Boolean
@ -230,6 +230,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `jsongz`*. <br> **Datatype:** String | `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `jsongz`*. <br> **Datatype:** String
| `position_adjustment_enable` | Enables the strategy to use position adjustments (additional buys or sells). [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.*<br> **Datatype:** Boolean | `position_adjustment_enable` | Enables the strategy to use position adjustments (additional buys or sells). [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.*<br> **Datatype:** Boolean
| `max_entry_position_adjustment` | Maximum additional order(s) for each open trade on top of the first entry Order. Set it to `-1` for unlimited additional orders. [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `-1`.*<br> **Datatype:** Positive Integer or -1 | `max_entry_position_adjustment` | Maximum additional order(s) for each open trade on top of the first entry Order. Set it to `-1` for unlimited additional orders. [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `-1`.*<br> **Datatype:** Positive Integer or -1
| `futures_funding_rate` | User-specified funding rate to be used when historical funding rates are not available from the exchange. This does not overwrite real historical rates. It is recommended that this be set to 0 unless you are testing a specific coin and you understand how the funding rate will affect freqtrade's profit calculations. [More information here](leverage.md#unavailable-funding-rates) <br>*Defaults to None.*<br> **Datatype:** Float
### Parameters in the strategy ### Parameters in the strategy
@ -583,7 +584,7 @@ Once you will be happy with your bot performance running in the Dry-run mode, yo
* Market orders fill based on orderbook volume the moment the order is placed. * Market orders fill based on orderbook volume the moment the order is placed.
* Limit orders fill once the price reaches the defined level - or time out based on `unfilledtimeout` settings. * Limit orders fill once the price reaches the defined level - or time out based on `unfilledtimeout` settings.
* In combination with `stoploss_on_exchange`, the stop_loss price is assumed to be filled. * In combination with `stoploss_on_exchange`, the stop_loss price is assumed to be filled.
* Open orders (not trades, which are stored in the database) are reset on bot restart. * Open orders (not trades, which are stored in the database) are kept open after bot restarts, with the assumption that they were not filled while being offline.
## Switch to production mode ## Switch to production mode

View File

@ -30,6 +30,7 @@ usage: freqtrade download-data [-h] [-v] [--logfile FILE] [-V] [-c PATH]
[--data-format-ohlcv {json,jsongz,hdf5}] [--data-format-ohlcv {json,jsongz,hdf5}]
[--data-format-trades {json,jsongz,hdf5}] [--data-format-trades {json,jsongz,hdf5}]
[--trading-mode {spot,margin,futures}] [--trading-mode {spot,margin,futures}]
[--prepend]
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
@ -62,6 +63,7 @@ optional arguments:
`jsongz`). `jsongz`).
--trading-mode {spot,margin,futures} --trading-mode {spot,margin,futures}
Select Trading mode Select Trading mode
--prepend Allow data prepending.
Common arguments: Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages). -v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
@ -157,10 +159,21 @@ freqtrade download-data --exchange binance --pairs .*/USDT
- To change the exchange used to download the historical data from, please use a different configuration file (you'll probably need to adjust rate limits etc.) - To change the exchange used to download the historical data from, please use a different configuration file (you'll probably need to adjust rate limits etc.)
- To use `pairs.json` from some other directory, use `--pairs-file some_other_dir/pairs.json`. - To use `pairs.json` from some other directory, use `--pairs-file some_other_dir/pairs.json`.
- To download historical candle (OHLCV) data for only 10 days, use `--days 10` (defaults to 30 days). - To download historical candle (OHLCV) data for only 10 days, use `--days 10` (defaults to 30 days).
- To download historical candle (OHLCV) data from a fixed starting point, use `--timerange 20200101-` - which will download all data from January 1st, 2020. Eventually set end dates are ignored. - To download historical candle (OHLCV) data from a fixed starting point, use `--timerange 20200101-` - which will download all data from January 1st, 2020.
- Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data. - Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data.
- To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options. - To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options.
#### Download additional data before the current timerange
Assuming you downloaded all data from 2022 (`--timerange 20220101-`) - but you'd now like to also backtest with earlier data.
You can do so by using the `--prepend` flag, combined with `--timerange` - specifying an end-date.
``` bash
freqtrade download-data --exchange binance --pairs ETH/USDT XRP/USDT BTC/USDT --prepend --timerange 20210101-20220101
```
!!! Note
Freqtrade will ignore the end-date in this mode if data is available, updating the end-date to the existing data start point.
### Data format ### Data format

View File

@ -200,11 +200,12 @@ For that reason, they must implement the following methods:
* `global_stop()` * `global_stop()`
* `stop_per_pair()`. * `stop_per_pair()`.
`global_stop()` and `stop_per_pair()` must return a ProtectionReturn tuple, which consists of: `global_stop()` and `stop_per_pair()` must return a ProtectionReturn object, which consists of:
* lock pair - boolean * lock pair - boolean
* lock until - datetime - until when should the pair be locked (will be rounded up to the next new candle) * lock until - datetime - until when should the pair be locked (will be rounded up to the next new candle)
* reason - string, used for logging and storage in the database * reason - string, used for logging and storage in the database
* lock_side - long, short or '*'.
The `until` portion should be calculated using the provided `calculate_lock_end()` method. The `until` portion should be calculated using the provided `calculate_lock_end()` method.
@ -313,6 +314,32 @@ The output will show the last entry from the Exchange as well as the current UTC
If the day shows the same day, then the last candle can be assumed as incomplete and should be dropped (leave the setting `"ohlcv_partial_candle"` from the exchange-class untouched / True). Otherwise, set `"ohlcv_partial_candle"` to `False` to not drop Candles (shown in the example above). If the day shows the same day, then the last candle can be assumed as incomplete and should be dropped (leave the setting `"ohlcv_partial_candle"` from the exchange-class untouched / True). Otherwise, set `"ohlcv_partial_candle"` to `False` to not drop Candles (shown in the example above).
Another way is to run this command multiple times in a row and observe if the volume is changing (while the date remains the same). Another way is to run this command multiple times in a row and observe if the volume is changing (while the date remains the same).
### Update binance cached leverage tiers
Updating leveraged tiers should be done regularly - and requires an authenticated account with futures enabled.
``` python
import ccxt
import json
from pathlib import Path
exchange = ccxt.binance({
'apiKey': '<apikey>',
'secret': '<secret>'
'options': {'defaultType': 'future'}
})
_ = exchange.load_markets()
lev_tiers = exchange.fetch_leverage_tiers()
# Assumes this is running in the root of the repository.
file = Path('freqtrade/exchange/binance_leverage_tiers.json')
json.dump(lev_tiers, file.open('w'), indent=2)
```
This file should then be contributed upstream, so others can benefit from this, too.
## Updating example notebooks ## Updating example notebooks
To keep the jupyter notebooks aligned with the documentation, the following should be ran after updating a example notebook. To keep the jupyter notebooks aligned with the documentation, the following should be ran after updating a example notebook.

View File

@ -230,6 +230,11 @@ OKX requires a passphrase for each api key, you will therefore need to add this
!!! Warning !!! Warning
OKX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode. OKX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode.
!!! Warning "Futures"
OKX Futures has the concept of "position mode" - which can be Net or long/short (hedge mode).
Freqtrade supports both modes - but changing the mode mid-trading is not supported and will lead to exceptions and failures to place trades.
OKX also only provides MARK candles for the past ~3 months. Backtesting futures prior to that date will therefore lead to slight deviations, as funding-fees cannot be calculated correctly without this data.
## Gate.io ## Gate.io
!!! Tip "Stoploss on Exchange" !!! Tip "Stoploss on Exchange"

View File

@ -116,7 +116,9 @@ optional arguments:
ShortTradeDurHyperOptLoss, OnlyProfitHyperOptLoss, ShortTradeDurHyperOptLoss, OnlyProfitHyperOptLoss,
SharpeHyperOptLoss, SharpeHyperOptLossDaily, SharpeHyperOptLoss, SharpeHyperOptLossDaily,
SortinoHyperOptLoss, SortinoHyperOptLossDaily, SortinoHyperOptLoss, SortinoHyperOptLossDaily,
CalmarHyperOptLoss, MaxDrawDownHyperOptLoss, ProfitDrawDownHyperOptLoss CalmarHyperOptLoss, MaxDrawDownHyperOptLoss,
MaxDrawDownRelativeHyperOptLoss,
ProfitDrawDownHyperOptLoss
--disable-param-export --disable-param-export
Disable automatic hyperopt parameter export. Disable automatic hyperopt parameter export.
--ignore-missing-spaces, --ignore-unparameterized-spaces --ignore-missing-spaces, --ignore-unparameterized-spaces
@ -563,7 +565,8 @@ Currently, the following loss functions are builtin:
* `SharpeHyperOptLossDaily` - optimizes Sharpe Ratio calculated on **daily** trade returns relative to standard deviation. * `SharpeHyperOptLossDaily` - optimizes Sharpe Ratio calculated on **daily** trade returns relative to standard deviation.
* `SortinoHyperOptLoss` - optimizes Sortino Ratio calculated on trade returns relative to **downside** standard deviation. * `SortinoHyperOptLoss` - optimizes Sortino Ratio calculated on trade returns relative to **downside** standard deviation.
* `SortinoHyperOptLossDaily` - optimizes Sortino Ratio calculated on **daily** trade returns relative to **downside** standard deviation. * `SortinoHyperOptLossDaily` - optimizes Sortino Ratio calculated on **daily** trade returns relative to **downside** standard deviation.
* `MaxDrawDownHyperOptLoss` - Optimizes Maximum drawdown. * `MaxDrawDownHyperOptLoss` - Optimizes Maximum absolute drawdown.
* `MaxDrawDownRelativeHyperOptLoss` - Optimizes both maximum absolute drawdown while also adjusting for maximum relative drawdown.
* `CalmarHyperOptLoss` - Optimizes Calmar Ratio calculated on trade returns relative to max drawdown. * `CalmarHyperOptLoss` - Optimizes Calmar Ratio calculated on trade returns relative to max drawdown.
* `ProfitDrawDownHyperOptLoss` - Optimizes by max Profit & min Drawdown objective. `DRAWDOWN_MULT` variable within the hyperoptloss file can be adjusted to be stricter or more flexible on drawdown purposes. * `ProfitDrawDownHyperOptLoss` - Optimizes by max Profit & min Drawdown objective. `DRAWDOWN_MULT` variable within the hyperoptloss file can be adjusted to be stricter or more flexible on drawdown purposes.

View File

@ -160,17 +160,17 @@ This filter allows freqtrade to ignore pairs until they have been listed for at
Offsets an incoming pairlist by a given `offset` value. Offsets an incoming pairlist by a given `offset` value.
As an example it can be used in conjunction with `VolumeFilter` to remove the top X volume pairs. Or to split As an example it can be used in conjunction with `VolumeFilter` to remove the top X volume pairs. Or to split a larger pairlist on two bot instances.
a larger pairlist on two bot instances.
Example to remove the first 10 pairs from the pairlist: Example to remove the first 10 pairs from the pairlist, and takes the next 20 (taking items 10-30 of the initial list):
```json ```json
"pairlists": [ "pairlists": [
// ... // ...
{ {
"method": "OffsetFilter", "method": "OffsetFilter",
"offset": 10 "offset": 10,
"number_assets": 20
} }
], ],
``` ```
@ -181,7 +181,7 @@ Example to remove the first 10 pairs from the pairlist:
`VolumeFilter`. `VolumeFilter`.
!!! Note !!! Note
An offset larger then the total length of the incoming pairlist will result in an empty pairlist. An offset larger than the total length of the incoming pairlist will result in an empty pairlist.
#### PerformanceFilter #### PerformanceFilter

View File

@ -48,6 +48,8 @@ If `trade_limit` or more trades resulted in stoploss, trading will stop for `sto
This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time. This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time.
Similarly, this protection will by default look at all trades (long and short). For futures bots, setting `only_per_side` will make the bot only consider one side, and will then only lock this one side, allowing for example shorts to continue after a series of long stoplosses.
The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles. The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles.
``` python ``` python
@ -59,7 +61,8 @@ def protections(self):
"lookback_period_candles": 24, "lookback_period_candles": 24,
"trade_limit": 4, "trade_limit": 4,
"stop_duration_candles": 4, "stop_duration_candles": 4,
"only_per_pair": False "only_per_pair": False,
"only_per_side": False
} }
] ]
``` ```
@ -93,6 +96,8 @@ def protections(self):
`LowProfitPairs` uses all trades for a pair within `lookback_period` in minutes (or in candles when using `lookback_period_candles`) to determine the overall profit ratio. `LowProfitPairs` uses all trades for a pair within `lookback_period` in minutes (or in candles when using `lookback_period_candles`) to determine the overall profit ratio.
If that ratio is below `required_profit`, that pair will be locked for `stop_duration` in minutes (or in candles when using `stop_duration_candles`). If that ratio is below `required_profit`, that pair will be locked for `stop_duration` in minutes (or in candles when using `stop_duration_candles`).
For futures bots, setting `only_per_side` will make the bot only consider one side, and will then only lock this one side, allowing for example shorts to continue after a series of long losses.
The below example will stop trading a pair for 60 minutes if the pair does not have a required profit of 2% (and a minimum of 2 trades) within the last 6 candles. The below example will stop trading a pair for 60 minutes if the pair does not have a required profit of 2% (and a minimum of 2 trades) within the last 6 candles.
``` python ``` python
@ -104,7 +109,8 @@ def protections(self):
"lookback_period_candles": 6, "lookback_period_candles": 6,
"trade_limit": 2, "trade_limit": 2,
"stop_duration": 60, "stop_duration": 60,
"required_profit": 0.02 "required_profit": 0.02,
"only_per_pair": False,
} }
] ]
``` ```

View File

@ -22,10 +22,6 @@ Freqtrade is a free and open source crypto trading bot written in Python. It is
![freqtrade screenshot](assets/freqtrade-screenshot.png) ![freqtrade screenshot](assets/freqtrade-screenshot.png)
## Sponsored promotion
[![tokenbot-promo](assets/TokenBot-Freqtrade-banner.png)](https://tokenbot.com/?utm_source=github&utm_medium=freqtrade&utm_campaign=algodevs)
## Features ## Features
- Develop your Strategy: Write your strategy in python, using [pandas](https://pandas.pydata.org/). Example strategies to inspire you are available in the [strategy repository](https://github.com/freqtrade/freqtrade-strategies). - Develop your Strategy: Write your strategy in python, using [pandas](https://pandas.pydata.org/). Example strategies to inspire you are available in the [strategy repository](https://github.com/freqtrade/freqtrade-strategies).
@ -51,7 +47,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual,
- [X] [OKX](https://okx.com/) (Former OKEX) - [X] [OKX](https://okx.com/) (Former OKEX)
- [ ] [potentially many others through <img alt="ccxt" width="30px" src="assets/ccxt-logo.svg" />](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_ - [ ] [potentially many others through <img alt="ccxt" width="30px" src="assets/ccxt-logo.svg" />](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
### Experimentally, freqtrade also supports futures on the following exchanges: ### Supported Futures Exchanges (experimental)
- [X] [Binance](https://www.binance.com/) - [X] [Binance](https://www.binance.com/)
- [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Gate.io](https://www.gate.io/ref/6266643)

View File

@ -101,6 +101,13 @@ Possible values are any floats between 0.0 and 0.99
!!! Danger "A `liquidation_buffer` of 0.0, or a low `liquidation_buffer` is likely to result in liquidations, and liquidation fees" !!! Danger "A `liquidation_buffer` of 0.0, or a low `liquidation_buffer` is likely to result in liquidations, and liquidation fees"
Currently Freqtrade is able to calculate liquidation prices, but does not calculate liquidation fees. Setting your `liquidation_buffer` to 0.0, or using a low `liquidation_buffer` could result in your positions being liquidated. Freqtrade does not track liquidation fees, so liquidations will result in inaccurate profit/loss results for your bot. If you use a low `liquidation_buffer`, it is recommended to use `stoploss_on_exchange` if your exchange supports this. Currently Freqtrade is able to calculate liquidation prices, but does not calculate liquidation fees. Setting your `liquidation_buffer` to 0.0, or using a low `liquidation_buffer` could result in your positions being liquidated. Freqtrade does not track liquidation fees, so liquidations will result in inaccurate profit/loss results for your bot. If you use a low `liquidation_buffer`, it is recommended to use `stoploss_on_exchange` if your exchange supports this.
## Unavailable funding rates
For futures data, exchanges commonly provide the futures candles, the marks, and the funding rates. However, it is common that whilst candles and marks might be available, the funding rates are not. This can affect backtesting timeranges, i.e. you may only be able to test recent timeranges and not earlier, experiencing the `No data found. Terminating.` error. To get around this, add the `futures_funding_rate` config option as listed in [configuration.md](configuration.md), and it is recommended that you set this to `0`, unless you know a given specific funding rate for your pair, exchange and timerange. Setting this to anything other than `0` can have drastic effects on your profit calculations within strategy, e.g. within the `custom_exit`, `custom_stoploss`, etc functions.
!!! Warning "This will mean your backtests are inaccurate."
This will not overwrite funding rates that are available from the exchange, but bear in mind that setting a false funding rate will mean backtesting results will be inaccurate for historical timeranges where funding rates are not available.
### Developer ### Developer
#### Margin mode #### Margin mode

View File

@ -1,5 +1,5 @@
mkdocs==1.3.0 mkdocs==1.3.0
mkdocs-material==8.2.10 mkdocs-material==8.2.15
mdx_truly_sane_lists==1.2 mdx_truly_sane_lists==1.2
pymdown-extensions==9.4 pymdown-extensions==9.4
jinja2==3.1.1 jinja2==3.1.2

View File

@ -17,6 +17,7 @@ Currently available callbacks:
* [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation) * [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation)
* [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation) * [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation)
* [`adjust_trade_position()`](#adjust-trade-position) * [`adjust_trade_position()`](#adjust-trade-position)
* [`adjust_entry_price()`](#adjust-entry-price)
* [`leverage()`](#leverage-callback) * [`leverage()`](#leverage-callback)
!!! Tip "Callback calling sequence" !!! Tip "Callback calling sequence"
@ -562,6 +563,14 @@ class AwesomeStrategy(IStrategy):
`confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect). `confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect).
`confirm_trade_exit()` may be called multiple times within one iteration for the same trade if different exit-reasons apply.
The exit-reasons (if applicable) will be in the following sequence:
* `exit_signal` / `custom_exit`
* `stop_loss`
* `roi`
* `trailing_stop_loss`
``` python ``` python
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
@ -604,6 +613,9 @@ class AwesomeStrategy(IStrategy):
``` ```
!!! Warning
`confirm_trade_exit()` can prevent stoploss exits, causing significant losses as this would ignore stoploss exits.
## Adjust trade position ## Adjust trade position
The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy. The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy.
@ -655,7 +667,7 @@ class DigDeeperStrategy(IStrategy):
# This is called when placing the initial order (opening trade) # This is called when placing the initial order (opening trade)
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float, proposed_stake: float, min_stake: Optional[float], max_stake: float,
entry_tag: Optional[str], side: str, **kwargs) -> float: entry_tag: Optional[str], side: str, **kwargs) -> float:
# We need to leave most of the funds for possible further DCA orders # We need to leave most of the funds for possible further DCA orders
@ -663,7 +675,7 @@ class DigDeeperStrategy(IStrategy):
return proposed_stake / self.max_dca_multiplier return proposed_stake / self.max_dca_multiplier
def adjust_trade_position(self, trade: Trade, current_time: datetime, def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, min_stake: float, current_rate: float, current_profit: float, min_stake: Optional[float],
max_stake: float, **kwargs): max_stake: float, **kwargs):
""" """
Custom trade adjustment logic, returning the stake amount that a trade should be increased. Custom trade adjustment logic, returning the stake amount that a trade should be increased.
@ -713,6 +725,69 @@ class DigDeeperStrategy(IStrategy):
``` ```
## Adjust Entry Price
The `adjust_entry_price()` callback may be used by strategy developer to refresh/replace limit orders upon arrival of new candles.
Be aware that `custom_entry_price()` is still the one dictating initial entry limit order price target at the time of entry trigger.
Orders can be cancelled out of this callback by returning `None`.
Returning `current_order_rate` will keep the order on the exchange "as is".
Returning any other price will cancel the existing order, and replace it with a new order.
The trade open-date (`trade.open_date_utc`) will remain at the time of the very first order placed.
Please make sure to be aware of this - and eventually adjust your logic in other callbacks to account for this, and use the date of the first filled order instead.
!!! Warning "Regular timeout"
Entry `unfilledtimeout` mechanism (as well as `check_entry_timeout()`) takes precedence over this.
Entry Orders that are cancelled via the above methods will not have this callback called. Be sure to update timeout values to match your expectations.
```python
from freqtrade.persistence import Trade
from datetime import timedelta
class AwesomeStrategy(IStrategy):
# ... populate_* methods
def adjust_entry_price(self, trade: Trade, order: Optional[Order], pair: str,
current_time: datetime, proposed_rate: float, current_order_rate: float,
entry_tag: Optional[str], side: str, **kwargs) -> float:
"""
Entry price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
When not implemented by a strategy, returns current_order_rate as default.
If current_order_rate is returned then the existing order is maintained.
If None is returned then order gets canceled but not replaced by a new one.
:param pair: Pair that's currently analyzed
:param trade: Trade object.
:param order: Order object
:param current_time: datetime object, containing the current datetime
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
"""
# Limit orders to use and follow SMA200 as price target for the first 10 minutes since entry trigger for BTC/USDT pair.
if pair == 'BTC/USDT' and entry_tag == 'long_sma200' and side == 'long' and (current_time - timedelta(minutes=10) > trade.open_date_utc:
# just cancel the order if it has been filled more than half of the amount
if order.filled > order.remaining:
return None
else:
dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
current_candle = dataframe.iloc[-1].squeeze()
# desired price
return current_candle['sma_200']
# default: maintain existing order
return current_order_rate
```
## Leverage Callback ## Leverage Callback
When trading in markets that allow leverage, this method must return the desired Leverage (Defaults to 1 -> No leverage). When trading in markets that allow leverage, this method must return the desired Leverage (Defaults to 1 -> No leverage).

View File

@ -199,7 +199,7 @@ New string argument `side` - which can be either `"long"` or `"short"`.
``` python hl_lines="4" ``` python hl_lines="4"
class AwesomeStrategy(IStrategy): class AwesomeStrategy(IStrategy):
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float, proposed_stake: float, min_stake: Optional[float], max_stake: float,
entry_tag: Optional[str], **kwargs) -> float: entry_tag: Optional[str], **kwargs) -> float:
# ... # ...
return proposed_stake return proposed_stake
@ -208,7 +208,7 @@ class AwesomeStrategy(IStrategy):
``` python hl_lines="4" ``` python hl_lines="4"
class AwesomeStrategy(IStrategy): class AwesomeStrategy(IStrategy):
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float, proposed_stake: float, min_stake: Optional[float], max_stake: float,
entry_tag: Optional[str], side: str, **kwargs) -> float: entry_tag: Optional[str], side: str, **kwargs) -> float:
# ... # ...
return proposed_stake return proposed_stake

View File

@ -119,6 +119,7 @@ This subcommand is useful for finding problems in your environment with loading
usage: freqtrade list-strategies [-h] [-v] [--logfile FILE] [-V] [-c PATH] usage: freqtrade list-strategies [-h] [-v] [--logfile FILE] [-V] [-c PATH]
[-d PATH] [--userdir PATH] [-d PATH] [--userdir PATH]
[--strategy-path PATH] [-1] [--no-color] [--strategy-path PATH] [-1] [--no-color]
[--recursive-strategy-search]
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
@ -126,6 +127,9 @@ optional arguments:
-1, --one-column Print output in one column. -1, --one-column Print output in one column.
--no-color Disable colorization of hyperopt results. May be --no-color Disable colorization of hyperopt results. May be
useful if you are redirecting output to a file. useful if you are redirecting output to a file.
--recursive-strategy-search
Recursively search for a strategy in the strategies
folder.
Common arguments: Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages). -v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
@ -134,9 +138,10 @@ Common arguments:
details. details.
-V, --version show program's version number and exit -V, --version show program's version number and exit
-c PATH, --config PATH -c PATH, --config PATH
Specify configuration file (default: `config.json`). Specify configuration file (default:
Multiple --config options may be used. Can be set to `userdir/config.json` or `config.json` whichever
`-` to read config from stdin. exists). Multiple --config options may be used. Can be
set to `-` to read config from stdin.
-d PATH, --datadir PATH -d PATH, --datadir PATH
Path to directory with historical backtesting data. Path to directory with historical backtesting data.
--userdir PATH, --user-data-dir PATH --userdir PATH, --user-data-dir PATH
@ -549,6 +554,27 @@ Show whitelist when using a [dynamic pairlist](plugins.md#pairlists).
freqtrade test-pairlist --config config.json --quote USDT BTC freqtrade test-pairlist --config config.json --quote USDT BTC
``` ```
## Convert database
`freqtrade convert-db` can be used to convert your database from one system to another (sqlite -> postgres, postgres -> other postgres), migrating all trades, orders and Pairlocks.
Please refer to the [SQL cheatsheet](sql_cheatsheet.md#use-a-different-database-system) to learn about requirements for different database systems.
```
usage: freqtrade convert-db [-h] [--db-url PATH] [--db-url-from PATH]
optional arguments:
-h, --help show this help message and exit
--db-url PATH Override trades database URL, this is useful in custom
deployments (default: `sqlite:///tradesv3.sqlite` for
Live Run mode, `sqlite:///tradesv3.dryrun.sqlite` for
Dry Run).
--db-url-from PATH Source db url to use when migrating a database.
```
!!! Warning
Please ensure to only use this on an empty target database. Freqtrade will perform a regular migration, but may fail if entries already existed.
## Webserver mode ## Webserver mode
!!! Warning "Experimental" !!! Warning "Experimental"

View File

@ -1,5 +1,5 @@
""" Freqtrade bot """ """ Freqtrade bot """
__version__ = '2022.4.2' __version__ = '2022.5'
if 'dev' in __version__: if 'dev' in __version__:
try: try:

View File

@ -10,6 +10,7 @@ from freqtrade.commands.arguments import Arguments
from freqtrade.commands.build_config_commands import start_new_config from freqtrade.commands.build_config_commands import start_new_config
from freqtrade.commands.data_commands import (start_convert_data, start_convert_trades, from freqtrade.commands.data_commands import (start_convert_data, start_convert_trades,
start_download_data, start_list_data) start_download_data, start_list_data)
from freqtrade.commands.db_commands import start_convert_db
from freqtrade.commands.deploy_commands import (start_create_userdir, start_install_ui, from freqtrade.commands.deploy_commands import (start_create_userdir, start_install_ui,
start_new_strategy) start_new_strategy)
from freqtrade.commands.hyperopt_commands import start_hyperopt_list, start_hyperopt_show from freqtrade.commands.hyperopt_commands import start_hyperopt_list, start_hyperopt_show

View File

@ -72,7 +72,8 @@ ARGS_LIST_DATA = ["exchange", "dataformat_ohlcv", "pairs", "trading_mode"]
ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "new_pairs_days", "include_inactive", ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "new_pairs_days", "include_inactive",
"timerange", "download_trades", "exchange", "timeframes", "timerange", "download_trades", "exchange", "timeframes",
"erase", "dataformat_ohlcv", "dataformat_trades", "trading_mode"] "erase", "dataformat_ohlcv", "dataformat_trades", "trading_mode",
"prepend_data"]
ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit", ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
"db_url", "trade_source", "export", "exportfilename", "db_url", "trade_source", "export", "exportfilename",
@ -81,7 +82,9 @@ ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url", ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url",
"trade_source", "timeframe", "plot_auto_open", ] "trade_source", "timeframe", "plot_auto_open", ]
ARGS_INSTALL_UI = ["erase_ui_only", 'ui_version'] ARGS_CONVERT_DB = ["db_url", "db_url_from"]
ARGS_INSTALL_UI = ["erase_ui_only", "ui_version"]
ARGS_SHOW_TRADES = ["db_url", "trade_ids", "print_json"] ARGS_SHOW_TRADES = ["db_url", "trade_ids", "print_json"]
@ -180,7 +183,7 @@ class Arguments:
self._build_args(optionlist=['version'], parser=self.parser) self._build_args(optionlist=['version'], parser=self.parser)
from freqtrade.commands import (start_backtesting, start_backtesting_show, from freqtrade.commands import (start_backtesting, start_backtesting_show,
start_convert_data, start_convert_trades, start_convert_data, start_convert_db, start_convert_trades,
start_create_userdir, start_download_data, start_edge, start_create_userdir, start_download_data, start_edge,
start_hyperopt, start_hyperopt_list, start_hyperopt_show, start_hyperopt, start_hyperopt_list, start_hyperopt_show,
start_install_ui, start_list_data, start_list_exchanges, start_install_ui, start_list_data, start_list_exchanges,
@ -373,6 +376,14 @@ class Arguments:
test_pairlist_cmd.set_defaults(func=start_test_pairlist) test_pairlist_cmd.set_defaults(func=start_test_pairlist)
self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd) self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd)
# Add db-convert subcommand
convert_db = subparsers.add_parser(
"convert-db",
help="Migrate database to different system",
)
convert_db.set_defaults(func=start_convert_db)
self._build_args(optionlist=ARGS_CONVERT_DB, parser=convert_db)
# Add install-ui subcommand # Add install-ui subcommand
install_ui_cmd = subparsers.add_parser( install_ui_cmd = subparsers.add_parser(
'install-ui', 'install-ui',

View File

@ -106,6 +106,11 @@ AVAILABLE_CLI_OPTIONS = {
f'`{constants.DEFAULT_DB_DRYRUN_URL}` for Dry Run).', f'`{constants.DEFAULT_DB_DRYRUN_URL}` for Dry Run).',
metavar='PATH', metavar='PATH',
), ),
"db_url_from": Arg(
'--db-url-from',
help='Source db url to use when migrating a database.',
metavar='PATH',
),
"sd_notify": Arg( "sd_notify": Arg(
'--sd-notify', '--sd-notify',
help='Notify systemd service manager.', help='Notify systemd service manager.',
@ -443,6 +448,11 @@ AVAILABLE_CLI_OPTIONS = {
default=['1m', '5m'], default=['1m', '5m'],
nargs='+', nargs='+',
), ),
"prepend_data": Arg(
'--prepend',
help='Allow data prepending.',
action='store_true',
),
"erase": Arg( "erase": Arg(
'--erase', '--erase',
help='Clean all existing data for the selected exchange/pairs/timeframes.', help='Clean all existing data for the selected exchange/pairs/timeframes.',

View File

@ -79,12 +79,19 @@ def start_download_data(args: Dict[str, Any]) -> None:
data_format_trades=config['dataformat_trades'], data_format_trades=config['dataformat_trades'],
) )
else: else:
if not exchange._ft_has.get('ohlcv_has_history', True):
raise OperationalException(
f"Historic klines not available for {exchange.name}. "
"Please use `--dl-trades` instead for this exchange "
"(will unfortunately take a long time)."
)
pairs_not_available = refresh_backtest_ohlcv_data( pairs_not_available = refresh_backtest_ohlcv_data(
exchange, pairs=expanded_pairs, timeframes=config['timeframes'], exchange, pairs=expanded_pairs, timeframes=config['timeframes'],
datadir=config['datadir'], timerange=timerange, datadir=config['datadir'], timerange=timerange,
new_pairs_days=config['new_pairs_days'], new_pairs_days=config['new_pairs_days'],
erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv'], erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv'],
trading_mode=config.get('trading_mode', 'spot'), trading_mode=config.get('trading_mode', 'spot'),
prepend=config.get('prepend_data', False)
) )
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -0,0 +1,55 @@
import logging
from typing import Any, Dict
from sqlalchemy import func
from freqtrade.configuration.config_setup import setup_utils_configuration
from freqtrade.enums.runmode import RunMode
logger = logging.getLogger(__name__)
def start_convert_db(args: Dict[str, Any]) -> None:
from sqlalchemy.orm import make_transient
from freqtrade.persistence import Order, Trade, init_db
from freqtrade.persistence.migrations import set_sequence_ids
from freqtrade.persistence.pairlock import PairLock
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
init_db(config['db_url'])
session_target = Trade._session
init_db(config['db_url_from'])
logger.info("Starting db migration.")
trade_count = 0
pairlock_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.query:
pairlock_count += 1
make_transient(pairlock)
session_target.add(pairlock)
session_target.commit()
# Update sequences
max_trade_id = session_target.query(func.max(Trade.id)).scalar()
max_order_id = session_target.query(func.max(Order.id)).scalar()
max_pairlock_id = session_target.query(func.max(PairLock.id)).scalar()
set_sequence_ids(session_target.get_bind(),
trade_id=max_trade_id,
order_id=max_order_id,
pairlock_id=max_pairlock_id)
logger.info(f"Migrated {trade_count} Trades, and {pairlock_count} Pairlocks.")

View File

@ -212,7 +212,7 @@ def start_show_trades(args: Dict[str, Any]) -> None:
raise OperationalException("--db-url is required for this command.") raise OperationalException("--db-url is required for this command.")
logger.info(f'Using DB: "{parse_db_uri_for_logging(config["db_url"])}"') logger.info(f'Using DB: "{parse_db_uri_for_logging(config["db_url"])}"')
init_db(config['db_url'], clean_open_orders=False) init_db(config['db_url'])
tfilter = [] tfilter = []
if config.get('trade_ids'): if config.get('trade_ids'):

View File

@ -27,7 +27,7 @@ def check_exchange(config: Dict[str, Any], check_for_bad: bool = True) -> bool:
return True return True
logger.info("Checking exchange...") logger.info("Checking exchange...")
exchange = config.get('exchange', {}).get('name').lower() exchange = config.get('exchange', {}).get('name', '').lower()
if not exchange: if not exchange:
raise OperationalException( raise OperationalException(
f'This command requires a configured exchange. You should either use ' f'This command requires a configured exchange. You should either use '

View File

@ -147,6 +147,9 @@ class Configuration:
config.update({'db_url': self.args['db_url']}) config.update({'db_url': self.args['db_url']})
logger.info('Parameter --db-url detected ...') logger.info('Parameter --db-url detected ...')
self._args_to_config(config, argname='db_url_from',
logstring='Parameter --db-url-from detected ...')
if config.get('force_entry_enable', False): if config.get('force_entry_enable', False):
logger.warning('`force_entry_enable` RPC message enabled.') logger.warning('`force_entry_enable` RPC message enabled.')
@ -393,6 +396,8 @@ class Configuration:
self._args_to_config(config, argname='trade_source', self._args_to_config(config, argname='trade_source',
logstring='Using trades from: {}') logstring='Using trades from: {}')
self._args_to_config(config, argname='prepend_data',
logstring='Prepend detected. Allowing data prepending.')
self._args_to_config(config, argname='erase', self._args_to_config(config, argname='erase',
logstring='Erase detected. Deleting existing data.') logstring='Erase detected. Deleting existing data.')
@ -485,7 +490,8 @@ class Configuration:
if not pairs_file.exists(): if not pairs_file.exists():
raise OperationalException(f'No pairs file found with path "{pairs_file}".') raise OperationalException(f'No pairs file found with path "{pairs_file}".')
config['pairs'] = load_file(pairs_file) config['pairs'] = load_file(pairs_file)
config['pairs'].sort() if isinstance(config['pairs'], list):
config['pairs'].sort()
return return
if 'config' in self.args and self.args['config']: if 'config' in self.args and self.args['config']:
@ -496,5 +502,5 @@ class Configuration:
pairs_file = config['datadir'] / 'pairs.json' pairs_file = config['datadir'] / 'pairs.json'
if pairs_file.exists(): if pairs_file.exists():
config['pairs'] = load_file(pairs_file) config['pairs'] = load_file(pairs_file)
if 'pairs' in config: if 'pairs' in config and isinstance(config['pairs'], list):
config['pairs'].sort() config['pairs'].sort()

View File

@ -113,7 +113,7 @@ def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None:
process_removed_setting(config, 'experimental', 'ignore_roi_if_buy_signal', process_removed_setting(config, 'experimental', 'ignore_roi_if_buy_signal',
None, 'ignore_roi_if_entry_signal') None, 'ignore_roi_if_entry_signal')
process_removed_setting(config, 'ask_strategy', 'use_sell_signal', None, 'exit_sell_signal') process_removed_setting(config, 'ask_strategy', 'use_sell_signal', None, 'use_exit_signal')
process_removed_setting(config, 'ask_strategy', 'sell_profit_only', None, 'exit_profit_only') process_removed_setting(config, 'ask_strategy', 'sell_profit_only', None, 'exit_profit_only')
process_removed_setting(config, 'ask_strategy', 'sell_profit_offset', process_removed_setting(config, 'ask_strategy', 'sell_profit_offset',
None, 'exit_profit_offset') None, 'exit_profit_offset')

View File

@ -15,7 +15,7 @@ def create_datadir(config: Dict[str, Any], datadir: Optional[str] = None) -> Pat
folder = Path(datadir) if datadir else Path(f"{config['user_data_dir']}/data") folder = Path(datadir) if datadir else Path(f"{config['user_data_dir']}/data")
if not datadir: if not datadir:
# set datadir # set datadir
exchange_name = config.get('exchange', {}).get('name').lower() exchange_name = config.get('exchange', {}).get('name', '').lower()
folder = folder.joinpath(exchange_name) folder = folder.joinpath(exchange_name)
if not folder.is_dir(): if not folder.is_dir():

View File

@ -28,7 +28,8 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss',
'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily', 'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily',
'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily', 'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily',
'CalmarHyperOptLoss', 'CalmarHyperOptLoss',
'MaxDrawDownHyperOptLoss', 'ProfitDrawDownHyperOptLoss'] 'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss',
'ProfitDrawDownHyperOptLoss']
AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
'AgeFilter', 'OffsetFilter', 'PerformanceFilter', 'AgeFilter', 'OffsetFilter', 'PerformanceFilter',
'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter',
@ -301,12 +302,12 @@ CONF_SCHEMA = {
'exit_fill': { 'exit_fill': {
'type': 'string', 'type': 'string',
'enum': TELEGRAM_SETTING_OPTIONS, 'enum': TELEGRAM_SETTING_OPTIONS,
'default': 'off' 'default': 'on'
}, },
'protection_trigger': { 'protection_trigger': {
'type': 'string', 'type': 'string',
'enum': TELEGRAM_SETTING_OPTIONS, 'enum': TELEGRAM_SETTING_OPTIONS,
'default': 'off' 'default': 'on'
}, },
'protection_trigger_global': { 'protection_trigger_global': {
'type': 'string', 'type': 'string',
@ -482,6 +483,8 @@ CANCEL_REASON = {
"ALL_CANCELLED": "cancelled (all unfilled and partially filled open orders cancelled)", "ALL_CANCELLED": "cancelled (all unfilled and partially filled open orders cancelled)",
"CANCELLED_ON_EXCHANGE": "cancelled on exchange", "CANCELLED_ON_EXCHANGE": "cancelled on exchange",
"FORCE_EXIT": "forcesold", "FORCE_EXIT": "forcesold",
"REPLACE": "cancelled to be replaced by new limit order",
"USER_CANCEL": "user requested order cancel"
} }
# List of pairs with their timeframes # List of pairs with their timeframes
@ -493,3 +496,4 @@ TradeList = List[List]
LongShort = Literal['long', 'short'] LongShort = Literal['long', 'short']
EntryExit = Literal['entry', 'exit'] EntryExit = Literal['entry', 'exit']
BuySell = Literal['buy', 'sell']

View File

@ -353,7 +353,7 @@ def load_trades_from_db(db_url: str, strategy: Optional[str] = None) -> pd.DataF
Can also serve as protection to load the correct result. Can also serve as protection to load the correct result.
:return: Dataframe containing Trades :return: Dataframe containing Trades
""" """
init_db(db_url, clean_open_orders=False) init_db(db_url)
filters = [] filters = []
if strategy: if strategy:

View File

@ -40,7 +40,7 @@ class HDF5DataHandler(IDataHandler):
return [ return [
( (
cls.rebuild_pair_from_filename(match[1]), cls.rebuild_pair_from_filename(match[1]),
match[2], cls.rebuild_timeframe_from_filename(match[2]),
CandleType.from_string(match[3]) CandleType.from_string(match[3])
) for match in _tmp if match and len(match.groups()) > 1] ) for match in _tmp if match and len(match.groups()) > 1]
@ -109,7 +109,11 @@ class HDF5DataHandler(IDataHandler):
) )
if not filename.exists(): if not filename.exists():
return pd.DataFrame(columns=self._columns) # Fallback mode for 1M files
filename = self._pair_data_filename(
self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True)
if not filename.exists():
return pd.DataFrame(columns=self._columns)
where = [] where = []
if timerange: if timerange:
if timerange.starttype == 'date': if timerange.starttype == 'date':

View File

@ -68,7 +68,8 @@ def load_data(datadir: Path,
startup_candles: int = 0, startup_candles: int = 0,
fail_without_data: bool = False, fail_without_data: bool = False,
data_format: str = 'json', data_format: str = 'json',
candle_type: CandleType = CandleType.SPOT candle_type: CandleType = CandleType.SPOT,
user_futures_funding_rate: int = None,
) -> Dict[str, DataFrame]: ) -> Dict[str, DataFrame]:
""" """
Load ohlcv history data for a list of pairs. Load ohlcv history data for a list of pairs.
@ -100,6 +101,10 @@ def load_data(datadir: Path,
) )
if not hist.empty: if not hist.empty:
result[pair] = hist result[pair] = hist
else:
if candle_type is CandleType.FUNDING_RATE and user_futures_funding_rate is not None:
logger.warn(f"{pair} using user specified [{user_futures_funding_rate}]")
result[pair] = DataFrame(columns=["open", "close", "high", "low", "volume"])
if fail_without_data and not result: if fail_without_data and not result:
raise OperationalException("No data found. Terminating.") raise OperationalException("No data found. Terminating.")
@ -139,8 +144,9 @@ def _load_cached_data_for_updating(
timeframe: str, timeframe: str,
timerange: Optional[TimeRange], timerange: Optional[TimeRange],
data_handler: IDataHandler, data_handler: IDataHandler,
candle_type: CandleType candle_type: CandleType,
) -> Tuple[DataFrame, Optional[int]]: prepend: bool = False,
) -> Tuple[DataFrame, Optional[int], Optional[int]]:
""" """
Load cached data to download more data. Load cached data to download more data.
If timerange is passed in, checks whether data from an before the stored data will be If timerange is passed in, checks whether data from an before the stored data will be
@ -150,9 +156,12 @@ def _load_cached_data_for_updating(
Note: Only used by download_pair_history(). Note: Only used by download_pair_history().
""" """
start = None start = None
end = None
if timerange: if timerange:
if timerange.starttype == 'date': if timerange.starttype == 'date':
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc) start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
if timerange.stoptype == 'date':
end = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
# Intentionally don't pass timerange in - since we need to load the full dataset. # Intentionally don't pass timerange in - since we need to load the full dataset.
data = data_handler.ohlcv_load(pair, timeframe=timeframe, data = data_handler.ohlcv_load(pair, timeframe=timeframe,
@ -160,14 +169,17 @@ def _load_cached_data_for_updating(
drop_incomplete=True, warn_no_data=False, drop_incomplete=True, warn_no_data=False,
candle_type=candle_type) candle_type=candle_type)
if not data.empty: if not data.empty:
if start and start < data.iloc[0]['date']: if not prepend and start and start < data.iloc[0]['date']:
# Earlier data than existing data requested, redownload all # Earlier data than existing data requested, redownload all
data = DataFrame(columns=DEFAULT_DATAFRAME_COLUMNS) data = DataFrame(columns=DEFAULT_DATAFRAME_COLUMNS)
else: else:
start = data.iloc[-1]['date'] if prepend:
end = data.iloc[0]['date']
else:
start = data.iloc[-1]['date']
start_ms = int(start.timestamp() * 1000) if start else None start_ms = int(start.timestamp() * 1000) if start else None
return data, start_ms end_ms = int(end.timestamp() * 1000) if end else None
return data, start_ms, end_ms
def _download_pair_history(pair: str, *, def _download_pair_history(pair: str, *,
@ -180,6 +192,7 @@ def _download_pair_history(pair: str, *,
timerange: Optional[TimeRange] = None, timerange: Optional[TimeRange] = None,
candle_type: CandleType, candle_type: CandleType,
erase: bool = False, erase: bool = False,
prepend: bool = False,
) -> bool: ) -> bool:
""" """
Download latest candles from the exchange for the pair and timeframe passed in parameters Download latest candles from the exchange for the pair and timeframe passed in parameters
@ -187,8 +200,6 @@ def _download_pair_history(pair: str, *,
exists in a cache. If timerange starts earlier than the data in the cache, exists in a cache. If timerange starts earlier than the data in the cache,
the full data will be redownloaded the full data will be redownloaded
Based on @Rybolov work: https://github.com/rybolov/freqtrade-data
:param pair: pair to download :param pair: pair to download
:param timeframe: Timeframe (e.g "5m") :param timeframe: Timeframe (e.g "5m")
:param timerange: range of time to download :param timerange: range of time to download
@ -203,14 +214,17 @@ def _download_pair_history(pair: str, *,
if data_handler.ohlcv_purge(pair, timeframe, candle_type=candle_type): if data_handler.ohlcv_purge(pair, timeframe, candle_type=candle_type):
logger.info(f'Deleting existing data for pair {pair}, {timeframe}, {candle_type}.') logger.info(f'Deleting existing data for pair {pair}, {timeframe}, {candle_type}.')
logger.info( data, since_ms, until_ms = _load_cached_data_for_updating(
f'Download history data for pair: "{pair}" ({process}), timeframe: {timeframe}, ' pair, timeframe, timerange,
f'candle type: {candle_type} and store in {datadir}.' data_handler=data_handler,
) candle_type=candle_type,
prepend=prepend)
data, since_ms = _load_cached_data_for_updating(pair, timeframe, timerange, logger.info(f'({process}) - Download history data for "{pair}", {timeframe}, '
data_handler=data_handler, f'{candle_type} and store in {datadir}.'
candle_type=candle_type) f'From {format_ms_time(since_ms) if since_ms else "start"} to '
f'{format_ms_time(until_ms) if until_ms else "now"}'
)
logger.debug("Current Start: %s", logger.debug("Current Start: %s",
f"{data.iloc[0]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None') f"{data.iloc[0]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None')
@ -225,6 +239,7 @@ def _download_pair_history(pair: str, *,
days=-new_pairs_days).int_timestamp * 1000, days=-new_pairs_days).int_timestamp * 1000,
is_new_pair=data.empty, is_new_pair=data.empty,
candle_type=candle_type, candle_type=candle_type,
until_ms=until_ms if until_ms else None
) )
# TODO: Maybe move parsing to exchange class (?) # TODO: Maybe move parsing to exchange class (?)
new_dataframe = ohlcv_to_dataframe(new_data, timeframe, pair, new_dataframe = ohlcv_to_dataframe(new_data, timeframe, pair,
@ -257,6 +272,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
timerange: Optional[TimeRange] = None, timerange: Optional[TimeRange] = None,
new_pairs_days: int = 30, erase: bool = False, new_pairs_days: int = 30, erase: bool = False,
data_format: str = None, data_format: str = None,
prepend: bool = False,
) -> List[str]: ) -> List[str]:
""" """
Refresh stored ohlcv data for backtesting and hyperopt operations. Refresh stored ohlcv data for backtesting and hyperopt operations.
@ -266,6 +282,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
pairs_not_available = [] pairs_not_available = []
data_handler = get_datahandler(datadir, data_format) data_handler = get_datahandler(datadir, data_format)
candle_type = CandleType.get_default(trading_mode) candle_type = CandleType.get_default(trading_mode)
process = ''
for idx, pair in enumerate(pairs, start=1): for idx, pair in enumerate(pairs, start=1):
if pair not in exchange.markets: if pair not in exchange.markets:
pairs_not_available.append(pair) pairs_not_available.append(pair)
@ -280,7 +297,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
timerange=timerange, data_handler=data_handler, timerange=timerange, data_handler=data_handler,
timeframe=str(timeframe), new_pairs_days=new_pairs_days, timeframe=str(timeframe), new_pairs_days=new_pairs_days,
candle_type=candle_type, candle_type=candle_type,
erase=erase) erase=erase, prepend=prepend)
if trading_mode == 'futures': if trading_mode == 'futures':
# Predefined candletype (and timeframe) depending on exchange # Predefined candletype (and timeframe) depending on exchange
# Downloads what is necessary to backtest based on futures data. # Downloads what is necessary to backtest based on futures data.
@ -294,7 +311,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
timerange=timerange, data_handler=data_handler, timerange=timerange, data_handler=data_handler,
timeframe=str(tf_mark), new_pairs_days=new_pairs_days, timeframe=str(tf_mark), new_pairs_days=new_pairs_days,
candle_type=funding_candle_type, candle_type=funding_candle_type,
erase=erase) erase=erase, prepend=prepend)
return pairs_not_available return pairs_not_available
@ -312,8 +329,9 @@ def _download_trades_history(exchange: Exchange,
try: try:
until = None until = None
if (timerange and timerange.starttype == 'date'): if timerange:
since = timerange.startts * 1000 if timerange.starttype == 'date':
since = timerange.startts * 1000
if timerange.stoptype == 'date': if timerange.stoptype == 'date':
until = timerange.stopts * 1000 until = timerange.stopts * 1000
else: else:

View File

@ -5,7 +5,7 @@ It's subclasses handle and storing data from disk.
""" """
import logging import logging
import re import re
from abc import ABC, abstractclassmethod, abstractmethod from abc import ABC, abstractmethod
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
class IDataHandler(ABC): class IDataHandler(ABC):
_OHLCV_REGEX = r'^([a-zA-Z_-]+)\-(\d+\S)\-?([a-zA-Z_]*)?(?=\.)' _OHLCV_REGEX = r'^([a-zA-Z_-]+)\-(\d+[a-zA-Z]{1,2})\-?([a-zA-Z_]*)?(?=\.)'
def __init__(self, datadir: Path) -> None: def __init__(self, datadir: Path) -> None:
self._datadir = datadir self._datadir = datadir
@ -38,7 +38,8 @@ class IDataHandler(ABC):
""" """
raise NotImplementedError() raise NotImplementedError()
@abstractclassmethod @classmethod
@abstractmethod
def ohlcv_get_available_data( def ohlcv_get_available_data(
cls, datadir: Path, trading_mode: TradingMode) -> ListPairsWithTimeframes: cls, datadir: Path, trading_mode: TradingMode) -> ListPairsWithTimeframes:
""" """
@ -48,7 +49,8 @@ class IDataHandler(ABC):
:return: List of Tuples of (pair, timeframe) :return: List of Tuples of (pair, timeframe)
""" """
@abstractclassmethod @classmethod
@abstractmethod
def ohlcv_get_pairs(cls, datadir: Path, timeframe: str, candle_type: CandleType) -> List[str]: def ohlcv_get_pairs(cls, datadir: Path, timeframe: str, candle_type: CandleType) -> List[str]:
""" """
Returns a list of all pairs with ohlcv data available in this datadir Returns a list of all pairs with ohlcv data available in this datadir
@ -118,7 +120,8 @@ class IDataHandler(ABC):
:param candle_type: Any of the enum CandleType (must match trading mode!) :param candle_type: Any of the enum CandleType (must match trading mode!)
""" """
@abstractclassmethod @classmethod
@abstractmethod
def trades_get_pairs(cls, datadir: Path) -> List[str]: def trades_get_pairs(cls, datadir: Path) -> List[str]:
""" """
Returns a list of all pairs for which trade data is available in this Returns a list of all pairs for which trade data is available in this
@ -190,10 +193,14 @@ class IDataHandler(ABC):
datadir: Path, datadir: Path,
pair: str, pair: str,
timeframe: str, timeframe: str,
candle_type: CandleType candle_type: CandleType,
no_timeframe_modify: bool = False
) -> Path: ) -> Path:
pair_s = misc.pair_to_filename(pair) pair_s = misc.pair_to_filename(pair)
candle = "" candle = ""
if not no_timeframe_modify:
timeframe = cls.timeframe_to_file(timeframe)
if candle_type != CandleType.SPOT: if candle_type != CandleType.SPOT:
datadir = datadir.joinpath('futures') datadir = datadir.joinpath('futures')
candle = f"-{candle_type}" candle = f"-{candle_type}"
@ -207,6 +214,18 @@ class IDataHandler(ABC):
filename = datadir.joinpath(f'{pair_s}-trades.{cls._get_file_extension()}') filename = datadir.joinpath(f'{pair_s}-trades.{cls._get_file_extension()}')
return filename return filename
@staticmethod
def timeframe_to_file(timeframe: str):
return timeframe.replace('M', 'Mo')
@staticmethod
def rebuild_timeframe_from_filename(timeframe: str) -> str:
"""
converts timeframe from disk to file
Replaces mo with M (to avoid problems on case-insensitive filesystems)
"""
return re.sub('1mo', '1M', timeframe, flags=re.IGNORECASE)
@staticmethod @staticmethod
def rebuild_pair_from_filename(pair: str) -> str: def rebuild_pair_from_filename(pair: str) -> str:
""" """

View File

@ -41,7 +41,7 @@ class JsonDataHandler(IDataHandler):
return [ return [
( (
cls.rebuild_pair_from_filename(match[1]), cls.rebuild_pair_from_filename(match[1]),
match[2], cls.rebuild_timeframe_from_filename(match[2]),
CandleType.from_string(match[3]) CandleType.from_string(match[3])
) for match in _tmp if match and len(match.groups()) > 1] ) for match in _tmp if match and len(match.groups()) > 1]
@ -103,9 +103,14 @@ class JsonDataHandler(IDataHandler):
:param candle_type: Any of the enum CandleType (must match trading mode!) :param candle_type: Any of the enum CandleType (must match trading mode!)
:return: DataFrame with ohlcv data, or empty DataFrame :return: DataFrame with ohlcv data, or empty DataFrame
""" """
filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type=candle_type) filename = self._pair_data_filename(
self._datadir, pair, timeframe, candle_type=candle_type)
if not filename.exists(): if not filename.exists():
return DataFrame(columns=self._columns) # Fallback mode for 1M files
filename = self._pair_data_filename(
self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True)
if not filename.exists():
return DataFrame(columns=self._columns)
try: try:
pairdata = read_json(filename, orient='values') pairdata = read_json(filename, orient='values')
pairdata.columns = self._columns pairdata.columns = self._columns

View File

@ -72,18 +72,28 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
return df return df
def _calc_drawdown_series(profit_results: pd.DataFrame, *, date_col: str, value_col: str def _calc_drawdown_series(profit_results: pd.DataFrame, *, date_col: str, value_col: str,
) -> pd.DataFrame: starting_balance: float) -> pd.DataFrame:
max_drawdown_df = pd.DataFrame() max_drawdown_df = pd.DataFrame()
max_drawdown_df['cumulative'] = profit_results[value_col].cumsum() max_drawdown_df['cumulative'] = profit_results[value_col].cumsum()
max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax() max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax()
max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value'] max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value']
max_drawdown_df['date'] = profit_results.loc[:, date_col] max_drawdown_df['date'] = profit_results.loc[:, date_col]
if starting_balance:
cumulative_balance = starting_balance + max_drawdown_df['cumulative']
max_balance = starting_balance + max_drawdown_df['high_value']
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
max_drawdown_df['drawdown_relative'] = (
(max_drawdown_df['high_value'] - max_drawdown_df['cumulative'])
/ max_drawdown_df['high_value'])
return max_drawdown_df return max_drawdown_df
def calculate_underwater(trades: pd.DataFrame, *, date_col: str = 'close_date', def calculate_underwater(trades: pd.DataFrame, *, date_col: str = 'close_date',
value_col: str = 'profit_ratio' value_col: str = 'profit_ratio', starting_balance: float = 0.0
): ):
""" """
Calculate max drawdown and the corresponding close dates Calculate max drawdown and the corresponding close dates
@ -97,13 +107,18 @@ def calculate_underwater(trades: pd.DataFrame, *, date_col: str = 'close_date',
if len(trades) == 0: if len(trades) == 0:
raise ValueError("Trade dataframe empty.") raise ValueError("Trade dataframe empty.")
profit_results = trades.sort_values(date_col).reset_index(drop=True) profit_results = trades.sort_values(date_col).reset_index(drop=True)
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col) max_drawdown_df = _calc_drawdown_series(
profit_results,
date_col=date_col,
value_col=value_col,
starting_balance=starting_balance)
return max_drawdown_df return max_drawdown_df
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date', def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date',
value_col: str = 'profit_abs', starting_balance: float = 0 value_col: str = 'profit_abs', starting_balance: float = 0,
relative: bool = False
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]: ) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]:
""" """
Calculate max drawdown and the corresponding close dates Calculate max drawdown and the corresponding close dates
@ -119,9 +134,15 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date'
if len(trades) == 0: if len(trades) == 0:
raise ValueError("Trade dataframe empty.") raise ValueError("Trade dataframe empty.")
profit_results = trades.sort_values(date_col).reset_index(drop=True) profit_results = trades.sort_values(date_col).reset_index(drop=True)
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col) max_drawdown_df = _calc_drawdown_series(
profit_results,
date_col=date_col,
value_col=value_col,
starting_balance=starting_balance
)
idxmin = max_drawdown_df['drawdown'].idxmin() idxmin = max_drawdown_df['drawdown_relative'].idxmax() if relative \
else max_drawdown_df['drawdown'].idxmin()
if idxmin == 0: if idxmin == 0:
raise ValueError("No losing trade, therefore no drawdown.") raise ValueError("No losing trade, therefore no drawdown.")
high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col] high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col]
@ -129,12 +150,10 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date'
high_val = max_drawdown_df.loc[max_drawdown_df.iloc[:idxmin] high_val = max_drawdown_df.loc[max_drawdown_df.iloc[:idxmin]
['high_value'].idxmax(), 'cumulative'] ['high_value'].idxmax(), 'cumulative']
low_val = max_drawdown_df.loc[idxmin, 'cumulative'] low_val = max_drawdown_df.loc[idxmin, 'cumulative']
max_drawdown_rel = 0.0 max_drawdown_rel = max_drawdown_df.loc[idxmin, 'drawdown_relative']
if high_val + starting_balance != 0:
max_drawdown_rel = (high_val - low_val) / (high_val + starting_balance)
return ( return (
abs(min(max_drawdown_df['drawdown'])), abs(max_drawdown_df.loc[idxmin, 'drawdown']),
high_date, high_date,
low_date, low_date,
high_val, high_val,

View File

@ -15,3 +15,9 @@ class ExitCheckTuple:
@property @property
def exit_flag(self): def exit_flag(self):
return self.exit_type != ExitType.NONE return self.exit_type != ExitType.NONE
def __eq__(self, other):
return self.exit_type == other.exit_type and self.exit_reason == other.exit_reason
def __repr__(self):
return f"ExitCheckTuple({self.exit_type}, {self.exit_reason})"

View File

@ -57,7 +57,7 @@ class Binance(Exchange):
(side == "buy" and stop_loss < float(order['info']['stopPrice'])) (side == "buy" and stop_loss < float(order['info']['stopPrice']))
) )
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict: def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
tickers = super().get_tickers(symbols=symbols, cached=cached) tickers = super().get_tickers(symbols=symbols, cached=cached)
if self.trading_mode == TradingMode.FUTURES: if self.trading_mode == TradingMode.FUTURES:
# Binance's future result has no bid/ask values. # Binance's future result has no bid/ask values.
@ -95,6 +95,7 @@ class Binance(Exchange):
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str, async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
since_ms: int, candle_type: CandleType, since_ms: int, candle_type: CandleType,
is_new_pair: bool = False, raise_: bool = False, is_new_pair: bool = False, raise_: bool = False,
until_ms: Optional[int] = None
) -> Tuple[str, str, str, List]: ) -> Tuple[str, str, str, List]:
""" """
Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date
@ -115,7 +116,8 @@ class Binance(Exchange):
since_ms=since_ms, since_ms=since_ms,
is_new_pair=is_new_pair, is_new_pair=is_new_pair,
raise_=raise_, raise_=raise_,
candle_type=candle_type candle_type=candle_type,
until_ms=until_ms,
) )
def funding_fee_cutoff(self, open_date: datetime): def funding_fee_cutoff(self, open_date: datetime):

File diff suppressed because it is too large Load Diff

View File

@ -29,3 +29,17 @@ class Bybit(Exchange):
# (TradingMode.FUTURES, MarginMode.CROSS), # (TradingMode.FUTURES, MarginMode.CROSS),
# (TradingMode.FUTURES, MarginMode.ISOLATED) # (TradingMode.FUTURES, MarginMode.ISOLATED)
] ]
@property
def _ccxt_config(self) -> Dict:
# Parameters to add directly to ccxt sync/async initialization.
# ccxt defaults to swap mode.
config = {}
if self.trading_mode == TradingMode.SPOT:
config.update({
"options": {
"defaultType": "spot"
}
})
config.update(super()._ccxt_config)
return config

View File

@ -2,6 +2,7 @@ import asyncio
import logging import logging
import time import time
from functools import wraps from functools import wraps
from typing import Any, Callable, Optional, TypeVar, cast, overload
from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
@ -11,6 +12,14 @@ logger = logging.getLogger(__name__)
__logging_mixin = None __logging_mixin = None
def _reset_logging_mixin():
"""
Reset global logging mixin - used in tests only.
"""
global __logging_mixin
__logging_mixin = LoggingMixin(logger)
def _get_logging_mixin(): def _get_logging_mixin():
# Logging-mixin to cache kucoin responses # Logging-mixin to cache kucoin responses
# Only to be used in retrier # Only to be used in retrier
@ -133,8 +142,22 @@ def retrier_async(f):
return wrapper return wrapper
def retrier(_func=None, retries=API_RETRY_COUNT): F = TypeVar('F', bound=Callable[..., Any])
def decorator(f):
# Type shenanigans
@overload
def retrier(_func: F) -> F:
...
@overload
def retrier(*, retries=API_RETRY_COUNT) -> Callable[[F], F]:
...
def retrier(_func: Optional[F] = None, *, retries=API_RETRY_COUNT):
def decorator(f: F) -> F:
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
count = kwargs.pop('count', retries) count = kwargs.pop('count', retries)
@ -155,7 +178,7 @@ def retrier(_func=None, retries=API_RETRY_COUNT):
else: else:
logger.warning(msg + 'Giving up.') logger.warning(msg + 'Giving up.')
raise ex raise ex
return wrapper return cast(F, wrapper)
# Support both @retrier and @retrier(retries=2) syntax # Support both @retrier and @retrier(retries=2) syntax
if _func is None: if _func is None:
return decorator return decorator

View File

@ -16,11 +16,10 @@ import arrow
import ccxt import ccxt
import ccxt.async_support as ccxt_async import ccxt.async_support as ccxt_async
from cachetools import TTLCache from cachetools import TTLCache
from ccxt.base.decimal_to_precision import (ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, Precise, decimal_to_precision
decimal_to_precision)
from pandas import DataFrame from pandas import DataFrame
from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BuySell,
EntryExit, ListPairsWithTimeframes, PairWithTimeframe) EntryExit, ListPairsWithTimeframes, PairWithTimeframe)
from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
@ -64,6 +63,7 @@ class Exchange:
"time_in_force_parameter": "timeInForce", "time_in_force_parameter": "timeInForce",
"ohlcv_params": {}, "ohlcv_params": {},
"ohlcv_candle_limit": 500, "ohlcv_candle_limit": 500,
"ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv
"ohlcv_partial_candle": True, "ohlcv_partial_candle": True,
"ohlcv_require_since": False, "ohlcv_require_since": False,
# Check https://github.com/ccxt/ccxt/issues/10767 for removal of ohlcv_volume_currency # Check https://github.com/ccxt/ccxt/issues/10767 for removal of ohlcv_volume_currency
@ -92,8 +92,8 @@ class Exchange:
it does basic validation whether the specified exchange and pairs are valid. it does basic validation whether the specified exchange and pairs are valid.
:return: None :return: None
""" """
self._api: ccxt.Exchange = None self._api: ccxt.Exchange
self._api_async: ccxt_async.Exchange = None self._api_async: ccxt_async.Exchange
self._markets: Dict = {} self._markets: Dict = {}
self._trading_fees: Dict[str, Any] = {} self._trading_fees: Dict[str, Any] = {}
self._leverage_tiers: Dict[str, List[Dict]] = {} self._leverage_tiers: Dict[str, List[Dict]] = {}
@ -198,6 +198,7 @@ class Exchange:
if self.trading_mode != TradingMode.SPOT: if self.trading_mode != TradingMode.SPOT:
self.fill_leverage_tiers() self.fill_leverage_tiers()
self.additional_exchange_init()
def __del__(self): def __del__(self):
""" """
@ -290,27 +291,38 @@ class Exchange:
return self._markets return self._markets
@property @property
def precisionMode(self) -> str: def precisionMode(self) -> int:
"""exchange ccxt precisionMode""" """exchange ccxt precisionMode"""
return self._api.precisionMode return self._api.precisionMode
def additional_exchange_init(self) -> None:
"""
Additional exchange initialization logic.
.api will be available at this point.
Must be overridden in child methods if required.
"""
pass
def _log_exchange_response(self, endpoint, response) -> None: def _log_exchange_response(self, endpoint, response) -> None:
""" Log exchange responses """ """ Log exchange responses """
if self.log_responses: if self.log_responses:
logger.info(f"API {endpoint}: {response}") logger.info(f"API {endpoint}: {response}")
def ohlcv_candle_limit(self, timeframe: str) -> int: def ohlcv_candle_limit(
self, timeframe: str, candle_type: CandleType, since_ms: Optional[int] = None) -> int:
""" """
Exchange ohlcv candle limit Exchange ohlcv candle limit
Uses ohlcv_candle_limit_per_timeframe if the exchange has different limits Uses ohlcv_candle_limit_per_timeframe if the exchange has different limits
per timeframe (e.g. bittrex), otherwise falls back to ohlcv_candle_limit per timeframe (e.g. bittrex), otherwise falls back to ohlcv_candle_limit
:param timeframe: Timeframe to check :param timeframe: Timeframe to check
:param candle_type: Candle-type
:param since_ms: Starting timestamp
:return: Candle limit as integer :return: Candle limit as integer
""" """
return int(self._ft_has.get('ohlcv_candle_limit_per_timeframe', {}).get( return int(self._ft_has.get('ohlcv_candle_limit_per_timeframe', {}).get(
timeframe, self._ft_has.get('ohlcv_candle_limit'))) timeframe, self._ft_has.get('ohlcv_candle_limit')))
def get_markets(self, base_currencies: List[str] = None, quote_currencies: List[str] = None, def get_markets(self, base_currencies: List[str] = [], quote_currencies: List[str] = [],
spot_only: bool = False, margin_only: bool = False, futures_only: bool = False, spot_only: bool = False, margin_only: bool = False, futures_only: bool = False,
tradable_only: bool = True, tradable_only: bool = True,
active_only: bool = False) -> Dict[str, Any]: active_only: bool = False) -> Dict[str, Any]:
@ -606,19 +618,28 @@ class Exchange:
Checks if required startup_candles is more than ohlcv_candle_limit(). Checks if required startup_candles is more than ohlcv_candle_limit().
Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default. Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default.
""" """
candle_limit = self.ohlcv_candle_limit(timeframe)
candle_limit = self.ohlcv_candle_limit(
timeframe, self._config['candle_type_def'],
int(date_minus_candles(timeframe, startup_candles).timestamp() * 1000)
if timeframe else None)
# Require one more candle - to account for the still open candle. # Require one more candle - to account for the still open candle.
candle_count = startup_candles + 1 candle_count = startup_candles + 1
# Allow 5 calls to the exchange per pair # Allow 5 calls to the exchange per pair
required_candle_call_count = int( required_candle_call_count = int(
(candle_count / candle_limit) + (0 if candle_count % candle_limit == 0 else 1)) (candle_count / candle_limit) + (0 if candle_count % candle_limit == 0 else 1))
if self._ft_has['ohlcv_has_history']:
if required_candle_call_count > 5: if required_candle_call_count > 5:
# Only allow 5 calls per pair to somewhat limit the impact # Only allow 5 calls per pair to somewhat limit the impact
raise OperationalException(
f"This strategy requires {startup_candles} candles to start, "
"which is more than 5x "
f"the amount of candles {self.name} provides for {timeframe}.")
elif required_candle_call_count > 1:
raise OperationalException( raise OperationalException(
f"This strategy requires {startup_candles} candles to start, which is more than 5x " f"This strategy requires {startup_candles} candles to start, which is more than "
f"the amount of candles {self.name} provides for {timeframe}.") f"the amount of candles {self.name} provides for {timeframe}.")
if required_candle_call_count > 1: if required_candle_call_count > 1:
logger.warning(f"Using {required_candle_call_count} calls to get OHLCV. " logger.warning(f"Using {required_candle_call_count} calls to get OHLCV. "
f"This can result in slower operations for the bot. Please check " f"This can result in slower operations for the bot. Please check "
@ -682,10 +703,11 @@ class Exchange:
# counting_mode=self.precisionMode, # counting_mode=self.precisionMode,
# )) # ))
if self.precisionMode == TICK_SIZE: if self.precisionMode == TICK_SIZE:
precision = self.markets[pair]['precision']['price'] precision = Precise(str(self.markets[pair]['precision']['price']))
missing = price % precision price_str = Precise(str(price))
if missing != 0: missing = price_str % precision
price = round(price - missing + precision, 10) if not missing == Precise("0"):
price = round(float(str(price_str - missing + precision)), 14)
else: else:
symbol_prec = self.markets[pair]['precision']['price'] symbol_prec = self.markets[pair]['precision']['price']
big_price = price * pow(10, symbol_prec) big_price = price * pow(10, symbol_prec)
@ -931,19 +953,26 @@ class Exchange:
order = self.check_dry_limit_order_filled(order) order = self.check_dry_limit_order_filled(order)
return order return order
except KeyError as e: except KeyError as e:
from freqtrade.persistence import Order
order = Order.order_by_id(order_id)
if order:
ccxt_order = order.to_ccxt_object()
self._dry_run_open_orders[order_id] = ccxt_order
return ccxt_order
# Gracefully handle errors with dry-run orders. # Gracefully handle errors with dry-run orders.
raise InvalidOrderException( raise InvalidOrderException(
f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e
# Order handling # Order handling
def _lev_prep(self, pair: str, leverage: float, side: str): def _lev_prep(self, pair: str, leverage: float, side: BuySell):
if self.trading_mode != TradingMode.SPOT: if self.trading_mode != TradingMode.SPOT:
self.set_margin_mode(pair, self.margin_mode) self.set_margin_mode(pair, self.margin_mode)
self._set_leverage(leverage, pair) self._set_leverage(leverage, pair)
def _get_params( def _get_params(
self, self,
side: BuySell,
ordertype: str, ordertype: str,
leverage: float, leverage: float,
reduceOnly: bool, reduceOnly: bool,
@ -962,7 +991,7 @@ class Exchange:
*, *,
pair: str, pair: str,
ordertype: str, ordertype: str,
side: str, side: BuySell,
amount: float, amount: float,
rate: float, rate: float,
leverage: float, leverage: float,
@ -973,7 +1002,7 @@ class Exchange:
dry_order = self.create_dry_run_order(pair, ordertype, side, amount, rate, leverage) dry_order = self.create_dry_run_order(pair, ordertype, side, amount, rate, leverage)
return dry_order return dry_order
params = self._get_params(ordertype, leverage, reduceOnly, time_in_force) params = self._get_params(side, ordertype, leverage, reduceOnly, time_in_force)
try: try:
# Set the precision for amount and price(rate) as accepted by the exchange # Set the precision for amount and price(rate) as accepted by the exchange
@ -1058,7 +1087,7 @@ class Exchange:
@retrier(retries=0) @retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict, def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict,
side: str, leverage: float) -> Dict: side: BuySell, leverage: float) -> Dict:
""" """
creates a stoploss order. creates a stoploss order.
requires `_ft_has['stoploss_order_types']` to be set as a dict mapping limit and market requires `_ft_has['stoploss_order_types']` to be set as a dict mapping limit and market
@ -1135,7 +1164,7 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT) @retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
def fetch_order(self, order_id: str, pair: str, params={}) -> Dict: def fetch_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
if self._config['dry_run']: if self._config['dry_run']:
return self.fetch_dry_run_order(order_id) return self.fetch_dry_run_order(order_id)
try: try:
@ -1157,8 +1186,8 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
# Assign method to fetch_stoploss_order to allow easy overriding in other classes def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
fetch_stoploss_order = fetch_order return self.fetch_order(order_id, pair, params)
def fetch_order_or_stoploss_order(self, order_id: str, pair: str, def fetch_order_or_stoploss_order(self, order_id: str, pair: str,
stoploss_order: bool = False) -> Dict: stoploss_order: bool = False) -> Dict:
@ -1183,7 +1212,7 @@ class Exchange:
and order.get('filled') == 0.0) and order.get('filled') == 0.0)
@retrier @retrier
def cancel_order(self, order_id: str, pair: str, params={}) -> Dict: def cancel_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
if self._config['dry_run']: if self._config['dry_run']:
try: try:
order = self.fetch_dry_run_order(order_id) order = self.fetch_dry_run_order(order_id)
@ -1209,8 +1238,8 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
# Assign method to cancel_stoploss_order to allow easy overriding in other classes def cancel_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
cancel_stoploss_order = cancel_order return self.cancel_order(order_id, pair, params)
def is_cancel_order_result_suitable(self, corder) -> bool: def is_cancel_order_result_suitable(self, corder) -> bool:
if not isinstance(corder, dict): if not isinstance(corder, dict):
@ -1322,7 +1351,7 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier @retrier
def fetch_bids_asks(self, symbols: List[str] = None, cached: bool = False) -> Dict: def fetch_bids_asks(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
""" """
:param cached: Allow cached result :param cached: Allow cached result
:return: fetch_tickers result :return: fetch_tickers result
@ -1350,7 +1379,7 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier @retrier
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict: def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
""" """
:param cached: Allow cached result :param cached: Allow cached result
:return: fetch_tickers result :return: fetch_tickers result
@ -1434,6 +1463,23 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
def _get_price_side(self, side: str, is_short: bool, conf_strategy: Dict) -> str:
price_side = conf_strategy['price_side']
if price_side in ('same', 'other'):
price_map = {
('entry', 'long', 'same'): 'bid',
('entry', 'long', 'other'): 'ask',
('entry', 'short', 'same'): 'ask',
('entry', 'short', 'other'): 'bid',
('exit', 'long', 'same'): 'ask',
('exit', 'long', 'other'): 'bid',
('exit', 'short', 'same'): 'bid',
('exit', 'short', 'other'): 'ask',
}
price_side = price_map[(side, 'short' if is_short else 'long', price_side)]
return price_side
def get_rate(self, pair: str, refresh: bool, def get_rate(self, pair: str, refresh: bool,
side: EntryExit, is_short: bool) -> float: side: EntryExit, is_short: bool) -> float:
""" """
@ -1460,20 +1506,7 @@ class Exchange:
conf_strategy = self._config.get(strat_name, {}) conf_strategy = self._config.get(strat_name, {})
price_side = conf_strategy['price_side'] price_side = self._get_price_side(side, is_short, conf_strategy)
if price_side in ('same', 'other'):
price_map = {
('entry', 'long', 'same'): 'bid',
('entry', 'long', 'other'): 'ask',
('entry', 'short', 'same'): 'ask',
('entry', 'short', 'other'): 'bid',
('exit', 'long', 'same'): 'ask',
('exit', 'long', 'other'): 'bid',
('exit', 'short', 'same'): 'bid',
('exit', 'short', 'other'): 'ask',
}
price_side = price_map[(side, 'short' if is_short else 'long', price_side)]
price_side_word = price_side.capitalize() price_side_word = price_side.capitalize()
@ -1648,7 +1681,8 @@ class Exchange:
def get_historic_ohlcv(self, pair: str, timeframe: str, def get_historic_ohlcv(self, pair: str, timeframe: str,
since_ms: int, candle_type: CandleType, since_ms: int, candle_type: CandleType,
is_new_pair: bool = False) -> List: is_new_pair: bool = False,
until_ms: int = None) -> List:
""" """
Get candle history using asyncio and returns the list of candles. Get candle history using asyncio and returns the list of candles.
Handles all async work for this. Handles all async work for this.
@ -1656,13 +1690,14 @@ class Exchange:
:param pair: Pair to download :param pair: Pair to download
:param timeframe: Timeframe to get data for :param timeframe: Timeframe to get data for
:param since_ms: Timestamp in milliseconds to get history from :param since_ms: Timestamp in milliseconds to get history from
:param until_ms: Timestamp in milliseconds to get history up to
:param candle_type: '', mark, index, premiumIndex, or funding_rate :param candle_type: '', mark, index, premiumIndex, or funding_rate
:return: List with candle (OHLCV) data :return: List with candle (OHLCV) data
""" """
pair, _, _, data = self.loop.run_until_complete( pair, _, _, data = self.loop.run_until_complete(
self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe, self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe,
since_ms=since_ms, is_new_pair=is_new_pair, since_ms=since_ms, until_ms=until_ms,
candle_type=candle_type)) is_new_pair=is_new_pair, candle_type=candle_type))
logger.info(f"Downloaded data for {pair} with length {len(data)}.") logger.info(f"Downloaded data for {pair} with length {len(data)}.")
return data return data
@ -1683,6 +1718,7 @@ class Exchange:
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str, async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
since_ms: int, candle_type: CandleType, since_ms: int, candle_type: CandleType,
is_new_pair: bool = False, raise_: bool = False, is_new_pair: bool = False, raise_: bool = False,
until_ms: Optional[int] = None
) -> Tuple[str, str, str, List]: ) -> Tuple[str, str, str, List]:
""" """
Download historic ohlcv Download historic ohlcv
@ -1690,7 +1726,8 @@ class Exchange:
:param candle_type: Any of the enum CandleType (must match trading mode!) :param candle_type: Any of the enum CandleType (must match trading mode!)
""" """
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(
timeframe, candle_type, since_ms)
logger.debug( logger.debug(
"one_call: %s msecs (%s)", "one_call: %s msecs (%s)",
one_call, one_call,
@ -1698,7 +1735,7 @@ class Exchange:
) )
input_coroutines = [self._async_get_candle_history( input_coroutines = [self._async_get_candle_history(
pair, timeframe, candle_type, since) for since in pair, timeframe, candle_type, since) for since in
range(since_ms, arrow.utcnow().int_timestamp * 1000, one_call)] range(since_ms, until_ms or (arrow.utcnow().int_timestamp * 1000), one_call)]
data: List = [] data: List = []
# Chunk requests into batches of 100 to avoid overwelming ccxt Throttling # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling
@ -1726,7 +1763,8 @@ class Exchange:
if (not since_ms if (not since_ms
and (self._ft_has["ohlcv_require_since"] or self.required_candle_call_count > 1)): and (self._ft_has["ohlcv_require_since"] or self.required_candle_call_count > 1)):
# Multiple calls for one pair - to get more history # Multiple calls for one pair - to get more history
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(
timeframe, candle_type, since_ms)
move_to = one_call * self.required_candle_call_count move_to = one_call * self.required_candle_call_count
now = timeframe_to_next_date(timeframe) now = timeframe_to_next_date(timeframe)
since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000) since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000)
@ -1741,7 +1779,7 @@ class Exchange:
def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *, def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *,
since_ms: Optional[int] = None, cache: bool = True, since_ms: Optional[int] = None, cache: bool = True,
drop_incomplete: bool = None drop_incomplete: Optional[bool] = None
) -> Dict[PairWithTimeframe, DataFrame]: ) -> Dict[PairWithTimeframe, DataFrame]:
""" """
Refresh in-memory OHLCV asynchronously and set `_klines` with the result Refresh in-memory OHLCV asynchronously and set `_klines` with the result
@ -1844,7 +1882,9 @@ class Exchange:
pair, timeframe, since_ms, s pair, timeframe, since_ms, s
) )
params = deepcopy(self._ft_has.get('ohlcv_params', {})) params = deepcopy(self._ft_has.get('ohlcv_params', {}))
candle_limit = self.ohlcv_candle_limit(timeframe) candle_limit = self.ohlcv_candle_limit(
timeframe, candle_type=candle_type, since_ms=since_ms)
if candle_type != CandleType.SPOT: if candle_type != CandleType.SPOT:
params.update({'price': candle_type}) params.update({'price': candle_type})
if candle_type != CandleType.FUNDING_RATE: if candle_type != CandleType.FUNDING_RATE:
@ -2379,14 +2419,35 @@ class Exchange:
) )
@staticmethod @staticmethod
def combine_funding_and_mark(funding_rates: DataFrame, mark_rates: DataFrame) -> DataFrame: def combine_funding_and_mark(funding_rates: DataFrame, mark_rates: DataFrame,
futures_funding_rate: Optional[int] = None) -> DataFrame:
""" """
Combine funding-rates and mark-rates dataframes Combine funding-rates and mark-rates dataframes
:param funding_rates: Dataframe containing Funding rates (Type FUNDING_RATE) :param funding_rates: Dataframe containing Funding rates (Type FUNDING_RATE)
:param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price) :param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price)
:param futures_funding_rate: Fake funding rate to use if funding_rates are not available
""" """
if futures_funding_rate is None:
return mark_rates.merge(
funding_rates, on='date', how="inner", suffixes=["_mark", "_fund"])
else:
if len(funding_rates) == 0:
# No funding rate candles - full fillup with fallback variable
mark_rates['open_fund'] = futures_funding_rate
return mark_rates.rename(
columns={'open': 'open_mark',
'close': 'close_mark',
'high': 'high_mark',
'low': 'low_mark',
'volume': 'volume_mark'})
return funding_rates.merge(mark_rates, on='date', how="inner", suffixes=["_fund", "_mark"]) else:
# Fill up missing funding_rate candles with fallback value
combined = mark_rates.merge(
funding_rates, on='date', how="outer", suffixes=["_mark", "_fund"]
)
combined['open_fund'] = combined['open_fund'].fillna(futures_funding_rate)
return combined
def calculate_funding_fees( def calculate_funding_fees(
self, self,
@ -2661,9 +2722,10 @@ def timeframe_to_msecs(timeframe: str) -> int:
def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime: def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime:
""" """
Use Timeframe and determine last possible candle. Use Timeframe and determine the candle start date for this date.
Does not round when given a candle start date.
:param timeframe: timeframe in string format (e.g. "5m") :param timeframe: timeframe in string format (e.g. "5m")
:param date: date to use. Defaults to utcnow() :param date: date to use. Defaults to now(utc)
:returns: date of previous candle (with utc timezone) :returns: date of previous candle (with utc timezone)
""" """
if not date: if not date:
@ -2678,7 +2740,7 @@ def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
""" """
Use Timeframe and determine next candle. Use Timeframe and determine next candle.
:param timeframe: timeframe in string format (e.g. "5m") :param timeframe: timeframe in string format (e.g. "5m")
:param date: date to use. Defaults to utcnow() :param date: date to use. Defaults to now(utc)
:returns: date of next candle (with utc timezone) :returns: date of next candle (with utc timezone)
""" """
if not date: if not date:
@ -2688,6 +2750,23 @@ def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
def date_minus_candles(
timeframe: str, candle_count: int, date: Optional[datetime] = None) -> datetime:
"""
subtract X candles from a date.
:param timeframe: timeframe in string format (e.g. "5m")
:param candle_count: Amount of candles to subtract.
:param date: date to use. Defaults to now(utc)
"""
if not date:
date = datetime.now(timezone.utc)
tf_min = timeframe_to_minutes(timeframe)
new_date = timeframe_to_prev_date(timeframe, date) - timedelta(minutes=tf_min * candle_count)
return new_date
def market_is_active(market: Dict) -> bool: def market_is_active(market: Dict) -> bool:
""" """
Return True if the market is active. Return True if the market is active.

View File

@ -4,6 +4,7 @@ from typing import Any, Dict, List, Tuple
import ccxt import ccxt
from freqtrade.constants import BuySell
from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums import MarginMode, TradingMode
from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException, from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException,
OperationalException, TemporaryError) OperationalException, TemporaryError)
@ -44,7 +45,7 @@ class Ftx(Exchange):
@retrier(retries=0) @retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, def stoploss(self, pair: str, amount: float, stop_price: float,
order_types: Dict, side: str, leverage: float) -> Dict: order_types: Dict, side: BuySell, leverage: float) -> Dict:
""" """
Creates a stoploss order. Creates a stoploss order.
depending on order_types.stoploss configuration, uses 'market' or limit order. depending on order_types.stoploss configuration, uses 'market' or limit order.
@ -103,7 +104,7 @@ class Ftx(Exchange):
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT) @retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
def fetch_stoploss_order(self, order_id: str, pair: str) -> Dict: def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
if self._config['dry_run']: if self._config['dry_run']:
return self.fetch_dry_run_order(order_id) return self.fetch_dry_run_order(order_id)
@ -144,7 +145,7 @@ class Ftx(Exchange):
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier @retrier
def cancel_stoploss_order(self, order_id: str, pair: str) -> Dict: def cancel_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
if self._config['dry_run']: if self._config['dry_run']:
return {} return {}
try: try:

View File

@ -71,14 +71,14 @@ class Gateio(Exchange):
} }
return trades return trades
def fetch_stoploss_order(self, order_id: str, pair: str, params={}) -> Dict: def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
return self.fetch_order( return self.fetch_order(
order_id=order_id, order_id=order_id,
pair=pair, pair=pair,
params={'stop': True} params={'stop': True}
) )
def cancel_stoploss_order(self, order_id: str, pair: str, params={}) -> Dict: def cancel_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
return self.cancel_order( return self.cancel_order(
order_id=order_id, order_id=order_id,
pair=pair, pair=pair,

View File

@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple
import ccxt import ccxt
from pandas import DataFrame from pandas import DataFrame
from freqtrade.constants import BuySell
from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums import MarginMode, TradingMode
from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException, from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException,
OperationalException, TemporaryError) OperationalException, TemporaryError)
@ -22,6 +23,7 @@ class Kraken(Exchange):
_ft_has: Dict = { _ft_has: Dict = {
"stoploss_on_exchange": True, "stoploss_on_exchange": True,
"ohlcv_candle_limit": 720, "ohlcv_candle_limit": 720,
"ohlcv_has_history": False,
"trades_pagination": "id", "trades_pagination": "id",
"trades_pagination_arg": "since", "trades_pagination_arg": "since",
"mark_ohlcv_timeframe": "4h", "mark_ohlcv_timeframe": "4h",
@ -43,7 +45,7 @@ class Kraken(Exchange):
return (parent_check and return (parent_check and
market.get('darkpool', False) is False) market.get('darkpool', False) is False)
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict: def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
# Only fetch tickers for current stake currency # Only fetch tickers for current stake currency
# Otherwise the request for kraken becomes too large. # Otherwise the request for kraken becomes too large.
symbols = list(self.get_markets(quote_currencies=[self._config['stake_currency']])) symbols = list(self.get_markets(quote_currencies=[self._config['stake_currency']]))
@ -95,7 +97,7 @@ class Kraken(Exchange):
@retrier(retries=0) @retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, def stoploss(self, pair: str, amount: float, stop_price: float,
order_types: Dict, side: str, leverage: float) -> Dict: order_types: Dict, side: BuySell, leverage: float) -> Dict:
""" """
Creates a stoploss market order. Creates a stoploss market order.
Stoploss market orders is the only stoploss type supported by kraken. Stoploss market orders is the only stoploss type supported by kraken.
@ -165,12 +167,14 @@ class Kraken(Exchange):
def _get_params( def _get_params(
self, self,
side: BuySell,
ordertype: str, ordertype: str,
leverage: float, leverage: float,
reduceOnly: bool, reduceOnly: bool,
time_in_force: str = 'gtc' time_in_force: str = 'gtc'
) -> Dict: ) -> Dict:
params = super()._get_params( params = super()._get_params(
side=side,
ordertype=ordertype, ordertype=ordertype,
leverage=leverage, leverage=leverage,
reduceOnly=reduceOnly, reduceOnly=reduceOnly,

View File

@ -1,12 +1,15 @@
import logging import logging
from typing import Dict, List, Tuple from typing import Dict, List, Optional, Tuple
import ccxt import ccxt
from freqtrade.constants import BuySell
from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums import MarginMode, TradingMode
from freqtrade.enums.candletype import CandleType
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange import date_minus_candles
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,7 +22,7 @@ class Okx(Exchange):
""" """
_ft_has: Dict = { _ft_has: Dict = {
"ohlcv_candle_limit": 300, "ohlcv_candle_limit": 100, # Warning, special case with data prior to X months
"mark_ohlcv_timeframe": "4h", "mark_ohlcv_timeframe": "4h",
"funding_fee_timeframe": "8h", "funding_fee_timeframe": "8h",
} }
@ -34,14 +37,69 @@ class Okx(Exchange):
(TradingMode.FUTURES, MarginMode.ISOLATED), (TradingMode.FUTURES, MarginMode.ISOLATED),
] ]
net_only = True
def ohlcv_candle_limit(
self, timeframe: str, candle_type: CandleType, since_ms: Optional[int] = None) -> int:
"""
Exchange ohlcv candle limit
OKX has the following behaviour:
* 300 candles for uptodate data
* 100 candles for historic data
* 100 candles for additional candles (not futures or spot).
:param timeframe: Timeframe to check
:param candle_type: Candle-type
:param since_ms: Starting timestamp
:return: Candle limit as integer
"""
if (
candle_type in (CandleType.FUTURES, CandleType.SPOT) and
(not since_ms or since_ms > (date_minus_candles(timeframe, 300).timestamp() * 1000))
):
return 300
return super().ohlcv_candle_limit(timeframe, candle_type, since_ms)
@retrier
def additional_exchange_init(self) -> None:
"""
Additional exchange initialization logic.
.api will be available at this point.
Must be overridden in child methods if required.
"""
try:
if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']:
accounts = self._api.fetch_accounts()
if len(accounts) > 0:
self.net_only = accounts[0].get('info', {}).get('posMode') == 'net_mode'
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
def _get_posSide(self, side: BuySell, reduceOnly: bool):
if self.net_only:
return 'net'
if not reduceOnly:
# Enter
return 'long' if side == 'buy' else 'short'
else:
# Exit
return 'long' if side == 'sell' else 'short'
def _get_params( def _get_params(
self, self,
side: BuySell,
ordertype: str, ordertype: str,
leverage: float, leverage: float,
reduceOnly: bool, reduceOnly: bool,
time_in_force: str = 'gtc', time_in_force: str = 'gtc',
) -> Dict: ) -> Dict:
params = super()._get_params( params = super()._get_params(
side=side,
ordertype=ordertype, ordertype=ordertype,
leverage=leverage, leverage=leverage,
reduceOnly=reduceOnly, reduceOnly=reduceOnly,
@ -49,10 +107,11 @@ class Okx(Exchange):
) )
if self.trading_mode == TradingMode.FUTURES and self.margin_mode: if self.trading_mode == TradingMode.FUTURES and self.margin_mode:
params['tdMode'] = self.margin_mode.value params['tdMode'] = self.margin_mode.value
params['posSide'] = self._get_posSide(side, reduceOnly)
return params return params
@retrier @retrier
def _lev_prep(self, pair: str, leverage: float, side: str): def _lev_prep(self, pair: str, leverage: float, side: BuySell):
if self.trading_mode != TradingMode.SPOT and self.margin_mode is not None: if self.trading_mode != TradingMode.SPOT and self.margin_mode is not None:
try: try:
# TODO-lev: Test me properly (check mgnMode passed) # TODO-lev: Test me properly (check mgnMode passed)
@ -61,7 +120,7 @@ class Okx(Exchange):
symbol=pair, symbol=pair,
params={ params={
"mgnMode": self.margin_mode.value, "mgnMode": self.margin_mode.value,
# "posSide": "net"", "posSide": self._get_posSide(side, False),
}) })
except ccxt.DDoSProtection as e: except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e raise DDosProtection(e) from e

View File

@ -13,7 +13,7 @@ from schedule import Scheduler
from freqtrade import __version__, constants from freqtrade import __version__, constants
from freqtrade.configuration import validate_config_consistency from freqtrade.configuration import validate_config_consistency
from freqtrade.constants import LongShort from freqtrade.constants import BuySell, LongShort
from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.converter import order_book_to_dataframe
from freqtrade.data.dataprovider import DataProvider from freqtrade.data.dataprovider import DataProvider
from freqtrade.edge import Edge from freqtrade.edge import Edge
@ -22,6 +22,7 @@ from freqtrade.enums import (ExitCheckTuple, ExitType, RPCMessageType, RunMode,
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
InvalidOrderException, PricingError) InvalidOrderException, PricingError)
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.exchange.exchange import timeframe_to_next_date
from freqtrade.misc import safe_value_fallback, safe_value_fallback2 from freqtrade.misc import safe_value_fallback, safe_value_fallback2
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db
@ -66,7 +67,7 @@ class FreqtradeBot(LoggingMixin):
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
init_db(self.config.get('db_url', None), clean_open_orders=self.config['dry_run']) init_db(self.config.get('db_url', None))
self.wallets = Wallets(self.config, self.exchange) self.wallets = Wallets(self.config, self.exchange)
@ -122,7 +123,7 @@ class FreqtradeBot(LoggingMixin):
self._schedule.every().day.at(t).do(update) self._schedule.every().day.at(t).do(update)
self.last_process = datetime(1970, 1, 1, tzinfo=timezone.utc) self.last_process = datetime(1970, 1, 1, tzinfo=timezone.utc)
self.strategy.bot_start() self.strategy.ft_bot_start()
def notify_status(self, msg: str) -> None: def notify_status(self, msg: str) -> None:
""" """
@ -190,8 +191,8 @@ class FreqtradeBot(LoggingMixin):
self.strategy.analyze(self.active_pair_whitelist) self.strategy.analyze(self.active_pair_whitelist)
with self._exit_lock: with self._exit_lock:
# Check and handle any timed out open orders # Check for exchange cancelations, timeouts and user requested replace
self.check_handle_timedout() self.manage_open_orders()
# Protect from collisions with force_exit. # Protect from collisions with force_exit.
# Without this, freqtrade my try to recreate stoploss_on_exchange orders # Without this, freqtrade my try to recreate stoploss_on_exchange orders
@ -298,7 +299,8 @@ class FreqtradeBot(LoggingMixin):
fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair, fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair,
order.ft_order_side == 'stoploss') order.ft_order_side == 'stoploss')
self.update_trade_state(order.trade, order.order_id, fo) self.update_trade_state(order.trade, order.order_id, fo,
stoploss_order=(order.ft_order_side == 'stoploss'))
except ExchangeError as e: except ExchangeError as e:
@ -401,7 +403,10 @@ class FreqtradeBot(LoggingMixin):
logger.info("No currency pair in active pair whitelist, " logger.info("No currency pair in active pair whitelist, "
"but checking to exit open trades.") "but checking to exit open trades.")
return trades_created return trades_created
if PairLocks.is_global_lock(): if PairLocks.is_global_lock(side='*'):
# This only checks for total locks (both sides).
# per-side locks will be evaluated by `is_pair_locked` within create_trade,
# once the direction for the trade is clear.
lock = PairLocks.get_pair_longest_lock('*') lock = PairLocks.get_pair_longest_lock('*')
if lock: if lock:
self.log_once(f"Global pairlock active until " self.log_once(f"Global pairlock active until "
@ -435,16 +440,6 @@ class FreqtradeBot(LoggingMixin):
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe)
nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None
if self.strategy.is_pair_locked(pair, nowtime):
lock = PairLocks.get_pair_longest_lock(pair, nowtime)
if lock:
self.log_once(f"Pair {pair} is still locked until "
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} "
f"due to {lock.reason}.",
logger.info)
else:
self.log_once(f"Pair {pair} is still locked.", logger.info)
return False
# get_free_open_trades is checked before create_trade is called # get_free_open_trades is checked before create_trade is called
# but it is still used here to prevent opening too many trades within one iteration # but it is still used here to prevent opening too many trades within one iteration
@ -460,6 +455,16 @@ class FreqtradeBot(LoggingMixin):
) )
if signal: if signal:
if self.strategy.is_pair_locked(pair, candle_date=nowtime, side=signal):
lock = PairLocks.get_pair_longest_lock(pair, nowtime, signal)
if lock:
self.log_once(f"Pair {pair} {lock.side} is locked until "
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} "
f"due to {lock.reason}.",
logger.info)
else:
self.log_once(f"Pair {pair} is currently locked.", logger.info)
return False
stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge) stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge)
bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {}) bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {})
@ -532,7 +537,8 @@ class FreqtradeBot(LoggingMixin):
if stake_amount is not None and stake_amount > 0.0: if stake_amount is not None and stake_amount > 0.0:
# We should increase our position # We should increase our position
self.execute_entry(trade.pair, stake_amount, trade=trade, is_short=trade.is_short) self.execute_entry(trade.pair, stake_amount, price=current_rate,
trade=trade, is_short=trade.is_short)
if stake_amount is not None and stake_amount < 0.0: if stake_amount is not None and stake_amount < 0.0:
# We should decrease our position # We should decrease our position
@ -582,6 +588,7 @@ class FreqtradeBot(LoggingMixin):
ordertype: Optional[str] = None, ordertype: Optional[str] = None,
enter_tag: Optional[str] = None, enter_tag: Optional[str] = None,
trade: Optional[Trade] = None, trade: Optional[Trade] = None,
order_adjust: bool = False
) -> bool: ) -> bool:
""" """
Executes a limit buy for the given pair Executes a limit buy for the given pair
@ -591,12 +598,13 @@ class FreqtradeBot(LoggingMixin):
""" """
time_in_force = self.strategy.order_time_in_force['entry'] time_in_force = self.strategy.order_time_in_force['entry']
[side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long'] side: BuySell = 'sell' if is_short else 'buy'
name = 'Short' if is_short else 'Long'
trade_side: LongShort = 'short' if is_short else 'long' trade_side: LongShort = 'short' if is_short else 'long'
pos_adjust = trade is not None pos_adjust = trade is not None
enter_limit_requested, stake_amount, leverage = self.get_valid_enter_price_and_stake( enter_limit_requested, stake_amount, leverage = self.get_valid_enter_price_and_stake(
pair, price, stake_amount, trade_side, enter_tag, trade) pair, price, stake_amount, trade_side, enter_tag, trade, order_adjust)
if not stake_amount: if not stake_amount:
return False return False
@ -741,23 +749,26 @@ class FreqtradeBot(LoggingMixin):
self, pair: str, price: Optional[float], stake_amount: float, self, pair: str, price: Optional[float], stake_amount: float,
trade_side: LongShort, trade_side: LongShort,
entry_tag: Optional[str], entry_tag: Optional[str],
trade: Optional[Trade] trade: Optional[Trade],
order_adjust: bool,
) -> Tuple[float, float, float]: ) -> Tuple[float, float, float]:
if price: if price:
enter_limit_requested = price enter_limit_requested = price
else: else:
# Calculate price # Calculate price
proposed_enter_rate = self.exchange.get_rate( enter_limit_requested = self.exchange.get_rate(
pair, side='entry', is_short=(trade_side == 'short'), refresh=True) pair, side='entry', is_short=(trade_side == 'short'), refresh=True)
if not order_adjust:
# Don't call custom_entry_price in order-adjust scenario
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price, custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=proposed_enter_rate)( default_retval=enter_limit_requested)(
pair=pair, current_time=datetime.now(timezone.utc), pair=pair, current_time=datetime.now(timezone.utc),
proposed_rate=proposed_enter_rate, entry_tag=entry_tag, proposed_rate=enter_limit_requested, entry_tag=entry_tag,
side=trade_side, side=trade_side,
) )
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate) enter_limit_requested = self.get_valid_price(custom_entry_price, enter_limit_requested)
if not enter_limit_requested: if not enter_limit_requested:
raise PricingError('Could not determine entry price.') raise PricingError('Could not determine entry price.')
@ -826,7 +837,7 @@ class FreqtradeBot(LoggingMixin):
'type': msg_type, 'type': msg_type,
'buy_tag': trade.enter_tag, 'buy_tag': trade.enter_tag,
'enter_tag': trade.enter_tag, 'enter_tag': trade.enter_tag,
'exchange': self.exchange.name.capitalize(), 'exchange': trade.exchange.capitalize(),
'pair': trade.pair, 'pair': trade.pair,
'leverage': trade.leverage if trade.leverage else None, 'leverage': trade.leverage if trade.leverage else None,
'direction': 'Short' if trade.is_short else 'Long', 'direction': 'Short' if trade.is_short else 'Long',
@ -856,7 +867,7 @@ class FreqtradeBot(LoggingMixin):
'type': RPCMessageType.ENTRY_CANCEL, 'type': RPCMessageType.ENTRY_CANCEL,
'buy_tag': trade.enter_tag, 'buy_tag': trade.enter_tag,
'enter_tag': trade.enter_tag, 'enter_tag': trade.enter_tag,
'exchange': self.exchange.name.capitalize(), 'exchange': trade.exchange.capitalize(),
'pair': trade.pair, 'pair': trade.pair,
'leverage': trade.leverage, 'leverage': trade.leverage,
'direction': 'Short' if trade.is_short else 'Long', 'direction': 'Short' if trade.is_short else 'Long',
@ -1008,7 +1019,7 @@ class FreqtradeBot(LoggingMixin):
# Lock pair for one candle to prevent immediate rebuys # Lock pair for one candle to prevent immediate rebuys
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
reason='Auto lock') reason='Auto lock')
self._notify_exit(trade, "stoploss") self._notify_exit(trade, "stoploss", True)
return True return True
if trade.open_order_id or not trade.is_open: if trade.open_order_id or not trade.is_open:
@ -1095,7 +1106,7 @@ class FreqtradeBot(LoggingMixin):
""" """
Check and execute trade exit Check and execute trade exit
""" """
should_exit: ExitCheckTuple = self.strategy.should_exit( exits: List[ExitCheckTuple] = self.strategy.should_exit(
trade, trade,
exit_rate, exit_rate,
datetime.now(timezone.utc), datetime.now(timezone.utc),
@ -1103,21 +1114,22 @@ class FreqtradeBot(LoggingMixin):
exit_=exit_, exit_=exit_,
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0 force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
) )
for should_exit in exits:
if should_exit.exit_flag: if should_exit.exit_flag:
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}' logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}'
f'Tag: {exit_tag if exit_tag is not None else "None"}') f'{f" Tag: {exit_tag}" if exit_tag is not None else ""}')
self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag) exited = self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag)
return True if exited:
return True
return False return False
def check_handle_timedout(self) -> None: def manage_open_orders(self) -> None:
""" """
Check if any orders are timed out and cancel if necessary Management of open orders on exchange. Unfilled orders might be cancelled if timeout
:param timeoutvalue: Number of minutes until order is considered timed out was met or replaced if there's a new candle and user has requested it.
Timeout setting takes priority over limit order adjustment request.
:return: None :return: None
""" """
for trade in Trade.get_open_order_trades(): for trade in Trade.get_open_order_trades():
try: try:
if not trade.open_order_id: if not trade.open_order_id:
@ -1128,33 +1140,88 @@ class FreqtradeBot(LoggingMixin):
continue continue
fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order) fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order)
is_entering = order['side'] == trade.entry_side
not_closed = order['status'] == 'open' or fully_cancelled not_closed = order['status'] == 'open' or fully_cancelled
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
order_obj = trade.select_order_by_order_id(trade.open_order_id) order_obj = trade.select_order_by_order_id(trade.open_order_id)
if not_closed and (fully_cancelled or (order_obj and self.strategy.ft_check_timed_out( if not_closed:
trade, order_obj, datetime.now(timezone.utc))) if fully_cancelled or (order_obj and self.strategy.ft_check_timed_out(
): trade, order_obj, datetime.now(timezone.utc))):
if is_entering: self.handle_timedout_order(order, trade)
self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
else: else:
canceled = self.handle_cancel_exit( self.replace_order(order, order_obj, trade)
trade, order, constants.CANCEL_REASON['TIMEOUT'])
canceled_count = trade.get_exit_order_count() def handle_timedout_order(self, order: Dict, trade: Trade) -> None:
max_timeouts = self.config.get( """
'unfilledtimeout', {}).get('exit_timeout_count', 0) Check if current analyzed order timed out and cancel if necessary.
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts: :param order: Order dict grabbed with exchange.fetch_order()
logger.warning(f'Emergency exiting trade {trade}, as the exit order ' :param trade: Trade object.
f'timed out {max_timeouts} times.') :return: None
try: """
self.execute_trade_exit( if order['side'] == trade.entry_side:
trade, order.get('price'), self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
exit_check=ExitCheckTuple(exit_type=ExitType.EMERGENCY_EXIT)) else:
except DependencyException as exception: canceled = self.handle_cancel_exit(
logger.warning( trade, order, constants.CANCEL_REASON['TIMEOUT'])
f'Unable to emergency sell trade {trade.pair}: {exception}') canceled_count = trade.get_exit_order_count()
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
logger.warning(f'Emergency exiting trade {trade}, as the exit order '
f'timed out {max_timeouts} times.')
try:
self.execute_trade_exit(
trade, order['price'],
exit_check=ExitCheckTuple(exit_type=ExitType.EMERGENCY_EXIT))
except DependencyException as exception:
logger.warning(
f'Unable to emergency sell trade {trade.pair}: {exception}')
def replace_order(self, order: Dict, order_obj: Optional[Order], trade: Trade) -> None:
"""
Check if current analyzed entry order should be replaced or simply cancelled.
To simply cancel the existing order(no replacement) adjust_entry_price() should return None
To maintain existing order adjust_entry_price() should return order_obj.price
To replace existing order adjust_entry_price() should return desired price for limit order
:param order: Order dict grabbed with exchange.fetch_order()
:param order_obj: Order object.
:param trade: Trade object.
:return: None
"""
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
self.strategy.timeframe)
latest_candle_open_date = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None
latest_candle_close_date = timeframe_to_next_date(self.strategy.timeframe,
latest_candle_open_date)
# Check if new candle
if order_obj and latest_candle_close_date > order_obj.order_date_utc:
# New candle
proposed_rate = self.exchange.get_rate(
trade.pair, side='entry', is_short=trade.is_short, refresh=True)
adjusted_entry_price = strategy_safe_wrapper(self.strategy.adjust_entry_price,
default_retval=order_obj.price)(
trade=trade, order=order_obj, pair=trade.pair,
current_time=datetime.now(timezone.utc), proposed_rate=proposed_rate,
current_order_rate=order_obj.price, entry_tag=trade.enter_tag,
side=trade.entry_side)
full_cancel = False
cancel_reason = constants.CANCEL_REASON['REPLACE']
if not adjusted_entry_price:
full_cancel = True if trade.nr_of_successful_entries == 0 else False
cancel_reason = constants.CANCEL_REASON['USER_CANCEL']
if order_obj.price != adjusted_entry_price:
# cancel existing order if new price is supplied or None
self.handle_cancel_enter(trade, order, cancel_reason,
allow_full_cancel=full_cancel)
if adjusted_entry_price:
# place new order only if new price is supplied
self.execute_entry(
pair=trade.pair,
stake_amount=(order_obj.remaining * order_obj.price),
price=adjusted_entry_price,
trade=trade,
is_short=trade.is_short,
order_adjust=True,
)
def cancel_all_open_orders(self) -> None: def cancel_all_open_orders(self) -> None:
""" """
@ -1176,7 +1243,10 @@ class FreqtradeBot(LoggingMixin):
self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
Trade.commit() Trade.commit()
def handle_cancel_enter(self, trade: Trade, order: Dict, reason: str) -> bool: def handle_cancel_enter(
self, trade: Trade, order: Dict, reason: str,
allow_full_cancel: Optional[bool] = True
) -> bool:
""" """
Buy cancel - cancel order Buy cancel - cancel order
:return: True if order was fully cancelled :return: True if order was fully cancelled
@ -1214,9 +1284,10 @@ class FreqtradeBot(LoggingMixin):
# Using filled to determine the filled amount # Using filled to determine the filled amount
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
# if trade is not partially completed and it's the only order, just delete the trade # if trade is not partially completed and it's the only order, just delete the trade
if len(trade.orders) <= 1: open_order_count = len([order for order in trade.orders if order.status == 'open'])
if open_order_count <= 1 and allow_full_cancel:
logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
trade.delete() trade.delete()
was_trade_fully_canceled = True was_trade_fully_canceled = True
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
@ -1336,7 +1407,7 @@ class FreqtradeBot(LoggingMixin):
:param trade: Trade instance :param trade: Trade instance
:param limit: limit rate for the sell order :param limit: limit rate for the sell order
:param exit_check: CheckTuple with signal and reason :param exit_check: CheckTuple with signal and reason
:return: True if it succeeds (supported) False (not supported) :return: True if it succeeds False
""" """
trade.funding_fees = self.exchange.get_funding_fees( trade.funding_fees = self.exchange.get_funding_fees(
pair=trade.pair, pair=trade.pair,
@ -1345,6 +1416,7 @@ class FreqtradeBot(LoggingMixin):
open_date=trade.open_date_utc, open_date=trade.open_date_utc,
) )
exit_type = 'exit' exit_type = 'exit'
exit_reason = exit_tag or exit_check.exit_reason
if exit_check.exit_type in (ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS): if exit_check.exit_type in (ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS):
exit_type = 'stoploss' exit_type = 'stoploss'
@ -1362,7 +1434,7 @@ class FreqtradeBot(LoggingMixin):
pair=trade.pair, trade=trade, pair=trade.pair, trade=trade,
current_time=datetime.now(timezone.utc), current_time=datetime.now(timezone.utc),
proposed_rate=proposed_limit_rate, current_profit=current_profit, proposed_rate=proposed_limit_rate, current_profit=current_profit,
exit_tag=exit_check.exit_reason) exit_tag=exit_reason)
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
@ -1379,10 +1451,10 @@ class FreqtradeBot(LoggingMixin):
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit, pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit,
time_in_force=time_in_force, exit_reason=exit_check.exit_reason, time_in_force=time_in_force, exit_reason=exit_reason,
sell_reason=exit_check.exit_reason, # sellreason -> compatibility sell_reason=exit_reason, # sellreason -> compatibility
current_time=datetime.now(timezone.utc)): current_time=datetime.now(timezone.utc)):
logger.info(f"User requested abortion of exiting {trade.pair}") logger.info(f"User requested abortion of {trade.pair} exit.")
return False return False
try: try:
@ -1409,7 +1481,7 @@ class FreqtradeBot(LoggingMixin):
trade.open_order_id = order['id'] trade.open_order_id = order['id']
trade.exit_order_status = '' trade.exit_order_status = ''
trade.close_rate_requested = limit trade.close_rate_requested = limit
trade.exit_reason = exit_tag or exit_check.exit_reason trade.exit_reason = exit_reason
# Lock pair for one candle to prevent immediate re-trading # Lock pair for one candle to prevent immediate re-trading
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
@ -1575,7 +1647,6 @@ class FreqtradeBot(LoggingMixin):
# TODO: Margin will need to use interest_rate as well. # TODO: Margin will need to use interest_rate as well.
# interest_rate = self.exchange.get_interest_rate() # interest_rate = self.exchange.get_interest_rate()
trade.set_isolated_liq(self.exchange.get_liquidation_price( trade.set_isolated_liq(self.exchange.get_liquidation_price(
leverage=trade.leverage, leverage=trade.leverage,
pair=trade.pair, pair=trade.pair,
amount=trade.amount, amount=trade.amount,
@ -1593,21 +1664,21 @@ class FreqtradeBot(LoggingMixin):
if not trade.is_open: if not trade.is_open:
if send_msg and not stoploss_order and not trade.open_order_id: if send_msg and not stoploss_order and not trade.open_order_id:
self._notify_exit(trade, '', True) self._notify_exit(trade, '', True)
self.handle_protections(trade.pair) self.handle_protections(trade.pair, trade.trade_direction)
elif send_msg and not trade.open_order_id: elif send_msg and not trade.open_order_id and not stoploss_order:
# Enter fill # Enter fill
self._notify_enter(trade, order, fill=True) self._notify_enter(trade, order, fill=True)
return False return False
def handle_protections(self, pair: str) -> None: def handle_protections(self, pair: str, side: LongShort) -> None:
prot_trig = self.protections.stop_per_pair(pair) prot_trig = self.protections.stop_per_pair(pair, side=side)
if prot_trig: if prot_trig:
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, } msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
msg.update(prot_trig.to_json()) msg.update(prot_trig.to_json())
self.rpc.send_msg(msg) self.rpc.send_msg(msg)
prot_trig_glb = self.protections.global_stop() prot_trig_glb = self.protections.global_stop(side=side)
if prot_trig_glb: if prot_trig_glb:
msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, } msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, }
msg.update(prot_trig_glb.to_json()) msg.update(prot_trig_glb.to_json())

View File

@ -187,7 +187,7 @@ class Backtesting:
# since a "perfect" stoploss-exit is assumed anyway # since a "perfect" stoploss-exit is assumed anyway
# And the regular "stoploss" function would not apply to that case # And the regular "stoploss" function would not apply to that case
self.strategy.order_types['stoploss_on_exchange'] = False self.strategy.order_types['stoploss_on_exchange'] = False
self.strategy.bot_start() self.strategy.ft_bot_start()
def _load_protections(self, strategy: IStrategy): def _load_protections(self, strategy: IStrategy):
if self.config.get('enable_protections', False): if self.config.get('enable_protections', False):
@ -275,8 +275,12 @@ class Backtesting:
if pair not in self.exchange._leverage_tiers: if pair not in self.exchange._leverage_tiers:
unavailable_pairs.append(pair) unavailable_pairs.append(pair)
continue continue
self.futures_data[pair] = funding_rates_dict[pair].merge(
mark_rates_dict[pair], on='date', how="inner", suffixes=["_fund", "_mark"]) self.futures_data[pair] = self.exchange.combine_funding_and_mark(
funding_rates=funding_rates_dict[pair],
mark_rates=mark_rates_dict[pair],
futures_funding_rate=self.config.get('futures_funding_rate', None),
)
if unavailable_pairs: if unavailable_pairs:
raise OperationalException( raise OperationalException(
@ -297,6 +301,9 @@ class Backtesting:
self.rejected_trades = 0 self.rejected_trades = 0
self.timedout_entry_orders = 0 self.timedout_entry_orders = 0
self.timedout_exit_orders = 0 self.timedout_exit_orders = 0
self.canceled_trade_entries = 0
self.canceled_entry_orders = 0
self.replaced_entry_orders = 0
self.dataprovider.clear_cache() self.dataprovider.clear_cache()
if enable_protections: if enable_protections:
self._load_protections(self.strategy) self._load_protections(self.strategy)
@ -493,7 +500,8 @@ class Backtesting:
stake_available = self.wallets.get_available_stake_amount() stake_available = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
default_retval=None)( default_retval=None)(
trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX], trade=trade, # type: ignore[arg-type]
current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
current_profit=current_profit, min_stake=min_stake, current_profit=current_profit, min_stake=min_stake,
max_stake=min(max_stake, stake_available)) max_stake=min(max_stake, stake_available))
@ -524,64 +532,76 @@ class Backtesting:
if check_adjust_entry: if check_adjust_entry:
trade = self._get_adjust_trade_entry_for_candle(trade, row) trade = self._get_adjust_trade_entry_for_candle(trade, row)
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX] enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX] exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
exit_ = self.strategy.should_exit( exits = self.strategy.should_exit(
trade, row[OPEN_IDX], exit_candle_time, # type: ignore trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(), # type: ignore
enter=enter, exit_=exit_sig, enter=enter, exit_=exit_sig,
low=row[LOW_IDX], high=row[HIGH_IDX] low=row[LOW_IDX], high=row[HIGH_IDX]
) )
for exit_ in exits:
t = self._get_exit_for_signal(trade, row, exit_)
if t:
return t
return None
def _get_exit_for_signal(self, trade: LocalTrade, row: Tuple,
exit_: ExitCheckTuple) -> Optional[LocalTrade]:
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
if exit_.exit_flag: if exit_.exit_flag:
trade.close_date = exit_candle_time trade.close_date = exit_candle_time
exit_reason = exit_.exit_reason
trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60) trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
try: try:
closerate = self._get_close_rate(row, trade, exit_, trade_dur) close_rate = self._get_close_rate(row, trade, exit_, trade_dur)
except ValueError: except ValueError:
return None return None
# call the custom exit price,with default value as previous closerate # call the custom exit price,with default value as previous close_rate
current_profit = trade.calc_profit_ratio(closerate) current_profit = trade.calc_profit_ratio(close_rate)
order_type = self.strategy.order_types['exit'] order_type = self.strategy.order_types['exit']
if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT): if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT):
# Checks and adds an exit tag, after checking that the length of the
# row has the length for an exit tag column
if(
len(row) > EXIT_TAG_IDX
and row[EXIT_TAG_IDX] is not None
and len(row[EXIT_TAG_IDX]) > 0
and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
):
exit_reason = row[EXIT_TAG_IDX]
# Custom exit pricing only for exit-signals # Custom exit pricing only for exit-signals
if order_type == 'limit': if order_type == 'limit':
closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, close_rate = strategy_safe_wrapper(self.strategy.custom_exit_price,
default_retval=closerate)( default_retval=close_rate)(
pair=trade.pair, trade=trade, pair=trade.pair,
trade=trade, # type: ignore[arg-type]
current_time=exit_candle_time, current_time=exit_candle_time,
proposed_rate=closerate, current_profit=current_profit, proposed_rate=close_rate, current_profit=current_profit,
exit_tag=exit_.exit_reason) exit_tag=exit_reason)
# We can't place orders lower than current low. # We can't place orders lower than current low.
# freqtrade does not support this in live, and the order would fill immediately # freqtrade does not support this in live, and the order would fill immediately
if trade.is_short: if trade.is_short:
closerate = min(closerate, row[HIGH_IDX]) close_rate = min(close_rate, row[HIGH_IDX])
else: else:
closerate = max(closerate, row[LOW_IDX]) close_rate = max(close_rate, row[LOW_IDX])
# Confirm trade exit: # Confirm trade exit:
time_in_force = self.strategy.order_time_in_force['exit'] time_in_force = self.strategy.order_time_in_force['exit']
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount, pair=trade.pair,
rate=closerate, trade=trade, # type: ignore[arg-type]
order_type='limit',
amount=trade.amount,
rate=close_rate,
time_in_force=time_in_force, time_in_force=time_in_force,
sell_reason=exit_.exit_reason, # deprecated sell_reason=exit_reason, # deprecated
exit_reason=exit_.exit_reason, exit_reason=exit_reason,
current_time=exit_candle_time): current_time=exit_candle_time):
return None return None
trade.exit_reason = exit_.exit_reason trade.exit_reason = exit_reason
# Checks and adds an exit tag, after checking that the length of the
# row has the length for an exit tag column
if(
len(row) > EXIT_TAG_IDX
and row[EXIT_TAG_IDX] is not None
and len(row[EXIT_TAG_IDX]) > 0
and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
):
trade.exit_reason = row[EXIT_TAG_IDX]
self.order_id_counter += 1 self.order_id_counter += 1
order = Order( order = Order(
@ -597,12 +617,12 @@ class Backtesting:
side=trade.exit_side, side=trade.exit_side,
order_type=order_type, order_type=order_type,
status="open", status="open",
price=closerate, price=close_rate,
average=closerate, average=close_rate,
amount=trade.amount, amount=trade.amount,
filled=0, filled=0,
remaining=trade.amount, remaining=trade.amount,
cost=trade.amount * closerate, cost=trade.amount * close_rate,
) )
trade.orders.append(order) trade.orders.append(order)
return trade return trade
@ -649,7 +669,7 @@ class Backtesting:
return self._get_exit_trade_entry_for_candle(trade, row) return self._get_exit_trade_entry_for_candle(trade, row)
def get_valid_price_and_stake( def get_valid_price_and_stake(
self, pair: str, row: Tuple, propose_rate: float, stake_amount: Optional[float], self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
direction: LongShort, current_time: datetime, entry_tag: Optional[str], direction: LongShort, current_time: datetime, entry_tag: Optional[str],
trade: Optional[LocalTrade], order_type: str trade: Optional[LocalTrade], order_type: str
) -> Tuple[float, float, float, float]: ) -> Tuple[float, float, float, float]:
@ -713,19 +733,26 @@ class Backtesting:
def _enter_trade(self, pair: str, row: Tuple, direction: LongShort, def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
stake_amount: Optional[float] = None, stake_amount: Optional[float] = None,
trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]: trade: Optional[LocalTrade] = None,
requested_rate: Optional[float] = None,
requested_stake: Optional[float] = None) -> Optional[LocalTrade]:
current_time = row[DATE_IDX].to_pydatetime() current_time = row[DATE_IDX].to_pydatetime()
entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None
# let's call the custom entry price, using the open price as default price # let's call the custom entry price, using the open price as default price
order_type = self.strategy.order_types['entry'] order_type = self.strategy.order_types['entry']
pos_adjust = trade is not None pos_adjust = trade is not None and requested_rate is None
stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake( propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
pair, row, row[OPEN_IDX], stake_amount, direction, current_time, entry_tag, trade, pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
order_type order_type
) )
# replace proposed rate if another rate was requested
propose_rate = requested_rate if requested_rate else propose_rate
stake_amount = requested_stake if requested_stake else stake_amount
if not stake_amount: if not stake_amount:
# In case of pos adjust, still return the original trade # In case of pos adjust, still return the original trade
# If not pos adjust, trade is None # If not pos adjust, trade is None
@ -806,11 +833,11 @@ class Backtesting:
remaining=amount, remaining=amount,
cost=stake_amount + trade.fee_open, cost=stake_amount + trade.fee_open,
) )
trade.orders.append(order)
if pos_adjust and self._get_order_filled(order.price, row): if pos_adjust and self._get_order_filled(order.price, row):
order.close_bt_order(current_time) order.close_bt_order(current_time, trade)
else: else:
trade.open_order_id = str(self.order_id_counter) trade.open_order_id = str(self.order_id_counter)
trade.orders.append(order)
trade.recalc_trade_from_orders() trade.recalc_trade_from_orders()
return trade return trade
@ -861,35 +888,90 @@ class Backtesting:
return 'short' return 'short'
return None return None
def run_protections(self, enable_protections, pair: str, current_time: datetime): def run_protections(
self, enable_protections, pair: str, current_time: datetime, side: LongShort):
if enable_protections: if enable_protections:
self.protections.stop_per_pair(pair, current_time) self.protections.stop_per_pair(pair, current_time, side)
self.protections.global_stop(current_time) self.protections.global_stop(current_time, side)
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool: def manage_open_orders(self, trade: LocalTrade, current_time, row: Tuple) -> bool:
""" """
Check if an order has been canceled. Check if any open order needs to be cancelled or replaced.
Returns True if the trade should be Deleted (initial order was canceled). Returns True if the trade should be deleted.
""" """
for order in [o for o in trade.orders if o.ft_is_open]: for order in [o for o in trade.orders if o.ft_is_open]:
if self.check_order_cancel(trade, order, current_time):
# delete trade due to order timeout
return True
elif self.check_order_replace(trade, order, current_time, row):
# delete trade due to user request
self.canceled_trade_entries += 1
return True
# default maintain trade
return False
timedout = self.strategy.ft_check_timed_out(trade, order, current_time) def check_order_cancel(self, trade: LocalTrade, order: Order, current_time) -> bool:
if timedout: """
if order.side == trade.entry_side: Check if current analyzed order has to be canceled.
self.timedout_entry_orders += 1 Returns True if the trade should be Deleted (initial order was canceled).
if trade.nr_of_successful_entries == 0: """
# Remove trade due to entry timeout expiration. timedout = self.strategy.ft_check_timed_out(
return True trade, # type: ignore[arg-type]
else: order, current_time)
# Close additional entry order if timedout:
del trade.orders[trade.orders.index(order)] if order.side == trade.entry_side:
if order.side == trade.exit_side: self.timedout_entry_orders += 1
self.timedout_exit_orders += 1 if trade.nr_of_successful_entries == 0:
# Close exit order and retry exiting on next signal. # Remove trade due to entry timeout expiration.
return True
else:
# Close additional entry order
del trade.orders[trade.orders.index(order)] del trade.orders[trade.orders.index(order)]
if order.side == trade.exit_side:
self.timedout_exit_orders += 1
# Close exit order and retry exiting on next signal.
del trade.orders[trade.orders.index(order)]
return False return False
def check_order_replace(self, trade: LocalTrade, order: Order, current_time,
row: Tuple) -> bool:
"""
Check if current analyzed entry order has to be replaced and do so.
If user requested cancellation and there are no filled orders in the trade will
instruct caller to delete the trade.
Returns True if the trade should be deleted.
"""
# only check on new candles for open entry orders
if order.side == trade.entry_side and current_time > order.order_date_utc:
requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price,
default_retval=order.price)(
trade=trade, # type: ignore[arg-type]
order=order, pair=trade.pair, current_time=current_time,
proposed_rate=row[OPEN_IDX], current_order_rate=order.price,
entry_tag=trade.enter_tag, side=trade.trade_direction
) # default value is current order price
# cancel existing order whenever a new rate is requested (or None)
if requested_rate == order.price:
# assumption: there can't be multiple open entry orders at any given time
return False
else:
del trade.orders[trade.orders.index(order)]
self.canceled_entry_orders += 1
# place new order if result was not None
if requested_rate:
self._enter_trade(pair=trade.pair, row=row, trade=trade,
requested_rate=requested_rate,
requested_stake=(order.remaining * order.price),
direction='short' if trade.is_short else 'long')
self.replaced_entry_orders += 1
else:
# assumption: there can't be multiple open entry orders at any given time
return (trade.nr_of_successful_entries == 0)
return False
def validate_row( def validate_row(
self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]: self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
try: try:
@ -959,9 +1041,9 @@ class Backtesting:
self.dataprovider._set_dataframe_max_index(row_index) self.dataprovider._set_dataframe_max_index(row_index)
for t in list(open_trades[pair]): for t in list(open_trades[pair]):
# 1. Cancel expired entry/exit orders. # 1. Manage currently open orders of active trades
if self.check_order_cancel(t, current_time): if self.manage_open_orders(t, current_time, row):
# Close trade due to entry timeout expiration. # Close trade
open_trade_count -= 1 open_trade_count -= 1
open_trades[pair].remove(t) open_trades[pair].remove(t)
self.wallets.update() self.wallets.update()
@ -976,7 +1058,7 @@ class Backtesting:
and self.trade_slot_available(max_open_trades, open_trade_count_start) and self.trade_slot_available(max_open_trades, open_trade_count_start)
and current_time != end_date and current_time != end_date
and trade_dir is not None and trade_dir is not None
and not PairLocks.is_pair_locked(pair, row[DATE_IDX]) and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
): ):
trade = self._enter_trade(pair, row, trade_dir) trade = self._enter_trade(pair, row, trade_dir)
if trade: if trade:
@ -992,7 +1074,7 @@ class Backtesting:
# 3. Process entry orders. # 3. Process entry orders.
order = trade.select_order(trade.entry_side, is_open=True) order = trade.select_order(trade.entry_side, is_open=True)
if order and self._get_order_filled(order.price, row): if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time) order.close_bt_order(current_time, trade)
trade.open_order_id = None trade.open_order_id = None
LocalTrade.add_bt_trade(trade) LocalTrade.add_bt_trade(trade)
self.wallets.update() self.wallets.update()
@ -1014,7 +1096,8 @@ class Backtesting:
LocalTrade.close_bt_trade(trade) LocalTrade.close_bt_trade(trade)
trades.append(trade) trades.append(trade)
self.wallets.update() self.wallets.update()
self.run_protections(enable_protections, pair, current_time) self.run_protections(
enable_protections, pair, current_time, trade.trade_direction)
# Move time one configured time_interval ahead. # Move time one configured time_interval ahead.
self.progress.increment() self.progress.increment()
@ -1031,6 +1114,9 @@ class Backtesting:
'rejected_signals': self.rejected_trades, 'rejected_signals': self.rejected_trades,
'timedout_entry_orders': self.timedout_entry_orders, 'timedout_entry_orders': self.timedout_entry_orders,
'timedout_exit_orders': self.timedout_exit_orders, 'timedout_exit_orders': self.timedout_exit_orders,
'canceled_trade_entries': self.canceled_trade_entries,
'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']), 'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
} }

View File

@ -44,7 +44,7 @@ class EdgeCli:
self.edge._timerange = TimeRange.parse_timerange(None if self.config.get( self.edge._timerange = TimeRange.parse_timerange(None if self.config.get(
'timerange') is None else str(self.config.get('timerange'))) 'timerange') is None else str(self.config.get('timerange')))
self.strategy.bot_start() self.strategy.ft_bot_start()
def start(self) -> None: def start(self) -> None:
result = self.edge.calculate(self.config['exchange']['pair_whitelist']) result = self.edge.calculate(self.config['exchange']['pair_whitelist'])

View File

@ -27,8 +27,7 @@ from freqtrade.misc import deep_merge_dicts, file_dump_json, plural
from freqtrade.optimize.backtesting import Backtesting from freqtrade.optimize.backtesting import Backtesting
# Import IHyperOpt and IHyperOptLoss to allow unpickling classes from these modules # Import IHyperOpt and IHyperOptLoss to allow unpickling classes from these modules
from freqtrade.optimize.hyperopt_auto import HyperOptAuto from freqtrade.optimize.hyperopt_auto import HyperOptAuto
from freqtrade.optimize.hyperopt_interface import IHyperOpt # noqa: F401 from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss
from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F401
from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer
from freqtrade.optimize.optimize_reports import generate_strategy_stats from freqtrade.optimize.optimize_reports import generate_strategy_stats
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver
@ -62,7 +61,6 @@ class Hyperopt:
hyperopt = Hyperopt(config) hyperopt = Hyperopt(config)
hyperopt.start() hyperopt.start()
""" """
custom_hyperopt: IHyperOpt
def __init__(self, config: Dict[str, Any]) -> None: def __init__(self, config: Dict[str, Any]) -> None:
self.buy_space: List[Dimension] = [] self.buy_space: List[Dimension] = []
@ -77,6 +75,7 @@ class Hyperopt:
self.backtesting = Backtesting(self.config) self.backtesting = Backtesting(self.config)
self.pairlist = self.backtesting.pairlists.whitelist self.pairlist = self.backtesting.pairlists.whitelist
self.custom_hyperopt: HyperOptAuto
if not self.config.get('hyperopt'): if not self.config.get('hyperopt'):
self.custom_hyperopt = HyperOptAuto(self.config) self.custom_hyperopt = HyperOptAuto(self.config)
@ -88,7 +87,8 @@ class Hyperopt:
self.backtesting._set_strategy(self.backtesting.strategylist[0]) self.backtesting._set_strategy(self.backtesting.strategylist[0])
self.custom_hyperopt.strategy = self.backtesting.strategy self.custom_hyperopt.strategy = self.backtesting.strategy
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config) self.custom_hyperoptloss: IHyperOptLoss = HyperOptLossResolver.load_hyperoptloss(
self.config)
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
strategy = str(self.config['strategy']) strategy = str(self.config['strategy'])

View File

@ -0,0 +1,47 @@
"""
MaxDrawDownRelativeHyperOptLoss
This module defines the alternative HyperOptLoss class which can be used for
Hyperoptimization.
"""
from typing import Dict
from pandas import DataFrame
from freqtrade.data.metrics import calculate_underwater
from freqtrade.optimize.hyperopt import IHyperOptLoss
class MaxDrawDownRelativeHyperOptLoss(IHyperOptLoss):
"""
Defines the loss function for hyperopt.
This implementation optimizes for max draw down and profit
Less max drawdown more profit -> Lower return value
"""
@staticmethod
def hyperopt_loss_function(results: DataFrame, config: Dict,
*args, **kwargs) -> float:
"""
Objective function.
Uses profit ratio weighted max_drawdown when drawdown is available.
Otherwise directly optimizes profit ratio.
"""
total_profit = results['profit_abs'].sum()
try:
drawdown_df = calculate_underwater(
results,
value_col='profit_abs',
starting_balance=config['dry_run_wallet']
)
max_drawdown = abs(min(drawdown_df['drawdown']))
relative_drawdown = max(drawdown_df['drawdown_relative'])
if max_drawdown == 0:
return -total_profit
return -total_profit / max_drawdown / relative_drawdown
except (Exception, ValueError):
return -total_profit

View File

@ -19,11 +19,11 @@ class IHyperOptLoss(ABC):
@staticmethod @staticmethod
@abstractmethod @abstractmethod
def hyperopt_loss_function(results: DataFrame, trade_count: int, def hyperopt_loss_function(*, results: DataFrame, trade_count: int,
min_date: datetime, max_date: datetime, min_date: datetime, max_date: datetime,
config: Dict, processed: Dict[str, DataFrame], config: Dict, processed: Dict[str, DataFrame],
backtest_stats: Dict[str, Any], backtest_stats: Dict[str, Any],
*args, **kwargs) -> float: **kwargs) -> float:
""" """
Objective function, returns smaller number for better results Objective function, returns smaller number for better results
""" """

View File

@ -468,6 +468,9 @@ def generate_strategy_stats(pairlist: List[str],
'rejected_signals': content['rejected_signals'], 'rejected_signals': content['rejected_signals'],
'timedout_entry_orders': content['timedout_entry_orders'], 'timedout_entry_orders': content['timedout_entry_orders'],
'timedout_exit_orders': content['timedout_exit_orders'], 'timedout_exit_orders': content['timedout_exit_orders'],
'canceled_trade_entries': content['canceled_trade_entries'],
'canceled_entry_orders': content['canceled_entry_orders'],
'replaced_entry_orders': content['replaced_entry_orders'],
'max_open_trades': max_open_trades, 'max_open_trades': max_open_trades,
'max_open_trades_setting': (config['max_open_trades'] 'max_open_trades_setting': (config['max_open_trades']
if config['max_open_trades'] != float('inf') else -1), if config['max_open_trades'] != float('inf') else -1),
@ -498,9 +501,12 @@ def generate_strategy_stats(pairlist: List[str],
(drawdown_abs, drawdown_start, drawdown_end, high_val, low_val, (drawdown_abs, drawdown_start, drawdown_end, high_val, low_val,
max_drawdown) = calculate_max_drawdown( max_drawdown) = calculate_max_drawdown(
results, value_col='profit_abs', starting_balance=start_balance) results, value_col='profit_abs', starting_balance=start_balance)
(_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown(
results, value_col='profit_abs', starting_balance=start_balance, relative=True)
strat_stats.update({ strat_stats.update({
'max_drawdown': max_drawdown_legacy, # Deprecated - do not use 'max_drawdown': max_drawdown_legacy, # Deprecated - do not use
'max_drawdown_account': max_drawdown, 'max_drawdown_account': max_drawdown,
'max_relative_drawdown': max_relative_drawdown,
'max_drawdown_abs': drawdown_abs, 'max_drawdown_abs': drawdown_abs,
'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT), 'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT),
'drawdown_start_ts': drawdown_start.timestamp() * 1000, 'drawdown_start_ts': drawdown_start.timestamp() * 1000,
@ -521,6 +527,7 @@ def generate_strategy_stats(pairlist: List[str],
strat_stats.update({ strat_stats.update({
'max_drawdown': 0.0, 'max_drawdown': 0.0,
'max_drawdown_account': 0.0, 'max_drawdown_account': 0.0,
'max_relative_drawdown': 0.0,
'max_drawdown_abs': 0.0, 'max_drawdown_abs': 0.0,
'max_drawdown_low': 0.0, 'max_drawdown_low': 0.0,
'max_drawdown_high': 0.0, 'max_drawdown_high': 0.0,
@ -729,6 +736,32 @@ def text_table_add_metrics(strat_results: Dict) -> str:
strat_results['stake_currency'])), strat_results['stake_currency'])),
] if strat_results.get('trade_count_short', 0) > 0 else [] ] if strat_results.get('trade_count_short', 0) > 0 else []
drawdown_metrics = []
if 'max_relative_drawdown' in strat_results:
# Compatibility to show old hyperopt results
drawdown_metrics.append(
('Max % of account underwater', f"{strat_results['max_relative_drawdown']:.2%}")
)
drawdown_metrics.extend([
('Absolute Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}")
if 'max_drawdown_account' in strat_results else (
'Drawdown', f"{strat_results['max_drawdown']:.2%}"),
('Absolute Drawdown', round_coin_value(strat_results['max_drawdown_abs'],
strat_results['stake_currency'])),
('Drawdown high', round_coin_value(strat_results['max_drawdown_high'],
strat_results['stake_currency'])),
('Drawdown low', round_coin_value(strat_results['max_drawdown_low'],
strat_results['stake_currency'])),
('Drawdown Start', strat_results['drawdown_start']),
('Drawdown End', strat_results['drawdown_end']),
])
entry_adjustment_metrics = [
('Canceled Trade Entries', strat_results.get('canceled_trade_entries', 'N/A')),
('Canceled Entry Orders', strat_results.get('canceled_entry_orders', 'N/A')),
('Replaced Entry Orders', strat_results.get('replaced_entry_orders', 'N/A')),
] if strat_results.get('canceled_entry_orders', 0) > 0 else []
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
# command stores these results and newer version of freqtrade must be able to handle old # command stores these results and newer version of freqtrade must be able to handle old
# results with missing new fields. # results with missing new fields.
@ -777,6 +810,7 @@ def text_table_add_metrics(strat_results: Dict) -> str:
('Entry/Exit Timeouts', ('Entry/Exit Timeouts',
f"{strat_results.get('timedout_entry_orders', 'N/A')} / " f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
f"{strat_results.get('timedout_exit_orders', 'N/A')}"), f"{strat_results.get('timedout_exit_orders', 'N/A')}"),
*entry_adjustment_metrics,
('', ''), # Empty line to improve readability ('', ''), # Empty line to improve readability
('Min balance', round_coin_value(strat_results['csum_min'], ('Min balance', round_coin_value(strat_results['csum_min'],
@ -784,18 +818,7 @@ def text_table_add_metrics(strat_results: Dict) -> str:
('Max balance', round_coin_value(strat_results['csum_max'], ('Max balance', round_coin_value(strat_results['csum_max'],
strat_results['stake_currency'])), strat_results['stake_currency'])),
# Compatibility to show old hyperopt results *drawdown_metrics,
('Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}")
if 'max_drawdown_account' in strat_results else (
'Drawdown', f"{strat_results['max_drawdown']:.2%}"),
('Drawdown', round_coin_value(strat_results['max_drawdown_abs'],
strat_results['stake_currency'])),
('Drawdown high', round_coin_value(strat_results['max_drawdown_high'],
strat_results['stake_currency'])),
('Drawdown low', round_coin_value(strat_results['max_drawdown_low'],
strat_results['stake_currency'])),
('Drawdown Start', strat_results['drawdown_start']),
('Drawdown End', strat_results['drawdown_end']),
('Market change', f"{strat_results['market_change']:.2%}"), ('Market change', f"{strat_results['market_change']:.2%}"),
] ]

View File

@ -1,5 +1,5 @@
# flake8: noqa: F401 # flake8: noqa: F401
from freqtrade.persistence.models import (LocalTrade, Order, Trade, clean_dry_run_db, cleanup_db, from freqtrade.persistence.models import cleanup_db, init_db
init_db)
from freqtrade.persistence.pairlock_middleware import PairLocks from freqtrade.persistence.pairlock_middleware import PairLocks
from freqtrade.persistence.trade_model import LocalTrade, Order, Trade

View File

@ -0,0 +1,7 @@
from typing import Any
from sqlalchemy.orm import declarative_base
_DECL_BASE: Any = declarative_base()

View File

@ -9,7 +9,7 @@ from freqtrade.exceptions import OperationalException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_table_names_for_table(inspector, tabletype): def get_table_names_for_table(inspector, tabletype) -> List[str]:
return [t for t in inspector.get_table_names() if t.startswith(tabletype)] return [t for t in inspector.get_table_names() if t.startswith(tabletype)]
@ -21,7 +21,7 @@ def get_column_def(columns: List, column: str, default: str) -> str:
return default if not has_column(columns, column) else column return default if not has_column(columns, column) else column
def get_backup_name(tabs, backup_prefix: str): def get_backup_name(tabs: List[str], backup_prefix: str):
table_back_name = backup_prefix table_back_name = backup_prefix
for i, table_back_name in enumerate(tabs): for i, table_back_name in enumerate(tabs):
table_back_name = f'{backup_prefix}{i}' table_back_name = f'{backup_prefix}{i}'
@ -46,7 +46,7 @@ def get_last_sequence_ids(engine, trade_back_name, order_back_name):
return order_id, trade_id return order_id, trade_id
def set_sequence_ids(engine, order_id, trade_id): def set_sequence_ids(engine, order_id, trade_id, pairlock_id=None):
if engine.name == 'postgresql': if engine.name == 'postgresql':
with engine.begin() as connection: with engine.begin() as connection:
@ -54,6 +54,19 @@ def set_sequence_ids(engine, order_id, trade_id):
connection.execute(text(f"ALTER SEQUENCE orders_id_seq RESTART WITH {order_id}")) connection.execute(text(f"ALTER SEQUENCE orders_id_seq RESTART WITH {order_id}"))
if trade_id: if trade_id:
connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}")) connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}"))
if pairlock_id:
connection.execute(
text(f"ALTER SEQUENCE pairlocks_id_seq RESTART WITH {pairlock_id}"))
def drop_index_on_table(engine, inspector, table_bak_name):
with engine.begin() as connection:
# 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}"))
else:
connection.execute(text(f"drop index {index['name']}"))
def migrate_trades_and_orders_table( def migrate_trades_and_orders_table(
@ -89,7 +102,10 @@ def migrate_trades_and_orders_table(
liquidation_price = get_column_def(cols, 'liquidation_price', liquidation_price = get_column_def(cols, 'liquidation_price',
get_column_def(cols, 'isolated_liq', 'null')) get_column_def(cols, 'isolated_liq', 'null'))
# sqlite does not support literals for booleans # sqlite does not support literals for booleans
is_short = get_column_def(cols, 'is_short', '0') if engine.name == 'postgresql':
is_short = get_column_def(cols, 'is_short', 'false')
else:
is_short = get_column_def(cols, 'is_short', '0')
# Margin Properties # Margin Properties
interest_rate = get_column_def(cols, 'interest_rate', '0.0') interest_rate = get_column_def(cols, 'interest_rate', '0.0')
@ -116,13 +132,7 @@ def migrate_trades_and_orders_table(
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text(f"alter table trades rename to {trade_back_name}")) connection.execute(text(f"alter table trades rename to {trade_back_name}"))
with engine.begin() as connection: drop_index_on_table(engine, inspector, trade_back_name)
# drop indexes on backup table in new session
for index in inspector.get_indexes(trade_back_name):
if engine.name == 'mysql':
connection.execute(text(f"drop index {index['name']} on {trade_back_name}"))
else:
connection.execute(text(f"drop index {index['name']}"))
order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_back_name) order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_back_name)
@ -205,6 +215,31 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List):
""")) """))
def migrate_pairlocks_table(
decl_base, inspector, engine,
pairlock_back_name: str, cols: List):
# Schema migration necessary
with engine.begin() as connection:
connection.execute(text(f"alter table pairlocks rename to {pairlock_back_name}"))
drop_index_on_table(engine, inspector, pairlock_back_name)
side = get_column_def(cols, 'side', "'*'")
# 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 pairlocks
(id, pair, side, reason, lock_time,
lock_end_time, active)
select id, pair, {side} side, reason, lock_time,
lock_end_time, active
from {pairlock_back_name}
"""))
def set_sqlite_to_wal(engine): def set_sqlite_to_wal(engine):
if engine.name == 'sqlite' and str(engine.url) != 'sqlite://': if engine.name == 'sqlite' and str(engine.url) != 'sqlite://':
# Set Mode to # Set Mode to
@ -220,10 +255,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
cols_trades = inspector.get_columns('trades') cols_trades = inspector.get_columns('trades')
cols_orders = inspector.get_columns('orders') cols_orders = inspector.get_columns('orders')
cols_pairlocks = inspector.get_columns('pairlocks')
tabs = get_table_names_for_table(inspector, 'trades') tabs = get_table_names_for_table(inspector, 'trades')
table_back_name = get_backup_name(tabs, 'trades_bak') table_back_name = get_backup_name(tabs, 'trades_bak')
order_tabs = get_table_names_for_table(inspector, 'orders') order_tabs = get_table_names_for_table(inspector, 'orders')
order_table_bak_name = get_backup_name(order_tabs, 'orders_bak') order_table_bak_name = get_backup_name(order_tabs, 'orders_bak')
pairlock_tabs = get_table_names_for_table(inspector, 'pairlocks')
pairlock_table_bak_name = get_backup_name(pairlock_tabs, 'pairlocks_bak')
# Check if migration necessary # Check if migration necessary
# Migrates both trades and orders table! # Migrates both trades and orders table!
@ -236,6 +274,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
decl_base, inspector, engine, table_back_name, cols_trades, decl_base, inspector, engine, table_back_name, cols_trades,
order_table_bak_name, cols_orders) order_table_bak_name, cols_orders)
if not has_column(cols_pairlocks, 'side'):
logger.info(f"Running database migration for pairlocks - "
f"backup: {pairlock_table_bak_name}")
migrate_pairlocks_table(
decl_base, inspector, engine, pairlock_table_bak_name, cols_pairlocks
)
if 'orders' not in previous_tables and 'trades' in previous_tables: if 'orders' not in previous_tables and 'trades' in previous_tables:
raise OperationalException( raise OperationalException(
"Your database seems to be very old. " "Your database seems to be very old. "

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, Column, DateTime, Integer, String, or_
from sqlalchemy.orm import Query
from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.persistence.base import _DECL_BASE
class PairLock(_DECL_BASE):
"""
Pair Locks database model.
"""
__tablename__ = 'pairlocks'
id = Column(Integer, primary_key=True)
pair = Column(String(25), nullable=False, index=True)
# lock direction - long, short or * (for both)
side = Column(String(25), nullable=False, default="*")
reason = Column(String(255), nullable=True)
# Time the pair was locked (start time)
lock_time = Column(DateTime, nullable=False)
# Time until the pair is locked (end time)
lock_end_time = Column(DateTime, nullable=False, index=True)
active = Column(Boolean, nullable=False, default=True, index=True)
def __repr__(self):
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT)
return (
f'PairLock(id={self.id}, pair={self.pair}, side={self.side}, lock_time={lock_time}, '
f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})')
@staticmethod
def query_pair_locks(pair: Optional[str], now: datetime, side: str = '*') -> Query:
"""
Get all currently active locks for this pair
:param pair: Pair to check for. Returns all current locks if pair is empty
:param now: Datetime object (generated via datetime.now(timezone.utc)).
"""
filters = [PairLock.lock_end_time > now,
# Only active locks
PairLock.active.is_(True), ]
if pair:
filters.append(PairLock.pair == pair)
if side != '*':
filters.append(or_(PairLock.side == side, PairLock.side == '*'))
else:
filters.append(PairLock.side == '*')
return PairLock.query.filter(
*filters
)
def to_json(self) -> Dict[str, Any]:
return {
'id': self.id,
'pair': self.pair,
'lock_time': self.lock_time.strftime(DATETIME_PRINT_FORMAT),
'lock_timestamp': int(self.lock_time.replace(tzinfo=timezone.utc).timestamp() * 1000),
'lock_end_time': self.lock_end_time.strftime(DATETIME_PRINT_FORMAT),
'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc
).timestamp() * 1000),
'reason': self.reason,
'side': self.side,
'active': self.active,
}

View File

@ -31,7 +31,7 @@ class PairLocks():
@staticmethod @staticmethod
def lock_pair(pair: str, until: datetime, reason: str = None, *, def lock_pair(pair: str, until: datetime, reason: str = None, *,
now: datetime = None) -> PairLock: now: datetime = None, side: str = '*') -> PairLock:
""" """
Create PairLock from now to "until". Create PairLock from now to "until".
Uses database by default, unless PairLocks.use_db is set to False, Uses database by default, unless PairLocks.use_db is set to False,
@ -40,12 +40,14 @@ class PairLocks():
:param until: End time of the lock. Will be rounded up to the next candle. :param until: End time of the lock. Will be rounded up to the next candle.
:param reason: Reason string that will be shown as reason for the lock :param reason: Reason string that will be shown as reason for the lock
:param now: Current timestamp. Used to determine lock start time. :param now: Current timestamp. Used to determine lock start time.
:param side: Side to lock pair, can be 'long', 'short' or '*'
""" """
lock = PairLock( lock = PairLock(
pair=pair, pair=pair,
lock_time=now or datetime.now(timezone.utc), lock_time=now or datetime.now(timezone.utc),
lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until), lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
reason=reason, reason=reason,
side=side,
active=True active=True
) )
if PairLocks.use_db: if PairLocks.use_db:
@ -56,7 +58,8 @@ class PairLocks():
return lock return lock
@staticmethod @staticmethod
def get_pair_locks(pair: Optional[str], now: Optional[datetime] = None) -> List[PairLock]: def get_pair_locks(
pair: Optional[str], now: Optional[datetime] = None, side: str = '*') -> List[PairLock]:
""" """
Get all currently active locks for this pair Get all currently active locks for this pair
:param pair: Pair to check for. Returns all current locks if pair is empty :param pair: Pair to check for. Returns all current locks if pair is empty
@ -67,26 +70,28 @@ class PairLocks():
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if PairLocks.use_db: if PairLocks.use_db:
return PairLock.query_pair_locks(pair, now).all() return PairLock.query_pair_locks(pair, now, side).all()
else: else:
locks = [lock for lock in PairLocks.locks if ( locks = [lock for lock in PairLocks.locks if (
lock.lock_end_time >= now lock.lock_end_time >= now
and lock.active is True and lock.active is True
and (pair is None or lock.pair == pair) and (pair is None or lock.pair == pair)
and (lock.side == '*' or lock.side == side)
)] )]
return locks return locks
@staticmethod @staticmethod
def get_pair_longest_lock(pair: str, now: Optional[datetime] = None) -> Optional[PairLock]: def get_pair_longest_lock(
pair: str, now: Optional[datetime] = None, side: str = '*') -> Optional[PairLock]:
""" """
Get the lock that expires the latest for the pair given. Get the lock that expires the latest for the pair given.
""" """
locks = PairLocks.get_pair_locks(pair, now) locks = PairLocks.get_pair_locks(pair, now, side=side)
locks = sorted(locks, key=lambda l: l.lock_end_time, reverse=True) locks = sorted(locks, key=lambda l: l.lock_end_time, reverse=True)
return locks[0] if locks else None return locks[0] if locks else None
@staticmethod @staticmethod
def unlock_pair(pair: str, now: Optional[datetime] = None) -> None: def unlock_pair(pair: str, now: Optional[datetime] = None, side: str = '*') -> None:
""" """
Release all locks for this pair. Release all locks for this pair.
:param pair: Pair to unlock :param pair: Pair to unlock
@ -97,7 +102,7 @@ class PairLocks():
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
logger.info(f"Releasing all locks for {pair}.") logger.info(f"Releasing all locks for {pair}.")
locks = PairLocks.get_pair_locks(pair, now) locks = PairLocks.get_pair_locks(pair, now, side=side)
for lock in locks: for lock in locks:
lock.active = False lock.active = False
if PairLocks.use_db: if PairLocks.use_db:
@ -134,7 +139,7 @@ class PairLocks():
lock.active = False lock.active = False
@staticmethod @staticmethod
def is_global_lock(now: Optional[datetime] = None) -> bool: def is_global_lock(now: Optional[datetime] = None, side: str = '*') -> bool:
""" """
:param now: Datetime object (generated via datetime.now(timezone.utc)). :param now: Datetime object (generated via datetime.now(timezone.utc)).
defaults to datetime.now(timezone.utc) defaults to datetime.now(timezone.utc)
@ -142,10 +147,10 @@ class PairLocks():
if not now: if not now:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
return len(PairLocks.get_pair_locks('*', now)) > 0 return len(PairLocks.get_pair_locks('*', now, side)) > 0
@staticmethod @staticmethod
def is_pair_locked(pair: str, now: Optional[datetime] = None) -> bool: def is_pair_locked(pair: str, now: Optional[datetime] = None, side: str = '*') -> bool:
""" """
:param pair: Pair to check for :param pair: Pair to check for
:param now: Datetime object (generated via datetime.now(timezone.utc)). :param now: Datetime object (generated via datetime.now(timezone.utc)).
@ -154,7 +159,10 @@ class PairLocks():
if not now: if not now:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
return len(PairLocks.get_pair_locks(pair, now)) > 0 or PairLocks.is_global_lock(now) return (
len(PairLocks.get_pair_locks(pair, now, side)) > 0
or PairLocks.is_global_lock(now, side)
)
@staticmethod @staticmethod
def get_all_locks() -> List[PairLock]: def get_all_locks() -> List[PairLock]:

File diff suppressed because it is too large Load Diff

View File

@ -159,12 +159,15 @@ def add_profit(fig, row, data: pd.DataFrame, column: str, name: str) -> make_sub
def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame, def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame,
timeframe: str) -> make_subplots: timeframe: str, starting_balance: float) -> make_subplots:
""" """
Add scatter points indicating max drawdown Add scatter points indicating max drawdown
""" """
try: try:
_, highdate, lowdate, _, _, max_drawdown = calculate_max_drawdown(trades) _, highdate, lowdate, _, _, max_drawdown = calculate_max_drawdown(
trades,
starting_balance=starting_balance
)
drawdown = go.Scatter( drawdown = go.Scatter(
x=[highdate, lowdate], x=[highdate, lowdate],
@ -189,22 +192,37 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame,
return fig return fig
def add_underwater(fig, row, trades: pd.DataFrame) -> make_subplots: def add_underwater(fig, row, trades: pd.DataFrame, starting_balance: float) -> make_subplots:
""" """
Add underwater plot Add underwater plots
""" """
try: try:
underwater = calculate_underwater(trades, value_col="profit_abs") underwater = calculate_underwater(
trades,
value_col="profit_abs",
starting_balance=starting_balance
)
underwater = go.Scatter( underwater_plot = go.Scatter(
x=underwater['date'], x=underwater['date'],
y=underwater['drawdown'], y=underwater['drawdown'],
name="Underwater Plot", name="Underwater Plot",
fill='tozeroy', fill='tozeroy',
fillcolor='#cc362b', fillcolor='#cc362b',
line={'color': '#cc362b'}, line={'color': '#cc362b'}
) )
fig.add_trace(underwater, row, 1)
underwater_plot_relative = go.Scatter(
x=underwater['date'],
y=(-underwater['drawdown_relative']),
name="Underwater Plot (%)",
fill='tozeroy',
fillcolor='green',
line={'color': 'green'}
)
fig.add_trace(underwater_plot, row, 1)
fig.add_trace(underwater_plot_relative, row + 1, 1)
except ValueError: except ValueError:
logger.warning("No trades found - not plotting underwater plot") logger.warning("No trades found - not plotting underwater plot")
return fig return fig
@ -507,7 +525,8 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra
def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame], def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
trades: pd.DataFrame, timeframe: str, stake_currency: str) -> go.Figure: trades: pd.DataFrame, timeframe: str, stake_currency: str,
starting_balance: float) -> go.Figure:
# Combine close-values for all pairs, rename columns to "pair" # Combine close-values for all pairs, rename columns to "pair"
try: try:
df_comb = combine_dataframes_with_mean(data, "close") df_comb = combine_dataframes_with_mean(data, "close")
@ -531,8 +550,8 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
name='Avg close price', name='Avg close price',
) )
fig = make_subplots(rows=5, cols=1, shared_xaxes=True, fig = make_subplots(rows=6, cols=1, shared_xaxes=True,
row_heights=[1, 1, 1, 0.5, 1], row_heights=[1, 1, 1, 0.5, 0.75, 0.75],
vertical_spacing=0.05, vertical_spacing=0.05,
subplot_titles=[ subplot_titles=[
"AVG Close Price", "AVG Close Price",
@ -540,6 +559,7 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
"Profit per pair", "Profit per pair",
"Parallelism", "Parallelism",
"Underwater", "Underwater",
"Relative Drawdown",
]) ])
fig['layout'].update(title="Freqtrade Profit plot") fig['layout'].update(title="Freqtrade Profit plot")
fig['layout']['yaxis1'].update(title='Price') fig['layout']['yaxis1'].update(title='Price')
@ -547,14 +567,16 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
fig['layout']['yaxis3'].update(title=f'Profit {stake_currency}') fig['layout']['yaxis3'].update(title=f'Profit {stake_currency}')
fig['layout']['yaxis4'].update(title='Trade count') fig['layout']['yaxis4'].update(title='Trade count')
fig['layout']['yaxis5'].update(title='Underwater Plot') fig['layout']['yaxis5'].update(title='Underwater Plot')
fig['layout']['yaxis6'].update(title='Underwater Plot Relative (%)', tickformat=',.2%')
fig['layout']['xaxis']['rangeslider'].update(visible=False) fig['layout']['xaxis']['rangeslider'].update(visible=False)
fig.update_layout(modebar_add=["v1hovermode", "toggleSpikeLines"]) fig.update_layout(modebar_add=["v1hovermode", "toggleSpikeLines"])
fig.add_trace(avgclose, 1, 1) fig.add_trace(avgclose, 1, 1)
fig = add_profit(fig, 2, df_comb, 'cum_profit', 'Profit') fig = add_profit(fig, 2, df_comb, 'cum_profit', 'Profit')
fig = add_max_drawdown(fig, 2, trades, df_comb, timeframe) fig = add_max_drawdown(fig, 2, trades, df_comb, timeframe, starting_balance)
fig = add_parallelism(fig, 4, trades, timeframe) fig = add_parallelism(fig, 4, trades, timeframe)
fig = add_underwater(fig, 5, trades) # Two rows consumed
fig = add_underwater(fig, 5, trades, starting_balance)
for pair in pairs: for pair in pairs:
profit_col = f'cum_profit_{pair}' profit_col = f'cum_profit_{pair}'
@ -611,7 +633,8 @@ def load_and_plot_trades(config: Dict[str, Any]):
exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config)
IStrategy.dp = DataProvider(config, exchange) IStrategy.dp = DataProvider(config, exchange)
strategy.bot_start() strategy.ft_bot_start()
strategy.bot_loop_start()
plot_elements = init_plotscript(config, list(exchange.markets), strategy.startup_candle_count) plot_elements = init_plotscript(config, list(exchange.markets), strategy.startup_candle_count)
timerange = plot_elements['timerange'] timerange = plot_elements['timerange']
trades = plot_elements['trades'] trades = plot_elements['trades']
@ -670,7 +693,8 @@ def plot_profit(config: Dict[str, Any]) -> None:
# this could be useful to gauge the overall market trend # this could be useful to gauge the overall market trend
fig = generate_profit_graph(plot_elements['pairs'], plot_elements['ohlcv'], fig = generate_profit_graph(plot_elements['pairs'], plot_elements['ohlcv'],
trades, config['timeframe'], trades, config['timeframe'],
config.get('stake_currency', '')) config.get('stake_currency', ''),
config.get('available_capital', config['dry_run_wallet']))
store_plot_file(fig, filename='freqtrade-profit-plot.html', store_plot_file(fig, filename='freqtrade-profit-plot.html',
directory=config['user_data_dir'] / 'plot', directory=config['user_data_dir'] / 'plot',
auto_open=config.get('plot_auto_open', False)) auto_open=config.get('plot_auto_open', False))

View File

@ -32,18 +32,19 @@ class AgeFilter(IPairList):
self._min_days_listed = pairlistconfig.get('min_days_listed', 10) self._min_days_listed = pairlistconfig.get('min_days_listed', 10)
self._max_days_listed = pairlistconfig.get('max_days_listed', None) self._max_days_listed = pairlistconfig.get('max_days_listed', None)
candle_limit = exchange.ohlcv_candle_limit('1d', self._config['candle_type_def'])
if self._min_days_listed < 1: if self._min_days_listed < 1:
raise OperationalException("AgeFilter requires min_days_listed to be >= 1") raise OperationalException("AgeFilter requires min_days_listed to be >= 1")
if self._min_days_listed > exchange.ohlcv_candle_limit('1d'): if self._min_days_listed > candle_limit:
raise OperationalException("AgeFilter requires min_days_listed to not exceed " raise OperationalException("AgeFilter requires min_days_listed to not exceed "
"exchange max request size " "exchange max request size "
f"({exchange.ohlcv_candle_limit('1d')})") f"({candle_limit})")
if self._max_days_listed and self._max_days_listed <= self._min_days_listed: if self._max_days_listed and self._max_days_listed <= self._min_days_listed:
raise OperationalException("AgeFilter max_days_listed <= min_days_listed not permitted") raise OperationalException("AgeFilter max_days_listed <= min_days_listed not permitted")
if self._max_days_listed and self._max_days_listed > exchange.ohlcv_candle_limit('1d'): if self._max_days_listed and self._max_days_listed > candle_limit:
raise OperationalException("AgeFilter requires max_days_listed to not exceed " raise OperationalException("AgeFilter requires max_days_listed to not exceed "
"exchange max request size " "exchange max request size "
f"({exchange.ohlcv_candle_limit('1d')})") f"({candle_limit})")
@property @property
def needstickers(self) -> bool: def needstickers(self) -> bool:

View File

@ -19,6 +19,7 @@ class OffsetFilter(IPairList):
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
self._offset = pairlistconfig.get('offset', 0) self._offset = pairlistconfig.get('offset', 0)
self._number_pairs = pairlistconfig.get('number_assets', 0)
if self._offset < 0: if self._offset < 0:
raise OperationalException("OffsetFilter requires offset to be >= 0") raise OperationalException("OffsetFilter requires offset to be >= 0")
@ -36,7 +37,9 @@ class OffsetFilter(IPairList):
""" """
Short whitelist method description - used for startup-messages Short whitelist method description - used for startup-messages
""" """
return f"{self.name} - Offseting pairs by {self._offset}." if self._number_pairs:
return f"{self.name} - Taking {self._number_pairs} Pairs, starting from {self._offset}."
return f"{self.name} - Offsetting pairs by {self._offset}."
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
""" """
@ -50,5 +53,9 @@ class OffsetFilter(IPairList):
self.log_once(f"Offset of {self._offset} is larger than " + self.log_once(f"Offset of {self._offset} is larger than " +
f"pair count of {len(pairlist)}", logger.warning) f"pair count of {len(pairlist)}", logger.warning)
pairs = pairlist[self._offset:] pairs = pairlist[self._offset:]
if self._number_pairs:
pairs = pairs[:self._number_pairs]
self.log_once(f"Searching {len(pairs)} pairs: {pairs}", logger.info) self.log_once(f"Searching {len(pairs)} pairs: {pairs}", logger.info)
return pairs return pairs

View File

@ -50,7 +50,7 @@ class SpreadFilter(IPairList):
:param ticker: ticker dict as returned from ccxt.fetch_tickers() :param ticker: ticker dict as returned from ccxt.fetch_tickers()
:return: True if the pair can stay, false if it should be removed :return: True if the pair can stay, false if it should be removed
""" """
if 'bid' in ticker and 'ask' in ticker and ticker['ask']: if 'bid' in ticker and 'ask' in ticker and ticker['ask'] and ticker['bid']:
spread = 1 - ticker['bid'] / ticker['ask'] spread = 1 - ticker['bid'] / ticker['ask']
if spread > self._max_spread_ratio: if spread > self._max_spread_ratio:
self.log_once(f"Removed {pair} from whitelist, because spread " self.log_once(f"Removed {pair} from whitelist, because spread "

View File

@ -38,12 +38,12 @@ class VolatilityFilter(IPairList):
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period) self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
candle_limit = exchange.ohlcv_candle_limit('1d', self._config['candle_type_def'])
if self._days < 1: if self._days < 1:
raise OperationalException("VolatilityFilter requires lookback_days to be >= 1") raise OperationalException("VolatilityFilter requires lookback_days to be >= 1")
if self._days > exchange.ohlcv_candle_limit('1d'): if self._days > candle_limit:
raise OperationalException("VolatilityFilter requires lookback_days to not " raise OperationalException("VolatilityFilter requires lookback_days to not "
"exceed exchange max request size " f"exceed exchange max request size ({candle_limit})")
f"({exchange.ohlcv_candle_limit('1d')})")
@property @property
def needstickers(self) -> bool: def needstickers(self) -> bool:

View File

@ -84,12 +84,13 @@ class VolumePairList(IPairList):
raise OperationalException( raise OperationalException(
f'key {self._sort_key} not in {SORT_VALUES}') f'key {self._sort_key} not in {SORT_VALUES}')
candle_limit = exchange.ohlcv_candle_limit(
self._lookback_timeframe, self._config['candle_type_def'])
if self._lookback_period < 0: if self._lookback_period < 0:
raise OperationalException("VolumeFilter requires lookback_period to be >= 0") raise OperationalException("VolumeFilter requires lookback_period to be >= 0")
if self._lookback_period > exchange.ohlcv_candle_limit(self._lookback_timeframe): if self._lookback_period > candle_limit:
raise OperationalException("VolumeFilter requires lookback_period to not " raise OperationalException("VolumeFilter requires lookback_period to not "
"exceed exchange max request size " f"exceed exchange max request size ({candle_limit})")
f"({exchange.ohlcv_candle_limit(self._lookback_timeframe)})")
@property @property
def needstickers(self) -> bool: def needstickers(self) -> bool:

View File

@ -33,12 +33,12 @@ class RangeStabilityFilter(IPairList):
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period) self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
candle_limit = exchange.ohlcv_candle_limit('1d', self._config['candle_type_def'])
if self._days < 1: if self._days < 1:
raise OperationalException("RangeStabilityFilter requires lookback_days to be >= 1") raise OperationalException("RangeStabilityFilter requires lookback_days to be >= 1")
if self._days > exchange.ohlcv_candle_limit('1d'): if self._days > candle_limit:
raise OperationalException("RangeStabilityFilter requires lookback_days to not " raise OperationalException("RangeStabilityFilter requires lookback_days to not "
"exceed exchange max request size " f"exceed exchange max request size ({candle_limit})")
f"({exchange.ohlcv_candle_limit('1d')})")
@property @property
def needstickers(self) -> bool: def needstickers(self) -> bool:

View File

@ -5,6 +5,7 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
from freqtrade.constants import LongShort
from freqtrade.persistence import PairLocks from freqtrade.persistence import PairLocks
from freqtrade.persistence.models import PairLock from freqtrade.persistence.models import PairLock
from freqtrade.plugins.protections import IProtection from freqtrade.plugins.protections import IProtection
@ -44,28 +45,31 @@ class ProtectionManager():
""" """
return [{p.name: p.short_desc()} for p in self._protection_handlers] return [{p.name: p.short_desc()} for p in self._protection_handlers]
def global_stop(self, now: Optional[datetime] = None) -> Optional[PairLock]: def global_stop(self, now: Optional[datetime] = None,
side: LongShort = 'long') -> Optional[PairLock]:
if not now: if not now:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result = None result = None
for protection_handler in self._protection_handlers: for protection_handler in self._protection_handlers:
if protection_handler.has_global_stop: if protection_handler.has_global_stop:
lock, until, reason = protection_handler.global_stop(now) lock = protection_handler.global_stop(date_now=now, side=side)
if lock and lock.until:
# Early stopping - first positive result blocks further trades if not PairLocks.is_global_lock(lock.until, side=lock.lock_side):
if lock and until: result = PairLocks.lock_pair(
if not PairLocks.is_global_lock(until): '*', lock.until, lock.reason, now=now, side=lock.lock_side)
result = PairLocks.lock_pair('*', until, reason, now=now)
return result return result
def stop_per_pair(self, pair, now: Optional[datetime] = None) -> Optional[PairLock]: def stop_per_pair(self, pair, now: Optional[datetime] = None,
side: LongShort = 'long') -> Optional[PairLock]:
if not now: if not now:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result = None result = None
for protection_handler in self._protection_handlers: for protection_handler in self._protection_handlers:
if protection_handler.has_local_stop: if protection_handler.has_local_stop:
lock, until, reason = protection_handler.stop_per_pair(pair, now) lock = protection_handler.stop_per_pair(
if lock and until: pair=pair, date_now=now, side=side)
if not PairLocks.is_pair_locked(pair, until): if lock and lock.until:
result = PairLocks.lock_pair(pair, until, reason, now=now) if not PairLocks.is_pair_locked(pair, lock.until, lock.lock_side):
result = PairLocks.lock_pair(
pair, lock.until, lock.reason, now=now, side=lock.lock_side)
return result return result

View File

@ -1,7 +1,9 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
from freqtrade.constants import LongShort
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.plugins.protections import IProtection, ProtectionReturn from freqtrade.plugins.protections import IProtection, ProtectionReturn
@ -26,7 +28,7 @@ class CooldownPeriod(IProtection):
""" """
return (f"{self.name} - Cooldown period of {self.stop_duration_str}.") return (f"{self.name} - Cooldown period of {self.stop_duration_str}.")
def _cooldown_period(self, pair: str, date_now: datetime, ) -> ProtectionReturn: def _cooldown_period(self, pair: str, date_now: datetime) -> Optional[ProtectionReturn]:
""" """
Get last trade for this pair Get last trade for this pair
""" """
@ -45,11 +47,15 @@ class CooldownPeriod(IProtection):
self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info)
until = self.calculate_lock_end([trade], self._stop_duration) until = self.calculate_lock_end([trade], self._stop_duration)
return True, until, self._reason() return ProtectionReturn(
lock=True,
until=until,
reason=self._reason(),
)
return False, None, None return None
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
@ -57,9 +63,10 @@ class CooldownPeriod(IProtection):
If true, all pairs will be locked with <reason> until <until> If true, all pairs will be locked with <reason> until <until>
""" """
# Not implemented for cooldown period. # Not implemented for cooldown period.
return False, None, None return None
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".

View File

@ -1,9 +1,11 @@
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional
from freqtrade.constants import LongShort
from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange import timeframe_to_minutes
from freqtrade.misc import plural from freqtrade.misc import plural
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
@ -12,7 +14,13 @@ from freqtrade.persistence import LocalTrade
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ProtectionReturn = Tuple[bool, Optional[datetime], Optional[str]]
@dataclass
class ProtectionReturn:
lock: bool
until: datetime
reason: Optional[str]
lock_side: str = '*'
class IProtection(LoggingMixin, ABC): class IProtection(LoggingMixin, ABC):
@ -80,14 +88,15 @@ class IProtection(LoggingMixin, ABC):
""" """
@abstractmethod @abstractmethod
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
""" """
@abstractmethod @abstractmethod
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".

View File

@ -1,8 +1,9 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict from typing import Any, Dict, Optional
from freqtrade.constants import LongShort
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.plugins.protections import IProtection, ProtectionReturn from freqtrade.plugins.protections import IProtection, ProtectionReturn
@ -20,6 +21,7 @@ class LowProfitPairs(IProtection):
self._trade_limit = protection_config.get('trade_limit', 1) self._trade_limit = protection_config.get('trade_limit', 1)
self._required_profit = protection_config.get('required_profit', 0.0) self._required_profit = protection_config.get('required_profit', 0.0)
self._only_per_side = protection_config.get('only_per_side', False)
def short_desc(self) -> str: def short_desc(self) -> str:
""" """
@ -35,7 +37,8 @@ class LowProfitPairs(IProtection):
return (f'{profit} < {self._required_profit} in {self.lookback_period_str}, ' return (f'{profit} < {self._required_profit} in {self.lookback_period_str}, '
f'locking for {self.stop_duration_str}.') f'locking for {self.stop_duration_str}.')
def _low_profit(self, date_now: datetime, pair: str) -> ProtectionReturn: def _low_profit(
self, date_now: datetime, pair: str, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Evaluate recent trades for pair Evaluate recent trades for pair
""" """
@ -51,33 +54,42 @@ class LowProfitPairs(IProtection):
# trades = Trade.get_trades(filters).all() # trades = Trade.get_trades(filters).all()
if len(trades) < self._trade_limit: if len(trades) < self._trade_limit:
# Not enough trades in the relevant period # Not enough trades in the relevant period
return False, None, None return None
profit = sum(trade.close_profit for trade in trades if trade.close_profit) profit = sum(
trade.close_profit for trade in trades if trade.close_profit
and (not self._only_per_side or trade.trade_direction == side)
)
if profit < self._required_profit: if profit < self._required_profit:
self.log_once( self.log_once(
f"Trading for {pair} stopped due to {profit:.2f} < {self._required_profit} " f"Trading for {pair} stopped due to {profit:.2f} < {self._required_profit} "
f"within {self._lookback_period} minutes.", logger.info) f"within {self._lookback_period} minutes.", logger.info)
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades, self._stop_duration)
return True, until, self._reason(profit) return ProtectionReturn(
lock=True,
until=until,
reason=self._reason(profit),
lock_side=(side if self._only_per_side else '*')
)
return False, None, None return None
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, all pairs will be locked with <reason> until <until> If true, all pairs will be locked with <reason> until <until>
""" """
return False, None, None return None
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, this pair will be locked with <reason> until <until> If true, this pair will be locked with <reason> until <until>
""" """
return self._low_profit(date_now, pair=pair) return self._low_profit(date_now, pair=pair, side=side)

View File

@ -1,10 +1,11 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict from typing import Any, Dict, Optional
import pandas as pd import pandas as pd
from freqtrade.constants import LongShort
from freqtrade.data.metrics import calculate_max_drawdown from freqtrade.data.metrics import calculate_max_drawdown
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.plugins.protections import IProtection, ProtectionReturn from freqtrade.plugins.protections import IProtection, ProtectionReturn
@ -39,7 +40,7 @@ class MaxDrawdown(IProtection):
return (f'{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, ' return (f'{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, '
f'locking for {self.stop_duration_str}.') f'locking for {self.stop_duration_str}.')
def _max_drawdown(self, date_now: datetime) -> ProtectionReturn: def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]:
""" """
Evaluate recent trades for drawdown ... Evaluate recent trades for drawdown ...
""" """
@ -51,14 +52,14 @@ class MaxDrawdown(IProtection):
if len(trades) < self._trade_limit: if len(trades) < self._trade_limit:
# Not enough trades in the relevant period # Not enough trades in the relevant period
return False, None, None return None
# Drawdown is always positive # Drawdown is always positive
try: try:
# TODO: This should use absolute profit calculation, considering account balance. # TODO: This should use absolute profit calculation, considering account balance.
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit') drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
except ValueError: except ValueError:
return False, None, None return None
if drawdown > self._max_allowed_drawdown: if drawdown > self._max_allowed_drawdown:
self.log_once( self.log_once(
@ -66,11 +67,15 @@ class MaxDrawdown(IProtection):
f" within {self.lookback_period_str}.", logger.info) f" within {self.lookback_period_str}.", logger.info)
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades, self._stop_duration)
return True, until, self._reason(drawdown) return ProtectionReturn(
lock=True,
until=until,
reason=self._reason(drawdown),
)
return False, None, None return None
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
@ -79,11 +84,12 @@ class MaxDrawdown(IProtection):
""" """
return self._max_drawdown(date_now) return self._max_drawdown(date_now)
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, this pair will be locked with <reason> until <until> If true, this pair will be locked with <reason> until <until>
""" """
return False, None, None return None

View File

@ -1,8 +1,9 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict from typing import Any, Dict, Optional
from freqtrade.constants import LongShort
from freqtrade.enums import ExitType from freqtrade.enums import ExitType
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.plugins.protections import IProtection, ProtectionReturn from freqtrade.plugins.protections import IProtection, ProtectionReturn
@ -21,6 +22,7 @@ class StoplossGuard(IProtection):
self._trade_limit = protection_config.get('trade_limit', 10) self._trade_limit = protection_config.get('trade_limit', 10)
self._disable_global_stop = protection_config.get('only_per_pair', False) self._disable_global_stop = protection_config.get('only_per_pair', False)
self._only_per_side = protection_config.get('only_per_side', False)
def short_desc(self) -> str: def short_desc(self) -> str:
""" """
@ -36,7 +38,8 @@ class StoplossGuard(IProtection):
return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, ' return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
f'locking for {self._stop_duration} min.') f'locking for {self._stop_duration} min.')
def _stoploss_guard(self, date_now: datetime, pair: str = None) -> ProtectionReturn: def _stoploss_guard(self, date_now: datetime, pair: Optional[str],
side: LongShort) -> Optional[ProtectionReturn]:
""" """
Evaluate recent trades Evaluate recent trades
""" """
@ -48,15 +51,24 @@ class StoplossGuard(IProtection):
ExitType.STOPLOSS_ON_EXCHANGE.value) ExitType.STOPLOSS_ON_EXCHANGE.value)
and trade.close_profit and trade.close_profit < 0)] and trade.close_profit and trade.close_profit < 0)]
if self._only_per_side:
# Long or short trades only
trades = [trade for trade in trades if trade.trade_direction == side]
if len(trades) < self._trade_limit: if len(trades) < self._trade_limit:
return False, None, None return None
self.log_once(f"Trading stopped due to {self._trade_limit} " self.log_once(f"Trading stopped due to {self._trade_limit} "
f"stoplosses within {self._lookback_period} minutes.", logger.info) f"stoplosses within {self._lookback_period} minutes.", logger.info)
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades, self._stop_duration)
return True, until, self._reason() return ProtectionReturn(
lock=True,
until=until,
reason=self._reason(),
lock_side=(side if self._only_per_side else '*')
)
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
@ -64,14 +76,15 @@ class StoplossGuard(IProtection):
If true, all pairs will be locked with <reason> until <until> If true, all pairs will be locked with <reason> until <until>
""" """
if self._disable_global_stop: if self._disable_global_stop:
return False, None, None return None
return self._stoploss_guard(date_now, None) return self._stoploss_guard(date_now, None, side)
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, this pair will be locked with <reason> until <until> If true, this pair will be locked with <reason> until <until>
""" """
return self._stoploss_guard(date_now, pair) return self._stoploss_guard(date_now, pair, side)

View File

@ -172,6 +172,7 @@ def api_delete_backtest(ws_mode=Depends(is_webserver_mode)):
"status_msg": "Backtest running", "status_msg": "Backtest running",
} }
if ApiServer._bt: if ApiServer._bt:
ApiServer._bt.cleanup()
del ApiServer._bt del ApiServer._bt
ApiServer._bt = None ApiServer._bt = None
del ApiServer._bt_data del ApiServer._bt_data

View File

@ -256,6 +256,7 @@ class TradeSchema(BaseModel):
leverage: Optional[float] leverage: Optional[float]
interest_rate: Optional[float] interest_rate: Optional[float]
liquidation_price: Optional[float]
funding_fees: Optional[float] funding_fees: Optional[float]
trading_mode: Optional[TradingMode] trading_mode: Optional[TradingMode]
@ -291,6 +292,7 @@ class LockModel(BaseModel):
lock_time: str lock_time: str
lock_timestamp: int lock_timestamp: int
pair: str pair: str
side: str
reason: str reason: str

View File

@ -177,16 +177,19 @@ class RPC:
current_rate = NAN current_rate = NAN
else: else:
current_rate = trade.close_rate current_rate = trade.close_rate
current_profit = trade.calc_profit_ratio(current_rate) if len(trade.select_filled_orders(trade.entry_side)) > 0:
current_profit_abs = trade.calc_profit(current_rate) current_profit = trade.calc_profit_ratio(current_rate)
current_profit_fiat: Optional[float] = None current_profit_abs = trade.calc_profit(current_rate)
# Calculate fiat profit current_profit_fiat: Optional[float] = None
if self._fiat_converter: # Calculate fiat profit
current_profit_fiat = self._fiat_converter.convert_amount( if self._fiat_converter:
current_profit_abs, current_profit_fiat = self._fiat_converter.convert_amount(
self._freqtrade.config['stake_currency'], current_profit_abs,
self._freqtrade.config['fiat_display_currency'] self._freqtrade.config['stake_currency'],
) self._freqtrade.config['fiat_display_currency']
)
else:
current_profit = current_profit_abs = current_profit_fiat = 0.0
# Calculate guaranteed profit (in case of trailing stop) # Calculate guaranteed profit (in case of trailing stop)
stoploss_entry_dist = trade.calc_profit(trade.stop_loss) stoploss_entry_dist = trade.calc_profit(trade.stop_loss)
@ -235,8 +238,12 @@ class RPC:
trade.pair, side='exit', is_short=trade.is_short, refresh=False) trade.pair, side='exit', is_short=trade.is_short, refresh=False)
except (PricingError, ExchangeError): except (PricingError, ExchangeError):
current_rate = NAN current_rate = NAN
trade_profit = trade.calc_profit(current_rate) if len(trade.select_filled_orders(trade.entry_side)) > 0:
profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}' trade_profit = trade.calc_profit(current_rate)
profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}'
else:
trade_profit = 0.0
profit_str = f'{0.0:.2f}'
direction_str = ('S' if trade.is_short else 'L') if nonspot else '' direction_str = ('S' if trade.is_short else 'L') if nonspot else ''
if self._fiat_converter: if self._fiat_converter:
fiat_profit = self._fiat_converter.convert_amount( fiat_profit = self._fiat_converter.convert_amount(
@ -244,7 +251,7 @@ class RPC:
stake_currency, stake_currency,
fiat_display_currency fiat_display_currency
) )
if fiat_profit and not isnan(fiat_profit): if not isnan(fiat_profit):
profit_str += f" ({fiat_profit:.2f})" profit_str += f" ({fiat_profit:.2f})"
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \ fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
else fiat_profit_sum + fiat_profit else fiat_profit_sum + fiat_profit

View File

@ -1410,14 +1410,14 @@ class Telegram(RPCHandler):
"Optionally takes a rate at which to sell " "Optionally takes a rate at which to sell "
"(only applies to limit orders).` \n") "(only applies to limit orders).` \n")
message = ( message = (
"_BotControl_\n" "_Bot Control_\n"
"------------\n" "------------\n"
"*/start:* `Starts the trader`\n" "*/start:* `Starts the trader`\n"
"*/stop:* Stops the trader\n" "*/stop:* Stops the trader\n"
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n"
"*/forceexit <trade_id>|all:* `Instantly exits the given trade or all trades, " "*/forceexit <trade_id>|all:* `Instantly exits the given trade or all trades, "
"regardless of profit`\n" "regardless of profit`\n"
"*/fe <trade_id>|all:* `Alias to /forceexit`" "*/fe <trade_id>|all:* `Alias to /forceexit`\n"
f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}" f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n" "*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/whitelist:* `Show current whitelist` \n" "*/whitelist:* `Show current whitelist` \n"

View File

@ -1,9 +1,9 @@
# flake8: noqa: F401 # flake8: noqa: F401
from freqtrade.exchange import (timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date, from freqtrade.exchange import (timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date,
timeframe_to_prev_date, timeframe_to_seconds) timeframe_to_prev_date, timeframe_to_seconds)
from freqtrade.strategy.hyper import (BooleanParameter, CategoricalParameter, DecimalParameter,
IntParameter, RealParameter)
from freqtrade.strategy.informative_decorator import informative from freqtrade.strategy.informative_decorator import informative
from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.interface import IStrategy
from freqtrade.strategy.parameters import (BooleanParameter, CategoricalParameter, DecimalParameter,
IntParameter, RealParameter)
from freqtrade.strategy.strategy_helper import (merge_informative_pair, stoploss_from_absolute, from freqtrade.strategy.strategy_helper import (merge_informative_pair, stoploss_from_absolute,
stoploss_from_open) stoploss_from_open)

View File

@ -3,295 +3,19 @@ IHyperStrategy interface, hyperoptable Parameter class.
This module defines a base class for auto-hyperoptable strategies. This module defines a base class for auto-hyperoptable strategies.
""" """
import logging import logging
from abc import ABC, abstractmethod
from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union from typing import Any, Dict, Iterator, List, Tuple
from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.optimize.hyperopt_tools import HyperoptTools
with suppress(ImportError):
from skopt.space import Integer, Real, Categorical
from freqtrade.optimize.space import SKDecimal
from freqtrade.enums import RunMode from freqtrade.enums import RunMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.optimize.hyperopt_tools import HyperoptTools
from freqtrade.strategy.parameters import BaseParameter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BaseParameter(ABC):
"""
Defines a parameter that can be optimized by hyperopt.
"""
category: Optional[str]
default: Any
value: Any
in_space: bool = False
name: str
def __init__(self, *, default: Any, space: Optional[str] = None,
optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable parameter.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter field
name is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.(Integer|Real|Categorical).
"""
if 'name' in kwargs:
raise OperationalException(
'Name is determined by parameter field name and can not be specified manually.')
self.category = space
self._space_params = kwargs
self.value = default
self.optimize = optimize
self.load = load
def __repr__(self):
return f'{self.__class__.__name__}({self.value})'
@abstractmethod
def get_space(self, name: str) -> Union['Integer', 'Real', 'SKDecimal', 'Categorical']:
"""
Get-space - will be used by Hyperopt to get the hyperopt Space
"""
class NumericParameter(BaseParameter):
""" Internal parameter used for Numeric purposes """
float_or_int = Union[int, float]
default: float_or_int
value: float_or_int
def __init__(self, low: Union[float_or_int, Sequence[float_or_int]],
high: Optional[float_or_int] = None, *, default: float_or_int,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable numeric parameter.
Cannot be instantiated, but provides the validation for other numeric parameters
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none of entire range is passed first parameter.
:param default: A default value.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.*.
"""
if high is not None and isinstance(low, Sequence):
raise OperationalException(f'{self.__class__.__name__} space invalid.')
if high is None or isinstance(low, Sequence):
if not isinstance(low, Sequence) or len(low) != 2:
raise OperationalException(f'{self.__class__.__name__} space must be [low, high]')
self.low, self.high = low
else:
self.low = low
self.high = high
super().__init__(default=default, space=space, optimize=optimize,
load=load, **kwargs)
class IntParameter(NumericParameter):
default: int
value: int
def __init__(self, low: Union[int, Sequence[int]], high: Optional[int] = None, *, default: int,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable integer parameter.
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none of entire range is passed first parameter.
:param default: A default value.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Integer.
"""
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'Integer':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return Integer(low=self.low, high=self.high, name=name, **self._space_params)
@property
def range(self):
"""
Get each value in this space as list.
Returns a List from low to high (inclusive) in Hyperopt mode.
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
calculating 100ds of indicators.
"""
if self.in_space and self.optimize:
# Scikit-optimize ranges are "inclusive", while python's "range" is exclusive
return range(self.low, self.high + 1)
else:
return range(self.value, self.value + 1)
class RealParameter(NumericParameter):
default: float
value: float
def __init__(self, low: Union[float, Sequence[float]], high: Optional[float] = None, *,
default: float, space: Optional[str] = None, optimize: bool = True,
load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable floating point parameter with unlimited precision.
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none if entire range is passed first parameter.
:param default: A default value.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Real.
"""
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'Real':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return Real(low=self.low, high=self.high, name=name, **self._space_params)
class DecimalParameter(NumericParameter):
default: float
value: float
def __init__(self, low: Union[float, Sequence[float]], high: Optional[float] = None, *,
default: float, decimals: int = 3, space: Optional[str] = None,
optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable decimal parameter with a limited precision.
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none if entire range is passed first parameter.
:param default: A default value.
:param decimals: A number of decimals after floating point to be included in testing.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Integer.
"""
self._decimals = decimals
default = round(default, self._decimals)
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'SKDecimal':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return SKDecimal(low=self.low, high=self.high, decimals=self._decimals, name=name,
**self._space_params)
@property
def range(self):
"""
Get each value in this space as list.
Returns a List from low to high (inclusive) in Hyperopt mode.
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
calculating 100ds of indicators.
"""
if self.in_space and self.optimize:
low = int(self.low * pow(10, self._decimals))
high = int(self.high * pow(10, self._decimals)) + 1
return [round(n * pow(0.1, self._decimals), self._decimals) for n in range(low, high)]
else:
return [self.value]
class CategoricalParameter(BaseParameter):
default: Any
value: Any
opt_range: Sequence[Any]
def __init__(self, categories: Sequence[Any], *, default: Optional[Any] = None,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable parameter.
:param categories: Optimization space, [a, b, ...].
:param default: A default value. If not specified, first item from specified space will be
used.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter field
name is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Categorical.
"""
if len(categories) < 2:
raise OperationalException(
'CategoricalParameter space must be [a, b, ...] (at least two parameters)')
self.opt_range = categories
super().__init__(default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'Categorical':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return Categorical(self.opt_range, name=name, **self._space_params)
@property
def range(self):
"""
Get each value in this space as list.
Returns a List of categories in Hyperopt mode.
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
calculating 100ds of indicators.
"""
if self.in_space and self.optimize:
return self.opt_range
else:
return [self.value]
class BooleanParameter(CategoricalParameter):
def __init__(self, *, default: Optional[Any] = None,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable Boolean Parameter.
It's a shortcut to `CategoricalParameter([True, False])`.
:param default: A default value. If not specified, first item from specified space will be
used.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter field
name is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Categorical.
"""
categories = [True, False]
super().__init__(categories=categories, default=default, space=space, optimize=optimize,
load=load, **kwargs)
class HyperStrategyMixin: class HyperStrategyMixin:
""" """
A helper base class which allows HyperOptAuto class to reuse implementations of buy/sell A helper base class which allows HyperOptAuto class to reuse implementations of buy/sell
@ -345,7 +69,7 @@ class HyperStrategyMixin:
@classmethod @classmethod
def detect_all_parameters(cls) -> Dict: def detect_all_parameters(cls) -> Dict:
""" Detect all parameters and return them as a list""" """ Detect all parameters and return them as a list"""
params: Dict = { params: Dict[str, Any] = {
'buy': list(cls.detect_parameters('buy')), 'buy': list(cls.detect_parameters('buy')),
'sell': list(cls.detect_parameters('sell')), 'sell': list(cls.detect_parameters('sell')),
'protection': list(cls.detect_parameters('protection')), 'protection': list(cls.detect_parameters('protection')),
@ -424,7 +148,7 @@ class HyperStrategyMixin:
""" """
Returns list of Parameters that are not part of the current optimize job Returns list of Parameters that are not part of the current optimize job
""" """
params = { params: Dict[str, Dict] = {
'buy': {}, 'buy': {},
'sell': {}, 'sell': {},
'protection': {}, 'protection': {},

View File

@ -15,10 +15,8 @@ from freqtrade.data.dataprovider import DataProvider
from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, SignalTagType, from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, SignalTagType,
SignalType, TradingMode) SignalType, TradingMode)
from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exceptions import OperationalException, StrategyError
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds
from freqtrade.exchange.exchange import timeframe_to_next_date from freqtrade.persistence import Order, PairLocks, Trade
from freqtrade.persistence import PairLocks, Trade
from freqtrade.persistence.models import LocalTrade, Order
from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.hyper import HyperStrategyMixin
from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators,
_create_and_merge_informative_pair, _create_and_merge_informative_pair,
@ -84,7 +82,7 @@ class IStrategy(ABC, HyperStrategyMixin):
} }
# run "populate_indicators" only for new candle # run "populate_indicators" only for new candle
process_only_new_candles: bool = False process_only_new_candles: bool = True
use_exit_signal: bool use_exit_signal: bool
exit_profit_only: bool exit_profit_only: bool
@ -146,6 +144,13 @@ class IStrategy(ABC, HyperStrategyMixin):
informative_data.candle_type = config['candle_type_def'] informative_data.candle_type = config['candle_type_def']
self._ft_informative.append((informative_data, cls_method)) self._ft_informative.append((informative_data, cls_method))
def ft_bot_start(self, **kwargs) -> None:
"""
Strategy init - runs after dataprovider has been added.
Must call bot_start()
"""
strategy_safe_wrapper(self.bot_start)()
@abstractmethod @abstractmethod
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
""" """
@ -431,7 +436,7 @@ class IStrategy(ABC, HyperStrategyMixin):
return self.custom_sell(pair, trade, current_time, current_rate, current_profit, **kwargs) return self.custom_sell(pair, trade, current_time, current_rate, current_profit, **kwargs)
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float, proposed_stake: float, min_stake: Optional[float], max_stake: float,
entry_tag: Optional[str], side: str, **kwargs) -> float: entry_tag: Optional[str], side: str, **kwargs) -> float:
""" """
Customize stake size for each new trade. Customize stake size for each new trade.
@ -449,8 +454,9 @@ class IStrategy(ABC, HyperStrategyMixin):
return proposed_stake return proposed_stake
def adjust_trade_position(self, trade: Trade, current_time: datetime, def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, min_stake: float, current_rate: float, current_profit: float,
max_stake: float, **kwargs) -> Optional[float]: min_stake: Optional[float], max_stake: float,
**kwargs) -> Optional[float]:
""" """
Custom trade adjustment logic, returning the stake amount that a trade should be increased. Custom trade adjustment logic, returning the stake amount that a trade should be increased.
This means extra buy orders with additional fees. This means extra buy orders with additional fees.
@ -471,6 +477,34 @@ class IStrategy(ABC, HyperStrategyMixin):
""" """
return None return None
def adjust_entry_price(self, trade: Trade, order: Optional[Order], pair: str,
current_time: datetime, proposed_rate: float, current_order_rate: float,
entry_tag: Optional[str], side: str, **kwargs) -> float:
"""
Entry price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
When not implemented by a strategy, returns current_order_rate as default.
If current_order_rate is returned then the existing order is maintained.
If None is returned then order gets canceled but not replaced by a new one.
:param pair: Pair that's currently analyzed
:param trade: Trade object.
:param order: Order object
:param current_time: datetime object, containing the current datetime
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
"""
return current_order_rate
def leverage(self, pair: str, current_time: datetime, current_rate: float, def leverage(self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, side: str, proposed_leverage: float, max_leverage: float, side: str,
**kwargs) -> float: **kwargs) -> float:
@ -545,7 +579,7 @@ class IStrategy(ABC, HyperStrategyMixin):
""" """
return self.__class__.__name__ return self.__class__.__name__
def lock_pair(self, pair: str, until: datetime, reason: str = None) -> None: def lock_pair(self, pair: str, until: datetime, reason: str = None, side: str = '*') -> None:
""" """
Locks pair until a given timestamp happens. Locks pair until a given timestamp happens.
Locked pairs are not analyzed, and are prevented from opening new trades. Locked pairs are not analyzed, and are prevented from opening new trades.
@ -555,8 +589,9 @@ class IStrategy(ABC, HyperStrategyMixin):
:param until: datetime in UTC until the pair should be blocked from opening new trades. :param until: datetime in UTC until the pair should be blocked from opening new trades.
Needs to be timezone aware `datetime.now(timezone.utc)` Needs to be timezone aware `datetime.now(timezone.utc)`
:param reason: Optional string explaining why the pair was locked. :param reason: Optional string explaining why the pair was locked.
:param side: Side to check, can be long, short or '*'
""" """
PairLocks.lock_pair(pair, until, reason) PairLocks.lock_pair(pair, until, reason, side=side)
def unlock_pair(self, pair: str) -> None: def unlock_pair(self, pair: str) -> None:
""" """
@ -576,7 +611,7 @@ class IStrategy(ABC, HyperStrategyMixin):
""" """
PairLocks.unlock_reason(reason, datetime.now(timezone.utc)) PairLocks.unlock_reason(reason, datetime.now(timezone.utc))
def is_pair_locked(self, pair: str, candle_date: datetime = None) -> bool: def is_pair_locked(self, pair: str, *, candle_date: datetime = None, side: str = '*') -> bool:
""" """
Checks if a pair is currently locked Checks if a pair is currently locked
The 2nd, optional parameter ensures that locks are applied until the new candle arrives, The 2nd, optional parameter ensures that locks are applied until the new candle arrives,
@ -584,15 +619,16 @@ class IStrategy(ABC, HyperStrategyMixin):
of 2 seconds for an entry order to happen on an old signal. of 2 seconds for an entry order to happen on an old signal.
:param pair: "Pair to check" :param pair: "Pair to check"
:param candle_date: Date of the last candle. Optional, defaults to current date :param candle_date: Date of the last candle. Optional, defaults to current date
:param side: Side to check, can be long, short or '*'
:returns: locking state of the pair in question. :returns: locking state of the pair in question.
""" """
if not candle_date: if not candle_date:
# Simple call ... # Simple call ...
return PairLocks.is_pair_locked(pair) return PairLocks.is_pair_locked(pair, side=side)
else: else:
lock_time = timeframe_to_next_date(self.timeframe, candle_date) lock_time = timeframe_to_next_date(self.timeframe, candle_date)
return PairLocks.is_pair_locked(pair, lock_time) return PairLocks.is_pair_locked(pair, lock_time, side=side)
def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
""" """
@ -850,16 +886,16 @@ class IStrategy(ABC, HyperStrategyMixin):
def should_exit(self, trade: Trade, rate: float, current_time: datetime, *, def should_exit(self, trade: Trade, rate: float, current_time: datetime, *,
enter: bool, exit_: bool, enter: bool, exit_: bool,
low: float = None, high: float = None, low: float = None, high: float = None,
force_stoploss: float = 0) -> ExitCheckTuple: force_stoploss: float = 0) -> List[ExitCheckTuple]:
""" """
This function evaluates if one of the conditions required to trigger an exit order This function evaluates if one of the conditions required to trigger an exit order
has been reached, which can either be a stop-loss, ROI or exit-signal. has been reached, which can either be a stop-loss, ROI or exit-signal.
:param low: Only used during backtesting to simulate (long)stoploss/(short)ROI :param low: Only used during backtesting to simulate (long)stoploss/(short)ROI
:param high: Only used during backtesting, to simulate (short)stoploss/(long)ROI :param high: Only used during backtesting, to simulate (short)stoploss/(long)ROI
:param force_stoploss: Externally provided stoploss :param force_stoploss: Externally provided stoploss
:return: True if trade should be exited, False otherwise :return: List of exit reasons - or empty list.
""" """
exits: List[ExitCheckTuple] = []
current_rate = rate current_rate = rate
current_profit = trade.calc_profit_ratio(current_rate) current_profit = trade.calc_profit_ratio(current_rate)
@ -889,19 +925,20 @@ class IStrategy(ABC, HyperStrategyMixin):
if exit_ and not enter: if exit_ and not enter:
exit_signal = ExitType.EXIT_SIGNAL exit_signal = ExitType.EXIT_SIGNAL
else: else:
custom_reason = strategy_safe_wrapper(self.custom_exit, default_retval=False)( reason_cust = strategy_safe_wrapper(self.custom_exit, default_retval=False)(
pair=trade.pair, trade=trade, current_time=current_time, pair=trade.pair, trade=trade, current_time=current_time,
current_rate=current_rate, current_profit=current_profit) current_rate=current_rate, current_profit=current_profit)
if custom_reason: if reason_cust:
exit_signal = ExitType.CUSTOM_EXIT exit_signal = ExitType.CUSTOM_EXIT
if isinstance(custom_reason, str): if isinstance(reason_cust, str):
if len(custom_reason) > CUSTOM_EXIT_MAX_LENGTH: custom_reason = reason_cust
if len(reason_cust) > CUSTOM_EXIT_MAX_LENGTH:
logger.warning(f'Custom exit reason returned from ' logger.warning(f'Custom exit reason returned from '
f'custom_exit is too long and was trimmed' f'custom_exit is too long and was trimmed'
f'to {CUSTOM_EXIT_MAX_LENGTH} characters.') f'to {CUSTOM_EXIT_MAX_LENGTH} characters.')
custom_reason = custom_reason[:CUSTOM_EXIT_MAX_LENGTH] custom_reason = reason_cust[:CUSTOM_EXIT_MAX_LENGTH]
else: else:
custom_reason = None custom_reason = ''
if ( if (
exit_signal == ExitType.CUSTOM_EXIT exit_signal == ExitType.CUSTOM_EXIT
or (exit_signal == ExitType.EXIT_SIGNAL or (exit_signal == ExitType.EXIT_SIGNAL
@ -910,24 +947,29 @@ class IStrategy(ABC, HyperStrategyMixin):
logger.debug(f"{trade.pair} - Sell signal received. " logger.debug(f"{trade.pair} - Sell signal received. "
f"exit_type=ExitType.{exit_signal.name}" + f"exit_type=ExitType.{exit_signal.name}" +
(f", custom_reason={custom_reason}" if custom_reason else "")) (f", custom_reason={custom_reason}" if custom_reason else ""))
return ExitCheckTuple(exit_type=exit_signal, exit_reason=custom_reason) exits.append(ExitCheckTuple(exit_type=exit_signal, exit_reason=custom_reason))
# Sequence: # Sequence:
# Exit-signal # Exit-signal
# ROI (if not stoploss)
# Stoploss # Stoploss
if roi_reached and stoplossflag.exit_type != ExitType.STOP_LOSS: # ROI
logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI") # Trailing stoploss
return ExitCheckTuple(exit_type=ExitType.ROI)
if stoplossflag.exit_flag: if stoplossflag.exit_type == ExitType.STOP_LOSS:
logger.debug(f"{trade.pair} - Stoploss hit. exit_type={stoplossflag.exit_type}") logger.debug(f"{trade.pair} - Stoploss hit. exit_type={stoplossflag.exit_type}")
return stoplossflag exits.append(stoplossflag)
# This one is noisy, commented out... if roi_reached:
# logger.debug(f"{trade.pair} - No exit signal.") logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI")
return ExitCheckTuple(exit_type=ExitType.NONE) exits.append(ExitCheckTuple(exit_type=ExitType.ROI))
if stoplossflag.exit_type == ExitType.TRAILING_STOP_LOSS:
logger.debug(f"{trade.pair} - Trailing stoploss hit.")
exits.append(stoplossflag)
return exits
def stop_loss_reached(self, current_rate: float, trade: Trade, def stop_loss_reached(self, current_rate: float, trade: Trade,
current_time: datetime, current_profit: float, current_time: datetime, current_profit: float,
@ -1042,7 +1084,7 @@ class IStrategy(ABC, HyperStrategyMixin):
else: else:
return current_profit > roi return current_profit > roi
def ft_check_timed_out(self, trade: LocalTrade, order: Order, def ft_check_timed_out(self, trade: Trade, order: Order,
current_time: datetime) -> bool: current_time: datetime) -> bool:
""" """
FT Internal method. FT Internal method.

View File

@ -0,0 +1,289 @@
"""
IHyperStrategy interface, hyperoptable Parameter class.
This module defines a base class for auto-hyperoptable strategies.
"""
import logging
from abc import ABC, abstractmethod
from contextlib import suppress
from typing import Any, Optional, Sequence, Union
with suppress(ImportError):
from skopt.space import Integer, Real, Categorical
from freqtrade.optimize.space import SKDecimal
from freqtrade.exceptions import OperationalException
logger = logging.getLogger(__name__)
class BaseParameter(ABC):
"""
Defines a parameter that can be optimized by hyperopt.
"""
category: Optional[str]
default: Any
value: Any
in_space: bool = False
name: str
def __init__(self, *, default: Any, space: Optional[str] = None,
optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable parameter.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter field
name is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.(Integer|Real|Categorical).
"""
if 'name' in kwargs:
raise OperationalException(
'Name is determined by parameter field name and can not be specified manually.')
self.category = space
self._space_params = kwargs
self.value = default
self.optimize = optimize
self.load = load
def __repr__(self):
return f'{self.__class__.__name__}({self.value})'
@abstractmethod
def get_space(self, name: str) -> Union['Integer', 'Real', 'SKDecimal', 'Categorical']:
"""
Get-space - will be used by Hyperopt to get the hyperopt Space
"""
class NumericParameter(BaseParameter):
""" Internal parameter used for Numeric purposes """
float_or_int = Union[int, float]
default: float_or_int
value: float_or_int
def __init__(self, low: Union[float_or_int, Sequence[float_or_int]],
high: Optional[float_or_int] = None, *, default: float_or_int,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable numeric parameter.
Cannot be instantiated, but provides the validation for other numeric parameters
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none of entire range is passed first parameter.
:param default: A default value.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.*.
"""
if high is not None and isinstance(low, Sequence):
raise OperationalException(f'{self.__class__.__name__} space invalid.')
if high is None or isinstance(low, Sequence):
if not isinstance(low, Sequence) or len(low) != 2:
raise OperationalException(f'{self.__class__.__name__} space must be [low, high]')
self.low, self.high = low
else:
self.low = low
self.high = high
super().__init__(default=default, space=space, optimize=optimize,
load=load, **kwargs)
class IntParameter(NumericParameter):
default: int
value: int
low: int
high: int
def __init__(self, low: Union[int, Sequence[int]], high: Optional[int] = None, *, default: int,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable integer parameter.
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none of entire range is passed first parameter.
:param default: A default value.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Integer.
"""
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'Integer':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return Integer(low=self.low, high=self.high, name=name, **self._space_params)
@property
def range(self):
"""
Get each value in this space as list.
Returns a List from low to high (inclusive) in Hyperopt mode.
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
calculating 100ds of indicators.
"""
if self.in_space and self.optimize:
# Scikit-optimize ranges are "inclusive", while python's "range" is exclusive
return range(self.low, self.high + 1)
else:
return range(self.value, self.value + 1)
class RealParameter(NumericParameter):
default: float
value: float
def __init__(self, low: Union[float, Sequence[float]], high: Optional[float] = None, *,
default: float, space: Optional[str] = None, optimize: bool = True,
load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable floating point parameter with unlimited precision.
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none if entire range is passed first parameter.
:param default: A default value.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Real.
"""
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'Real':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return Real(low=self.low, high=self.high, name=name, **self._space_params)
class DecimalParameter(NumericParameter):
default: float
value: float
def __init__(self, low: Union[float, Sequence[float]], high: Optional[float] = None, *,
default: float, decimals: int = 3, space: Optional[str] = None,
optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable decimal parameter with a limited precision.
:param low: Lower end (inclusive) of optimization space or [low, high].
:param high: Upper end (inclusive) of optimization space.
Must be none if entire range is passed first parameter.
:param default: A default value.
:param decimals: A number of decimals after floating point to be included in testing.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter fieldname is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Integer.
"""
self._decimals = decimals
default = round(default, self._decimals)
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'SKDecimal':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return SKDecimal(low=self.low, high=self.high, decimals=self._decimals, name=name,
**self._space_params)
@property
def range(self):
"""
Get each value in this space as list.
Returns a List from low to high (inclusive) in Hyperopt mode.
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
calculating 100ds of indicators.
"""
if self.in_space and self.optimize:
low = int(self.low * pow(10, self._decimals))
high = int(self.high * pow(10, self._decimals)) + 1
return [round(n * pow(0.1, self._decimals), self._decimals) for n in range(low, high)]
else:
return [self.value]
class CategoricalParameter(BaseParameter):
default: Any
value: Any
opt_range: Sequence[Any]
def __init__(self, categories: Sequence[Any], *, default: Optional[Any] = None,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable parameter.
:param categories: Optimization space, [a, b, ...].
:param default: A default value. If not specified, first item from specified space will be
used.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter field
name is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Categorical.
"""
if len(categories) < 2:
raise OperationalException(
'CategoricalParameter space must be [a, b, ...] (at least two parameters)')
self.opt_range = categories
super().__init__(default=default, space=space, optimize=optimize,
load=load, **kwargs)
def get_space(self, name: str) -> 'Categorical':
"""
Create skopt optimization space.
:param name: A name of parameter field.
"""
return Categorical(self.opt_range, name=name, **self._space_params)
@property
def range(self):
"""
Get each value in this space as list.
Returns a List of categories in Hyperopt mode.
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
calculating 100ds of indicators.
"""
if self.in_space and self.optimize:
return self.opt_range
else:
return [self.value]
class BooleanParameter(CategoricalParameter):
def __init__(self, *, default: Optional[Any] = None,
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
"""
Initialize hyperopt-optimizable Boolean Parameter.
It's a shortcut to `CategoricalParameter([True, False])`.
:param default: A default value. If not specified, first item from specified space will be
used.
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
parameter field
name is prefixed with 'buy_' or 'sell_'.
:param optimize: Include parameter in hyperopt optimizations.
:param load: Load parameter value from {space}_params.
:param kwargs: Extra parameters to skopt.space.Categorical.
"""
categories = [True, False]
super().__init__(categories=categories, default=default, space=space, optimize=optimize,
load=load, **kwargs)

View File

@ -1,5 +1,7 @@
import logging import logging
from copy import deepcopy from copy import deepcopy
from functools import wraps
from typing import Any, Callable, TypeVar, cast
from freqtrade.exceptions import StrategyError from freqtrade.exceptions import StrategyError
@ -7,12 +9,16 @@ from freqtrade.exceptions import StrategyError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_error=False): F = TypeVar('F', bound=Callable[..., Any])
def strategy_safe_wrapper(f: F, message: str = "", default_retval=None, supress_error=False) -> F:
""" """
Wrapper around user-provided methods and functions. Wrapper around user-provided methods and functions.
Caches all exceptions and returns either the default_retval (if it's not None) or raises Caches all exceptions and returns either the default_retval (if it's not None) or raises
a StrategyError exception, which then needs to be handled by the calling method. a StrategyError exception, which then needs to be handled by the calling method.
""" """
@wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
if 'trade' in kwargs: if 'trade' in kwargs:
@ -37,4 +43,4 @@ def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_err
raise StrategyError(str(error)) from error raise StrategyError(str(error)) from error
return default_retval return default_retval
return wrapper return cast(F, wrapper)

View File

@ -4,7 +4,9 @@
# --- Do not remove these libs --- # --- Do not remove these libs ---
import numpy as np # noqa import numpy as np # noqa
import pandas as pd # noqa import pandas as pd # noqa
from pandas import DataFrame from pandas import DataFrame # noqa
from datetime import datetime # noqa
from typing import Optional, Union # noqa
from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter, from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter,
IStrategy, IntParameter) IStrategy, IntParameter)
@ -62,7 +64,7 @@ class {{ strategy }}(IStrategy):
# trailing_stop_positive_offset = 0.0 # Disabled / not configured # trailing_stop_positive_offset = 0.0 # Disabled / not configured
# Run "populate_indicators()" only for new candle. # Run "populate_indicators()" only for new candle.
process_only_new_candles = False process_only_new_candles = True
# These values can be overridden in the config. # These values can be overridden in the config.
use_exit_signal = True use_exit_signal = True

View File

@ -62,7 +62,7 @@ class SampleStrategy(IStrategy):
timeframe = '5m' timeframe = '5m'
# Run "populate_indicators()" only for new candle. # Run "populate_indicators()" only for new candle.
process_only_new_candles = False process_only_new_candles = True
# These values can be overridden in the config. # These values can be overridden in the config.
use_exit_signal = True use_exit_signal = True

View File

@ -13,7 +13,7 @@ def bot_loop_start(self, **kwargs) -> None:
pass pass
def custom_entry_price(self, pair: str, current_time: 'datetime', proposed_rate: float, def custom_entry_price(self, pair: str, current_time: 'datetime', proposed_rate: float,
entry_tag: 'Optional[str]', **kwargs) -> float: entry_tag: 'Optional[str]', side: str, **kwargs) -> float:
""" """
Custom entry price logic, returning the new entry price. Custom entry price logic, returning the new entry price.
@ -30,6 +30,34 @@ def custom_entry_price(self, pair: str, current_time: 'datetime', proposed_rate:
""" """
return proposed_rate return proposed_rate
def adjust_entry_price(self, trade: 'Trade', order: 'Optional[Order]', pair: str,
current_time: datetime, proposed_rate: float, current_order_rate: float,
entry_tag: Optional[str], side: str, **kwargs) -> float:
"""
Entry price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
When not implemented by a strategy, returns current_order_rate as default.
If current_order_rate is returned then the existing order is maintained.
If None is returned then order gets canceled but not replaced by a new one.
:param pair: Pair that's currently analyzed
:param trade: Trade object.
:param order: Order object
:param current_time: datetime object, containing the current datetime
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
"""
return current_order_rate
def custom_exit_price(self, pair: str, trade: 'Trade', def custom_exit_price(self, pair: str, trade: 'Trade',
current_time: 'datetime', proposed_rate: float, current_time: 'datetime', proposed_rate: float,
current_profit: float, exit_tag: Optional[str], **kwargs) -> float: current_profit: float, exit_tag: Optional[str], **kwargs) -> float:
@ -52,8 +80,8 @@ def custom_exit_price(self, pair: str, trade: 'Trade',
return proposed_rate return proposed_rate
def custom_stake_amount(self, pair: str, current_time: 'datetime', current_rate: float, def custom_stake_amount(self, pair: str, current_time: 'datetime', current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float, proposed_stake: float, min_stake: Optional[float], max_stake: float,
side: str, entry_tag: 'Optional[str]', **kwargs) -> float: entry_tag: 'Optional[str]', side: str, **kwargs) -> float:
""" """
Customize stake size for each new trade. Customize stake size for each new trade.
@ -118,7 +146,7 @@ def custom_exit(self, pair: str, trade: 'Trade', current_time: 'datetime', curre
return None return None
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, current_time: datetime, entry_tag: 'Optional[str]', time_in_force: str, current_time: datetime, entry_tag: Optional[str],
side: str, **kwargs) -> bool: side: str, **kwargs) -> bool:
""" """
Called right before placing a entry order. Called right before placing a entry order.
@ -216,7 +244,7 @@ def check_exit_timeout(self, pair: str, trade: 'Trade', order: 'Order',
return False return False
def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime', def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime',
current_rate: float, current_profit: float, min_stake: float, current_rate: float, current_profit: float, min_stake: Optional[float],
max_stake: float, **kwargs) -> 'Optional[float]': max_stake: float, **kwargs) -> 'Optional[float]':
""" """
Custom trade adjustment logic, returning the stake amount that a trade should be increased. Custom trade adjustment logic, returning the stake amount that a trade should be increased.

View File

@ -300,7 +300,8 @@ class Wallets:
if min_stake_amount is not None and min_stake_amount > max_stake_amount: if min_stake_amount is not None and min_stake_amount > max_stake_amount:
if self._log: if self._log:
logger.warning("Minimum stake amount > available balance.") logger.warning("Minimum stake amount > available balance. "
f"{min_stake_amount} > {max_stake_amount}")
return 0 return 0
if min_stake_amount is not None and stake_amount < min_stake_amount: if min_stake_amount is not None and stake_amount < min_stake_amount:
if self._log: if self._log:

View File

@ -28,6 +28,28 @@ skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*", "**/user_data/*"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
[tool.mypy]
ignore_missing_imports = true
warn_unused_ignores = true
exclude = [
'^build_helpers\.py$'
]
[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
[build-system] [build-system]
requires = ["setuptools >= 46.4.0", "wheel"] requires = ["setuptools >= 46.4.0", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.pyright]
include = ["freqtrade"]
exclude = [
"**/__pycache__",
"build_helpers/*.py",
]
ignore = ["freqtrade/vendor/**"]
# Align pyright to mypy config
strictParameterNoneValue = false

View File

@ -6,9 +6,9 @@
coveralls==3.3.1 coveralls==3.3.1
flake8==4.0.1 flake8==4.0.1
flake8-tidy-imports==4.6.0 flake8-tidy-imports==4.8.0
mypy==0.942 mypy==0.950
pre-commit==2.18.1 pre-commit==2.19.0
pytest==7.1.2 pytest==7.1.2
pytest-asyncio==0.18.3 pytest-asyncio==0.18.3
pytest-cov==3.0.0 pytest-cov==3.0.0
@ -16,14 +16,14 @@ pytest-mock==3.7.0
pytest-random-order==1.0.4 pytest-random-order==1.0.4
isort==5.10.1 isort==5.10.1
# For datetime mocking # For datetime mocking
time-machine==2.6.0 time-machine==2.7.0
# Convert jupyter notebooks to markdown documents # Convert jupyter notebooks to markdown documents
nbconvert==6.5.0 nbconvert==6.5.0
# mypy types # mypy types
types-cachetools==5.0.1 types-cachetools==5.0.1
types-filelock==3.2.5 types-filelock==3.2.6
types-requests==2.27.20 types-requests==2.27.27
types-tabulate==0.8.7 types-tabulate==0.8.9
types-python-dateutil==2.8.12 types-python-dateutil==2.8.16

View File

@ -2,8 +2,8 @@
-r requirements.txt -r requirements.txt
# Required for hyperopt # Required for hyperopt
scipy==1.8.0 scipy==1.8.1
scikit-learn==1.0.2 scikit-learn==1.1.1
scikit-optimize==0.9.0 scikit-optimize==0.9.0
filelock==3.6.0 filelock==3.7.0
progressbar2==4.0.0 progressbar2==4.0.0

View File

@ -1,4 +1,4 @@
# Include all requirements to run the bot. # Include all requirements to run the bot.
-r requirements.txt -r requirements.txt
plotly==5.7.0 plotly==5.8.0

View File

@ -1,23 +1,23 @@
numpy==1.22.3 numpy==1.22.4
pandas==1.4.2 pandas==1.4.2
pandas-ta==0.3.14b pandas-ta==0.3.14b
ccxt==1.80.61 ccxt==1.83.62
# Pin cryptography for now due to rust build errors with piwheels # Pin cryptography for now due to rust build errors with piwheels
cryptography==36.0.2 cryptography==37.0.2
aiohttp==3.8.1 aiohttp==3.8.1
SQLAlchemy==1.4.35 SQLAlchemy==1.4.36
python-telegram-bot==13.11 python-telegram-bot==13.11
arrow==1.2.2 arrow==1.2.2
cachetools==4.2.2 cachetools==4.2.2
requests==2.27.1 requests==2.27.1
urllib3==1.26.9 urllib3==1.26.9
jsonschema==4.4.0 jsonschema==4.5.1
TA-Lib==0.4.24 TA-Lib==0.4.24
technical==1.3.0 technical==1.3.0
tabulate==0.8.9 tabulate==0.8.9
pycoingecko==2.2.0 pycoingecko==2.2.0
jinja2==3.1.1 jinja2==3.1.2
tables==3.7.0 tables==3.7.0
blosc==1.10.6 blosc==1.10.6
joblib==1.1.0 joblib==1.1.0
@ -34,11 +34,11 @@ orjson==3.6.8
sdnotify==0.3.2 sdnotify==0.3.2
# API Server # API Server
fastapi==0.75.2 fastapi==0.78.0
uvicorn==0.17.6 uvicorn==0.17.6
pyjwt==2.3.0 pyjwt==2.4.0
aiofiles==0.8.0 aiofiles==0.8.0
psutil==5.9.0 psutil==5.9.1
# Support for colorized terminal output # Support for colorized terminal output
colorama==0.4.4 colorama==0.4.4

View File

@ -32,7 +32,7 @@ tests_require =
pytest-mock pytest-mock
packages = find: packages = find:
python_requires = >=3.6 python_requires = >=3.8
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
@ -50,13 +50,3 @@ exclude =
.eggs, .eggs,
user_data, user_data,
[mypy]
ignore_missing_imports = True
warn_unused_ignores = True
exclude = (?x)(
^build_helpers\.py$
)
[mypy-tests.*]
ignore_errors = True

View File

@ -42,7 +42,7 @@ setup(
], ],
install_requires=[ install_requires=[
# from requirements.txt # from requirements.txt
'ccxt>=1.79.69', 'ccxt>=1.83.12',
'SQLAlchemy', 'SQLAlchemy',
'python-telegram-bot>=13.4', 'python-telegram-bot>=13.4',
'arrow>=0.17.0', 'arrow>=0.17.0',

View File

@ -25,7 +25,7 @@ function check_installed_python() {
exit 2 exit 2
fi fi
for v in 9 10 8 for v in 10 9 8
do do
PYTHON="python3.${v}" PYTHON="python3.${v}"
which $PYTHON which $PYTHON
@ -155,7 +155,7 @@ function install_macos() {
# Install bot Debian_ubuntu # Install bot Debian_ubuntu
function install_debian() { function install_debian() {
sudo apt-get update sudo apt-get update
sudo apt-get install -y gcc build-essential autoconf libtool pkg-config make wget git $(echo lib${PYTHON}-dev ${PYTHON}-venv) sudo apt-get install -y gcc build-essential autoconf libtool pkg-config make wget git curl $(echo lib${PYTHON}-dev ${PYTHON}-venv)
install_talib install_talib
} }

View File

@ -1,5 +1,6 @@
import json import json
import re import re
from datetime import datetime
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, PropertyMock from unittest.mock import MagicMock, PropertyMock
@ -14,11 +15,14 @@ from freqtrade.commands import (start_backtesting_show, start_convert_data, star
start_list_exchanges, start_list_markets, start_list_strategies, start_list_exchanges, start_list_markets, start_list_strategies,
start_list_timeframes, start_new_strategy, start_show_trades, start_list_timeframes, start_new_strategy, start_show_trades,
start_test_pairlist, start_trading, start_webserver) start_test_pairlist, start_trading, start_webserver)
from freqtrade.commands.db_commands import start_convert_db
from freqtrade.commands.deploy_commands import (clean_ui_subdir, download_and_install_ui, from freqtrade.commands.deploy_commands import (clean_ui_subdir, download_and_install_ui,
get_ui_download_url, read_ui_version) get_ui_download_url, read_ui_version)
from freqtrade.configuration import setup_utils_configuration from freqtrade.configuration import setup_utils_configuration
from freqtrade.enums import RunMode from freqtrade.enums import RunMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.persistence.models import init_db
from freqtrade.persistence.pairlock_middleware import PairLocks
from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_args, log_has, from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_args, log_has,
log_has_re, patch_exchange, patched_configuration_load_config_file) log_has_re, patch_exchange, patched_configuration_load_config_file)
from tests.conftest_trades import MOCK_TRADE_COUNT from tests.conftest_trades import MOCK_TRADE_COUNT
@ -831,6 +835,23 @@ def test_download_data_trades(mocker, caplog):
start_download_data(pargs) start_download_data(pargs)
def test_download_data_data_invalid(mocker):
patch_exchange(mocker, id="kraken")
mocker.patch(
'freqtrade.exchange.Exchange.markets', PropertyMock(return_value={})
)
args = [
"download-data",
"--exchange", "kraken",
"--pairs", "ETH/BTC", "XRP/BTC",
"--days", "20",
]
pargs = get_args(args)
pargs['config'] = None
with pytest.raises(OperationalException, match=r"Historic klines not available for .*"):
start_download_data(pargs)
def test_start_convert_trades(mocker, caplog): def test_start_convert_trades(mocker, caplog):
convert_mock = mocker.patch('freqtrade.commands.data_commands.convert_trades_to_ohlcv', convert_mock = mocker.patch('freqtrade.commands.data_commands.convert_trades_to_ohlcv',
MagicMock(return_value=[])) MagicMock(return_value=[]))
@ -1458,3 +1479,33 @@ def test_backtesting_show(mocker, testdatadir, capsys):
assert sbr.call_count == 1 assert sbr.call_count == 1
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "Pairs for Strategy" in out assert "Pairs for Strategy" in out
def test_start_convert_db(mocker, fee, tmpdir, caplog):
db_src_file = Path(f"{tmpdir}/db.sqlite")
db_from = f"sqlite:///{db_src_file}"
db_target_file = Path(f"{tmpdir}/db_target.sqlite")
db_to = f"sqlite:///{db_target_file}"
args = [
"convert-db",
"--db-url-from",
db_from,
"--db-url",
db_to,
]
assert not db_src_file.is_file()
init_db(db_from)
create_mock_trades(fee)
PairLocks.timeframe = '5m'
PairLocks.lock_pair('XRP/USDT', datetime.now(), 'Random reason 125', side='long')
assert db_src_file.is_file()
assert not db_target_file.is_file()
pargs = get_args(args)
pargs['config'] = None
start_convert_db(pargs)
assert db_target_file.is_file()

View File

@ -384,7 +384,7 @@ def patch_coingekko(mocker) -> None:
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def init_persistence(default_conf): def init_persistence(default_conf):
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -1616,6 +1616,7 @@ def limit_buy_order_open():
'datetime': arrow.utcnow().isoformat(), 'datetime': arrow.utcnow().isoformat(),
'price': 0.00001099, 'price': 0.00001099,
'amount': 90.99181073, 'amount': 90.99181073,
'average': None,
'filled': 0.0, 'filled': 0.0,
'cost': 0.0009999, 'cost': 0.0009999,
'remaining': 90.99181073, 'remaining': 90.99181073,

View File

@ -376,3 +376,38 @@ def test_calculate_max_drawdown2():
df = DataFrame(zip(values[:5], dates[:5]), columns=['profit', 'open_date']) df = DataFrame(zip(values[:5], dates[:5]), columns=['profit', 'open_date'])
with pytest.raises(ValueError, match='No losing trade, therefore no drawdown.'): with pytest.raises(ValueError, match='No losing trade, therefore no drawdown.'):
calculate_max_drawdown(df, date_col='open_date', value_col='profit') calculate_max_drawdown(df, date_col='open_date', value_col='profit')
@pytest.mark.parametrize('profits,relative,highd,lowd,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, lowd, 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 = Arrow(2020, 1, 1)
dates = [init_date.shift(days=i) for i in range(len(profits))]
df = DataFrame(zip(profits, dates), 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, hdate, ldate, hval, lval, drawdown_rel = 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, float)
assert isinstance(drawdown_rel, float)
assert hdate == init_date.shift(days=highd)
assert ldate == init_date.shift(days=lowd)
# High must be before low
assert hdate < ldate
# High value must be higher than low value
assert hval > lval
assert drawdown == result
assert pytest.approx(drawdown_rel) == result_rel

View File

@ -149,8 +149,8 @@ def test_load_data_with_new_pair_1min(ohlcv_history_list, mocker, caplog,
load_pair_history(datadir=tmpdir1, timeframe='1m', pair='MEME/BTC', candle_type=candle_type) load_pair_history(datadir=tmpdir1, timeframe='1m', pair='MEME/BTC', candle_type=candle_type)
assert file.is_file() assert file.is_file()
assert log_has_re( assert log_has_re(
r'Download history data for pair: "MEME/BTC" \(0/1\), timeframe: 1m, ' r'\(0/1\) - Download history data for "MEME/BTC", 1m, '
r'candle type: spot and store in .*', caplog r'spot and store in .*', caplog
) )
@ -158,21 +158,22 @@ def test_testdata_path(testdatadir) -> None:
assert str(Path('tests') / 'testdata') in str(testdatadir) assert str(Path('tests') / 'testdata') in str(testdatadir)
@pytest.mark.parametrize("pair,expected_result,candle_type", [ @pytest.mark.parametrize("pair,timeframe,expected_result,candle_type", [
("ETH/BTC", 'freqtrade/hello/world/ETH_BTC-5m.json', ""), ("ETH/BTC", "5m", "freqtrade/hello/world/ETH_BTC-5m.json", ""),
("Fabric Token/ETH", 'freqtrade/hello/world/Fabric_Token_ETH-5m.json', ""), ("ETH/USDT", "1M", "freqtrade/hello/world/ETH_USDT-1Mo.json", ""),
("ETHH20", 'freqtrade/hello/world/ETHH20-5m.json', ""), ("Fabric Token/ETH", "5m", "freqtrade/hello/world/Fabric_Token_ETH-5m.json", ""),
(".XBTBON2H", 'freqtrade/hello/world/_XBTBON2H-5m.json', ""), ("ETHH20", "5m", "freqtrade/hello/world/ETHH20-5m.json", ""),
("ETHUSD.d", 'freqtrade/hello/world/ETHUSD_d-5m.json', ""), (".XBTBON2H", "5m", "freqtrade/hello/world/_XBTBON2H-5m.json", ""),
("ACC_OLD/BTC", 'freqtrade/hello/world/ACC_OLD_BTC-5m.json', ""), ("ETHUSD.d", "5m", "freqtrade/hello/world/ETHUSD_d-5m.json", ""),
("ETH/BTC", 'freqtrade/hello/world/futures/ETH_BTC-5m-mark.json', "mark"), ("ACC_OLD/BTC", "5m", "freqtrade/hello/world/ACC_OLD_BTC-5m.json", ""),
("ACC_OLD/BTC", 'freqtrade/hello/world/futures/ACC_OLD_BTC-5m-index.json', "index"), ("ETH/BTC", "5m", "freqtrade/hello/world/futures/ETH_BTC-5m-mark.json", "mark"),
("ACC_OLD/BTC", "5m", "freqtrade/hello/world/futures/ACC_OLD_BTC-5m-index.json", "index"),
]) ])
def test_json_pair_data_filename(pair, expected_result, candle_type): def test_json_pair_data_filename(pair, timeframe, expected_result, candle_type):
fn = JsonDataHandler._pair_data_filename( fn = JsonDataHandler._pair_data_filename(
Path('freqtrade/hello/world'), Path('freqtrade/hello/world'),
pair, pair,
'5m', timeframe,
CandleType.from_string(candle_type) CandleType.from_string(candle_type)
) )
assert isinstance(fn, Path) assert isinstance(fn, Path)
@ -180,7 +181,7 @@ def test_json_pair_data_filename(pair, expected_result, candle_type):
fn = JsonGzDataHandler._pair_data_filename( fn = JsonGzDataHandler._pair_data_filename(
Path('freqtrade/hello/world'), Path('freqtrade/hello/world'),
pair, pair,
'5m', timeframe,
candle_type=CandleType.from_string(candle_type) candle_type=CandleType.from_string(candle_type)
) )
assert isinstance(fn, Path) assert isinstance(fn, Path)
@ -223,42 +224,65 @@ def test_load_cached_data_for_updating(mocker, testdatadir) -> None:
# timeframe starts earlier than the cached data # timeframe starts earlier than the cached data
# should fully update data # should fully update data
timerange = TimeRange('date', None, test_data[0][0] / 1000 - 1, 0) timerange = TimeRange('date', None, test_data[0][0] / 1000 - 1, 0)
data, start_ts = _load_cached_data_for_updating( data, start_ts, end_ts = _load_cached_data_for_updating(
'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT) 'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT)
assert data.empty assert data.empty
assert start_ts == test_data[0][0] - 1000 assert start_ts == test_data[0][0] - 1000
assert end_ts is None
# timeframe starts earlier than the cached data - prepending
timerange = TimeRange('date', None, test_data[0][0] / 1000 - 1, 0)
data, start_ts, end_ts = _load_cached_data_for_updating(
'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT, True)
assert_frame_equal(data, test_data_df.iloc[:-1])
assert start_ts == test_data[0][0] - 1000
assert end_ts == test_data[0][0]
# timeframe starts in the center of the cached data # timeframe starts in the center of the cached data
# should return the cached data w/o the last item # should return the cached data w/o the last item
timerange = TimeRange('date', None, test_data[0][0] / 1000 + 1, 0) timerange = TimeRange('date', None, test_data[0][0] / 1000 + 1, 0)
data, start_ts = _load_cached_data_for_updating( data, start_ts, end_ts = _load_cached_data_for_updating(
'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT) 'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT)
assert_frame_equal(data, test_data_df.iloc[:-1]) assert_frame_equal(data, test_data_df.iloc[:-1])
assert test_data[-2][0] <= start_ts < test_data[-1][0] assert test_data[-2][0] <= start_ts < test_data[-1][0]
assert end_ts is None
# timeframe starts after the cached data # timeframe starts after the cached data
# should return the cached data w/o the last item # should return the cached data w/o the last item
timerange = TimeRange('date', None, test_data[-1][0] / 1000 + 100, 0) timerange = TimeRange('date', None, test_data[-1][0] / 1000 + 100, 0)
data, start_ts = _load_cached_data_for_updating( data, start_ts, end_ts = _load_cached_data_for_updating(
'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT) 'UNITTEST/BTC', '1m', timerange, data_handler, CandleType.SPOT)
assert_frame_equal(data, test_data_df.iloc[:-1]) assert_frame_equal(data, test_data_df.iloc[:-1])
assert test_data[-2][0] <= start_ts < test_data[-1][0] assert test_data[-2][0] <= start_ts < test_data[-1][0]
assert end_ts is None
# no datafile exist # no datafile exist
# should return timestamp start time # should return timestamp start time
timerange = TimeRange('date', None, now_ts - 10000, 0) timerange = TimeRange('date', None, now_ts - 10000, 0)
data, start_ts = _load_cached_data_for_updating( data, start_ts, end_ts = _load_cached_data_for_updating(
'NONEXIST/BTC', '1m', timerange, data_handler, CandleType.SPOT) 'NONEXIST/BTC', '1m', timerange, data_handler, CandleType.SPOT)
assert data.empty assert data.empty
assert start_ts == (now_ts - 10000) * 1000 assert start_ts == (now_ts - 10000) * 1000
assert end_ts is None
# no datafile exist
# should return timestamp start and end time time
timerange = TimeRange('date', 'date', now_ts - 1000000, now_ts - 100000)
data, start_ts, end_ts = _load_cached_data_for_updating(
'NONEXIST/BTC', '1m', timerange, data_handler, CandleType.SPOT)
assert data.empty
assert start_ts == (now_ts - 1000000) * 1000
assert end_ts == (now_ts - 100000) * 1000
# no datafile exist, no timeframe is set # no datafile exist, no timeframe is set
# should return an empty array and None # should return an empty array and None
data, start_ts = _load_cached_data_for_updating( data, start_ts, end_ts = _load_cached_data_for_updating(
'NONEXIST/BTC', '1m', None, data_handler, CandleType.SPOT) 'NONEXIST/BTC', '1m', None, data_handler, CandleType.SPOT)
assert data.empty assert data.empty
assert start_ts is None assert start_ts is None
assert end_ts is None
@pytest.mark.parametrize('candle_type,subdir,file_tail', [ @pytest.mark.parametrize('candle_type,subdir,file_tail', [

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